From 1d97cc4e310b17b318844073b53a511c5f7c5a5a Mon Sep 17 00:00:00 2001 From: jieun Date: Tue, 20 Oct 2026 17:50:01 +0900 Subject: [PATCH] Generate JHipster 9.3.0 baseline application --- .devcontainer/Dockerfile | 25 + .devcontainer/devcontainer.json | 57 + .editorconfig | 24 + .gitattributes | 149 + .gitignore | 153 + .husky/pre-commit | 6 + .lintstagedrc.cjs | 3 + .mvn/jvm.config | 1 + .mvn/wrapper/maven-wrapper.jar | Bin 0 -> 62547 bytes .mvn/wrapper/maven-wrapper.properties | 2 + .prettierignore | 10 + .prettierrc | 14 + .yo-rc.json | 27 + README.md | 241 ++ checkstyle.xml | 18 + eslint.config.ts | 66 + mvnw | 295 ++ mvnw.cmd | 189 + npmw | 34 + npmw.cmd | 24 + package.json | 141 + pom.xml | 1143 +++++ sonar-project.properties | 55 + src/main/docker/app.yml | 31 + .../grafana/provisioning/dashboards/JVM.json | 3778 +++++++++++++++++ .../provisioning/dashboards/dashboard.yml | 11 + .../provisioning/datasources/datasource.yml | 50 + src/main/docker/jhipster-control-center.yml | 51 + src/main/docker/jib/entrypoint.sh | 40 + src/main/docker/monitoring.yml | 31 + src/main/docker/postgresql.yml | 19 + src/main/docker/prometheus/prometheus.yml | 31 + src/main/docker/services.yml | 7 + src/main/docker/sonar.yml | 15 + .../questionbank/ApplicationWebXml.java | 19 + .../questionbank/GeneratedByJHipster.java | 12 + .../example/questionbank/QuestionBankApp.java | 110 + .../aop/logging/LoggingAspect.java | 131 + .../aop/logging/package-info.java | 4 + .../config/ApplicationProperties.java | 38 + .../config/AsyncConfiguration.java | 48 + .../questionbank/config/CRLFLogConverter.java | 69 + .../questionbank/config/Constants.java | 15 + .../config/DatabaseConfiguration.java | 12 + .../config/DateTimeFormatConfiguration.java | 20 + .../config/JacksonHibernateConfiguration.java | 18 + .../config/LiquibaseConfiguration.java | 83 + .../config/LoggingAspectConfiguration.java | 17 + .../config/LoggingConfiguration.java | 47 + .../config/SecurityConfiguration.java | 85 + .../config/SecurityJwtConfiguration.java | 62 + .../StaticResourcesWebConfiguration.java | 47 + .../questionbank/config/WebConfigurer.java | 96 + .../questionbank/config/package-info.java | 4 + .../domain/AbstractAuditingEntity.java | 77 + .../questionbank/domain/Authority.java | 96 + .../com/example/questionbank/domain/User.java | 231 + .../questionbank/domain/package-info.java | 4 + .../management/SecurityMetersService.java | 50 + .../questionbank/management/package-info.java | 4 + .../example/questionbank/package-info.java | 4 + .../repository/AuthorityRepository.java | 12 + .../repository/UserRepository.java | 34 + .../questionbank/repository/package-info.java | 4 + .../security/AuthoritiesConstants.java | 15 + .../security/DomainUserDetailsService.java | 90 + .../questionbank/security/SecurityUtils.java | 129 + .../security/SpringSecurityAuditorAware.java | 18 + .../security/UserNotActivatedException.java | 21 + .../questionbank/security/package-info.java | 4 + .../service/EmailAlreadyUsedException.java | 13 + .../service/InvalidPasswordException.java | 13 + .../questionbank/service/MailService.java | 120 + .../questionbank/service/UserService.java | 288 ++ .../service/UsernameAlreadyUsedException.java | 13 + .../service/dto/AdminUserDTO.java | 198 + .../service/dto/PasswordChangeDTO.java | 41 + .../questionbank/service/dto/UserDTO.java | 76 + .../service/dto/package-info.java | 4 + .../service/mapper/UserMapper.java | 150 + .../service/mapper/package-info.java | 4 + .../questionbank/service/package-info.java | 4 + .../questionbank/web/filter/SpaWebFilter.java | 33 + .../questionbank/web/filter/package-info.java | 4 + .../web/rest/AccountResource.java | 204 + .../web/rest/AuthenticateController.java | 125 + .../web/rest/AuthorityResource.java | 103 + .../web/rest/PublicUserResource.java | 61 + .../questionbank/web/rest/UserResource.java | 208 + .../rest/errors/BadRequestAlertException.java | 51 + .../errors/EmailAlreadyUsedException.java | 14 + .../web/rest/errors/ErrorConstants.java | 17 + .../web/rest/errors/ExceptionTranslator.java | 275 ++ .../web/rest/errors/FieldErrorVM.java | 34 + .../rest/errors/InvalidPasswordException.java | 25 + .../errors/LoginAlreadyUsedException.java | 14 + .../web/rest/errors/package-info.java | 4 + .../questionbank/web/rest/package-info.java | 4 + .../web/rest/vm/KeyAndPasswordVM.java | 27 + .../questionbank/web/rest/vm/LoginVM.java | 53 + .../web/rest/vm/ManagedUserVM.java | 35 + .../web/rest/vm/package-info.java | 4 + src/main/resources/banner.txt | 10 + src/main/resources/config/application-dev.yml | 97 + .../resources/config/application-prod.yml | 110 + .../config/application-secret-samples.yml | 23 + src/main/resources/config/application-tls.yml | 27 + src/main/resources/config/application.yml | 229 + .../00000000000000_initial_schema.xml | 121 + .../config/liquibase/data/authority.csv | 3 + .../resources/config/liquibase/data/user.csv | 3 + .../config/liquibase/data/user_authority.csv | 4 + .../resources/config/liquibase/master.xml | 18 + src/main/resources/config/tls/keystore.p12 | Bin 0 -> 2768 bytes src/main/resources/i18n/messages.properties | 21 + .../resources/i18n/messages_en.properties | 21 + src/main/resources/logback-spring.xml | 79 + src/main/resources/templates/error.html | 94 + .../templates/mail/activationEmail.html | 20 + .../templates/mail/creationEmail.html | 20 + .../templates/mail/passwordResetEmail.html | 22 + src/main/webapp/404.html | 57 + src/main/webapp/WEB-INF/web.xml | 13 + .../app/account/account.service.spec.ts | 121 + .../webapp/app/account/account.service.ts | 87 + .../activate/activate.component.spec.ts | 59 + .../account/activate/activate.component.ts | 38 + .../app/account/activate/activate.service.ts | 13 + .../webapp/app/account/activate/activate.vue | 17 + .../change-password.component.spec.ts | 83 + .../change-password.component.ts | 71 + .../change-password/change-password.vue | 92 + .../login-form/login-form.component.spec.ts | 125 + .../login-form/login-form.component.ts | 62 + .../app/account/login-form/login-form.vue | 62 + src/main/webapp/app/account/login-modal.ts | 21 + .../register/register.component.spec.ts | 142 + .../account/register/register.component.ts | 98 + .../app/account/register/register.service.ts | 7 + .../webapp/app/account/register/register.vue | 152 + .../reset-password-finish.component.spec.ts | 59 + .../finish/reset-password-finish.component.ts | 77 + .../finish/reset-password-finish.vue | 85 + .../reset-password-init.component.spec.ts | 54 + .../init/reset-password-init.component.ts | 60 + .../init/reset-password-init.vue | 56 + .../settings/settings.component.spec.ts | 113 + .../account/settings/settings.component.ts | 78 + .../webapp/app/account/settings/settings.vue | 114 + .../configuration.component.spec.ts | 77 + .../configuration/configuration.component.ts | 67 + .../configuration/configuration.service.ts | 55 + .../app/admin/configuration/configuration.vue | 62 + .../webapp/app/admin/docs/docs.component.ts | 5 + src/main/webapp/app/admin/docs/docs.vue | 13 + .../health/health-modal.component.spec.ts | 90 + .../admin/health/health-modal.component.ts | 46 + .../webapp/app/admin/health/health-modal.vue | 29 + .../app/admin/health/health.component.spec.ts | 93 + .../app/admin/health/health.component.ts | 63 + .../app/admin/health/health.service.spec.ts | 246 ++ .../webapp/app/admin/health/health.service.ts | 118 + src/main/webapp/app/admin/health/health.vue | 45 + .../app/admin/logs/logs.component.spec.ts | 73 + .../webapp/app/admin/logs/logs.component.ts | 63 + .../webapp/app/admin/logs/logs.service.ts | 11 + src/main/webapp/app/admin/logs/logs.vue | 57 + .../metrics/metrics-modal.component.spec.ts | 59 + .../admin/metrics/metrics-modal.component.ts | 64 + .../app/admin/metrics/metrics-modal.vue | 75 + .../admin/metrics/metrics.component.spec.ts | 272 ++ .../app/admin/metrics/metrics.component.ts | 131 + .../app/admin/metrics/metrics.service.ts | 11 + src/main/webapp/app/admin/metrics/metrics.vue | 371 ++ .../user-management-edit.component.spec.ts | 161 + .../user-management-edit.component.ts | 125 + .../user-management/user-management-edit.vue | 143 + .../user-management-view.component.spec.ts | 83 + .../user-management-view.component.ts | 40 + .../user-management/user-management-view.vue | 80 + .../user-management.component.spec.ts | 116 + .../user-management.component.ts | 141 + .../user-management.service.ts | 33 + .../admin/user-management/user-management.vue | 152 + src/main/webapp/app/app.component.ts | 33 + src/main/webapp/app/app.vue | 24 + .../app/core/error/error.component.spec.ts | 95 + .../webapp/app/core/error/error.component.ts | 33 + src/main/webapp/app/core/error/error.vue | 20 + .../app/core/home/home.component.spec.ts | 55 + .../webapp/app/core/home/home.component.ts | 19 + src/main/webapp/app/core/home/home.vue | 60 + .../core/jhi-footer/jhi-footer.component.ts | 11 + .../webapp/app/core/jhi-footer/jhi-footer.vue | 7 + .../jhi-navbar/jhi-navbar.component.spec.ts | 110 + .../core/jhi-navbar/jhi-navbar.component.ts | 79 + .../webapp/app/core/jhi-navbar/jhi-navbar.vue | 193 + .../app/core/ribbon/ribbon.component.spec.ts | 47 + .../app/core/ribbon/ribbon.component.ts | 19 + src/main/webapp/app/core/ribbon/ribbon.vue | 43 + src/main/webapp/app/declarations.d.ts | 6 + .../app/entities/entities-menu.component.ts | 12 + .../webapp/app/entities/entities-menu.vue | 7 + .../webapp/app/entities/entities.component.ts | 12 + src/main/webapp/app/entities/entities.vue | 5 + .../webapp/app/entities/user/user.service.ts | 9 + .../webapp/app/locale/translation.service.ts | 40 + src/main/webapp/app/main.ts | 132 + src/main/webapp/app/router/account.ts | 43 + src/main/webapp/app/router/admin.ts | 67 + src/main/webapp/app/router/entities.ts | 11 + src/main/webapp/app/router/index.ts | 47 + src/main/webapp/app/router/pages.ts | 5 + .../app/shared/alert/alert.service.spec.ts | 188 + .../webapp/app/shared/alert/alert.service.ts | 86 + .../app/shared/composables/date-format.ts | 51 + .../webapp/app/shared/composables/index.ts | 2 + .../app/shared/composables/validation.ts | 14 + .../webapp/app/shared/computables/arrays.ts | 58 + .../webapp/app/shared/computables/index.ts | 1 + .../shared/config/axios-interceptor.spec.ts | 72 + .../app/shared/config/axios-interceptor.ts | 36 + .../app/shared/config/config-bootstrap-vue.ts | 52 + src/main/webapp/app/shared/config/config.ts | 105 + src/main/webapp/app/shared/config/dayjs.ts | 11 + .../webapp/app/shared/config/languages.ts | 6 + .../app/shared/config/store/account-store.ts | 59 + .../shared/config/store/translation-store.ts | 17 + .../shared/data/data-utils.service.spec.ts | 120 + .../app/shared/data/data-utils.service.ts | 94 + .../app/shared/jhi-item-count.component.ts | 29 + src/main/webapp/app/shared/jhi-item-count.vue | 7 + .../webapp/app/shared/jhipster/constants.ts | 10 + .../webapp/app/shared/jhipster/data-utils.ts | 47 + .../app/shared/jhipster/error.constants.ts | 4 + .../webapp/app/shared/jhipster/headers.ts | 62 + .../webapp/app/shared/jhipster/link-header.ts | 55 + .../app/shared/jhipster/problem-details.ts | 35 + .../webapp/app/shared/model/user.model.ts | 33 + .../sort/jhi-sort-indicator.component.ts | 16 + .../app/shared/sort/jhi-sort-indicator.vue | 5 + src/main/webapp/app/shared/sort/sorts.spec.ts | 11 + src/main/webapp/app/shared/sort/sorts.ts | 13 + src/main/webapp/app/shims-vue.d.ts | 5 + src/main/webapp/app/store.ts | 5 + src/main/webapp/app/test-setup.ts | 22 + src/main/webapp/content/css/loading.css | 152 + .../images/jhipster_family_member_0.svg | 1 + .../jhipster_family_member_0_head-192.png | Bin 0 -> 13439 bytes .../jhipster_family_member_0_head-256.png | Bin 0 -> 7037 bytes .../jhipster_family_member_0_head-384.png | Bin 0 -> 10350 bytes .../jhipster_family_member_0_head-512.png | Bin 0 -> 11431 bytes .../images/jhipster_family_member_1.svg | 1 + .../jhipster_family_member_1_head-192.png | Bin 0 -> 7046 bytes .../jhipster_family_member_1_head-256.png | Bin 0 -> 9505 bytes .../jhipster_family_member_1_head-384.png | Bin 0 -> 15054 bytes .../jhipster_family_member_1_head-512.png | Bin 0 -> 16456 bytes .../images/jhipster_family_member_2.svg | 1 + .../jhipster_family_member_2_head-192.png | Bin 0 -> 5423 bytes .../jhipster_family_member_2_head-256.png | Bin 0 -> 6687 bytes .../jhipster_family_member_2_head-384.png | Bin 0 -> 9682 bytes .../jhipster_family_member_2_head-512.png | Bin 0 -> 10514 bytes .../images/jhipster_family_member_3.svg | 1 + .../jhipster_family_member_3_head-192.png | Bin 0 -> 6148 bytes .../jhipster_family_member_3_head-256.png | Bin 0 -> 8028 bytes .../jhipster_family_member_3_head-384.png | Bin 0 -> 11998 bytes .../jhipster_family_member_3_head-512.png | Bin 0 -> 13555 bytes .../webapp/content/images/logo-jhipster.png | Bin 0 -> 605 bytes .../content/scss/_bootstrap-variables.scss | 36 + src/main/webapp/content/scss/global.scss | 288 ++ src/main/webapp/content/scss/vendor.scss | 8 + src/main/webapp/favicon.ico | Bin 0 -> 1574 bytes src/main/webapp/i18n/en/activate.json | 9 + src/main/webapp/i18n/en/configuration.json | 10 + src/main/webapp/i18n/en/en.js | 3 + src/main/webapp/i18n/en/error.json | 14 + src/main/webapp/i18n/en/global.json | 147 + src/main/webapp/i18n/en/health.json | 32 + src/main/webapp/i18n/en/home.json | 19 + src/main/webapp/i18n/en/login.json | 19 + src/main/webapp/i18n/en/logs.json | 11 + src/main/webapp/i18n/en/metrics.json | 102 + src/main/webapp/i18n/en/password.json | 12 + src/main/webapp/i18n/en/register.json | 24 + src/main/webapp/i18n/en/reset.json | 26 + src/main/webapp/i18n/en/sessions.json | 15 + src/main/webapp/i18n/en/settings.json | 32 + src/main/webapp/i18n/en/user-management.json | 34 + src/main/webapp/index.html | 117 + src/main/webapp/manifest.webapp | 31 + src/main/webapp/robots.txt | 8 + src/main/webapp/swagger-ui/index.html | 115 + .../example/questionbank/IntegrationTest.java | 24 + .../questionbank/TechnicalStructureTest.java | 38 + .../aop/logging/LoggingAspectTest.java | 102 + .../config/AsyncSyncConfiguration.java | 15 + .../config/CRLFLogConverterTest.java | 132 + .../config/DatabaseTestcontainer.java | 24 + .../config/SpringBootTestClassOrderer.java | 22 + .../StaticResourcesWebConfigurerTest.java | 76 + .../config/WebConfigurerTest.java | 113 + .../config/WebConfigurerTestController.java | 18 + .../config/timezone/HibernateTimeZoneIT.java | 171 + .../questionbank/domain/AssertUtils.java | 15 + .../questionbank/domain/AuthorityAsserts.java | 60 + .../questionbank/domain/AuthorityTest.java | 34 + .../domain/AuthorityTestSamples.java | 18 + .../SecurityMetersServiceTests.java | 70 + .../repository/timezone/DateTimeWrapper.java | 135 + .../timezone/DateTimeWrapperRepository.java | 10 + .../security/DomainUserDetailsServiceIT.java | 136 + .../security/SecurityUtilsUnitTest.java | 137 + .../jwt/AuthenticationIntegrationTest.java | 32 + .../jwt/JwtAuthenticationTestUtils.java | 100 + .../security/jwt/TokenAuthenticationIT.java | 55 + .../TokenAuthenticationSecurityMetersIT.java | 88 + .../questionbank/service/MailServiceIT.java | 237 ++ .../questionbank/service/UserServiceIT.java | 192 + .../service/mapper/UserMapperTest.java | 180 + .../web/filter/SpaWebFilterIT.java | 88 + .../web/rest/AccountResourceIT.java | 790 ++++ .../web/rest/AuthenticateControllerIT.java | 102 + .../web/rest/AuthorityResourceIT.java | 206 + .../web/rest/PublicUserResourceIT.java | 87 + .../questionbank/web/rest/TestUtil.java | 190 + .../questionbank/web/rest/UserResourceIT.java | 484 +++ .../web/rest/WithUnauthenticatedMockUser.java | 23 + .../rest/errors/ExceptionTranslatorIT.java | 117 + .../ExceptionTranslatorTestController.java | 72 + .../resources/config/application-testdev.yml | 32 + .../resources/config/application-testprod.yml | 32 + src/test/resources/config/application.yml | 97 + .../resources/i18n/messages_en.properties | 4 + src/test/resources/junit-platform.properties | 4 + src/test/resources/logback.xml | 45 + .../templates/mail/activationEmail.html | 19 + .../templates/mail/creationEmail.html | 19 + .../templates/mail/passwordResetEmail.html | 21 + .../resources/templates/mail/testEmail.html | 1 + tsconfig.app.json | 13 + tsconfig.json | 14 + tsconfig.node.json | 11 + tsconfig.vitest.json | 9 + vite.config.ts | 76 + vitest.config.ts | 32 + 345 files changed, 25563 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .husky/pre-commit create mode 100644 .lintstagedrc.cjs create mode 100644 .mvn/jvm.config create mode 100644 .mvn/wrapper/maven-wrapper.jar create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 .yo-rc.json create mode 100644 README.md create mode 100644 checkstyle.xml create mode 100644 eslint.config.ts create mode 100644 mvnw create mode 100644 mvnw.cmd create mode 100644 npmw create mode 100644 npmw.cmd create mode 100644 package.json create mode 100644 pom.xml create mode 100644 sonar-project.properties create mode 100644 src/main/docker/app.yml create mode 100644 src/main/docker/grafana/provisioning/dashboards/JVM.json create mode 100644 src/main/docker/grafana/provisioning/dashboards/dashboard.yml create mode 100644 src/main/docker/grafana/provisioning/datasources/datasource.yml create mode 100644 src/main/docker/jhipster-control-center.yml create mode 100644 src/main/docker/jib/entrypoint.sh create mode 100644 src/main/docker/monitoring.yml create mode 100644 src/main/docker/postgresql.yml create mode 100644 src/main/docker/prometheus/prometheus.yml create mode 100644 src/main/docker/services.yml create mode 100644 src/main/docker/sonar.yml create mode 100644 src/main/java/com/example/questionbank/ApplicationWebXml.java create mode 100644 src/main/java/com/example/questionbank/GeneratedByJHipster.java create mode 100644 src/main/java/com/example/questionbank/QuestionBankApp.java create mode 100644 src/main/java/com/example/questionbank/aop/logging/LoggingAspect.java create mode 100644 src/main/java/com/example/questionbank/aop/logging/package-info.java create mode 100644 src/main/java/com/example/questionbank/config/ApplicationProperties.java create mode 100644 src/main/java/com/example/questionbank/config/AsyncConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/CRLFLogConverter.java create mode 100644 src/main/java/com/example/questionbank/config/Constants.java create mode 100644 src/main/java/com/example/questionbank/config/DatabaseConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/DateTimeFormatConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/JacksonHibernateConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/LiquibaseConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/LoggingAspectConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/LoggingConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/SecurityConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/SecurityJwtConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/StaticResourcesWebConfiguration.java create mode 100644 src/main/java/com/example/questionbank/config/WebConfigurer.java create mode 100644 src/main/java/com/example/questionbank/config/package-info.java create mode 100644 src/main/java/com/example/questionbank/domain/AbstractAuditingEntity.java create mode 100644 src/main/java/com/example/questionbank/domain/Authority.java create mode 100644 src/main/java/com/example/questionbank/domain/User.java create mode 100644 src/main/java/com/example/questionbank/domain/package-info.java create mode 100644 src/main/java/com/example/questionbank/management/SecurityMetersService.java create mode 100644 src/main/java/com/example/questionbank/management/package-info.java create mode 100644 src/main/java/com/example/questionbank/package-info.java create mode 100644 src/main/java/com/example/questionbank/repository/AuthorityRepository.java create mode 100644 src/main/java/com/example/questionbank/repository/UserRepository.java create mode 100644 src/main/java/com/example/questionbank/repository/package-info.java create mode 100644 src/main/java/com/example/questionbank/security/AuthoritiesConstants.java create mode 100644 src/main/java/com/example/questionbank/security/DomainUserDetailsService.java create mode 100644 src/main/java/com/example/questionbank/security/SecurityUtils.java create mode 100644 src/main/java/com/example/questionbank/security/SpringSecurityAuditorAware.java create mode 100644 src/main/java/com/example/questionbank/security/UserNotActivatedException.java create mode 100644 src/main/java/com/example/questionbank/security/package-info.java create mode 100644 src/main/java/com/example/questionbank/service/EmailAlreadyUsedException.java create mode 100644 src/main/java/com/example/questionbank/service/InvalidPasswordException.java create mode 100644 src/main/java/com/example/questionbank/service/MailService.java create mode 100644 src/main/java/com/example/questionbank/service/UserService.java create mode 100644 src/main/java/com/example/questionbank/service/UsernameAlreadyUsedException.java create mode 100644 src/main/java/com/example/questionbank/service/dto/AdminUserDTO.java create mode 100644 src/main/java/com/example/questionbank/service/dto/PasswordChangeDTO.java create mode 100644 src/main/java/com/example/questionbank/service/dto/UserDTO.java create mode 100644 src/main/java/com/example/questionbank/service/dto/package-info.java create mode 100644 src/main/java/com/example/questionbank/service/mapper/UserMapper.java create mode 100644 src/main/java/com/example/questionbank/service/mapper/package-info.java create mode 100644 src/main/java/com/example/questionbank/service/package-info.java create mode 100644 src/main/java/com/example/questionbank/web/filter/SpaWebFilter.java create mode 100644 src/main/java/com/example/questionbank/web/filter/package-info.java create mode 100644 src/main/java/com/example/questionbank/web/rest/AccountResource.java create mode 100644 src/main/java/com/example/questionbank/web/rest/AuthenticateController.java create mode 100644 src/main/java/com/example/questionbank/web/rest/AuthorityResource.java create mode 100644 src/main/java/com/example/questionbank/web/rest/PublicUserResource.java create mode 100644 src/main/java/com/example/questionbank/web/rest/UserResource.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/BadRequestAlertException.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/EmailAlreadyUsedException.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/ErrorConstants.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/ExceptionTranslator.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/FieldErrorVM.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/InvalidPasswordException.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/LoginAlreadyUsedException.java create mode 100644 src/main/java/com/example/questionbank/web/rest/errors/package-info.java create mode 100644 src/main/java/com/example/questionbank/web/rest/package-info.java create mode 100644 src/main/java/com/example/questionbank/web/rest/vm/KeyAndPasswordVM.java create mode 100644 src/main/java/com/example/questionbank/web/rest/vm/LoginVM.java create mode 100644 src/main/java/com/example/questionbank/web/rest/vm/ManagedUserVM.java create mode 100644 src/main/java/com/example/questionbank/web/rest/vm/package-info.java create mode 100644 src/main/resources/banner.txt create mode 100644 src/main/resources/config/application-dev.yml create mode 100644 src/main/resources/config/application-prod.yml create mode 100644 src/main/resources/config/application-secret-samples.yml create mode 100644 src/main/resources/config/application-tls.yml create mode 100644 src/main/resources/config/application.yml create mode 100644 src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml create mode 100644 src/main/resources/config/liquibase/data/authority.csv create mode 100644 src/main/resources/config/liquibase/data/user.csv create mode 100644 src/main/resources/config/liquibase/data/user_authority.csv create mode 100644 src/main/resources/config/liquibase/master.xml create mode 100644 src/main/resources/config/tls/keystore.p12 create mode 100644 src/main/resources/i18n/messages.properties create mode 100644 src/main/resources/i18n/messages_en.properties create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/templates/error.html create mode 100644 src/main/resources/templates/mail/activationEmail.html create mode 100644 src/main/resources/templates/mail/creationEmail.html create mode 100644 src/main/resources/templates/mail/passwordResetEmail.html create mode 100644 src/main/webapp/404.html create mode 100644 src/main/webapp/WEB-INF/web.xml create mode 100644 src/main/webapp/app/account/account.service.spec.ts create mode 100644 src/main/webapp/app/account/account.service.ts create mode 100644 src/main/webapp/app/account/activate/activate.component.spec.ts create mode 100644 src/main/webapp/app/account/activate/activate.component.ts create mode 100644 src/main/webapp/app/account/activate/activate.service.ts create mode 100644 src/main/webapp/app/account/activate/activate.vue create mode 100644 src/main/webapp/app/account/change-password/change-password.component.spec.ts create mode 100644 src/main/webapp/app/account/change-password/change-password.component.ts create mode 100644 src/main/webapp/app/account/change-password/change-password.vue create mode 100644 src/main/webapp/app/account/login-form/login-form.component.spec.ts create mode 100644 src/main/webapp/app/account/login-form/login-form.component.ts create mode 100644 src/main/webapp/app/account/login-form/login-form.vue create mode 100644 src/main/webapp/app/account/login-modal.ts create mode 100644 src/main/webapp/app/account/register/register.component.spec.ts create mode 100644 src/main/webapp/app/account/register/register.component.ts create mode 100644 src/main/webapp/app/account/register/register.service.ts create mode 100644 src/main/webapp/app/account/register/register.vue create mode 100644 src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.spec.ts create mode 100644 src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.ts create mode 100644 src/main/webapp/app/account/reset-password/finish/reset-password-finish.vue create mode 100644 src/main/webapp/app/account/reset-password/init/reset-password-init.component.spec.ts create mode 100644 src/main/webapp/app/account/reset-password/init/reset-password-init.component.ts create mode 100644 src/main/webapp/app/account/reset-password/init/reset-password-init.vue create mode 100644 src/main/webapp/app/account/settings/settings.component.spec.ts create mode 100644 src/main/webapp/app/account/settings/settings.component.ts create mode 100644 src/main/webapp/app/account/settings/settings.vue create mode 100644 src/main/webapp/app/admin/configuration/configuration.component.spec.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.component.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.service.ts create mode 100644 src/main/webapp/app/admin/configuration/configuration.vue create mode 100644 src/main/webapp/app/admin/docs/docs.component.ts create mode 100644 src/main/webapp/app/admin/docs/docs.vue create mode 100644 src/main/webapp/app/admin/health/health-modal.component.spec.ts create mode 100644 src/main/webapp/app/admin/health/health-modal.component.ts create mode 100644 src/main/webapp/app/admin/health/health-modal.vue create mode 100644 src/main/webapp/app/admin/health/health.component.spec.ts create mode 100644 src/main/webapp/app/admin/health/health.component.ts create mode 100644 src/main/webapp/app/admin/health/health.service.spec.ts create mode 100644 src/main/webapp/app/admin/health/health.service.ts create mode 100644 src/main/webapp/app/admin/health/health.vue create mode 100644 src/main/webapp/app/admin/logs/logs.component.spec.ts create mode 100644 src/main/webapp/app/admin/logs/logs.component.ts create mode 100644 src/main/webapp/app/admin/logs/logs.service.ts create mode 100644 src/main/webapp/app/admin/logs/logs.vue create mode 100644 src/main/webapp/app/admin/metrics/metrics-modal.component.spec.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics-modal.component.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics-modal.vue create mode 100644 src/main/webapp/app/admin/metrics/metrics.component.spec.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.component.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.service.ts create mode 100644 src/main/webapp/app/admin/metrics/metrics.vue create mode 100644 src/main/webapp/app/admin/user-management/user-management-edit.component.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management-edit.component.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management-edit.vue create mode 100644 src/main/webapp/app/admin/user-management/user-management-view.component.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management-view.component.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management-view.vue create mode 100644 src/main/webapp/app/admin/user-management/user-management.component.spec.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management.component.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management.service.ts create mode 100644 src/main/webapp/app/admin/user-management/user-management.vue create mode 100644 src/main/webapp/app/app.component.ts create mode 100644 src/main/webapp/app/app.vue create mode 100644 src/main/webapp/app/core/error/error.component.spec.ts create mode 100644 src/main/webapp/app/core/error/error.component.ts create mode 100644 src/main/webapp/app/core/error/error.vue create mode 100644 src/main/webapp/app/core/home/home.component.spec.ts create mode 100644 src/main/webapp/app/core/home/home.component.ts create mode 100644 src/main/webapp/app/core/home/home.vue create mode 100644 src/main/webapp/app/core/jhi-footer/jhi-footer.component.ts create mode 100644 src/main/webapp/app/core/jhi-footer/jhi-footer.vue create mode 100644 src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.spec.ts create mode 100644 src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.ts create mode 100644 src/main/webapp/app/core/jhi-navbar/jhi-navbar.vue create mode 100644 src/main/webapp/app/core/ribbon/ribbon.component.spec.ts create mode 100644 src/main/webapp/app/core/ribbon/ribbon.component.ts create mode 100644 src/main/webapp/app/core/ribbon/ribbon.vue create mode 100644 src/main/webapp/app/declarations.d.ts create mode 100644 src/main/webapp/app/entities/entities-menu.component.ts create mode 100644 src/main/webapp/app/entities/entities-menu.vue create mode 100644 src/main/webapp/app/entities/entities.component.ts create mode 100644 src/main/webapp/app/entities/entities.vue create mode 100644 src/main/webapp/app/entities/user/user.service.ts create mode 100644 src/main/webapp/app/locale/translation.service.ts create mode 100644 src/main/webapp/app/main.ts create mode 100644 src/main/webapp/app/router/account.ts create mode 100644 src/main/webapp/app/router/admin.ts create mode 100644 src/main/webapp/app/router/entities.ts create mode 100644 src/main/webapp/app/router/index.ts create mode 100644 src/main/webapp/app/router/pages.ts create mode 100644 src/main/webapp/app/shared/alert/alert.service.spec.ts create mode 100644 src/main/webapp/app/shared/alert/alert.service.ts create mode 100644 src/main/webapp/app/shared/composables/date-format.ts create mode 100644 src/main/webapp/app/shared/composables/index.ts create mode 100644 src/main/webapp/app/shared/composables/validation.ts create mode 100644 src/main/webapp/app/shared/computables/arrays.ts create mode 100644 src/main/webapp/app/shared/computables/index.ts create mode 100644 src/main/webapp/app/shared/config/axios-interceptor.spec.ts create mode 100644 src/main/webapp/app/shared/config/axios-interceptor.ts create mode 100644 src/main/webapp/app/shared/config/config-bootstrap-vue.ts create mode 100644 src/main/webapp/app/shared/config/config.ts create mode 100644 src/main/webapp/app/shared/config/dayjs.ts create mode 100644 src/main/webapp/app/shared/config/languages.ts create mode 100644 src/main/webapp/app/shared/config/store/account-store.ts create mode 100644 src/main/webapp/app/shared/config/store/translation-store.ts create mode 100644 src/main/webapp/app/shared/data/data-utils.service.spec.ts create mode 100644 src/main/webapp/app/shared/data/data-utils.service.ts create mode 100644 src/main/webapp/app/shared/jhi-item-count.component.ts create mode 100644 src/main/webapp/app/shared/jhi-item-count.vue create mode 100644 src/main/webapp/app/shared/jhipster/constants.ts create mode 100644 src/main/webapp/app/shared/jhipster/data-utils.ts create mode 100644 src/main/webapp/app/shared/jhipster/error.constants.ts create mode 100644 src/main/webapp/app/shared/jhipster/headers.ts create mode 100644 src/main/webapp/app/shared/jhipster/link-header.ts create mode 100644 src/main/webapp/app/shared/jhipster/problem-details.ts create mode 100644 src/main/webapp/app/shared/model/user.model.ts create mode 100644 src/main/webapp/app/shared/sort/jhi-sort-indicator.component.ts create mode 100644 src/main/webapp/app/shared/sort/jhi-sort-indicator.vue create mode 100644 src/main/webapp/app/shared/sort/sorts.spec.ts create mode 100644 src/main/webapp/app/shared/sort/sorts.ts create mode 100644 src/main/webapp/app/shims-vue.d.ts create mode 100644 src/main/webapp/app/store.ts create mode 100644 src/main/webapp/app/test-setup.ts create mode 100644 src/main/webapp/content/css/loading.css create mode 100644 src/main/webapp/content/images/jhipster_family_member_0.svg create mode 100644 src/main/webapp/content/images/jhipster_family_member_0_head-192.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_0_head-256.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_0_head-384.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_0_head-512.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_1.svg create mode 100644 src/main/webapp/content/images/jhipster_family_member_1_head-192.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_1_head-256.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_1_head-384.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_1_head-512.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_2.svg create mode 100644 src/main/webapp/content/images/jhipster_family_member_2_head-192.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_2_head-256.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_2_head-384.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_2_head-512.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_3.svg create mode 100644 src/main/webapp/content/images/jhipster_family_member_3_head-192.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_3_head-256.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_3_head-384.png create mode 100644 src/main/webapp/content/images/jhipster_family_member_3_head-512.png create mode 100644 src/main/webapp/content/images/logo-jhipster.png create mode 100644 src/main/webapp/content/scss/_bootstrap-variables.scss create mode 100644 src/main/webapp/content/scss/global.scss create mode 100644 src/main/webapp/content/scss/vendor.scss create mode 100644 src/main/webapp/favicon.ico create mode 100644 src/main/webapp/i18n/en/activate.json create mode 100644 src/main/webapp/i18n/en/configuration.json create mode 100644 src/main/webapp/i18n/en/en.js create mode 100644 src/main/webapp/i18n/en/error.json create mode 100644 src/main/webapp/i18n/en/global.json create mode 100644 src/main/webapp/i18n/en/health.json create mode 100644 src/main/webapp/i18n/en/home.json create mode 100644 src/main/webapp/i18n/en/login.json create mode 100644 src/main/webapp/i18n/en/logs.json create mode 100644 src/main/webapp/i18n/en/metrics.json create mode 100644 src/main/webapp/i18n/en/password.json create mode 100644 src/main/webapp/i18n/en/register.json create mode 100644 src/main/webapp/i18n/en/reset.json create mode 100644 src/main/webapp/i18n/en/sessions.json create mode 100644 src/main/webapp/i18n/en/settings.json create mode 100644 src/main/webapp/i18n/en/user-management.json create mode 100644 src/main/webapp/index.html create mode 100644 src/main/webapp/manifest.webapp create mode 100644 src/main/webapp/robots.txt create mode 100644 src/main/webapp/swagger-ui/index.html create mode 100644 src/test/java/com/example/questionbank/IntegrationTest.java create mode 100644 src/test/java/com/example/questionbank/TechnicalStructureTest.java create mode 100644 src/test/java/com/example/questionbank/aop/logging/LoggingAspectTest.java create mode 100644 src/test/java/com/example/questionbank/config/AsyncSyncConfiguration.java create mode 100644 src/test/java/com/example/questionbank/config/CRLFLogConverterTest.java create mode 100644 src/test/java/com/example/questionbank/config/DatabaseTestcontainer.java create mode 100644 src/test/java/com/example/questionbank/config/SpringBootTestClassOrderer.java create mode 100644 src/test/java/com/example/questionbank/config/StaticResourcesWebConfigurerTest.java create mode 100644 src/test/java/com/example/questionbank/config/WebConfigurerTest.java create mode 100644 src/test/java/com/example/questionbank/config/WebConfigurerTestController.java create mode 100644 src/test/java/com/example/questionbank/config/timezone/HibernateTimeZoneIT.java create mode 100644 src/test/java/com/example/questionbank/domain/AssertUtils.java create mode 100644 src/test/java/com/example/questionbank/domain/AuthorityAsserts.java create mode 100644 src/test/java/com/example/questionbank/domain/AuthorityTest.java create mode 100644 src/test/java/com/example/questionbank/domain/AuthorityTestSamples.java create mode 100644 src/test/java/com/example/questionbank/management/SecurityMetersServiceTests.java create mode 100644 src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapper.java create mode 100644 src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapperRepository.java create mode 100644 src/test/java/com/example/questionbank/security/DomainUserDetailsServiceIT.java create mode 100644 src/test/java/com/example/questionbank/security/SecurityUtilsUnitTest.java create mode 100644 src/test/java/com/example/questionbank/security/jwt/AuthenticationIntegrationTest.java create mode 100644 src/test/java/com/example/questionbank/security/jwt/JwtAuthenticationTestUtils.java create mode 100644 src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationIT.java create mode 100644 src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationSecurityMetersIT.java create mode 100644 src/test/java/com/example/questionbank/service/MailServiceIT.java create mode 100644 src/test/java/com/example/questionbank/service/UserServiceIT.java create mode 100644 src/test/java/com/example/questionbank/service/mapper/UserMapperTest.java create mode 100644 src/test/java/com/example/questionbank/web/filter/SpaWebFilterIT.java create mode 100644 src/test/java/com/example/questionbank/web/rest/AccountResourceIT.java create mode 100644 src/test/java/com/example/questionbank/web/rest/AuthenticateControllerIT.java create mode 100644 src/test/java/com/example/questionbank/web/rest/AuthorityResourceIT.java create mode 100644 src/test/java/com/example/questionbank/web/rest/PublicUserResourceIT.java create mode 100644 src/test/java/com/example/questionbank/web/rest/TestUtil.java create mode 100644 src/test/java/com/example/questionbank/web/rest/UserResourceIT.java create mode 100644 src/test/java/com/example/questionbank/web/rest/WithUnauthenticatedMockUser.java create mode 100644 src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorIT.java create mode 100644 src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorTestController.java create mode 100644 src/test/resources/config/application-testdev.yml create mode 100644 src/test/resources/config/application-testprod.yml create mode 100644 src/test/resources/config/application.yml create mode 100644 src/test/resources/i18n/messages_en.properties create mode 100644 src/test/resources/junit-platform.properties create mode 100644 src/test/resources/logback.xml create mode 100644 src/test/resources/templates/mail/activationEmail.html create mode 100644 src/test/resources/templates/mail/creationEmail.html create mode 100644 src/test/resources/templates/mail/passwordResetEmail.html create mode 100644 src/test/resources/templates/mail/testEmail.html create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 tsconfig.vitest.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..a914fd2 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,25 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/java/.devcontainer/base.Dockerfile + +# [Choice] Java version (use -bullseye variants on local arm64/Apple Silicon): 17, 17-bullseye, 17-buster +ARG VARIANT="17" +FROM mcr.microsoft.com/vscode/devcontainers/java:0-${VARIANT} + +# [Option] Install Maven +ARG INSTALL_MAVEN="false" +ARG MAVEN_VERSION="" +# [Option] Install Gradle +ARG INSTALL_GRADLE="false" +ARG GRADLE_VERSION="" +RUN if [ "${INSTALL_MAVEN}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/sdkman/bin/sdkman-init.sh && sdk install maven \"${MAVEN_VERSION}\""; fi \ + && if [ "${INSTALL_GRADLE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/sdkman/bin/sdkman-init.sh && sdk install gradle \"${GRADLE_VERSION}\""; fi + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8e55b7f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,57 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/java +{ + "name": "QuestionBank", + "build": { + "dockerfile": "Dockerfile", + "args": { + // Update the VARIANT arg to pick a Java version: 17, 19 + // Append -bullseye or -buster to pin to an OS version. + // Use the -bullseye variants on local arm64/Apple Silicon. + "VARIANT": "21-bullseye", + // Options + // maven and gradle wrappers are used by default, we don't need them installed globally + // "INSTALL_MAVEN": "true", + // "INSTALL_GRADLE": "false", + "NODE_VERSION": "24.20.0" + } + }, + + "customizations": { + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "java.jdt.ls.java.home": "/docker-java-home" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "christian-kohler.npm-intellisense", + "firsttris.vscode-jest-runner", + "ms-vscode.vscode-typescript-tslint-plugin", + "dbaeumer.vscode-eslint", + "vscjava.vscode-java-pack", + "pivotal.vscode-boot-dev-pack", + "esbenp.prettier-vscode" + ] + } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [9060, 3001, 9000, 8080], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "java -version", + + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode", + + // Mount the workspace folder to the same path inside the container, so docker-in-docker containers work correctly. See https://github.com/jhipster/generator-jhipster/issues/31239 + "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", + "workspaceFolder": "${localWorkspaceFolder}", + + "features": { + "docker-in-docker": "latest", + "docker-from-docker": "latest" + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..864bca4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] + +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# Change these settings to your own preference +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +# Generated by jhipster:java:bootstrap generator +[*.java] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6159150 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,149 @@ +# This file is inspired by https://github.com/alexkaratarakis/gitattributes +# +# Auto detect text files and perform LF normalization +# https://davidlaing.com/2012/09/19/customise-your-gitattributes-to-become-a-git-ninja/ +* text=auto + +# The above will handle all files NOT found below +# These files are text and should be normalized (Convert crlf => lf) + +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf +*.coffee text +*.css text +*.cql text +*.df text +*.ejs text +*.html text +*.java text +*.js text +*.json text +*.less text +*.properties text +*.sass text +*.scss text +*.sh text eol=lf +*.sql text +*.ts text +*.xml text +*.yaml text +*.yml text + +# Documents +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain +*.markdown text +*.md text +*.adoc text +*.textile text +*.mustache text +*.csv text +*.tab text +*.tsv text +*.txt text +AUTHORS text +CHANGELOG text +CHANGES text +CONTRIBUTING text +COPYING text +copyright text +*COPYRIGHT* text +INSTALL text +license text +LICENSE text +NEWS text +readme text +*README* text +TODO text + +# Graphics +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.tif binary +*.tiff binary +*.ico binary +# SVG treated as an asset (binary) by default. If you want to treat it as text, +# comment-out the following line and uncomment the line after. +*.svg binary +#*.svg text +*.eps binary + +# These files are binary and should be left untouched +# (binary is a macro for -text -diff) +*.class binary +*.jar binary +*.war binary + +## LINTERS +.csslintrc text +.eslintrc text +.jscsrc text +.jshintrc text +.jshintignore text +.stylelintrc text + +## CONFIGS +*.conf text +*.config text +.editorconfig text +.gitattributes text +.gitconfig text +.gitignore text +.htaccess text +*.npmignore text + +## HEROKU +Procfile text +.slugignore text + +## AUDIO +*.kar binary +*.m4a binary +*.mid binary +*.midi binary +*.mp3 binary +*.ogg binary +*.ra binary + +## VIDEO +*.3gpp binary +*.3gp binary +*.as binary +*.asf binary +*.asx binary +*.fla binary +*.flv binary +*.m4v binary +*.mng binary +*.mov binary +*.mp4 binary +*.mpeg binary +*.mpg binary +*.swc binary +*.swf binary +*.webm binary + +## ARCHIVES +*.7z binary +*.gz binary +*.rar binary +*.tar binary +*.zip binary + +## FONTS +*.ttf binary +*.eot binary +*.otf binary +*.woff binary +*.woff2 binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c336dd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,153 @@ +###################### +# Node +###################### +/node/ +node_tmp/ +node_modules/ +npm-debug.log.* +/.awcache/* +/.cache-loader/* +/dist/ + +###################### +# SASS +###################### +.sass-cache/ + +###################### +# Eclipse +###################### +*.pydevproject +.project +.metadata +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath +.factorypath + +# External tool builders +.externalToolBuilders/** + +# Locally stored "Eclipse launch configurations" +*.launch + +# CDT-specific +.cproject + +# PDT-specific +.buildpath + +# STS-specific +/.sts4-cache/* + +###################### +# IntelliJ +###################### +.idea/ +*.iml +*.iws +*.ipr +*.ids +*.orig +classes/ +out/ + +###################### +# Visual Studio Code +###################### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +###################### +# Maven +###################### +/log/ +/target/ + +###################### +# Gradle +###################### +.gradle/ +/build/ +/buildSrc/.gradle/ +/buildSrc/build/ + +###################### +# Package Files +###################### +*.jar +*.war +*.ear +*.db + +###################### +# Windows +###################### +# Windows image file caches +Thumbs.db + +# Folder config file +Desktop.ini + +###################### +# Mac OSX +###################### +.DS_Store +.svn + +# Thumbnails +._* + +# Files that might appear on external disk +.Spotlight-V100 +.Trashes + +###################### +# Directories +###################### +/bin/ +/deploy/ + +###################### +# Logs +###################### +*.log* + +###################### +# Others +###################### +*.class +*.*~ +*~ +.merge_file* + +###################### +# Gradle Wrapper +###################### +!gradle/wrapper/gradle-wrapper.jar + +###################### +# Maven Wrapper +###################### +!.mvn/wrapper/maven-wrapper.jar + +###################### +# ESLint +###################### +.eslintcache + +###################### +# Code coverage +###################### +coverage/ +.nyc_output/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..611e501 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,6 @@ +if [ -z "$(which lint-staged)" ]; then + echo 'Running package manager install' + ./npmw install +fi + +lint-staged diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs new file mode 100644 index 0000000..6079102 --- /dev/null +++ b/.lintstagedrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + '{,.blueprint/**/,src/**/}*.{md,json,yml,js,cjs,mjs,ts,cts,mts,java,html,vue,css,scss}': ['prettier --write'], +}; diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1 @@ + diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..cb28b0e37c7d206feb564310fdeec0927af4123a GIT binary patch literal 62547 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 A?*IS* literal 0 HcmV?d00001 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..e788e5e --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e6e44d6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules +package-lock.json +.git + +# Generated by jhipster:java-simple-application:maven +target +.mvn + +# Generated by jhipster:client +target/classes/static/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7849a93 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +endOfLine: auto +printWidth: 140 +singleQuote: true +tabWidth: 2 +useTabs: false +arrowParens: avoid +bracketSameLine: false +plugins: + - prettier-plugin-packagejson + - prettier-plugin-java +overrides: + - files: "*.java" + options: + tabWidth: 4 diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..1589107 --- /dev/null +++ b/.yo-rc.json @@ -0,0 +1,27 @@ +{ + "generator-jhipster": { + "applicationType": "monolith", + "authenticationType": "jwt", + "baseName": "questionBank", + "buildTool": "maven", + "cacheProvider": "no", + "clientFramework": "vue", + "creationTimestamp": 1792485949603, + "databaseMigration": "liquibase", + "databaseType": "sql", + "defaultCommand": "app", + "devDatabaseType": "postgresql", + "entities": [], + "jhipsterVersion": "9.3.0", + "jwtSecretKey": "MzY1ZDU0MDM3M2Y5YWRiMDYxYjBmYzZlOTMxMWFiNmYyMDEyZTUzZjYxOWFjNjhlNjU2NzcxYjkwY2Y0MGIwOGUyNTc5NWQyOGY3OGJkMjExZjhkYzU3ZGU4ZDc0ZmZiMTYwMzY0NGM4ZWZmODRhZTZjNzBlNjk5MWJkYjM5YjI=", + "languages": ["en"], + "messageBroker": "no", + "nativeLanguage": "en", + "packageName": "com.example.questionbank", + "prodDatabaseType": "postgresql", + "searchEngine": "no", + "serviceDiscoveryType": "no", + "websocket": "no", + "withAdminUi": true + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e7cec5 --- /dev/null +++ b/README.md @@ -0,0 +1,241 @@ +# questionBank + +This application was generated using JHipster 9.3.0, you can find documentation and help at [https://www.jhipster.tech/documentation-archive/v9.3.0](https://www.jhipster.tech/documentation-archive/v9.3.0). + +## Project Structure + +Node is required for generation and recommended for development. `package.json` is always generated for a better development experience with prettier, commit hooks, scripts and so on. + +In the project root, JHipster generates configuration files for tools like git, prettier, eslint, husky, and others that are well known and you can find references in the web. + +`/src/*` structure follows default Java structure. + +- `.yo-rc.json` - Yeoman configuration file + JHipster configuration is stored in this file at `generator-jhipster` key. You may find `generator-jhipster-*` for specific blueprints configuration. +- `.yo-resolve` (optional) - Yeoman conflict resolver + Allows to use a specific action when conflicts are found skipping prompts for files that matches a pattern. Each line should match `[pattern] [action]` with pattern been a [Minimatch](https://github.com/isaacs/minimatch#minimatch) pattern and action been one of skip (default if omitted) or force. Lines starting with `#` are considered comments and are ignored. +- `.jhipster/*.json` - JHipster entity configuration files + +- `npmw` - wrapper to use locally installed npm. + JHipster installs Node and npm locally using the build tool by default. This wrapper makes sure npm is installed locally and uses it avoiding some differences different versions can cause. By using `./npmw` instead of the traditional `npm` you can configure a Node-less environment to develop or test your application. +- `/src/main/docker` - Docker configurations for the application and services that the application depends on + +## Development + +The build system will install automatically the recommended version of Node and npm. + +We provide a wrapper to launch npm. +You will only need to run this command when dependencies change in [package.json](package.json). + +```bash +./npmw install +``` + +We use npm scripts and Vite as our build system. + +Run the following commands in two separate terminals to create a blissful development experience where your browser +auto-refreshes when files change on your hard drive. + +```bash +./npmw run backend:start +./npmw run start +``` + +Npm is also used to manage CSS and JavaScript dependencies used in this application. You can upgrade dependencies by +specifying a newer version in [package.json](package.json). You can also run `./npmw update` and `./npmw install` to manage dependencies. +Add the `help` flag on any command to see how you can use it. For example, `./npmw help update`. + +The `./npmw run` command will list all the scripts available to run for this project. + +### PWA Support + +JHipster ships with PWA (Progressive Web App) support, and it's turned off by default. One of the main components of a PWA is a service worker. + +The service worker initialization code is commented out by default. To enable it, uncomment the following code in `src/main/webapp/index.html`: + +```html + +``` + +Note: [Workbox](https://developer.chrome.com/docs/workbox) powers JHipster's service worker. It dynamically generates the `service-worker.js` file. + +### Managing dependencies + +For example, to add [Leaflet](https://leafletjs.com/) library as a runtime dependency of your application, you would run the following command: + +```bash +./npmw install --save --save-exact leaflet +``` + +To benefit from TypeScript type definitions from [DefinitelyTyped](https://definitelytyped.org/) repository in development, you would run the following command: + +```bash +./npmw install --save-dev --save-exact @types/leaflet +``` + +Then you would import the JS and CSS files specified in library's installation instructions so that [Vite][] knows about them: +Note: There are still a few other things remaining to do for Leaflet that we won't detail here. + +For further instructions on how to develop with JHipster, have a look at [Using JHipster in development](https://www.jhipster.tech/development/). + +## Building for production + +### Packaging as jar + +To build the final jar and optimize the questionBank application for production, run: + +```bash +./mvnw -Pprod clean verify +``` + +This will concatenate and minify the client CSS and JavaScript files. It will also modify `index.html` so it references these new files. +To ensure everything worked, run: + +```bash +java -jar target/*.jar +``` + +Then navigate to [http://localhost:8080](http://localhost:8080) in your browser. + +Refer to [Using JHipster in production][] for more details. + +### Packaging as war + +To package your application as a war in order to deploy it to an application server, run: + +```bash +./mvnw -Pprod,war clean verify +``` + +### JHipster Control Center + +JHipster Control Center can help you manage and control your application(s). You can start a local control center server (accessible on http://localhost:7419) with: + +```bash +docker compose -f src/main/docker/jhipster-control-center.yml up +``` + +## Testing + +### Spring Boot tests + +To launch your application's tests, run: + +```bash +./mvnw verify +``` + +### Client tests + +Unit tests are run by Vitest. They're located near components and can be run with: + +```bash +./npmw test +``` + +## Others + +### Code quality using Sonar + +Sonar is used to analyse code quality. You can start a local Sonar server (accessible on http://localhost:9001) with: + +```bash +docker compose -f src/main/docker/sonar.yml up -d +``` + +Note: we have turned off forced authentication redirect for UI in [src/main/docker/sonar.yml](src/main/docker/sonar.yml) for out of the box experience while trying out SonarQube, for real use cases turn it back on. + +You can run a Sonar analysis with using the [sonar-scanner](https://docs.sonarqube.org/display/SCAN/Analyzing+with+SonarQube+Scanner) or by using the maven plugin. + +Then, run a Sonar analysis: + +```bash +./mvnw -Pprod clean verify sonar:sonar -Dsonar.login=admin -Dsonar.password=admin +``` + +If you need to re-run the Sonar phase, please be sure to specify at least the `initialize` phase since Sonar properties are loaded from the sonar-project.properties file. + +```bash +./mvnw initialize sonar:sonar -Dsonar.login=admin -Dsonar.password=admin +``` + +Additionally, Instead of passing `sonar.password` and `sonar.login` as CLI arguments, these parameters can be configured from [sonar-project.properties](sonar-project.properties) as shown below: + +```bash +sonar.login=admin +sonar.password=admin +``` + +For more information, refer to the [Code quality page][]. + +### Docker Compose support + +JHipster generates a number of Docker Compose configuration files in the [src/main/docker/](src/main/docker/) folder to launch required third party services. + +For example, to start required services in Docker containers, run: + +```bash +docker compose -f src/main/docker/services.yml up -d +``` + +To stop and remove the containers, run: + +```bash +docker compose -f src/main/docker/services.yml down +``` + +[Spring Docker Compose Integration](https://docs.spring.io/spring-boot/reference/features/dev-services.html) is enabled by default. It's possible to disable it in `application.yml`: + +```yaml +spring: + ... + docker: + compose: + enabled: false +``` + +You can also fully dockerize your application and all the services that it depends on. +To achieve this, first build a Docker image of your app by running: + +```bash +npm run java:docker +``` + +Or build an arm64 Docker image when using an arm64 processor OS, i.e., Apple Silicon chips (M*), running: + +```bash +npm run java:docker:arm64 +``` + +Then run: + +```bash +docker compose -f src/main/docker/app.yml up -d +``` + +For more information refer to [Docker and Docker-Compose](https://www.jhipster.tech/documentation-archive/v9.3.0/docker-compose/), this page also contains information on the Docker Compose sub-generator (`jhipster docker-compose`), which is able to generate Docker configurations for one or several JHipster applications. + +## Continuous Integration (optional) + +To configure CI for your project, run the ci-cd sub-generator (`jhipster ci-cd`), this will let you generate configuration files for a number of Continuous Integration systems. Consult the [Setting up Continuous Integration](https://www.jhipster.tech/documentation-archive/v9.3.0/setting-up-ci/) page for more information. + +## References + +- [JHipster Homepage and latest documentation](https://www.jhipster.tech/) +- [JHipster 9.3.0 archive](https://www.jhipster.tech/documentation-archive/v9.3.0) +- [Using JHipster in development](https://www.jhipster.tech/documentation-archive/v9.3.0/development/) +- [Using Docker and Docker-Compose](https://www.jhipster.tech/documentation-archive/v9.3.0/docker-compose) +- [Using JHipster in production](https://www.jhipster.tech/documentation-archive/v9.3.0/production/) +- [Running tests page](https://www.jhipster.tech/documentation-archive/v9.3.0/running-tests/) +- [Code quality page](https://www.jhipster.tech/documentation-archive/v9.3.0/code-quality/) +- [Setting up Continuous Integration](https://www.jhipster.tech/documentation-archive/v9.3.0/setting-up-ci/) +- [Node.js](https://nodejs.org/) +- [NPM](https://www.npmjs.com/) +- [Leaflet](https://leafletjs.com/) +- [DefinitelyTyped](https://definitelytyped.org/) diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..71b9f90 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..cff5d6d --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,66 @@ +import js from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import prettier from 'eslint-plugin-prettier/recommended'; +import vue from 'eslint-plugin-vue'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +// jhipster-needle-eslint-add-import - JHipster will add additional import here + +export default defineConfig( + { + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + { ignores: ['src/main/docker/'] }, + { ignores: ['target/classes/static/', 'target/'] }, + js.configs.recommended, + ...tseslint.configs.recommended.map(config => + config.name === 'typescript-eslint/base' ? config : { ...config, files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'] }, + ), + { + files: ['**/*.{js,cjs,mjs}'], + rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, + ...vue.configs['flat/recommended'], + { + files: ['**/*.vue'], + languageOptions: { + parserOptions: { parser: '@typescript-eslint/parser' }, + globals: { ...globals.browser }, + }, + }, + { + files: ['src/main/webapp/**/*.vue', 'src/main/webapp/**/*.ts'], + languageOptions: { + globals: { ...globals.browser }, + }, + rules: { + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'vue/multi-word-component-names': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/consistent-type-imports': 'error', + 'vue/no-v-text-v-html-on-component': ['error', { allow: ['router-link', 'b-alert', 'b-badge', 'b-button', 'b-link'] }], + 'vue/no-reserved-component-names': 'off', + 'vue/attributes-order': 'off', + }, + }, + { + files: ['src/main/webapp/**/*.spec.ts'], + rules: { + '@typescript-eslint/no-empty-function': 'off', + }, + }, + // jhipster-needle-eslint-add-config - JHipster will add additional config here + prettier, +); diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/npmw b/npmw new file mode 100644 index 0000000..bf57625 --- /dev/null +++ b/npmw @@ -0,0 +1,34 @@ +#!/bin/sh + +basedir=$(dirname "$0") + +if [ -f "$basedir/mvnw" ]; then + bindir="$basedir/target/node" + repodir="$basedir/target/node/node_modules" + installCommand="$basedir/mvnw --batch-mode -ntp -Pwebapp frontend:install-node-and-npm@install-node-and-npm" +else + echo "Using npm installed globally" + exec npm "$@" +fi + +NPM_EXE="$repodir/npm/bin/npm-cli.js" +NODE_EXE="$bindir/node" + +if [ ! -x "$NPM_EXE" ] || [ ! -x "$NODE_EXE" ]; then + $installCommand || true +fi + +if [ -x "$NODE_EXE" ]; then + echo "Using node installed locally $($NODE_EXE --version)" + PATH="$bindir:$PATH" +else + NODE_EXE='node' +fi + +if [ ! -x "$NPM_EXE" ]; then + echo "Local npm not found, using npm installed globally" + npm "$@" +else + echo "Using npm installed locally $($NODE_EXE $NPM_EXE --version)" + $NODE_EXE $NPM_EXE "$@" +fi diff --git a/npmw.cmd b/npmw.cmd new file mode 100644 index 0000000..847fcd1 --- /dev/null +++ b/npmw.cmd @@ -0,0 +1,24 @@ +@echo off + +setlocal + +set NPMW_DIR=%~dp0 + +set NODE_EXE=^"^" +set NODE_PATH=%NPMW_DIR%target\node\ +set NPM_EXE=^"%NPMW_DIR%target\node\npm.cmd^" +set INSTALL_NPM_COMMAND=^"%NPMW_DIR%mvnw.cmd^" -Pwebapp frontend:install-node-and-npm@install-node-and-npm + +if not exist %NPM_EXE% ( + call %INSTALL_NPM_COMMAND% +) + +if exist %NODE_EXE% ( + Rem execute local npm with local node, whilst adding local node location to the PATH for this CMD session + endlocal & echo "%PATH%"|find /i "%NODE_PATH%;">nul || set "PATH=%NODE_PATH%;%PATH%" & call %NODE_EXE% %NPM_EXE% %* +) else if exist %NPM_EXE% ( + Rem execute local npm, whilst adding local npm location to the PATH for this CMD session + endlocal & echo "%PATH%"|find /i "%NODE_PATH%;">nul || set "PATH=%NODE_PATH%;%PATH%" & call %NPM_EXE% %* +) else ( + call npm %* +) diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea65749 --- /dev/null +++ b/package.json @@ -0,0 +1,141 @@ +{ + "name": "question-bank", + "version": "0.0.0", + "private": true, + "description": "Description for Question Bank", + "license": "UNLICENSED", + "type": "module", + "scripts": { + "app:start": "./mvnw -ntp --batch-mode", + "app:up": "docker compose -f src/main/docker/app.yml up --wait", + "backend:build-cache": "./mvnw dependency:go-offline -ntp", + "backend:debug": "./mvnw -Dspring-boot.run.jvmArguments=\"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000\"", + "backend:doc:test": "./mvnw -ntp javadoc:javadoc --batch-mode", + "backend:info": "./mvnw --version", + "backend:nohttp:test": "./mvnw -ntp checkstyle:check --batch-mode", + "backend:start": "./mvnw -Dskip.installnodenpm -Dskip.npm -ntp --batch-mode", + "backend:unit:test": "./mvnw -ntp -Dskip.installnodenpm -Dskip.npm verify --batch-mode -Dlogging.level.ROOT=OFF -Dlogging.level.tech.jhipster=OFF -Dlogging.level.com.example.questionbank=OFF -Dlogging.level.org.springframework=OFF -Dlogging.level.org.springframework.web=OFF -Dlogging.level.org.springframework.security=OFF", + "build": "npm run webapp:prod --", + "build-watch": "concurrently 'npm run webapp:build:dev -- --watch' npm:backend:start", + "ci:backend:test": "npm run backend:info && npm run backend:doc:test && npm run backend:nohttp:test && npm run backend:unit:test -- -P$npm_package_config_default_environment", + "ci:e2e:package": "npm run java:$npm_package_config_packaging:$npm_package_config_default_environment -- -Pe2e -Denforcer.skip=true", + "ci:e2e:prepare": "npm run ci:e2e:prepare:docker", + "ci:e2e:prepare:docker": "npm run services:up --if-present && docker ps -a", + "preci:e2e:server:start": "npm run services:db:await --if-present && npm run services:others:await --if-present", + "ci:e2e:server:start": "java -jar target/e2e.$npm_package_config_packaging --spring.profiles.active=e2e,secret-samples,$npm_package_config_default_environment -Dlogging.level.ROOT=OFF -Dlogging.level.tech.jhipster=OFF -Dlogging.level.com.example.questionbank=OFF -Dlogging.level.org.springframework=OFF -Dlogging.level.org.springframework.web=OFF -Dlogging.level.org.springframework.security=OFF --logging.level.org.springframework.web=ERROR", + "ci:e2e:teardown": "npm run ci:e2e:teardown:docker --if-present", + "ci:e2e:teardown:docker": "docker compose -f src/main/docker/services.yml down -v && docker ps -a", + "ci:frontend:build": "npm run webapp:build:$npm_package_config_default_environment", + "ci:frontend:test": "npm run ci:frontend:build && npm test", + "ci:server:await": "echo \"Waiting for server at port $npm_package_config_backend_port to start\" && wait-on -t 180000 http-get://127.0.0.1:$npm_package_config_backend_port/management/health && echo \"Server at port $npm_package_config_backend_port started\"", + "clean-www": "rimraf target/classes/static/", + "cleanup": "rimraf target/", + "docker:db:down": "docker compose -f src/main/docker/postgresql.yml down -v", + "docker:db:up": "docker compose -f src/main/docker/postgresql.yml up --wait", + "java:docker": "./mvnw -ntp verify -DskipTests -Pprod jib:dockerBuild", + "java:docker:arm64": "npm run java:docker -- -Djib-maven-plugin.architecture=arm64", + "java:docker:dev": "npm run java:docker -- -Pdev,webapp", + "java:docker:prod": "npm run java:docker -- -Pprod", + "java:jar": "./mvnw -ntp verify -DskipTests --batch-mode", + "java:jar:dev": "npm run java:jar -- -Pdev,webapp", + "java:jar:prod": "npm run java:jar -- -Pprod", + "java:war": "./mvnw -ntp verify -DskipTests --batch-mode -Pwar", + "java:war:dev": "npm run java:war -- -Pdev,webapp", + "java:war:prod": "npm run java:war -- -Pprod", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "prepare": "husky", + "prettier:check": "prettier --check \"{,.blueprint/**/,src/**/}*.{md,json,yml,js,cjs,mjs,ts,cts,mts,java,html,vue,css,scss}\"", + "prettier:format": "prettier --write \"{,.blueprint/**/,src/**/}*.{md,json,yml,js,cjs,mjs,ts,cts,mts,java,html,vue,css,scss}\"", + "serve": "npm run start --", + "services:up": "docker compose -f src/main/docker/services.yml up --wait", + "start": "npm run webapp:dev --", + "start-tls": "npm run webapp:dev -- --env.tls", + "pretest": "npm run lint", + "test": "npm run vitest-run --", + "test:watch": "npm run vitest", + "vite-build": "vite build", + "vite-serve": "vite", + "vitest": "vitest", + "vitest-run": "vitest --run --coverage", + "watch": "concurrently npm:start npm:backend:start", + "webapp:build": "npm run clean-www && npm run webapp:build:dev --", + "webapp:build:dev": "npm run vite-build", + "webapp:build:prod": "npm run vite-build", + "webapp:dev": "npm run vite-serve", + "webapp:prod": "npm run clean-www && npm run webapp:build:prod --", + "webapp:serve": "npm run vite-serve", + "webapp:test": "npm run test --" + }, + "config": { + "backend_port": 8080, + "default_environment": "prod", + "packaging": "jar" + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not ie <= 8" + ], + "dependencies": { + "@fortawesome/fontawesome-svg-core": "7.3.1", + "@fortawesome/free-solid-svg-icons": "7.3.1", + "@fortawesome/vue-fontawesome": "3.3.3", + "@vuelidate/core": "2.0.3", + "@vuelidate/validators": "2.0.4", + "@vueuse/core": "14.4.0", + "axios": "1.19.0", + "bootstrap": "5.3.7", + "bootstrap-vue-next": "1.0.1", + "dayjs": "1.11.23", + "deepmerge": "4.3.1", + "pinia": "4.0.3", + "vue": "3.5.41", + "vue-i18n": "11.4.9", + "vue-router": "5.2.0" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@pinia/testing": "2.0.1", + "@tsconfig/node18": "18.2.7", + "@types/node": "22.20.1", + "@vitejs/plugin-vue": "6.0.8", + "@vitest/coverage-v8": "4.1.11", + "@vue/test-utils": "2.4.11", + "@vue/tsconfig": "0.9.1", + "autoprefixer": "10.5.4", + "axios-mock-adapter": "2.1.0", + "concurrently": "10.0.5", + "eslint": "10.9.1", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-prettier": "5.5.6", + "eslint-plugin-vue": "10.10.0", + "generator-jhipster": "9.3.0", + "globals": "17.11.0", + "happy-dom": "20.11.6", + "husky": "9.1.7", + "jiti": "2.7.0", + "lint-staged": "17.3.0", + "postcss-import": "17.0.0", + "postcss-url": "10.1.4", + "prettier": "3.9.6", + "prettier-plugin-java": "2.10.3", + "prettier-plugin-packagejson": "3.0.2", + "rimraf": "6.1.3", + "sass": "1.70.0", + "swagger-ui-dist": "5.32.14", + "typescript": "6.0.3", + "typescript-eslint": "8.68.0", + "vite": "8.2.2", + "vite-plugin-static-copy": "4.1.1", + "vitest": "4.1.11", + "vitest-sonar-reporter": "3.0.0", + "wait-on": "9.1.0" + }, + "engines": { + "node": ">=24.20.0" + }, + "cacheDirectories": [ + "node_modules" + ] +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bbbb0f5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,1143 @@ + + + 4.0.0 + + com.example.questionbank + question-bank + 0.0.1-SNAPSHOT + jar + Question Bank + Description for Question Bank + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + + + + 3.2.5 + 21 + v24.20.0 + 12.0.2 + UTF-8 + UTF-8 + yyyyMMddHHmmss + ${java.version} + ${java.version} + com.example.questionbank.QuestionBankApp + -Xmx1G + jdt_apt + false + ${project.parent.version} + 1.5.0 + 14.0.0 + 1.11 + 2.0.2 + 0.8.15 + 9.3.0 + amd64 + eclipse-temurin:25-jre-noble + 3.5.2 + 1.0.0 + + + + 1.6.3 + 3.2.0 + 3.6.0 + 3.5.0 + 0.0.11 + + + + + + 1.3.0 + @ + 5.7.0.6970 + 3.10.0 + dev + 3.1.0 + + + + + tech.jhipster + jhipster-framework + ${jhipster-framework.version} + + + org.springframework.boot + spring-boot-configuration-processor + provided + + + org.springframework.boot + spring-boot-h2console + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-aspectj + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-jackson + + + org.springframework.boot + spring-boot-starter-jackson-test + test + + + org.springframework.boot + spring-boot-starter-liquibase + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-security-test + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-tomcat + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.springframework.security + spring-security-data + + + org.springframework.security + spring-security-test + test + + + org.springdoc + springdoc-openapi-starter-webmvc-api + ${springdoc-openapi-starter-webmvc-api.version} + + + com.tngtech.archunit + archunit-junit5-api + ${archunit-junit5.version} + test + + + org.slf4j + slf4j-api + + + + + com.tngtech.archunit + archunit-junit5-engine + ${archunit-junit5.version} + test + + + org.slf4j + slf4j-api + + + + + com.zaxxer + HikariCP + + + io.micrometer + micrometer-registry-prometheus + + + org.apache.commons + commons-lang3 + + + org.glassfish.jaxb + jaxb-runtime + provided + + + org.hibernate.orm + hibernate-core + + + org.hibernate.orm + hibernate-processor + provided + + + org.hibernate.validator + hibernate-validator + + + org.liquibase + liquibase-core + ${liquibase.version} + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.postgresql + postgresql + + + org.testcontainers + testcontainers + test + + + org.testcontainers + testcontainers-jdbc + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.testcontainers + testcontainers-postgresql + test + + + tools.jackson.datatype + jackson-datatype-hibernate7 + + + tools.jackson.module + jackson-module-jaxb-annotations + + + + + spring-boot:run + + + ${basedir}/src/main/resources/ + true + + config/**/*.yml + + + + ${basedir}/src/main/resources/ + + config/**/*.yml + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-enforcer-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-resources-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + com.diffplug.spotless + spotless-maven-plugin + + + com.google.cloud.tools + jib-maven-plugin + + + org.codehaus.mojo + properties-maven-plugin + + + org.gaul + modernizer-maven-plugin + + + org.jacoco + jacoco-maven-plugin + + + org.sonarsource.scanner.maven + sonar-maven-plugin + + + + + + org.apache.maven.plugins + maven-antrun-plugin + ${maven-antrun-plugin.version} + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven-checkstyle-plugin.version} + + + com.puppycrawl.tools + checkstyle + ${checkstyle.version} + + + io.spring.nohttp + nohttp-checkstyle + ${nohttp-checkstyle.version} + + + + checkstyle.xml + pom.xml,README.md + .git/**/*,target/**/*,node_modules/**/* + ./ + + + + + check + + + + + + org.apache.maven.plugins + maven-clean-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + true + + + org.springframework.boot + spring-boot-configuration-processor + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.hibernate.orm + hibernate-processor + + + org.glassfish.jaxb + jaxb-runtime + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-versions + + enforce + + + + enforce-dependencyConvergence + + + + + false + + + enforce + + + + + + + You are running an older version of Maven. JHipster requires at least Maven ${maven.version} + [${maven.version},) + + + You are running an incompatible version of Java. JHipster supports JDK 21 to 25. + [21,22),[25,26) + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + ${project.build.outputDirectory} + alphabetical + + **/*IT* + **/*IntTest* + + @{argLine} -Dspring.profiles.active=${profile.test} + + + + integration-test + + integration-test + + + + verify + + verify + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + ${maven.compiler.source} + + + + org.apache.maven.plugins + maven-surefire-plugin + + + alphabetical + + **/*IT* + **/*IntTest* + + + src/test/resources/logback.xml + + + + + org.apache.maven.plugins + maven-war-plugin + + + default-war + + war + + package + + + + WEB-INF/**,META-INF/** + false + target/classes/static/ + + + src/main/webapp/ + + WEB-INF/** + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + build-info + + build-info + + + + repackage + + repackage + + + + + ${start-class} + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless-maven-plugin.version} + + + + + + + + spotless + process-sources + + apply + + + + + + com.github.eirslett + frontend-maven-plugin + ${frontend-maven-plugin.version} + + target + ${node.version} + ${npm.version} + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + ${jib-maven-plugin.image} + + + ${jib-maven-plugin.architecture} + linux + + + + + questionbank:latest + + + + bash + + /entrypoint.sh + + + 8080 + + + ALWAYS + 0 + + USE_CURRENT_TIMESTAMP + 1000 + + + src/main/docker/jib + + + /entrypoint.sh + 755 + + + + + + com.google.cloud.tools.jib.maven.extension.springboot.JibSpringBootExtension + + + + + + com.google.cloud.tools + jib-spring-boot-extension-maven + 0.1.0 + + + + + io.github.git-commit-id + git-commit-id-maven-plugin + + + + revision + + + + + false + false + true + + ^git.commit.id.abbrev$ + ^git.commit.id.describe$ + ^git.branch$ + + + + + net.nicoulaj.maven.plugins + checksum-maven-plugin + ${checksum-maven-plugin.version} + + + org.codehaus.mojo + properties-maven-plugin + ${properties-maven-plugin.version} + + + initialize + + read-project-properties + + + + sonar-project.properties + + + + + + + org.gaul + modernizer-maven-plugin + ${modernizer-maven-plugin.version} + + + modernizer + package + + modernizer + + + + + ${java.version} + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + pre-unit-tests + + prepare-agent + + + + + post-unit-test + test + + report + + + + pre-integration-tests + + prepare-agent-integration + + + + + post-integration-tests + post-integration-test + + report-integration + + + + + + org.liquibase + liquibase-maven-plugin + ${liquibase.version} + + config/liquibase/master.xml + ${project.basedir}/src/main/resources/config/liquibase/changelog/${maven.build.timestamp}_changelog.xml + org.postgresql.Driver + ${liquibase-plugin.url} + + ${liquibase-plugin.username} + ${liquibase-plugin.password} + hibernate:spring:com.example.questionbank.domain?dialect=org.hibernate.dialect.PostgreSQLDialect&hibernate.physical_naming_strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy&hibernate.implicit_naming_strategy=org.springframework.boot.hibernate.SpringImplicitNamingStrategy + true + !test + + + + org.liquibase + liquibase-core + ${liquibase.version} + + + org.liquibase.ext + liquibase-hibernate6 + ${liquibase.version} + + + jakarta.persistence + jakarta.persistence-api + ${jakarta-persistence.version} + + + jakarta.validation + jakarta.validation-api + ${jakarta-validation.version} + + + org.jboss.logging + jboss-logging + ${jboss-logging.version} + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring-boot.version} + + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + ${sonar-maven-plugin.version} + + + + + + + + api-docs + + ,api-docs + + + + dev + + true + + + + + jdbc:postgresql://localhost:5432/questionBank + questionBank + test,testdev + dev${profile.tls}${profile.no-liquibase} + + + + org.springframework.boot + spring-boot-devtools + true + + + + + docker-compose + + true + + + + org.springframework.boot + spring-boot-docker-compose + true + + + + + + eclipse + + + m2e.version + + + + + + + + org.eclipse.m2e + lifecycle-mapping + ${lifecycle-mapping.version} + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + prepare-agent + + + + + + + + + com.github.eirslett + frontend-maven-plugin + ${frontend-maven-plugin.version} + + install-node-and-npm + npm + + + + + + + + + + + + + + + + + IDE + + + org.hibernate.orm + hibernate-processor + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + no-liquibase + + ,no-liquibase + + + + prod + + + + jdbc:postgresql://localhost:5432/questionBank + questionBank + test,testprod + prod${profile.api-docs}${profile.tls}${profile.e2e}${profile.no-liquibase} + + + + + org.apache.maven.plugins + maven-clean-plugin + + + + target/classes/static/ + + + + + + io.github.git-commit-id + git-commit-id-maven-plugin + + + com.github.eirslett + frontend-maven-plugin + + + install-node-and-npm + + install-node-and-npm + + + + npm install + + npm + + + + webapp build test + + npm + + test + + run webapp:test + false + + + + webapp build prod + + npm + + generate-resources + + run webapp:prod + + ${project.version} + + false + + + + + + + + + tls + + ,tls + + + + war + + + + org.apache.maven.plugins + maven-war-plugin + + + + + + webapp + + true + + + + dev${profile.no-liquibase} + + + + + net.nicoulaj.maven.plugins + checksum-maven-plugin + + + create-pre-compiled-webapp-checksum + + files + + generate-resources + + + create-compiled-webapp-checksum + + files + + compile + + checksums.csv.old + + + + + + + ${project.basedir} + + .postcssrc.js + package-lock.json + package.json + src/main/webapp/**/*.* + target/classes/static/**/*.* + tsconfig.app.json + tsconfig.json + vite.config.ts + + + **/app/**/service-worker.js + **/app/**/vendor.css + + + + false + false + false + + SHA-1 + + true + true + + + + org.apache.maven.plugins + maven-antrun-plugin + + + eval-frontend-checksum + generate-resources + + run + + + + + + + + + + + + true + + + + + + com.github.eirslett + frontend-maven-plugin + + + install-node-and-npm + + install-node-and-npm + + + + npm install + + npm + + + + webapp build dev + + npm + + generate-resources + + run webapp:build + + ${project.version} + + false + + + + + + + + + diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..4965838 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,55 @@ +sonar.projectKey = questionBank +sonar.projectName = questionBank generated by jhipster + +# Typescript tests files must be inside sources and tests, otherwise `INFO: Test execution data ignored for 80 unknown +# files, including:` is shown. +sonar.sources = src +sonar.tests = src +sonar.host.url = http://localhost:9001 +sonar.sourceEncoding = UTF-8 + +sonar.test.inclusions = src/test/**/*.*, src/main/webapp/app/**/*.spec.ts +sonar.junit.reportPaths = target/surefire-reports,target/failsafe-reports +sonar.java.codeCoveragePlugin = jacoco +sonar.coverage.jacoco.xmlReportPaths = target/site/**/jacoco*.xml +sonar.exclusions = src/main/webapp/content/**/*.*, src/main/webapp/i18n/*.ts, target/classes/static/**/*.* +sonar.javascript.lcov.reportPaths = target/test-results/lcov.info +sonar.testExecutionReportPaths = target/test-results/jest/TESTS-results-sonar.xml + +sonar.issue.ignore.multicriteria = S7027-domain, S3437, S7027-dto, UndocumentedApi, S125, S4502, S4684, S5145, S6437, S6437-2, S6418 +# Rule https://rules.sonarsource.com/java/RSPEC-7027 is ignored for entities +sonar.issue.ignore.multicriteria.S7027-domain.resourceKey = src/main/java/com/example/questionbank/domain/**/* +sonar.issue.ignore.multicriteria.S7027-domain.ruleKey = javaarchitecture:S7027 +# Rule https://rules.sonarsource.com/java/RSPEC-3437 is ignored +sonar.issue.ignore.multicriteria.S3437.resourceKey = src/main/java/com/example/questionbank/**/* +sonar.issue.ignore.multicriteria.S3437.ruleKey = squid:S3437 +# Rule https://rules.sonarsource.com/java/RSPEC-7027 is ignored for dtos +sonar.issue.ignore.multicriteria.S7027-dto.resourceKey = src/main/java/com/example/questionbank/service/dto/**/* +sonar.issue.ignore.multicriteria.S7027-dto.ruleKey = javaarchitecture:S7027 +# Rule https://rules.sonarsource.com/java/RSPEC-1176 is ignored, as we want to follow "clean code" guidelines and +# classes, methods and arguments names should be self-explanatory +sonar.issue.ignore.multicriteria.UndocumentedApi.resourceKey = src/main/java/com/example/questionbank/**/* +sonar.issue.ignore.multicriteria.UndocumentedApi.ruleKey = squid:UndocumentedApi +# Rule https://rules.sonarsource.com/xml/RSPEC-125 is ignored, we provide commented examples +sonar.issue.ignore.multicriteria.S125.resourceKey = src/main/resources/logback-spring.xml +sonar.issue.ignore.multicriteria.S125.ruleKey = xml:S125 +# Rule https://rules.sonarsource.com/java/RSPEC-4502 is ignored, as for JWT tokens we are not subject to CSRF attack +sonar.issue.ignore.multicriteria.S4502.resourceKey = src/main/java/com/example/questionbank/config/SecurityConfiguration.java +sonar.issue.ignore.multicriteria.S4502.ruleKey = java:S4502 +# Rule https://rules.sonarsource.com/java/RSPEC-4684 +sonar.issue.ignore.multicriteria.S4684.resourceKey = src/main/java/com/example/questionbank/web/rest/**/* +sonar.issue.ignore.multicriteria.S4684.ruleKey = java:S4684 +# Rule https://rules.sonarsource.com/java/RSPEC-5145 is ignored, as we use log filter to format log messages +sonar.issue.ignore.multicriteria.S5145.resourceKey = src/main/java/com/example/questionbank/**/* +sonar.issue.ignore.multicriteria.S5145.ruleKey = javasecurity:S5145 +# Rule https://rules.sonarsource.com/java/RSPEC-6437 is ignored, hardcoded passwords are provided for development +# purposes +sonar.issue.ignore.multicriteria.S6437.resourceKey = src/main/resources/config/application-secret-samples.yml +sonar.issue.ignore.multicriteria.S6437.ruleKey = java:S6437 +# Rule https://rules.sonarsource.com/java/RSPEC-6437 is ignored, hardcoded passwords are provided for development +# purposes +sonar.issue.ignore.multicriteria.S6437-2.resourceKey = src/main/resources/config/application-tls.yml +sonar.issue.ignore.multicriteria.S6437-2.ruleKey = java:S6437 +# Rule yaml:S6418 is ignored, hardcoded passwords are provided for development and testing purposes +sonar.issue.ignore.multicriteria.S6418.resourceKey = src/main/resources/config/application-secret-samples.yml +sonar.issue.ignore.multicriteria.S6418.ruleKey = yaml:S6418 diff --git a/src/main/docker/app.yml b/src/main/docker/app.yml new file mode 100644 index 0000000..8a116ed --- /dev/null +++ b/src/main/docker/app.yml @@ -0,0 +1,31 @@ +# This configuration is intended for development purpose, it's **your** responsibility to harden it for production +name: questionbank +services: + app: + image: questionbank + environment: + - _JAVA_OPTIONS=-Xmx512m -Xms256m + - SPRING_PROFILES_ACTIVE=prod,api-docs,secret-samples + - MANAGEMENT_PROMETHEUS_METRICS_EXPORT_ENABLED=true + - SPRING_DATASOURCE_URL=jdbc:postgresql://postgresql:5432/questionBank + - SPRING_DATASOURCE_USERNAME=questionBank + - SPRING_DATASOURCE_PASSWORD= + - SPRING_LIQUIBASE_URL=jdbc:postgresql://postgresql:5432/questionBank + ports: + - 127.0.0.1:8080:8080 + healthcheck: + test: + - CMD-SHELL + - "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080; printf \"GET + /management/health HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n\" >&3; + grep -q \"\\\"status\\\":\\\"UP\\\"\" <&3'" + interval: 5s + timeout: 5s + retries: 40 + depends_on: + postgresql: + condition: service_healthy + postgresql: + extends: + file: ./postgresql.yml + service: postgresql diff --git a/src/main/docker/grafana/provisioning/dashboards/JVM.json b/src/main/docker/grafana/provisioning/dashboards/JVM.json new file mode 100644 index 0000000..d132b00 --- /dev/null +++ b/src/main/docker/grafana/provisioning/dashboards/JVM.json @@ -0,0 +1,3778 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "limit": 100, + "name": "Annotations & Alerts", + "showIn": 0, + "type": "dashboard" + }, + { + "datasource": "Prometheus", + "enable": true, + "expr": "resets(process_uptime_seconds{application=\"$application\", instance=\"$instance\"}[1m]) > 0", + "iconColor": "rgba(255, 96, 96, 1)", + "name": "Restart Detection", + "showIn": 0, + "step": "1m", + "tagKeys": "restart-tag", + "textFormat": "uptime reset", + "titleFormat": "Restart" + } + ] + }, + "description": "Dashboard for Micrometer instrumented applications (Java, Spring Boot, Micronaut)", + "editable": true, + "gnetId": 4701, + "graphTooltip": 1, + "iteration": 1553765841423, + "links": [], + "panels": [ + { + "content": "\n# Acknowledgments\n\nThank you to [Michael Weirauch](https://twitter.com/emwexx) for creating this dashboard: see original JVM (Micrometer) dashboard at [https://grafana.com/dashboards/4701](https://grafana.com/dashboards/4701)\n\n\n\n", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 141, + "links": [], + "mode": "markdown", + "timeFrom": null, + "timeShift": null, + "title": "Acknowledgments", + "type": "text" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 3 + }, + "id": 125, + "panels": [], + "repeat": null, + "title": "Quick Facts", + "type": "row" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + "datasource": "Prometheus", + "decimals": 1, + "editable": true, + "error": false, + "format": "s", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 0, + "y": 4 + }, + "height": "", + "id": 63, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "process_uptime_seconds{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "", + "title": "Uptime", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], + "datasource": "Prometheus", + "decimals": null, + "editable": true, + "error": false, + "format": "dateTimeAsIso", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 6, + "y": 4 + }, + "height": "", + "id": 92, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "process_start_time_seconds{application=\"$application\", instance=\"$instance\"}*1000", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "metric": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "", + "title": "Start time", + "type": "singlestat", + "valueFontSize": "70%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": ["rgba(50, 172, 45, 0.97)", "rgba(237, 129, 40, 0.89)", "rgba(245, 54, 54, 0.9)"], + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 65, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})*100/sum(jvm_memory_max_bytes{application=\"$application\",instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "70,90", + "title": "Heap used", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": true, + "colors": ["rgba(50, 172, 45, 0.97)", "rgba(237, 129, 40, 0.89)", "rgba(245, 54, 54, 0.9)"], + "datasource": "Prometheus", + "decimals": 2, + "editable": true, + "error": false, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "gridPos": { + "h": 3, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 75, + "interval": null, + "links": [], + "mappingType": 2, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "70%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + }, + { + "from": "-99999999999999999999999999999999", + "text": "N/A", + "to": "0" + } + ], + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "", + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})*100/sum(jvm_memory_max_bytes{application=\"$application\",instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "", + "refId": "A", + "step": 14400 + } + ], + "thresholds": "70,90", + "title": "Non-Heap used", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + }, + { + "op": "=", + "text": "x", + "value": "" + } + ], + "valueName": "current" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 7 + }, + "id": 126, + "panels": [], + "repeat": null, + "title": "I/O Overview", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 8 + }, + "id": 111, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\", instance=\"$instance\"}[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "HTTP", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Rate", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "HTTP": "#890f02", + "HTTP - 5xx": "#bf1b00" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 8 + }, + "id": 112, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\", instance=\"$instance\", status=~\"5..\"}[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "HTTP - 5xx", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Errors", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "decimals": null, + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 8 + }, + "id": 113, + "legend": { + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_sum{application=\"$application\", instance=\"$instance\", status!~\"5..\"}[1m]))/sum(rate(http_server_requests_seconds_count{application=\"$application\", instance=\"$instance\", status!~\"5..\"}[1m]))", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "HTTP - AVG", + "refId": "A" + }, + { + "expr": "max(http_server_requests_seconds_max{application=\"$application\", instance=\"$instance\", status!~\"5..\"})", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "HTTP - MAX", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Duration", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 15 + }, + "id": 127, + "panels": [], + "repeat": null, + "title": "JVM Memory", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 16 + }, + "id": 24, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "committed", + "refId": "B", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "C", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "JVM Heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 16 + }, + "id": 25, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "committed", + "refId": "B", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "C", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "JVM Non-Heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 16 + }, + "id": 26, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "committed", + "refId": "B", + "step": 2400 + }, + { + "expr": "sum(jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\"})", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "C", + "step": 2400 + }, + { + "expr": "process_memory_vss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": true, + "intervalFactor": 2, + "legendFormat": "vss", + "metric": "", + "refId": "D", + "step": 2400 + }, + { + "expr": "process_memory_rss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "rss", + "refId": "E", + "step": 2400 + }, + { + "expr": "process_memory_pss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "pss", + "refId": "F", + "step": 2400 + }, + { + "expr": "process_memory_swap_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "swap", + "refId": "G", + "step": 2400 + }, + { + "expr": "process_memory_swappss_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "swappss", + "refId": "H", + "step": 2400 + }, + { + "expr": "process_memory_pss_bytes{application=\"$application\", instance=\"$instance\"} + process_memory_swap_bytes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "phys (pss+swap)", + "refId": "I", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "JVM Total", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 128, + "panels": [], + "repeat": null, + "title": "JVM Misc", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 24 + }, + "id": 106, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "system_cpu_usage{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "system", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "process_cpu_usage{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "process", + "refId": "B" + }, + { + "expr": "avg_over_time(process_cpu_usage{application=\"$application\", instance=\"$instance\"}[1h])", + "format": "time_series", + "hide": false, + "intervalFactor": 1, + "legendFormat": "process-1h", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "CPU", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "decimals": 1, + "format": "percentunit", + "label": "", + "logBase": 1, + "max": "1", + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 24 + }, + "id": 93, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "system_load_average_1m{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "system-1m", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "", + "format": "time_series", + "intervalFactor": 2, + "refId": "B" + }, + { + "expr": "system_cpu_count{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "cpu", + "refId": "C" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Load", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "decimals": 1, + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 24 + }, + "id": 32, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_threads_live{application=\"$application\", instance=\"$instance\"} or jvm_threads_live_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "live", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "jvm_threads_daemon{application=\"$application\", instance=\"$instance\"} or jvm_threads_daemon_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "daemon", + "metric": "", + "refId": "B", + "step": 2400 + }, + { + "expr": "jvm_threads_peak{application=\"$application\", instance=\"$instance\"} or jvm_threads_peak_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "peak", + "refId": "C", + "step": 2400 + }, + { + "expr": "process_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "process", + "refId": "D", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Threads", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "blocked": "#bf1b00", + "new": "#fce2de", + "runnable": "#7eb26d", + "terminated": "#511749", + "timed-waiting": "#c15c17", + "waiting": "#eab839" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 24 + }, + "id": 124, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_threads_states_threads{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "{{state}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Thread States", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": { + "debug": "#1F78C1", + "error": "#BF1B00", + "info": "#508642", + "trace": "#6ED0E0", + "warn": "#EAB839" + }, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 18, + "x": 0, + "y": 31 + }, + "height": "", + "id": 91, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "hideEmpty": false, + "hideZero": false, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": true, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [ + { + "alias": "error", + "yaxis": 1 + }, + { + "alias": "warn", + "yaxis": 1 + } + ], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "increase(logback_events_total{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{level}}", + "metric": "", + "refId": "A", + "step": 1200 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Log Events (1m)", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 31 + }, + "id": 61, + "legend": { + "avg": false, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "process_open_fds{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "open", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "process_max_fds{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "B", + "step": 2400 + }, + { + "expr": "process_files_open{application=\"$application\", instance=\"$instance\"} or process_files_open_files{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "open", + "refId": "C" + }, + { + "expr": "process_files_max{application=\"$application\", instance=\"$instance\"} or process_files_max_files{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "max", + "refId": "D" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "File Descriptors", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 10, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 129, + "panels": [], + "repeat": "persistence_counts", + "title": "JVM Memory Pools (Heap)", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 39 + }, + "id": 3, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "maxPerRow": 3, + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "jvm_memory_pool_heap", + "scopedVars": { + "jvm_memory_pool_heap": { + "selected": false, + "text": "PS Eden Space", + "value": "PS Eden Space" + } + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "committed", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "$jvm_memory_pool_heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 39 + }, + "id": 134, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "maxPerRow": 3, + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "repeatIteration": 1553765841423, + "repeatPanelId": 3, + "scopedVars": { + "jvm_memory_pool_heap": { + "selected": false, + "text": "PS Old Gen", + "value": "PS Old Gen" + } + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "committed", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "$jvm_memory_pool_heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 39 + }, + "id": 135, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "maxPerRow": 3, + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "repeatIteration": 1553765841423, + "repeatPanelId": 3, + "scopedVars": { + "jvm_memory_pool_heap": { + "selected": false, + "text": "PS Survivor Space", + "value": "PS Survivor Space" + } + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "committed", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_heap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "$jvm_memory_pool_heap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 46 + }, + "id": 130, + "panels": [], + "repeat": null, + "title": "JVM Memory Pools (Non-Heap)", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 47 + }, + "id": 78, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "maxPerRow": 3, + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "jvm_memory_pool_nonheap", + "scopedVars": { + "jvm_memory_pool_nonheap": { + "selected": false, + "text": "Metaspace", + "value": "Metaspace" + } + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "committed", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "$jvm_memory_pool_nonheap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 47 + }, + "id": 136, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "maxPerRow": 3, + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "repeatIteration": 1553765841423, + "repeatPanelId": 78, + "scopedVars": { + "jvm_memory_pool_nonheap": { + "selected": false, + "text": "Compressed Class Space", + "value": "Compressed Class Space" + } + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "committed", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "$jvm_memory_pool_nonheap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 47 + }, + "id": 137, + "legend": { + "alignAsTable": false, + "avg": false, + "current": true, + "max": true, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "maxPerRow": 3, + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": null, + "repeatIteration": 1553765841423, + "repeatPanelId": 78, + "scopedVars": { + "jvm_memory_pool_nonheap": { + "selected": false, + "text": "Code Cache", + "value": "Code Cache" + } + }, + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 1800 + }, + { + "expr": "jvm_memory_committed_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "committed", + "metric": "", + "refId": "B", + "step": 1800 + }, + { + "expr": "jvm_memory_max_bytes{application=\"$application\", instance=\"$instance\", id=\"$jvm_memory_pool_nonheap\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "max", + "metric": "", + "refId": "C", + "step": 1800 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "$jvm_memory_pool_nonheap", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["mbytes", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 54 + }, + "id": 131, + "panels": [], + "repeat": null, + "title": "Garbage Collection", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 55 + }, + "id": 98, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_count{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "hide": false, + "intervalFactor": 2, + "legendFormat": "{{action}} ({{cause}})", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Collections", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ops", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 55 + }, + "id": 101, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum{application=\"$application\", instance=\"$instance\"}[1m])/rate(jvm_gc_pause_seconds_count{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "hide": false, + "instant": false, + "intervalFactor": 1, + "legendFormat": "avg {{action}} ({{cause}})", + "refId": "A" + }, + { + "expr": "jvm_gc_pause_seconds_max{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "hide": false, + "instant": false, + "intervalFactor": 1, + "legendFormat": "max {{action}} ({{cause}})", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Pause Durations", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "fill": 1, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 55 + }, + "id": 99, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "rate(jvm_gc_memory_allocated_bytes_total{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "allocated", + "refId": "A" + }, + { + "expr": "rate(jvm_gc_memory_promoted_bytes_total{application=\"$application\", instance=\"$instance\"}[1m])", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "promoted", + "refId": "B" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Allocated/Promoted", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 62 + }, + "id": 132, + "panels": [], + "repeat": null, + "title": "Classloading", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 63 + }, + "id": 37, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_classes_loaded{application=\"$application\", instance=\"$instance\"} or jvm_classes_loaded_classes{application=\"$application\", instance=\"$instance\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "loaded", + "metric": "", + "refId": "A", + "step": 1200 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Classes loaded", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 63 + }, + "id": 38, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "delta(jvm_classes_loaded{application=\"$application\",instance=\"$instance\"}[5m]) or delta(jvm_classes_loaded_classes{application=\"$application\",instance=\"$instance\"}[5m])", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "delta", + "metric": "", + "refId": "A", + "step": 1200 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Class delta (5m)", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["ops", "short"], + "yaxes": [ + { + "decimals": null, + "format": "short", + "label": "", + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 70 + }, + "id": 133, + "panels": [], + "repeat": null, + "title": "Buffer Pools", + "type": "row" + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 71 + }, + "id": 33, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"direct\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "jvm_buffer_total_capacity_bytes{application=\"$application\", instance=\"$instance\", id=\"direct\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "capacity", + "metric": "", + "refId": "B", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Direct Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 6, + "y": 71 + }, + "id": 83, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_count{application=\"$application\", instance=\"$instance\", id=\"direct\"} or jvm_buffer_count_buffers{application=\"$application\", instance=\"$instance\", id=\"direct\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "count", + "metric": "", + "refId": "A", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Direct Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 71 + }, + "id": 85, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_memory_used_bytes{application=\"$application\", instance=\"$instance\", id=\"mapped\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "used", + "metric": "", + "refId": "A", + "step": 2400 + }, + { + "expr": "jvm_buffer_total_capacity_bytes{application=\"$application\", instance=\"$instance\", id=\"mapped\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "capacity", + "metric": "", + "refId": "B", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Mapped Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "Prometheus", + "editable": true, + "error": false, + "fill": 1, + "grid": { + "leftLogBase": 1, + "leftMax": null, + "leftMin": null, + "rightLogBase": 1, + "rightMax": null, + "rightMin": null + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 71 + }, + "id": 84, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "paceLength": 10, + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "jvm_buffer_count{application=\"$application\", instance=\"$instance\", id=\"mapped\"} or jvm_buffer_count_buffers{application=\"$application\", instance=\"$instance\", id=\"mapped\"}", + "format": "time_series", + "intervalFactor": 2, + "legendFormat": "count", + "metric": "", + "refId": "A", + "step": 2400 + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "Mapped Buffers", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "x-axis": true, + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "y-axis": true, + "y_formats": ["short", "short"], + "yaxes": [ + { + "decimals": 0, + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": 0, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } + } + ], + "refresh": "10s", + "schemaVersion": 18, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": { + "text": "test", + "value": "test" + }, + "datasource": "Prometheus", + "definition": "", + "hide": 0, + "includeAll": false, + "label": "Application", + "multi": false, + "name": "application", + "options": [], + "query": "label_values(application)", + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allFormat": "glob", + "allValue": null, + "current": { + "text": "localhost:8080", + "value": "localhost:8080" + }, + "datasource": "Prometheus", + "definition": "", + "hide": 0, + "includeAll": false, + "label": "Instance", + "multi": false, + "multiFormat": "glob", + "name": "instance", + "options": [], + "query": "label_values(jvm_memory_used_bytes{application=\"$application\"}, instance)", + "refresh": 2, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allFormat": "glob", + "allValue": null, + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "Prometheus", + "definition": "", + "hide": 0, + "includeAll": true, + "label": "JVM Memory Pools Heap", + "multi": false, + "multiFormat": "glob", + "name": "jvm_memory_pool_heap", + "options": [], + "query": "label_values(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"heap\"},id)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allFormat": "glob", + "allValue": null, + "current": { + "text": "All", + "value": "$__all" + }, + "datasource": "Prometheus", + "definition": "", + "hide": 0, + "includeAll": true, + "label": "JVM Memory Pools Non-Heap", + "multi": false, + "multiFormat": "glob", + "name": "jvm_memory_pool_nonheap", + "options": [], + "query": "label_values(jvm_memory_used_bytes{application=\"$application\", instance=\"$instance\", area=\"nonheap\"},id)", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 2, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": { + "now": true, + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "browser", + "title": "JVM (Micrometer)", + "uid": "Ud1CFe3iz", + "version": 1 +} diff --git a/src/main/docker/grafana/provisioning/dashboards/dashboard.yml b/src/main/docker/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 0000000..4817a83 --- /dev/null +++ b/src/main/docker/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Prometheus' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/src/main/docker/grafana/provisioning/datasources/datasource.yml b/src/main/docker/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 0000000..ef8b00d --- /dev/null +++ b/src/main/docker/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,50 @@ +apiVersion: 1 + +# list of datasources that should be deleted from the database +deleteDatasources: + - name: Prometheus + orgId: 1 + +# list of datasources to insert/update depending +# whats available in the database +datasources: + # name of the datasource. Required + - name: Prometheus + # datasource type. Required + type: prometheus + # access mode. direct or proxy. Required + access: proxy + # org id. will default to orgId 1 if not specified + orgId: 1 + # url + # On macOS, replace localhost by host.docker.internal + url: http://localhost:9090 + # database password, if used + password: + # database user, if used + user: + # database name, if used + database: + # enable/disable basic auth + basicAuth: false + # basic auth username + basicAuthUser: admin + # basic auth password + basicAuthPassword: admin + # enable/disable with credentials headers + withCredentials: + # mark as default datasource. Max one per org + isDefault: true + # fields that will be converted to json and stored in json_data + jsonData: + graphiteVersion: '1.1' + tlsAuth: false + tlsAuthWithCACert: false + # json object of data that will be encrypted. + secureJsonData: + tlsCACert: '...' + tlsClientCert: '...' + tlsClientKey: '...' + version: 1 + # allow users to edit datasources from the UI. + editable: true diff --git a/src/main/docker/jhipster-control-center.yml b/src/main/docker/jhipster-control-center.yml new file mode 100644 index 0000000..03012f7 --- /dev/null +++ b/src/main/docker/jhipster-control-center.yml @@ -0,0 +1,51 @@ +## How to use JHCC docker compose +# To allow JHCC to reach JHipster application from a docker container note that we set the host as host.docker.internal +# To reach the application from a browser, you need to add '127.0.0.1 host.docker.internal' to your hosts file. +### Discovery mode +# JHCC supports 3 kinds of discovery mode: Consul, Eureka and static +# In order to use one, please set SPRING_PROFILES_ACTIVE to one (and only one) of these values: consul,eureka,static +### Discovery properties +# According to the discovery mode choose as Spring profile, you have to set the right properties +# please note that current properties are set to run JHCC with default values, personalize them if needed +# and remove those from other modes. You can only have one mode active. +#### Eureka +# - EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://admin:admin@host.docker.internal:8761/eureka/ +#### Consul +# - SPRING_CLOUD_CONSUL_HOST=host.docker.internal +# - SPRING_CLOUD_CONSUL_PORT=8500 +#### Static +# Add instances to "MyApp" +# - SPRING_CLOUD_DISCOVERY_CLIENT_SIMPLE_INSTANCES_MYAPP_0_URI=http://host.docker.internal:8081 +# - SPRING_CLOUD_DISCOVERY_CLIENT_SIMPLE_INSTANCES_MYAPP_1_URI=http://host.docker.internal:8082 +# Or add a new application named MyNewApp +# - SPRING_CLOUD_DISCOVERY_CLIENT_SIMPLE_INSTANCES_MYNEWAPP_0_URI=http://host.docker.internal:8080 +# This configuration is intended for development purpose, it's **your** responsibility to harden it for production + +#### IMPORTANT +# If you choose Consul or Eureka mode: +# Do not forget to remove the prefix "127.0.0.1" in front of their port in order to expose them. +# This is required because JHCC needs to communicate with Consul or Eureka. +# - In Consul mode, the ports are in the consul.yml file. +# - In Eureka mode, the ports are in the jhipster-registry.yml file. + +name: questionbank +services: + jhipster-control-center: + image: 'jhipster/jhipster-control-center:v0.5.0' + command: + - /bin/sh + - -c + # Patch /etc/hosts to support resolving host.docker.internal to the internal IP address used by the host in all OSes + - echo "`ip route | grep default | cut -d ' ' -f3` host.docker.internal" | tee -a /etc/hosts > /dev/null && java -jar /jhipster-control-center.jar + environment: + - _JAVA_OPTIONS=-Xmx512m -Xms256m + - SPRING_PROFILES_ACTIVE=prod,api-docs,static + - SPRING_SECURITY_USER_PASSWORD=admin + # The token should have the same value as the one declared in you Spring configuration under the jhipster.security.authentication.jwt.base64-secret configuration's entry + - JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64_SECRET=MzY1ZDU0MDM3M2Y5YWRiMDYxYjBmYzZlOTMxMWFiNmYyMDEyZTUzZjYxOWFjNjhlNjU2NzcxYjkwY2Y0MGIwOGUyNTc5NWQyOGY3OGJkMjExZjhkYzU3ZGU4ZDc0ZmZiMTYwMzY0NGM4ZWZmODRhZTZjNzBlNjk5MWJkYjM5YjI= + - SPRING_CLOUD_DISCOVERY_CLIENT_SIMPLE_INSTANCES_QUESTIONBANK_0_URI=http://host.docker.internal:8080 + - LOGGING_FILE_NAME=/tmp/jhipster-control-center.log + # If you want to expose these ports outside your dev PC, + # remove the "127.0.0.1:" prefix + ports: + - 127.0.0.1:7419:7419 diff --git a/src/main/docker/jib/entrypoint.sh b/src/main/docker/jib/entrypoint.sh new file mode 100644 index 0000000..3d4b64d --- /dev/null +++ b/src/main/docker/jib/entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +echo "The application will start in ${JHIPSTER_SLEEP}s..." && sleep ${JHIPSTER_SLEEP} + +# usage: file_env VAR [DEFAULT] +# ie: file_env 'XYZ_DB_PASSWORD' 'example' +# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of +# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature) +file_env() { + local var="$1" + local file_var="${var}_FILE" + local def="${2:-}" + if [[ ${!var:-} && ${!file_var:-} ]]; then + echo >&2 "error: both $var and $file_var are set (but are exclusive)" + exit 1 + fi + local val="$def" + if [[ ${!var:-} ]]; then + val="${!var}" + elif [[ ${!file_var:-} ]]; then + val="$(< "${!file_var}")" + fi + + if [[ -n $val ]]; then + export "$var"="$val" + fi + + unset "$file_var" + return 0 +} + +file_env 'SPRING_DATASOURCE_URL' +file_env 'SPRING_DATASOURCE_USERNAME' +file_env 'SPRING_DATASOURCE_PASSWORD' +file_env 'SPRING_LIQUIBASE_URL' +file_env 'SPRING_LIQUIBASE_USER' +file_env 'SPRING_LIQUIBASE_PASSWORD' +file_env 'JHIPSTER_REGISTRY_PASSWORD' + +exec java ${JAVA_OPTS} -noverify -XX:+AlwaysPreTouch -cp /app/resources/:/app/classes/:/app/libs/* "com.example.questionbank.QuestionBankApp" "$@" diff --git a/src/main/docker/monitoring.yml b/src/main/docker/monitoring.yml new file mode 100644 index 0000000..b19a677 --- /dev/null +++ b/src/main/docker/monitoring.yml @@ -0,0 +1,31 @@ +# This configuration is intended for development purpose, it's **your** responsibility to harden it for production +name: questionbank +services: + prometheus: + image: prom/prometheus:v3.14.0 + volumes: + - ./prometheus/:/etc/prometheus/ + command: + - '--config.file=/etc/prometheus/prometheus.yml' + # If you want to expose these ports outside your dev PC, + # remove the "127.0.0.1:" prefix + ports: + - 127.0.0.1:9090:9090 + # On macOS, remove next line and replace localhost by host.docker.internal in prometheus/prometheus.yml and + # grafana/provisioning/datasources/datasource.yml + network_mode: 'host' # to test locally running service + grafana: + image: grafana/grafana:13.2.0 + volumes: + - ./grafana/provisioning/:/etc/grafana/provisioning/ + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + - GF_INSTALL_PLUGINS=grafana-piechart-panel + # If you want to expose these ports outside your dev PC, + # remove the "127.0.0.1:" prefix + ports: + - 127.0.0.1:3000:3000 + # On macOS, remove next line and replace localhost by host.docker.internal in prometheus/prometheus.yml and + # grafana/provisioning/datasources/datasource.yml + network_mode: 'host' # to test locally running service diff --git a/src/main/docker/postgresql.yml b/src/main/docker/postgresql.yml new file mode 100644 index 0000000..98cbc36 --- /dev/null +++ b/src/main/docker/postgresql.yml @@ -0,0 +1,19 @@ +# This configuration is intended for development purpose, it's **your** responsibility to harden it for production +name: questionbank +services: + postgresql: + image: postgres:18.6 + # volumes: + # - ~/volumes/jhipster/questionBank/postgresql/:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=questionBank + - POSTGRES_HOST_AUTH_METHOD=trust + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER}'] + interval: 5s + timeout: 5s + retries: 10 + # If you want to expose these ports outside your dev PC, + # remove the "127.0.0.1:" prefix + ports: + - 127.0.0.1:5432:5432 diff --git a/src/main/docker/prometheus/prometheus.yml b/src/main/docker/prometheus/prometheus.yml new file mode 100644 index 0000000..5717cac --- /dev/null +++ b/src/main/docker/prometheus/prometheus.yml @@ -0,0 +1,31 @@ +# Sample global config for monitoring JHipster applications +global: + scrape_interval: 15s # By default, scrape targets every 15 seconds. + evaluation_interval: 15s # By default, scrape targets every 15 seconds. + # scrape_timeout is set to the global default (10s). + + # Attach these labels to any time series or alerts when communicating with + # external systems (federation, remote storage, Alertmanager). + external_labels: + monitor: 'jhipster' + +# A scrape configuration containing exactly one endpoint to scrape: +# Here it's Prometheus itself. +scrape_configs: + # The job name is added as a label `job=` to any timeseries scraped from this config. + - job_name: 'prometheus' + + # Override the global default and scrape targets from this job every 5 seconds. + scrape_interval: 5s + + # scheme defaults to 'http' enable https in case your application is server via https + #scheme: https + # basic auth is not needed by default. See https://www.jhipster.tech/monitoring/#configuring-metrics-forwarding for details + #basic_auth: + # username: admin + # password: admin + metrics_path: /management/prometheus + static_configs: + - targets: + # On macOS, replace localhost by host.docker.internal + - localhost:8080 diff --git a/src/main/docker/services.yml b/src/main/docker/services.yml new file mode 100644 index 0000000..01644c5 --- /dev/null +++ b/src/main/docker/services.yml @@ -0,0 +1,7 @@ +# This configuration is intended for development purpose, it's **your** responsibility to harden it for production +name: questionbank +services: + postgresql: + extends: + file: ./postgresql.yml + service: postgresql diff --git a/src/main/docker/sonar.yml b/src/main/docker/sonar.yml new file mode 100644 index 0000000..5647321 --- /dev/null +++ b/src/main/docker/sonar.yml @@ -0,0 +1,15 @@ +# This configuration is intended for development purpose, it's **your** responsibility to harden it for production +name: questionbank +services: + sonar: + container_name: sonarqube + image: sonarqube:26.8.0.126808-community + # Forced authentication redirect for UI is turned off for out of the box experience while trying out SonarQube + # For real use cases delete SONAR_FORCEAUTHENTICATION variable or set SONAR_FORCEAUTHENTICATION=true + environment: + - SONAR_FORCEAUTHENTICATION=false + # If you want to expose these ports outside your dev PC, + # remove the "127.0.0.1:" prefix + ports: + - 127.0.0.1:9001:9000 + - 127.0.0.1:9000:9000 diff --git a/src/main/java/com/example/questionbank/ApplicationWebXml.java b/src/main/java/com/example/questionbank/ApplicationWebXml.java new file mode 100644 index 0000000..d81dada --- /dev/null +++ b/src/main/java/com/example/questionbank/ApplicationWebXml.java @@ -0,0 +1,19 @@ +package com.example.questionbank; + +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; +import tech.jhipster.config.DefaultProfileUtil; + +/** + * This is a helper Java class that provides an alternative to creating a {@code web.xml}. + * This will be invoked only when the application is deployed to a Servlet container like Tomcat, JBoss etc. + */ +public class ApplicationWebXml extends SpringBootServletInitializer { + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { + // set a default to use when no profile is configured. + DefaultProfileUtil.addDefaultProfile(application.application()); + return application.sources(QuestionBankApp.class); + } +} diff --git a/src/main/java/com/example/questionbank/GeneratedByJHipster.java b/src/main/java/com/example/questionbank/GeneratedByJHipster.java new file mode 100644 index 0000000..98acebf --- /dev/null +++ b/src/main/java/com/example/questionbank/GeneratedByJHipster.java @@ -0,0 +1,12 @@ +package com.example.questionbank; + +import jakarta.annotation.Generated; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Generated(value = "JHipster", comments = "Generated by JHipster 9.3.0") +@Retention(RetentionPolicy.SOURCE) +@Target({ ElementType.TYPE }) +public @interface GeneratedByJHipster {} diff --git a/src/main/java/com/example/questionbank/QuestionBankApp.java b/src/main/java/com/example/questionbank/QuestionBankApp.java new file mode 100644 index 0000000..743cd0b --- /dev/null +++ b/src/main/java/com/example/questionbank/QuestionBankApp.java @@ -0,0 +1,110 @@ +package com.example.questionbank; + +import com.example.questionbank.config.ApplicationProperties; +import com.example.questionbank.config.CRLFLogConverter; +import jakarta.annotation.PostConstruct; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.liquibase.autoconfigure.LiquibaseProperties; +import org.springframework.core.env.Environment; +import tech.jhipster.config.DefaultProfileUtil; +import tech.jhipster.config.JHipsterConstants; + +@SpringBootApplication() +@EnableConfigurationProperties({ LiquibaseProperties.class, ApplicationProperties.class }) +public class QuestionBankApp { + + private static final Logger LOG = LoggerFactory.getLogger(QuestionBankApp.class); + + private final Environment env; + + public QuestionBankApp(Environment env) { + this.env = env; + } + + /** + * Initializes questionBank. + *

+ * Spring profiles can be configured with a program argument --spring.profiles.active=your-active-profile + *

+ * You can find more information on how profiles work with JHipster on https://www.jhipster.tech/profiles/. + */ + @PostConstruct + public void initApplication() { + Collection activeProfiles = List.of(env.getActiveProfiles()); + if ( + activeProfiles.contains(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT) && + activeProfiles.contains(JHipsterConstants.SPRING_PROFILE_PRODUCTION) + ) { + LOG.error( + "You have misconfigured your application! It should not run " + "with both the 'dev' and 'prod' profiles at the same time." + ); + } + if ( + activeProfiles.contains(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT) && + activeProfiles.contains(JHipsterConstants.SPRING_PROFILE_CLOUD) + ) { + LOG.error( + "You have misconfigured your application! It should not " + "run with both the 'dev' and 'cloud' profiles at the same time." + ); + } + } + + /** + * Main method, used to run the application. + * + * @param args the command line arguments. + */ + public static void main(String[] args) { + var app = new SpringApplication(QuestionBankApp.class); + DefaultProfileUtil.addDefaultProfile(app); + Environment env = app.run(args).getEnvironment(); + logApplicationStartup(env); + } + + private static void logApplicationStartup(Environment env) { + String protocol = Optional.ofNullable(env.getProperty("server.ssl.key-store")) + .map(key -> "https") + .orElse("http"); + String applicationName = env.getProperty("spring.application.name"); + String serverPort = env.getProperty("server.port"); + String contextPath = Optional.ofNullable(env.getProperty("server.servlet.context-path")) + .filter(StringUtils::isNotBlank) + .orElse("/"); + var hostAddress = "localhost"; + try { + hostAddress = InetAddress.getLocalHost().getHostAddress(); + } catch (UnknownHostException e) { + LOG.warn("The host name could not be determined, using `localhost` as fallback"); + } + LOG.info( + CRLFLogConverter.CRLF_SAFE_MARKER, + """ + + ---------------------------------------------------------- + \tApplication '{}' is running! Access URLs: + \tLocal: \t\t{}://localhost:{}{} + \tExternal: \t{}://{}:{}{} + \tProfile(s): \t{} + ----------------------------------------------------------""", + applicationName, + protocol, + serverPort, + contextPath, + protocol, + hostAddress, + serverPort, + contextPath, + env.getActiveProfiles().length == 0 ? env.getDefaultProfiles() : env.getActiveProfiles() + ); + } +} diff --git a/src/main/java/com/example/questionbank/aop/logging/LoggingAspect.java b/src/main/java/com/example/questionbank/aop/logging/LoggingAspect.java new file mode 100644 index 0000000..fab79a2 --- /dev/null +++ b/src/main/java/com/example/questionbank/aop/logging/LoggingAspect.java @@ -0,0 +1,131 @@ +package com.example.questionbank.aop.logging; + +import java.util.Arrays; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import tech.jhipster.config.JHipsterConstants; + +/** + * Aspect for logging execution of service and repository Spring components. + * + * By default, it only runs with the "dev" profile. + */ +@Aspect +public class LoggingAspect { + + private final Environment env; + + public LoggingAspect(Environment env) { + this.env = env; + } + + /** + * Pointcut that matches all repositories, services and Web REST endpoints. + */ + @Pointcut( + """ + within(@org.springframework.stereotype.Repository *) + || within(@org.springframework.stereotype.Service *) + || within(@org.springframework.web.bind.annotation.RestController *) + """ + ) + public void springBeanPointcut() { + // Method is empty as this is just a Pointcut, the implementations are in the advice methods. + } + + /** + * Pointcut that matches all Spring beans in the application's main packages. + */ + @Pointcut( + """ + within(com.example.questionbank.repository..*) + || within(com.example.questionbank.service..*) + || within(com.example.questionbank.web.rest..*) + """ + ) + public void applicationPackagePointcut() { + // Method is empty as this is just a Pointcut, the implementations are in the advice methods. + } + + /** + * Retrieves the {@link Logger} associated to the given {@link JoinPoint}. + * + * @param joinPoint join point we want the logger for. + * @return {@link Logger} associated to the given {@link JoinPoint}. + */ + private Logger logger(JoinPoint joinPoint) { + return LoggerFactory.getLogger(joinPoint.getSignature().getDeclaringTypeName()); + } + + /** + * Advice that logs methods throwing exceptions. + * + * @param joinPoint join point for advice. + * @param e exception. + */ + @AfterThrowing(pointcut = "applicationPackagePointcut() && springBeanPointcut()", throwing = "e") + public void logAfterThrowing(JoinPoint joinPoint, Throwable e) { + logException(joinPoint, e); + } + + /** + * Advice that logs when a method is entered and exited. + * + * @param joinPoint join point for advice. + * @return result. + * @throws Throwable rethrows the exception thrown by the advised method. + */ + @Around("applicationPackagePointcut() && springBeanPointcut()") + public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable { + var log = logger(joinPoint); + if (log.isDebugEnabled()) { + log.debug("Enter: {}() with argument[s] = {}", joinPoint.getSignature().getName(), Arrays.toString(joinPoint.getArgs())); + } + try { + Object result = joinPoint.proceed(); + logExit(log, joinPoint, result); + return result; + } catch (IllegalArgumentException e) { + logIllegalArgument(log, joinPoint); + throw e; + } + } + + private void logExit(Logger log, JoinPoint joinPoint, Object result) { + if (log.isDebugEnabled()) { + log.debug("Exit: {}() with result = {}", joinPoint.getSignature().getName(), result); + } + } + + private void logIllegalArgument(Logger log, JoinPoint joinPoint) { + if (log.isErrorEnabled()) { + log.error("Illegal argument: {} in {}()", Arrays.toString(joinPoint.getArgs()), joinPoint.getSignature().getName()); + } + } + + private void logException(JoinPoint joinPoint, Throwable e) { + if (env.acceptsProfiles(Profiles.of(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT))) { + logger(joinPoint).error( + "Exception in {}() with cause = '{}' and exception = '{}'", + joinPoint.getSignature().getName(), + e.getCause() != null ? e.getCause() : "NULL", + e.getMessage(), + e + ); + } else { + logger(joinPoint).error( + "Exception in {}() with cause = {}", + joinPoint.getSignature().getName(), + e.getCause() != null ? String.valueOf(e.getCause()) : "NULL" + ); + } + } +} diff --git a/src/main/java/com/example/questionbank/aop/logging/package-info.java b/src/main/java/com/example/questionbank/aop/logging/package-info.java new file mode 100644 index 0000000..d398822 --- /dev/null +++ b/src/main/java/com/example/questionbank/aop/logging/package-info.java @@ -0,0 +1,4 @@ +/** + * Logging aspect. + */ +package com.example.questionbank.aop.logging; diff --git a/src/main/java/com/example/questionbank/config/ApplicationProperties.java b/src/main/java/com/example/questionbank/config/ApplicationProperties.java new file mode 100644 index 0000000..b62de5d --- /dev/null +++ b/src/main/java/com/example/questionbank/config/ApplicationProperties.java @@ -0,0 +1,38 @@ +package com.example.questionbank.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Properties specific to Question Bank. + *

+ * Properties are configured in the {@code application.yml} file. + * See {@link tech.jhipster.config.JHipsterProperties} for a good example. + */ +@ConfigurationProperties(prefix = "application", ignoreUnknownFields = false) +public class ApplicationProperties { + + private final Liquibase liquibase = new Liquibase(); + + // jhipster-needle-application-properties-property + + public Liquibase getLiquibase() { + return liquibase; + } + + // jhipster-needle-application-properties-property-getter + + public static class Liquibase { + + private Boolean asyncStart = true; + + public Boolean getAsyncStart() { + return asyncStart; + } + + public void setAsyncStart(Boolean asyncStart) { + this.asyncStart = asyncStart; + } + } + + // jhipster-needle-application-properties-property-class +} diff --git a/src/main/java/com/example/questionbank/config/AsyncConfiguration.java b/src/main/java/com/example/questionbank/config/AsyncConfiguration.java new file mode 100644 index 0000000..aad90d0 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/AsyncConfiguration.java @@ -0,0 +1,48 @@ +package com.example.questionbank.config; + +import java.util.concurrent.Executor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; +import org.springframework.boot.autoconfigure.task.TaskExecutionProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import tech.jhipster.async.ExceptionHandlingAsyncTaskExecutor; + +@Configuration +@EnableAsync +@EnableScheduling +@Profile("!testdev & !testprod") +public class AsyncConfiguration implements AsyncConfigurer { + + private static final Logger LOG = LoggerFactory.getLogger(AsyncConfiguration.class); + + private final TaskExecutionProperties taskExecutionProperties; + + public AsyncConfiguration(TaskExecutionProperties taskExecutionProperties) { + this.taskExecutionProperties = taskExecutionProperties; + } + + @Override + @Bean(name = "taskExecutor") + public Executor getAsyncExecutor() { + LOG.debug("Creating Async Task Executor"); + var executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(taskExecutionProperties.getPool().getCoreSize()); + executor.setMaxPoolSize(taskExecutionProperties.getPool().getMaxSize()); + executor.setQueueCapacity(taskExecutionProperties.getPool().getQueueCapacity()); + executor.setThreadNamePrefix(taskExecutionProperties.getThreadNamePrefix()); + return new ExceptionHandlingAsyncTaskExecutor(executor); + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new SimpleAsyncUncaughtExceptionHandler(); + } +} diff --git a/src/main/java/com/example/questionbank/config/CRLFLogConverter.java b/src/main/java/com/example/questionbank/config/CRLFLogConverter.java new file mode 100644 index 0000000..1ef5a01 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/CRLFLogConverter.java @@ -0,0 +1,69 @@ +package com.example.questionbank.config; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.pattern.CompositeConverter; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; +import org.springframework.boot.ansi.AnsiColor; +import org.springframework.boot.ansi.AnsiElement; +import org.springframework.boot.ansi.AnsiOutput; +import org.springframework.boot.ansi.AnsiStyle; + +/** + * Log filter to prevent attackers from forging log entries by submitting input containing CRLF characters. + * CRLF characters are replaced with a red colored _ character. + * + * @see Log Forging Description + * @see JHipster issue + */ +public class CRLFLogConverter extends CompositeConverter { + + public static final Marker CRLF_SAFE_MARKER = MarkerFactory.getMarker("CRLF_SAFE"); + + private static final String[] SAFE_LOGS = { + "org.hibernate", + "org.springframework.boot.autoconfigure", + "org.springframework.boot.diagnostics", + }; + private static final Map ELEMENTS; + + static { + Map ansiElements = new HashMap<>(); + ansiElements.put("faint", AnsiStyle.FAINT); + ansiElements.put("red", AnsiColor.RED); + ansiElements.put("green", AnsiColor.GREEN); + ansiElements.put("yellow", AnsiColor.YELLOW); + ansiElements.put("blue", AnsiColor.BLUE); + ansiElements.put("magenta", AnsiColor.MAGENTA); + ansiElements.put("cyan", AnsiColor.CYAN); + ELEMENTS = Collections.unmodifiableMap(ansiElements); + } + + @Override + protected String transform(ILoggingEvent event, String in) { + AnsiElement element = ELEMENTS.get(getFirstOption()); + List markers = event.getMarkerList(); + if ((markers != null && !markers.isEmpty() && markers.getFirst().contains(CRLF_SAFE_MARKER)) || isLoggerSafe(event)) { + return in; + } + String replacement = element == null ? "_" : toAnsiString("_", element); + return in.replaceAll("[\n\r\t]", replacement); + } + + protected boolean isLoggerSafe(ILoggingEvent event) { + for (String safeLogger : SAFE_LOGS) { + if (event.getLoggerName().startsWith(safeLogger)) { + return true; + } + } + return false; + } + + protected String toAnsiString(String in, AnsiElement element) { + return AnsiOutput.toString(element, in); + } +} diff --git a/src/main/java/com/example/questionbank/config/Constants.java b/src/main/java/com/example/questionbank/config/Constants.java new file mode 100644 index 0000000..1a650cc --- /dev/null +++ b/src/main/java/com/example/questionbank/config/Constants.java @@ -0,0 +1,15 @@ +package com.example.questionbank.config; + +/** + * Application constants. + */ +public final class Constants { + + // Regex for acceptable logins + public static final String LOGIN_REGEX = "^(?>[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*)|(?>[_.@A-Za-z0-9-]+)$"; + + public static final String SYSTEM = "system"; + public static final String DEFAULT_LANGUAGE = "en"; + + private Constants() {} +} diff --git a/src/main/java/com/example/questionbank/config/DatabaseConfiguration.java b/src/main/java/com/example/questionbank/config/DatabaseConfiguration.java new file mode 100644 index 0000000..f0b05f2 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/DatabaseConfiguration.java @@ -0,0 +1,12 @@ +package com.example.questionbank.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Configuration +@EnableJpaRepositories({ "com.example.questionbank.repository" }) +@EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware") +@EnableTransactionManagement +public class DatabaseConfiguration {} diff --git a/src/main/java/com/example/questionbank/config/DateTimeFormatConfiguration.java b/src/main/java/com/example/questionbank/config/DateTimeFormatConfiguration.java new file mode 100644 index 0000000..365eb16 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/DateTimeFormatConfiguration.java @@ -0,0 +1,20 @@ +package com.example.questionbank.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Configure the converters to use the ISO format for dates by default. + */ +@Configuration +public class DateTimeFormatConfiguration implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + var registrar = new DateTimeFormatterRegistrar(); + registrar.setUseIsoFormat(true); + registrar.registerFormatters(registry); + } +} diff --git a/src/main/java/com/example/questionbank/config/JacksonHibernateConfiguration.java b/src/main/java/com/example/questionbank/config/JacksonHibernateConfiguration.java new file mode 100644 index 0000000..6af1056 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/JacksonHibernateConfiguration.java @@ -0,0 +1,18 @@ +package com.example.questionbank.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.datatype.hibernate7.Hibernate7Module; +import tools.jackson.datatype.hibernate7.Hibernate7Module.Feature; + +@Configuration +public class JacksonHibernateConfiguration { + + /* + * Support for Hibernate types in Jackson. + */ + @Bean + public Hibernate7Module hibernate7Module() { + return new Hibernate7Module().configure(Feature.SERIALIZE_IDENTIFIER_FOR_LAZY_NOT_LOADED_OBJECTS, true); + } +} diff --git a/src/main/java/com/example/questionbank/config/LiquibaseConfiguration.java b/src/main/java/com/example/questionbank/config/LiquibaseConfiguration.java new file mode 100644 index 0000000..b6646ff --- /dev/null +++ b/src/main/java/com/example/questionbank/config/LiquibaseConfiguration.java @@ -0,0 +1,83 @@ +package com.example.questionbank.config; + +import java.util.concurrent.Executor; +import javax.sql.DataSource; +import liquibase.integration.spring.SpringLiquibase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties; +import org.springframework.boot.liquibase.autoconfigure.LiquibaseDataSource; +import org.springframework.boot.liquibase.autoconfigure.LiquibaseProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import tech.jhipster.config.JHipsterConstants; +import tech.jhipster.config.liquibase.SpringLiquibaseUtil; + +@Configuration +public class LiquibaseConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(LiquibaseConfiguration.class); + + private final Environment env; + + public LiquibaseConfiguration(Environment env) { + this.env = env; + } + + @Bean + public SpringLiquibase liquibase( + @Qualifier("taskExecutor") Executor executor, + LiquibaseProperties liquibaseProperties, + @LiquibaseDataSource ObjectProvider liquibaseDataSource, + ObjectProvider dataSource, + ApplicationProperties applicationProperties, + DataSourceProperties dataSourceProperties + ) { + SpringLiquibase liquibase; + if (Boolean.TRUE.equals(applicationProperties.getLiquibase().getAsyncStart())) { + liquibase = SpringLiquibaseUtil.createAsyncSpringLiquibase( + this.env, + executor, + liquibaseDataSource.getIfAvailable(), + liquibaseProperties, + dataSource.getIfUnique(), + dataSourceProperties + ); + } else { + liquibase = SpringLiquibaseUtil.createSpringLiquibase( + liquibaseDataSource.getIfAvailable(), + liquibaseProperties, + dataSource.getIfUnique(), + dataSourceProperties + ); + } + liquibase.setChangeLog("classpath:config/liquibase/master.xml"); + if (!CollectionUtils.isEmpty(liquibaseProperties.getContexts())) { + liquibase.setContexts(StringUtils.collectionToCommaDelimitedString(liquibaseProperties.getContexts())); + } + liquibase.setDefaultSchema(liquibaseProperties.getDefaultSchema()); + liquibase.setLiquibaseSchema(liquibaseProperties.getLiquibaseSchema()); + liquibase.setLiquibaseTablespace(liquibaseProperties.getLiquibaseTablespace()); + liquibase.setDatabaseChangeLogLockTable(liquibaseProperties.getDatabaseChangeLogLockTable()); + liquibase.setDatabaseChangeLogTable(liquibaseProperties.getDatabaseChangeLogTable()); + liquibase.setDropFirst(liquibaseProperties.isDropFirst()); + if (!CollectionUtils.isEmpty(liquibaseProperties.getLabelFilter())) { + liquibase.setLabelFilter(StringUtils.collectionToCommaDelimitedString(liquibaseProperties.getLabelFilter())); + } + liquibase.setChangeLogParameters(liquibaseProperties.getParameters()); + liquibase.setRollbackFile(liquibaseProperties.getRollbackFile()); + liquibase.setTestRollbackOnUpdate(liquibaseProperties.isTestRollbackOnUpdate()); + if (env.matchesProfiles(JHipsterConstants.SPRING_PROFILE_NO_LIQUIBASE)) { + liquibase.setShouldRun(false); + } else { + liquibase.setShouldRun(liquibaseProperties.isEnabled()); + LOG.debug("Configuring Liquibase"); + } + return liquibase; + } +} diff --git a/src/main/java/com/example/questionbank/config/LoggingAspectConfiguration.java b/src/main/java/com/example/questionbank/config/LoggingAspectConfiguration.java new file mode 100644 index 0000000..f7ae9e0 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/LoggingAspectConfiguration.java @@ -0,0 +1,17 @@ +package com.example.questionbank.config; + +import com.example.questionbank.aop.logging.LoggingAspect; +import org.springframework.context.annotation.*; +import org.springframework.core.env.Environment; +import tech.jhipster.config.JHipsterConstants; + +@Configuration +@EnableAspectJAutoProxy +public class LoggingAspectConfiguration { + + @Bean + @Profile(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT) + public LoggingAspect loggingAspect(Environment env) { + return new LoggingAspect(env); + } +} diff --git a/src/main/java/com/example/questionbank/config/LoggingConfiguration.java b/src/main/java/com/example/questionbank/config/LoggingConfiguration.java new file mode 100644 index 0000000..55a3d01 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/LoggingConfiguration.java @@ -0,0 +1,47 @@ +package com.example.questionbank.config; + +import static tech.jhipster.config.logging.LoggingUtils.*; + +import ch.qos.logback.classic.LoggerContext; +import java.util.HashMap; +import java.util.Map; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import tech.jhipster.config.JHipsterProperties; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/* + * Configures the console and Logstash log appenders from the app properties + */ +@Configuration +public class LoggingConfiguration { + + public LoggingConfiguration( + @Value("${spring.application.name}") String appName, + @Value("${server.port}") String serverPort, + JHipsterProperties jHipsterProperties, + ObjectMapper mapper + ) throws JacksonException { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + + Map map = new HashMap<>(); + map.put("app_name", appName); + map.put("app_port", serverPort); + var customFields = mapper.writeValueAsString(map); + + var loggingProperties = jHipsterProperties.getLogging(); + var logstashProperties = loggingProperties.getLogstash(); + + if (loggingProperties.isUseJsonFormat()) { + addJsonConsoleAppender(context, customFields); + } + if (logstashProperties.isEnabled()) { + addLogstashTcpSocketAppender(context, customFields, logstashProperties); + } + if (loggingProperties.isUseJsonFormat() || logstashProperties.isEnabled()) { + addContextListener(context, customFields, loggingProperties); + } + } +} diff --git a/src/main/java/com/example/questionbank/config/SecurityConfiguration.java b/src/main/java/com/example/questionbank/config/SecurityConfiguration.java new file mode 100644 index 0000000..80311d6 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/SecurityConfiguration.java @@ -0,0 +1,85 @@ +package com.example.questionbank.config; + +import static org.springframework.security.config.Customizer.withDefaults; + +import com.example.questionbank.security.*; +import com.example.questionbank.web.filter.SpaWebFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; +import tech.jhipster.config.JHipsterProperties; + +@Configuration +@EnableMethodSecurity(securedEnabled = true) +public class SecurityConfiguration { + + private final JHipsterProperties jHipsterProperties; + + public SecurityConfiguration(JHipsterProperties jHipsterProperties) { + this.jHipsterProperties = jHipsterProperties; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http.cors(withDefaults()) + .csrf(csrf -> csrf.disable()) + .addFilterAfter(new SpaWebFilter(), BasicAuthenticationFilter.class) + .headers(headers -> + headers + .contentSecurityPolicy(csp -> csp.policyDirectives(jHipsterProperties.getSecurity().getContentSecurityPolicy())) + .frameOptions(FrameOptionsConfig::sameOrigin) + .referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) + .permissionsPolicyHeader(permissions -> + permissions.policy( + "camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr=()" + ) + ) + ) + .authorizeHttpRequests(authz -> + // prettier-ignore + authz + .requestMatchers("/index.html", "/*.js", "/*.txt", "/*.json", "/*.map", "/*.css").permitAll() + .requestMatchers("/*.ico", "/*.png", "/*.svg", "/*.webapp").permitAll() + .requestMatchers("/assets/**").permitAll() + .requestMatchers("/swagger-ui/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/authenticate").permitAll() + .requestMatchers(HttpMethod.GET, "/api/authenticate").permitAll() + .requestMatchers("/api/register").permitAll() + .requestMatchers("/api/activate").permitAll() + .requestMatchers("/api/account/reset-password/init").permitAll() + .requestMatchers("/api/account/reset-password/finish").permitAll() + .requestMatchers("/api/admin/**").hasAuthority(AuthoritiesConstants.ADMIN) + .requestMatchers("/api/**").authenticated() + .requestMatchers("/v3/api-docs/**").hasAuthority(AuthoritiesConstants.ADMIN) + .requestMatchers("/management/health").permitAll() + .requestMatchers("/management/health/**").permitAll() + .requestMatchers("/management/info").permitAll() + .requestMatchers("/management/prometheus").permitAll() + .requestMatchers("/management/**").hasAuthority(AuthoritiesConstants.ADMIN) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> + exceptions + .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())); + return http.build(); + } +} diff --git a/src/main/java/com/example/questionbank/config/SecurityJwtConfiguration.java b/src/main/java/com/example/questionbank/config/SecurityJwtConfiguration.java new file mode 100644 index 0000000..9ac20bc --- /dev/null +++ b/src/main/java/com/example/questionbank/config/SecurityJwtConfiguration.java @@ -0,0 +1,62 @@ +package com.example.questionbank.config; + +import static com.example.questionbank.security.SecurityUtils.JWT_ALGORITHM; + +import com.example.questionbank.management.SecurityMetersService; +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.util.Base64; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Configuration +public class SecurityJwtConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(SecurityJwtConfiguration.class); + + @Value("${jhipster.security.authentication.jwt.base64-secret}") + private String jwtKey; + + @Bean + public JwtDecoder jwtDecoder(SecurityMetersService metersService) { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(getSecretKey()).macAlgorithm(JWT_ALGORITHM).build(); + return token -> { + try { + return jwtDecoder.decode(token); + } catch (Exception e) { + if (e.getMessage().contains("Invalid signature")) { + metersService.trackTokenInvalidSignature(); + } else if (e.getMessage().contains("Jwt expired at")) { + metersService.trackTokenExpired(); + } else if ( + e.getMessage().contains("Invalid JWT serialization") || + e.getMessage().contains("Malformed token") || + e.getMessage().contains("Invalid unsecured/JWS/JWE") + ) { + metersService.trackTokenMalformed(); + } else { + LOG.error("Unknown JWT error {}", e.getMessage()); + } + throw e; + } + }; + } + + @Bean + public JwtEncoder jwtEncoder() { + return new NimbusJwtEncoder(new ImmutableSecret<>(getSecretKey())); + } + + private SecretKey getSecretKey() { + byte[] keyBytes = Base64.from(jwtKey).decode(); + return new SecretKeySpec(keyBytes, 0, keyBytes.length, JWT_ALGORITHM.getName()); + } +} diff --git a/src/main/java/com/example/questionbank/config/StaticResourcesWebConfiguration.java b/src/main/java/com/example/questionbank/config/StaticResourcesWebConfiguration.java new file mode 100644 index 0000000..0ca6913 --- /dev/null +++ b/src/main/java/com/example/questionbank/config/StaticResourcesWebConfiguration.java @@ -0,0 +1,47 @@ +package com.example.questionbank.config; + +import java.util.concurrent.TimeUnit; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.CacheControl; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import tech.jhipster.config.JHipsterConstants; +import tech.jhipster.config.JHipsterProperties; + +@Configuration +@Profile({ JHipsterConstants.SPRING_PROFILE_PRODUCTION }) +public class StaticResourcesWebConfiguration implements WebMvcConfigurer { + + protected static final String[] RESOURCE_LOCATIONS = { "classpath:/static/", "classpath:/static/content/", "classpath:/static/i18n/" }; + protected static final String[] RESOURCE_PATHS = { "/*.js", "/*.css", "/*.svg", "/*.png", "*.ico", "/content/**", "/i18n/*" }; + + private final JHipsterProperties jhipsterProperties; + + public StaticResourcesWebConfiguration(JHipsterProperties jHipsterProperties) { + this.jhipsterProperties = jHipsterProperties; + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + ResourceHandlerRegistration resourceHandlerRegistration = appendResourceHandler(registry); + initializeResourceHandler(resourceHandlerRegistration); + } + + protected ResourceHandlerRegistration appendResourceHandler(ResourceHandlerRegistry registry) { + return registry.addResourceHandler(RESOURCE_PATHS); + } + + protected void initializeResourceHandler(ResourceHandlerRegistration resourceHandlerRegistration) { + resourceHandlerRegistration.addResourceLocations(RESOURCE_LOCATIONS).setCacheControl(getCacheControl()); + } + + protected CacheControl getCacheControl() { + return CacheControl.maxAge(getJHipsterHttpCacheProperty(), TimeUnit.DAYS).cachePublic(); + } + + private int getJHipsterHttpCacheProperty() { + return jhipsterProperties.getHttp().getCache().getTimeToLiveInDays(); + } +} diff --git a/src/main/java/com/example/questionbank/config/WebConfigurer.java b/src/main/java/com/example/questionbank/config/WebConfigurer.java new file mode 100644 index 0000000..94fd28c --- /dev/null +++ b/src/main/java/com/example/questionbank/config/WebConfigurer.java @@ -0,0 +1,96 @@ +package com.example.questionbank.config; + +import static java.net.URLDecoder.decode; + +import jakarta.servlet.*; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.server.*; +import org.springframework.boot.web.server.servlet.ConfigurableServletWebServerFactory; +import org.springframework.boot.web.servlet.ServletContextInitializer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.util.CollectionUtils; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import tech.jhipster.config.JHipsterProperties; + +/** + * Configuration of web application with Servlet 3.0 APIs. + */ +@Configuration +public class WebConfigurer implements ServletContextInitializer, WebServerFactoryCustomizer { + + private static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class); + + private final Environment env; + + private final JHipsterProperties jHipsterProperties; + + public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties) { + this.env = env; + this.jHipsterProperties = jHipsterProperties; + } + + @Override + public void onStartup(ServletContext servletContext) { + if (env.getActiveProfiles().length != 0) { + LOG.info("Web application configuration, using profiles: {}", (Object[]) env.getActiveProfiles()); + } + + LOG.info("Web application fully configured"); + } + + /** + * Customize the Servlet engine: Mime types, the document root, the cache. + */ + @Override + public void customize(WebServerFactory server) { + // When running in an IDE or with ./mvnw spring-boot:run, set location of the static web assets. + setLocationForStaticAssets(server); + } + + private void setLocationForStaticAssets(WebServerFactory server) { + if (server instanceof ConfigurableServletWebServerFactory servletWebServer) { + File root; + String prefixPath = resolvePathPrefix(); + root = Path.of(prefixPath + "target/classes/static/").toFile(); + if (root.exists() && root.isDirectory()) { + servletWebServer.setDocumentRoot(root); + } + } + } + + /** + * Resolve path prefix to static resources. + */ + private String resolvePathPrefix() { + String fullExecutablePath = decode(this.getClass().getResource("").getPath(), StandardCharsets.UTF_8); + String rootPath = Path.of(".").toUri().normalize().getPath(); + String extractedPath = fullExecutablePath.replace(rootPath, ""); + int extractionEndIndex = extractedPath.indexOf("target/"); + if (extractionEndIndex <= 0) { + return ""; + } + return extractedPath.substring(0, extractionEndIndex); + } + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = jHipsterProperties.getCors(); + if (!CollectionUtils.isEmpty(config.getAllowedOrigins()) || !CollectionUtils.isEmpty(config.getAllowedOriginPatterns())) { + LOG.debug("Registering CORS filter"); + source.registerCorsConfiguration("/api/**", config); + source.registerCorsConfiguration("/management/**", config); + source.registerCorsConfiguration("/v3/api-docs", config); + source.registerCorsConfiguration("/swagger-ui/**", config); + } + return new CorsFilter(source); + } +} diff --git a/src/main/java/com/example/questionbank/config/package-info.java b/src/main/java/com/example/questionbank/config/package-info.java new file mode 100644 index 0000000..02c8e7d --- /dev/null +++ b/src/main/java/com/example/questionbank/config/package-info.java @@ -0,0 +1,4 @@ +/** + * Application configuration. + */ +package com.example.questionbank.config; diff --git a/src/main/java/com/example/questionbank/domain/AbstractAuditingEntity.java b/src/main/java/com/example/questionbank/domain/AbstractAuditingEntity.java new file mode 100644 index 0000000..d30d365 --- /dev/null +++ b/src/main/java/com/example/questionbank/domain/AbstractAuditingEntity.java @@ -0,0 +1,77 @@ +package com.example.questionbank.domain; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * Base abstract class for entities which will hold definitions for created, last modified, created by, + * last modified by attributes. + */ +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@JsonIgnoreProperties(value = { "createdBy", "createdDate", "lastModifiedBy", "lastModifiedDate" }, allowGetters = true) +public abstract class AbstractAuditingEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + public abstract T getId(); + + @CreatedBy + @Column(name = "created_by", nullable = false, length = 50, updatable = false) + private String createdBy; + + @CreatedDate + @Column(name = "created_date", updatable = false) + private Instant createdDate = Instant.now(); + + @LastModifiedBy + @Column(name = "last_modified_by", length = 50) + private String lastModifiedBy; + + @LastModifiedDate + @Column(name = "last_modified_date") + private Instant lastModifiedDate = Instant.now(); + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public Instant getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(Instant createdDate) { + this.createdDate = createdDate; + } + + public String getLastModifiedBy() { + return lastModifiedBy; + } + + public void setLastModifiedBy(String lastModifiedBy) { + this.lastModifiedBy = lastModifiedBy; + } + + public Instant getLastModifiedDate() { + return lastModifiedDate; + } + + public void setLastModifiedDate(Instant lastModifiedDate) { + this.lastModifiedDate = lastModifiedDate; + } +} diff --git a/src/main/java/com/example/questionbank/domain/Authority.java b/src/main/java/com/example/questionbank/domain/Authority.java new file mode 100644 index 0000000..50718fd --- /dev/null +++ b/src/main/java/com/example/questionbank/domain/Authority.java @@ -0,0 +1,96 @@ +package com.example.questionbank.domain; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; +import org.springframework.data.domain.Persistable; + +/** + * A Authority. + */ +@Entity +@Table(name = "jhi_authority") +@JsonIgnoreProperties(value = { "new", "id" }) +@SuppressWarnings("common-java:DuplicatedBlocks") +public class Authority implements Serializable, Persistable { + + @Serial + private static final long serialVersionUID = 1L; + + @NotNull + @Size(max = 50) + @Id + @Column(name = "name", length = 50, nullable = false) + private String name; + + @org.springframework.data.annotation.Transient + @Transient + private boolean isPersisted; + + // jhipster-needle-entity-add-field - JHipster will add fields here + + public String getName() { + return this.name; + } + + public Authority name(String name) { + this.setName(name); + return this; + } + + public void setName(String name) { + this.name = name; + } + + @PostLoad + @PostPersist + public void updateEntityState() { + this.setIsPersisted(); + } + + @Override + public String getId() { + return this.name; + } + + @org.springframework.data.annotation.Transient + @Transient + @Override + public boolean isNew() { + return !this.isPersisted; + } + + public Authority setIsPersisted() { + this.isPersisted = true; + return this; + } + + // jhipster-needle-entity-add-getters-setters - JHipster will add getters and setters here + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Authority)) { + return false; + } + return getName() != null && getName().equals(((Authority) o).getName()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getName()); + } + + // prettier-ignore + @Override + public String toString() { + return "Authority{" + + "name=" + getName() + + "}"; + } +} diff --git a/src/main/java/com/example/questionbank/domain/User.java b/src/main/java/com/example/questionbank/domain/User.java new file mode 100644 index 0000000..578b02a --- /dev/null +++ b/src/main/java/com/example/questionbank/domain/User.java @@ -0,0 +1,231 @@ +package com.example.questionbank.domain; + +import com.example.questionbank.config.Constants; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.annotations.BatchSize; + +/** + * A user. + */ +@Entity +@Table(name = "jhi_user") +public class User extends AbstractAuditingEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator") + @SequenceGenerator(name = "sequenceGenerator") + @Column(name = "id") + private Long id; + + @NotNull + @Pattern(regexp = Constants.LOGIN_REGEX) + @Size(min = 1, max = 50) + @Column(name = "login", length = 50, unique = true, nullable = false) + private String login; + + @JsonIgnore + @NotNull + @Size(min = 60, max = 60) + @Column(name = "password_hash", length = 60, nullable = false) + private String password; + + @Size(max = 50) + @Column(name = "first_name", length = 50) + private String firstName; + + @Size(max = 50) + @Column(name = "last_name", length = 50) + private String lastName; + + @Email + @Size(min = 5, max = 254) + @Column(name = "email", length = 254, unique = true) + private String email; + + @NotNull + @Column(name = "activated", nullable = false) + private boolean activated = false; + + @Size(min = 2, max = 10) + @Column(name = "lang_key", length = 10) + private String langKey; + + @Size(max = 256) + @Column(name = "image_url", length = 256) + private String imageUrl; + + @Size(max = 20) + @Column(name = "activation_key", length = 20) + @JsonIgnore + private String activationKey; + + @Size(max = 20) + @Column(name = "reset_key", length = 20) + @JsonIgnore + private String resetKey; + + @Column(name = "reset_date") + private Instant resetDate = null; + + @JsonIgnore + @ManyToMany + @JoinTable( + name = "jhi_user_authority", + joinColumns = { @JoinColumn(name = "user_id", referencedColumnName = "id") }, + inverseJoinColumns = { @JoinColumn(name = "authority_name", referencedColumnName = "name") } + ) + @BatchSize(size = 20) + private Set authorities = new HashSet<>(); + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getLogin() { + return login; + } + + // Lowercase the login before saving it in database + public void setLogin(String login) { + this.login = StringUtils.lowerCase(login, Locale.ENGLISH); + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public boolean isActivated() { + return activated; + } + + public void setActivated(boolean activated) { + this.activated = activated; + } + + public String getActivationKey() { + return activationKey; + } + + public void setActivationKey(String activationKey) { + this.activationKey = activationKey; + } + + public String getResetKey() { + return resetKey; + } + + public void setResetKey(String resetKey) { + this.resetKey = resetKey; + } + + public Instant getResetDate() { + return resetDate; + } + + public void setResetDate(Instant resetDate) { + this.resetDate = resetDate; + } + + public String getLangKey() { + return langKey; + } + + public void setLangKey(String langKey) { + this.langKey = langKey; + } + + public Set getAuthorities() { + return authorities; + } + + public void setAuthorities(Set authorities) { + this.authorities = authorities; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof User)) { + return false; + } + return id != null && id.equals(((User) o).id); + } + + @Override + public int hashCode() { + // see https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/ + return getClass().hashCode(); + } + + // prettier-ignore + @Override + public String toString() { + return "User{" + + "login='" + login + '\'' + + ", firstName='" + firstName + '\'' + + ", lastName='" + lastName + '\'' + + ", email='" + email + '\'' + + ", imageUrl='" + imageUrl + '\'' + + ", activated='" + activated + '\'' + + ", langKey='" + langKey + '\'' + + ", activationKey='" + activationKey + '\'' + + "}"; + } +} diff --git a/src/main/java/com/example/questionbank/domain/package-info.java b/src/main/java/com/example/questionbank/domain/package-info.java new file mode 100644 index 0000000..e0109ff --- /dev/null +++ b/src/main/java/com/example/questionbank/domain/package-info.java @@ -0,0 +1,4 @@ +/** + * Domain objects. + */ +package com.example.questionbank.domain; diff --git a/src/main/java/com/example/questionbank/management/SecurityMetersService.java b/src/main/java/com/example/questionbank/management/SecurityMetersService.java new file mode 100644 index 0000000..6071434 --- /dev/null +++ b/src/main/java/com/example/questionbank/management/SecurityMetersService.java @@ -0,0 +1,50 @@ +package com.example.questionbank.management; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.stereotype.Service; + +@Service +public class SecurityMetersService { + + public static final String INVALID_TOKENS_METER_NAME = "security.authentication.invalid-tokens"; + public static final String INVALID_TOKENS_METER_DESCRIPTION = + "Indicates validation error count of the tokens presented by the clients."; + public static final String INVALID_TOKENS_METER_BASE_UNIT = "errors"; + public static final String INVALID_TOKENS_METER_CAUSE_DIMENSION = "cause"; + + private final Counter tokenInvalidSignatureCounter; + private final Counter tokenExpiredCounter; + private final Counter tokenUnsupportedCounter; + private final Counter tokenMalformedCounter; + + public SecurityMetersService(MeterRegistry registry) { + this.tokenInvalidSignatureCounter = invalidTokensCounterForCauseBuilder("invalid-signature").register(registry); + this.tokenExpiredCounter = invalidTokensCounterForCauseBuilder("expired").register(registry); + this.tokenUnsupportedCounter = invalidTokensCounterForCauseBuilder("unsupported").register(registry); + this.tokenMalformedCounter = invalidTokensCounterForCauseBuilder("malformed").register(registry); + } + + private Counter.Builder invalidTokensCounterForCauseBuilder(String cause) { + return Counter.builder(INVALID_TOKENS_METER_NAME) + .baseUnit(INVALID_TOKENS_METER_BASE_UNIT) + .description(INVALID_TOKENS_METER_DESCRIPTION) + .tag(INVALID_TOKENS_METER_CAUSE_DIMENSION, cause); + } + + public void trackTokenInvalidSignature() { + this.tokenInvalidSignatureCounter.increment(); + } + + public void trackTokenExpired() { + this.tokenExpiredCounter.increment(); + } + + public void trackTokenUnsupported() { + this.tokenUnsupportedCounter.increment(); + } + + public void trackTokenMalformed() { + this.tokenMalformedCounter.increment(); + } +} diff --git a/src/main/java/com/example/questionbank/management/package-info.java b/src/main/java/com/example/questionbank/management/package-info.java new file mode 100644 index 0000000..0522913 --- /dev/null +++ b/src/main/java/com/example/questionbank/management/package-info.java @@ -0,0 +1,4 @@ +/** + * Application management. + */ +package com.example.questionbank.management; diff --git a/src/main/java/com/example/questionbank/package-info.java b/src/main/java/com/example/questionbank/package-info.java new file mode 100644 index 0000000..75b9769 --- /dev/null +++ b/src/main/java/com/example/questionbank/package-info.java @@ -0,0 +1,4 @@ +/** + * Application root. + */ +package com.example.questionbank; diff --git a/src/main/java/com/example/questionbank/repository/AuthorityRepository.java b/src/main/java/com/example/questionbank/repository/AuthorityRepository.java new file mode 100644 index 0000000..2f05981 --- /dev/null +++ b/src/main/java/com/example/questionbank/repository/AuthorityRepository.java @@ -0,0 +1,12 @@ +package com.example.questionbank.repository; + +import com.example.questionbank.domain.Authority; +import org.springframework.data.jpa.repository.*; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for the Authority entity. + */ +@SuppressWarnings("unused") +@Repository +public interface AuthorityRepository extends JpaRepository {} diff --git a/src/main/java/com/example/questionbank/repository/UserRepository.java b/src/main/java/com/example/questionbank/repository/UserRepository.java new file mode 100644 index 0000000..6c92fed --- /dev/null +++ b/src/main/java/com/example/questionbank/repository/UserRepository.java @@ -0,0 +1,34 @@ +package com.example.questionbank.repository; + +import com.example.questionbank.domain.User; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.*; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for the {@link User} entity. + */ +@Repository +public interface UserRepository extends JpaRepository { + Optional findOneByActivationKey(String activationKey); + + List findAllByActivatedIsFalseAndActivationKeyIsNotNullAndCreatedDateBefore(Instant dateTime); + + Optional findOneByResetKey(String resetKey); + + Optional findOneByEmailIgnoreCase(String email); + + Optional findOneByLogin(String login); + + @EntityGraph(attributePaths = "authorities") + Optional findOneWithAuthoritiesByLogin(String login); + + @EntityGraph(attributePaths = "authorities") + Optional findOneWithAuthoritiesByEmailIgnoreCase(String email); + + Page findAllByIdNotNullAndActivatedIsTrue(Pageable pageable); +} diff --git a/src/main/java/com/example/questionbank/repository/package-info.java b/src/main/java/com/example/questionbank/repository/package-info.java new file mode 100644 index 0000000..9c1913d --- /dev/null +++ b/src/main/java/com/example/questionbank/repository/package-info.java @@ -0,0 +1,4 @@ +/** + * Repository layer. + */ +package com.example.questionbank.repository; diff --git a/src/main/java/com/example/questionbank/security/AuthoritiesConstants.java b/src/main/java/com/example/questionbank/security/AuthoritiesConstants.java new file mode 100644 index 0000000..39d4b91 --- /dev/null +++ b/src/main/java/com/example/questionbank/security/AuthoritiesConstants.java @@ -0,0 +1,15 @@ +package com.example.questionbank.security; + +/** + * Constants for Spring Security authorities. + */ +public final class AuthoritiesConstants { + + public static final String ADMIN = "ROLE_ADMIN"; + + public static final String USER = "ROLE_USER"; + + public static final String ANONYMOUS = "ROLE_ANONYMOUS"; + + private AuthoritiesConstants() {} +} diff --git a/src/main/java/com/example/questionbank/security/DomainUserDetailsService.java b/src/main/java/com/example/questionbank/security/DomainUserDetailsService.java new file mode 100644 index 0000000..30a008b --- /dev/null +++ b/src/main/java/com/example/questionbank/security/DomainUserDetailsService.java @@ -0,0 +1,90 @@ +package com.example.questionbank.security; + +import com.example.questionbank.domain.Authority; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import java.util.*; +import org.hibernate.validator.internal.constraintvalidators.bv.EmailValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Authenticate a user from the database. + */ +@Service("userDetailsService") +public class DomainUserDetailsService implements UserDetailsService { + + private static final Logger LOG = LoggerFactory.getLogger(DomainUserDetailsService.class); + + private final UserRepository userRepository; + + public DomainUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(final String login) { + LOG.debug("Authenticating {}", login); + + if (new EmailValidator().isValid(login, null)) { + return userRepository + .findOneWithAuthoritiesByEmailIgnoreCase(login) + .map(user -> createSpringSecurityUser(login, user)) + .orElseThrow(() -> new UsernameNotFoundException("User with email " + login + " was not found in the database")); + } + + String lowercaseLogin = login.toLowerCase(Locale.ENGLISH); + return userRepository + .findOneWithAuthoritiesByLogin(lowercaseLogin) + .map(user -> createSpringSecurityUser(lowercaseLogin, user)) + .orElseThrow(() -> new UsernameNotFoundException("User " + lowercaseLogin + " was not found in the database")); + } + + private org.springframework.security.core.userdetails.User createSpringSecurityUser(String lowercaseLogin, User user) { + if (!user.isActivated()) { + throw new UserNotActivatedException("User " + lowercaseLogin + " was not activated"); + } + return UserWithId.fromUser(user); + } + + public static class UserWithId extends org.springframework.security.core.userdetails.User { + + private final Long id; + + public UserWithId(String login, String password, Collection authorities, Long id) { + super(login, password, authorities); + this.id = id; + } + + public Long getId() { + return id; + } + + @Override + public boolean equals(Object obj) { + return super.equals(obj); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + public static UserWithId fromUser(User user) { + return new UserWithId( + user.getLogin(), + user.getPassword(), + user.getAuthorities().stream().map(Authority::getName).map(SimpleGrantedAuthority::new).toList(), + user.getId() + ); + } + } +} diff --git a/src/main/java/com/example/questionbank/security/SecurityUtils.java b/src/main/java/com/example/questionbank/security/SecurityUtils.java new file mode 100644 index 0000000..9d70c6f --- /dev/null +++ b/src/main/java/com/example/questionbank/security/SecurityUtils.java @@ -0,0 +1,129 @@ +package com.example.questionbank.security; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * Utility class for Spring Security. + */ +public final class SecurityUtils { + + public static final MacAlgorithm JWT_ALGORITHM = MacAlgorithm.HS512; + + public static final String AUTHORITIES_CLAIM = "auth"; + + public static final String USER_ID_CLAIM = "userId"; + + private SecurityUtils() {} + + /** + * Get the login of the current user. + * + * @return the login of the current user. + */ + public static Optional getCurrentUserLogin() { + SecurityContext securityContext = SecurityContextHolder.getContext(); + return Optional.ofNullable(extractPrincipal(securityContext.getAuthentication())); + } + + private static String extractPrincipal(Authentication authentication) { + // AnonymousAuthenticationToken has a String principal that would fall through below + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + return null; + } else if (authentication.getPrincipal() instanceof UserDetails springSecurityUser) { + return springSecurityUser.getUsername(); + } else if (authentication.getPrincipal() instanceof Jwt jwt) { + return jwt.getSubject(); + } else if (authentication.getPrincipal() instanceof String s) { + return s; + } + return null; + } + + /** + * Get the JWT of the current user. + * + * @return the JWT of the current user. + */ + public static Optional getCurrentUserJWT() { + SecurityContext securityContext = SecurityContextHolder.getContext(); + return Optional.ofNullable(securityContext.getAuthentication()).map(SecurityUtils::extractCredentials); + } + + private static String extractCredentials(Authentication authentication) { + if (authentication.getCredentials() instanceof String token) { + return token; + } else if (authentication.getCredentials() instanceof Jwt jwt) { + return jwt.getTokenValue(); + } + return null; + } + + /** + * Get the Id of the current user. + * + * @return the Id of the current user. + */ + public static Optional getCurrentUserId() { + SecurityContext securityContext = SecurityContextHolder.getContext(); + return Optional.ofNullable(securityContext.getAuthentication()) + .filter(authentication -> authentication.getPrincipal() instanceof ClaimAccessor) + .map(authentication -> (ClaimAccessor) authentication.getPrincipal()) + .map(principal -> principal.getClaim(USER_ID_CLAIM)); + } + + /** + * Check if a user is authenticated. + * + * @return true if the user is authenticated, false otherwise. + */ + public static boolean isAuthenticated() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null && getAuthorities(authentication).noneMatch(AuthoritiesConstants.ANONYMOUS::equals); + } + + /** + * Checks if the current user has any of the authorities. + * + * @param authorities the authorities to check. + * @return true if the current user has any of the authorities, false otherwise. + */ + public static boolean hasCurrentUserAnyOfAuthorities(String... authorities) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null && getAuthorities(authentication).anyMatch(authority -> List.of(authorities).contains(authority)); + } + + /** + * Checks if the current user has none of the authorities. + * + * @param authorities the authorities to check. + * @return true if the current user has none of the authorities, false otherwise. + */ + public static boolean hasCurrentUserNoneOfAuthorities(String... authorities) { + return !hasCurrentUserAnyOfAuthorities(authorities); + } + + /** + * Checks if the current user has a specific authority. + * + * @param authority the authority to check. + * @return true if the current user has the authority, false otherwise. + */ + public static boolean hasCurrentUserThisAuthority(String authority) { + return hasCurrentUserAnyOfAuthorities(authority); + } + + private static Stream getAuthorities(Authentication authentication) { + return authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority); + } +} diff --git a/src/main/java/com/example/questionbank/security/SpringSecurityAuditorAware.java b/src/main/java/com/example/questionbank/security/SpringSecurityAuditorAware.java new file mode 100644 index 0000000..17022a4 --- /dev/null +++ b/src/main/java/com/example/questionbank/security/SpringSecurityAuditorAware.java @@ -0,0 +1,18 @@ +package com.example.questionbank.security; + +import com.example.questionbank.config.Constants; +import java.util.Optional; +import org.springframework.data.domain.AuditorAware; +import org.springframework.stereotype.Component; + +/** + * Implementation of {@link AuditorAware} based on Spring Security. + */ +@Component +public class SpringSecurityAuditorAware implements AuditorAware { + + @Override + public Optional getCurrentAuditor() { + return Optional.of(SecurityUtils.getCurrentUserLogin().orElse(Constants.SYSTEM)); + } +} diff --git a/src/main/java/com/example/questionbank/security/UserNotActivatedException.java b/src/main/java/com/example/questionbank/security/UserNotActivatedException.java new file mode 100644 index 0000000..bef9bba --- /dev/null +++ b/src/main/java/com/example/questionbank/security/UserNotActivatedException.java @@ -0,0 +1,21 @@ +package com.example.questionbank.security; + +import java.io.Serial; +import org.springframework.security.core.AuthenticationException; + +/** + * This exception is thrown in case of a not activated user trying to authenticate. + */ +public class UserNotActivatedException extends AuthenticationException { + + @Serial + private static final long serialVersionUID = 1L; + + public UserNotActivatedException(String message) { + super(message); + } + + public UserNotActivatedException(String message, Throwable t) { + super(message, t); + } +} diff --git a/src/main/java/com/example/questionbank/security/package-info.java b/src/main/java/com/example/questionbank/security/package-info.java new file mode 100644 index 0000000..00cd602 --- /dev/null +++ b/src/main/java/com/example/questionbank/security/package-info.java @@ -0,0 +1,4 @@ +/** + * Application security utilities. + */ +package com.example.questionbank.security; diff --git a/src/main/java/com/example/questionbank/service/EmailAlreadyUsedException.java b/src/main/java/com/example/questionbank/service/EmailAlreadyUsedException.java new file mode 100644 index 0000000..e87c8de --- /dev/null +++ b/src/main/java/com/example/questionbank/service/EmailAlreadyUsedException.java @@ -0,0 +1,13 @@ +package com.example.questionbank.service; + +import java.io.Serial; + +public class EmailAlreadyUsedException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + public EmailAlreadyUsedException() { + super("Email is already in use!"); + } +} diff --git a/src/main/java/com/example/questionbank/service/InvalidPasswordException.java b/src/main/java/com/example/questionbank/service/InvalidPasswordException.java new file mode 100644 index 0000000..1a99981 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/InvalidPasswordException.java @@ -0,0 +1,13 @@ +package com.example.questionbank.service; + +import java.io.Serial; + +public class InvalidPasswordException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + public InvalidPasswordException() { + super("Incorrect password"); + } +} diff --git a/src/main/java/com/example/questionbank/service/MailService.java b/src/main/java/com/example/questionbank/service/MailService.java new file mode 100644 index 0000000..a013ebc --- /dev/null +++ b/src/main/java/com/example/questionbank/service/MailService.java @@ -0,0 +1,120 @@ +package com.example.questionbank.service; + +import com.example.questionbank.domain.User; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.MessageSource; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import tech.jhipster.config.JHipsterProperties; + +/** + * Service for sending emails asynchronously. + *

+ * We use the {@link Async} annotation to send emails asynchronously. + */ +@Service +public class MailService { + + private static final Logger LOG = LoggerFactory.getLogger(MailService.class); + + private static final String USER = "user"; + + private static final String BASE_URL = "baseUrl"; + + private final JHipsterProperties jHipsterProperties; + + private final JavaMailSender javaMailSender; + + private final MessageSource messageSource; + + private final SpringTemplateEngine templateEngine; + + public MailService( + JHipsterProperties jHipsterProperties, + JavaMailSender javaMailSender, + MessageSource messageSource, + SpringTemplateEngine templateEngine + ) { + this.jHipsterProperties = jHipsterProperties; + this.javaMailSender = javaMailSender; + this.messageSource = messageSource; + this.templateEngine = templateEngine; + } + + @Async + public void sendEmail(String to, String subject, String content, boolean isMultipart, boolean isHtml) { + sendEmailSync(to, subject, content, isMultipart, isHtml); + } + + private void sendEmailSync(String to, String subject, String content, boolean isMultipart, boolean isHtml) { + LOG.debug( + "Send email[multipart '{}' and html '{}'] to '{}' with subject '{}' and content={}", + isMultipart, + isHtml, + to, + subject, + content + ); + + // Prepare message using a Spring helper + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + try { + MimeMessageHelper message = new MimeMessageHelper(mimeMessage, isMultipart, StandardCharsets.UTF_8.name()); + message.setTo(to); + message.setFrom(jHipsterProperties.getMail().getFrom()); + message.setSubject(subject); + message.setText(content, isHtml); + javaMailSender.send(mimeMessage); + LOG.debug("Sent email to User '{}'", to); + } catch (MailException | MessagingException e) { + LOG.warn("Email could not be sent to user '{}'", to, e); + } + } + + @Async + public void sendEmailFromTemplate(User user, String templateName, String titleKey) { + sendEmailFromTemplateSync(user, templateName, titleKey); + } + + private void sendEmailFromTemplateSync(User user, String templateName, String titleKey) { + if (user.getEmail() == null) { + LOG.debug("Email doesn't exist for user '{}'", user.getLogin()); + return; + } + Locale locale = Locale.forLanguageTag(user.getLangKey()); + Context context = new Context(locale); + context.setVariable(USER, user); + context.setVariable(BASE_URL, jHipsterProperties.getMail().getBaseUrl()); + String content = templateEngine.process(templateName, context); + String subject = messageSource.getMessage(titleKey, null, locale); + sendEmailSync(user.getEmail(), subject, content, false, true); + } + + @Async + public void sendActivationEmail(User user) { + LOG.debug("Sending activation email to '{}'", user.getEmail()); + sendEmailFromTemplateSync(user, "mail/activationEmail", "email.activation.title"); + } + + @Async + public void sendCreationEmail(User user) { + LOG.debug("Sending creation email to '{}'", user.getEmail()); + sendEmailFromTemplateSync(user, "mail/creationEmail", "email.activation.title"); + } + + @Async + public void sendPasswordResetMail(User user) { + LOG.debug("Sending password reset email to '{}'", user.getEmail()); + sendEmailFromTemplateSync(user, "mail/passwordResetEmail", "email.reset.title"); + } +} diff --git a/src/main/java/com/example/questionbank/service/UserService.java b/src/main/java/com/example/questionbank/service/UserService.java new file mode 100644 index 0000000..4b596d8 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/UserService.java @@ -0,0 +1,288 @@ +package com.example.questionbank.service; + +import com.example.questionbank.config.Constants; +import com.example.questionbank.domain.Authority; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.AuthorityRepository; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.security.AuthoritiesConstants; +import com.example.questionbank.security.SecurityUtils; +import com.example.questionbank.service.dto.AdminUserDTO; +import com.example.questionbank.service.dto.UserDTO; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tech.jhipster.security.RandomUtil; + +/** + * Service class for managing users. + */ +@Service +@Transactional +public class UserService { + + private static final Logger LOG = LoggerFactory.getLogger(UserService.class); + + private final UserRepository userRepository; + + private final PasswordEncoder passwordEncoder; + + private final AuthorityRepository authorityRepository; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, AuthorityRepository authorityRepository) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.authorityRepository = authorityRepository; + } + + public Optional activateRegistration(String key) { + LOG.debug("Activating user for activation key {}", key); + return userRepository.findOneByActivationKey(key).map(user -> { + // activate given user for the registration key. + user.setActivated(true); + user.setActivationKey(null); + LOG.debug("Activated user: {}", user); + return user; + }); + } + + public Optional completePasswordReset(String newPassword, String key) { + LOG.debug("Reset user password for reset key {}", key); + return userRepository + .findOneByResetKey(key) + .filter(user -> user.getResetDate().isAfter(Instant.now().minus(1, ChronoUnit.DAYS))) + .map(user -> { + user.setPassword(passwordEncoder.encode(newPassword)); + user.setResetKey(null); + user.setResetDate(null); + return user; + }); + } + + public Optional requestPasswordReset(String mail) { + return userRepository + .findOneByEmailIgnoreCase(mail) + .filter(User::isActivated) + .map(user -> { + user.setResetKey(RandomUtil.generateResetKey()); + user.setResetDate(Instant.now()); + return user; + }); + } + + public User registerUser(AdminUserDTO userDTO, String password) { + userRepository.findOneByLogin(userDTO.getLogin().toLowerCase()).ifPresent(existingUser -> { + boolean removed = removeNonActivatedUser(existingUser); + if (!removed) { + throw new UsernameAlreadyUsedException(); + } + }); + userRepository.findOneByEmailIgnoreCase(userDTO.getEmail()).ifPresent(existingUser -> { + boolean removed = removeNonActivatedUser(existingUser); + if (!removed) { + throw new EmailAlreadyUsedException(); + } + }); + User newUser = new User(); + String encryptedPassword = passwordEncoder.encode(password); + newUser.setLogin(userDTO.getLogin().toLowerCase()); + // new user gets initially a generated password + newUser.setPassword(encryptedPassword); + newUser.setFirstName(userDTO.getFirstName()); + newUser.setLastName(userDTO.getLastName()); + if (userDTO.getEmail() != null) { + newUser.setEmail(userDTO.getEmail().toLowerCase()); + } + newUser.setImageUrl(userDTO.getImageUrl()); + newUser.setLangKey(userDTO.getLangKey()); + // new user is not active + newUser.setActivated(false); + // new user gets registration key + newUser.setActivationKey(RandomUtil.generateActivationKey()); + Set authorities = new HashSet<>(); + authorityRepository.findById(AuthoritiesConstants.USER).ifPresent(authorities::add); + newUser.setAuthorities(authorities); + userRepository.save(newUser); + LOG.debug("Created Information for User: {}", newUser); + return newUser; + } + + private boolean removeNonActivatedUser(User existingUser) { + if (existingUser.isActivated()) { + return false; + } + userRepository.delete(existingUser); + userRepository.flush(); + return true; + } + + public User createUser(AdminUserDTO userDTO) { + User user = new User(); + user.setLogin(userDTO.getLogin().toLowerCase()); + user.setFirstName(userDTO.getFirstName()); + user.setLastName(userDTO.getLastName()); + if (userDTO.getEmail() != null) { + user.setEmail(userDTO.getEmail().toLowerCase()); + } + user.setImageUrl(userDTO.getImageUrl()); + if (userDTO.getLangKey() == null) { + user.setLangKey(Constants.DEFAULT_LANGUAGE); // default language + } else { + user.setLangKey(userDTO.getLangKey()); + } + String encryptedPassword = passwordEncoder.encode(RandomUtil.generatePassword()); + user.setPassword(encryptedPassword); + user.setResetKey(RandomUtil.generateResetKey()); + user.setResetDate(Instant.now()); + user.setActivated(true); + if (userDTO.getAuthorities() != null) { + Set authorities = userDTO + .getAuthorities() + .stream() + .map(authorityRepository::findById) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + user.setAuthorities(authorities); + } + userRepository.save(user); + LOG.debug("Created Information for User: {}", user); + return user; + } + + /** + * Update all information for a specific user, and return the modified user. + * + * @param userDTO user to update. + * @return updated user. + */ + public Optional updateUser(AdminUserDTO userDTO) { + return Optional.of(userRepository.findById(userDTO.getId())) + .filter(Optional::isPresent) + .map(Optional::get) + .map(user -> { + user.setLogin(userDTO.getLogin().toLowerCase()); + user.setFirstName(userDTO.getFirstName()); + user.setLastName(userDTO.getLastName()); + if (userDTO.getEmail() != null) { + user.setEmail(userDTO.getEmail().toLowerCase()); + } + user.setImageUrl(userDTO.getImageUrl()); + user.setActivated(userDTO.isActivated()); + user.setLangKey(userDTO.getLangKey()); + Set managedAuthorities = user.getAuthorities(); + managedAuthorities.clear(); + userDTO + .getAuthorities() + .stream() + .map(authorityRepository::findById) + .filter(Optional::isPresent) + .map(Optional::get) + .forEach(managedAuthorities::add); + userRepository.save(user); + LOG.debug("Changed Information for User: {}", user); + return user; + }) + .map(AdminUserDTO::new); + } + + public void deleteUser(String login) { + userRepository.findOneByLogin(login).ifPresent(user -> { + userRepository.delete(user); + LOG.debug("Deleted User: {}", user); + }); + } + + /** + * Update basic information (first name, last name, email, language) for the current user. + * + * @param firstName first name of user. + * @param lastName last name of user. + * @param email email id of user. + * @param langKey language key. + * @param imageUrl image URL of user. + */ + public void updateUser(String firstName, String lastName, String email, String langKey, String imageUrl) { + SecurityUtils.getCurrentUserLogin() + .flatMap(userRepository::findOneByLogin) + .ifPresent(user -> { + user.setFirstName(firstName); + user.setLastName(lastName); + if (email != null) { + user.setEmail(email.toLowerCase()); + } + user.setLangKey(langKey); + user.setImageUrl(imageUrl); + userRepository.save(user); + LOG.debug("Changed Information for User: {}", user); + }); + } + + @Transactional + public void changePassword(String currentClearTextPassword, String newPassword) { + SecurityUtils.getCurrentUserLogin() + .flatMap(userRepository::findOneByLogin) + .ifPresent(user -> { + String currentEncryptedPassword = user.getPassword(); + if (!passwordEncoder.matches(currentClearTextPassword, currentEncryptedPassword)) { + throw new InvalidPasswordException(); + } + String encryptedPassword = passwordEncoder.encode(newPassword); + user.setPassword(encryptedPassword); + LOG.debug("Changed password for User: {}", user); + }); + } + + @Transactional(readOnly = true) + public Page getAllManagedUsers(Pageable pageable) { + return userRepository.findAll(pageable).map(AdminUserDTO::new); + } + + @Transactional(readOnly = true) + public Page getAllPublicUsers(Pageable pageable) { + return userRepository.findAllByIdNotNullAndActivatedIsTrue(pageable).map(UserDTO::new); + } + + @Transactional(readOnly = true) + public Optional getUserWithAuthoritiesByLogin(String login) { + return userRepository.findOneWithAuthoritiesByLogin(login); + } + + @Transactional(readOnly = true) + public Optional getUserWithAuthorities() { + return SecurityUtils.getCurrentUserLogin().flatMap(userRepository::findOneWithAuthoritiesByLogin); + } + + /** + * Not activated users should be automatically deleted after 3 days. + *

+ * This is scheduled to get fired every day, at 01:00 (am). + */ + @Scheduled(cron = "0 0 1 * * ?") + public void removeNotActivatedUsers() { + userRepository + .findAllByActivatedIsFalseAndActivationKeyIsNotNullAndCreatedDateBefore(Instant.now().minus(3, ChronoUnit.DAYS)) + .forEach(user -> { + LOG.debug("Deleting not activated user {}", user.getLogin()); + userRepository.delete(user); + }); + } + + /** + * Gets a list of all the authorities. + * @return a list of all the authorities. + */ + @Transactional(readOnly = true) + public List getAuthorities() { + return authorityRepository.findAll().stream().map(Authority::getName).toList(); + } +} diff --git a/src/main/java/com/example/questionbank/service/UsernameAlreadyUsedException.java b/src/main/java/com/example/questionbank/service/UsernameAlreadyUsedException.java new file mode 100644 index 0000000..07993d3 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/UsernameAlreadyUsedException.java @@ -0,0 +1,13 @@ +package com.example.questionbank.service; + +import java.io.Serial; + +public class UsernameAlreadyUsedException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + public UsernameAlreadyUsedException() { + super("Login name already used!"); + } +} diff --git a/src/main/java/com/example/questionbank/service/dto/AdminUserDTO.java b/src/main/java/com/example/questionbank/service/dto/AdminUserDTO.java new file mode 100644 index 0000000..91c0cad --- /dev/null +++ b/src/main/java/com/example/questionbank/service/dto/AdminUserDTO.java @@ -0,0 +1,198 @@ +package com.example.questionbank.service.dto; + +import com.example.questionbank.config.Constants; +import com.example.questionbank.domain.Authority; +import com.example.questionbank.domain.User; +import jakarta.validation.constraints.*; +import java.io.Serial; +import java.io.Serializable; +import java.time.Instant; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A DTO representing a user, with his authorities. + */ +public class AdminUserDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + @NotBlank + @Pattern(regexp = Constants.LOGIN_REGEX) + @Size(min = 1, max = 50) + private String login; + + @Size(max = 50) + private String firstName; + + @Size(max = 50) + private String lastName; + + @Email + @Size(min = 5, max = 254) + private String email; + + @Size(max = 256) + private String imageUrl; + + private boolean activated = false; + + @Size(min = 2, max = 10) + private String langKey; + + private String createdBy; + + private Instant createdDate; + + private String lastModifiedBy; + + private Instant lastModifiedDate; + + private Set authorities; + + public AdminUserDTO() { + // Empty constructor needed for Jackson. + } + + public AdminUserDTO(User user) { + this.id = user.getId(); + this.login = user.getLogin(); + this.firstName = user.getFirstName(); + this.lastName = user.getLastName(); + this.email = user.getEmail(); + this.activated = user.isActivated(); + this.imageUrl = user.getImageUrl(); + this.langKey = user.getLangKey(); + this.createdBy = user.getCreatedBy(); + this.createdDate = user.getCreatedDate(); + this.lastModifiedBy = user.getLastModifiedBy(); + this.lastModifiedDate = user.getLastModifiedDate(); + this.authorities = user.getAuthorities().stream().map(Authority::getName).collect(Collectors.toSet()); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public boolean isActivated() { + return activated; + } + + public void setActivated(boolean activated) { + this.activated = activated; + } + + public String getLangKey() { + return langKey; + } + + public void setLangKey(String langKey) { + this.langKey = langKey; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public Instant getCreatedDate() { + return createdDate; + } + + public void setCreatedDate(Instant createdDate) { + this.createdDate = createdDate; + } + + public String getLastModifiedBy() { + return lastModifiedBy; + } + + public void setLastModifiedBy(String lastModifiedBy) { + this.lastModifiedBy = lastModifiedBy; + } + + public Instant getLastModifiedDate() { + return lastModifiedDate; + } + + public void setLastModifiedDate(Instant lastModifiedDate) { + this.lastModifiedDate = lastModifiedDate; + } + + public Set getAuthorities() { + return authorities; + } + + public void setAuthorities(Set authorities) { + this.authorities = authorities; + } + + // prettier-ignore + @Override + public String toString() { + return "AdminUserDTO{" + + "login='" + login + '\'' + + ", firstName='" + firstName + '\'' + + ", lastName='" + lastName + '\'' + + ", email='" + email + '\'' + + ", imageUrl='" + imageUrl + '\'' + + ", activated=" + activated + + ", langKey='" + langKey + '\'' + + ", createdBy=" + createdBy + + ", createdDate=" + createdDate + + ", lastModifiedBy='" + lastModifiedBy + '\'' + + ", lastModifiedDate=" + lastModifiedDate + + ", authorities=" + authorities + + "}"; + } +} diff --git a/src/main/java/com/example/questionbank/service/dto/PasswordChangeDTO.java b/src/main/java/com/example/questionbank/service/dto/PasswordChangeDTO.java new file mode 100644 index 0000000..0c0a992 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/dto/PasswordChangeDTO.java @@ -0,0 +1,41 @@ +package com.example.questionbank.service.dto; + +import java.io.Serial; +import java.io.Serializable; + +/** + * A DTO representing a password change required data - current and new password. + */ +public class PasswordChangeDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String currentPassword; + private String newPassword; + + public PasswordChangeDTO() { + // Empty constructor needed for Jackson. + } + + public PasswordChangeDTO(String currentPassword, String newPassword) { + this.currentPassword = currentPassword; + this.newPassword = newPassword; + } + + public String getCurrentPassword() { + return currentPassword; + } + + public void setCurrentPassword(String currentPassword) { + this.currentPassword = currentPassword; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/src/main/java/com/example/questionbank/service/dto/UserDTO.java b/src/main/java/com/example/questionbank/service/dto/UserDTO.java new file mode 100644 index 0000000..7319d84 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/dto/UserDTO.java @@ -0,0 +1,76 @@ +package com.example.questionbank.service.dto; + +import com.example.questionbank.domain.User; +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * A DTO representing a user, with only the public attributes. + */ +public class UserDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long id; + + private String login; + + public UserDTO() { + // Empty constructor needed for Jackson. + } + + public UserDTO(User user) { + this.id = user.getId(); + // Customize it here if you need, or not, firstName/lastName/etc + this.login = user.getLogin(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + UserDTO userDTO = (UserDTO) o; + if (userDTO.getId() == null || getId() == null) { + return false; + } + + return Objects.equals(getId(), userDTO.getId()) && Objects.equals(getLogin(), userDTO.getLogin()); + } + + @Override + public int hashCode() { + return Objects.hash(getId(), getLogin()); + } + + // prettier-ignore + @Override + public String toString() { + return "UserDTO{" + + "id='" + id + '\'' + + ", login='" + login + '\'' + + "}"; + } +} diff --git a/src/main/java/com/example/questionbank/service/dto/package-info.java b/src/main/java/com/example/questionbank/service/dto/package-info.java new file mode 100644 index 0000000..8bcb931 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/dto/package-info.java @@ -0,0 +1,4 @@ +/** + * Data transfer objects for rest mapping. + */ +package com.example.questionbank.service.dto; diff --git a/src/main/java/com/example/questionbank/service/mapper/UserMapper.java b/src/main/java/com/example/questionbank/service/mapper/UserMapper.java new file mode 100644 index 0000000..ca0ded3 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/mapper/UserMapper.java @@ -0,0 +1,150 @@ +package com.example.questionbank.service.mapper; + +import com.example.questionbank.domain.Authority; +import com.example.questionbank.domain.User; +import com.example.questionbank.service.dto.AdminUserDTO; +import com.example.questionbank.service.dto.UserDTO; +import java.util.*; +import java.util.stream.Collectors; +import org.mapstruct.BeanMapping; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.springframework.stereotype.Service; + +/** + * Mapper for the entity {@link User} and its DTO called {@link UserDTO}. + * + * Normal mappers are generated using MapStruct, this one is hand-coded as MapStruct + * support is still in beta, and requires a manual step with an IDE. + */ +@Service +public class UserMapper { + + public List usersToUserDTOs(List users) { + return users.stream().filter(Objects::nonNull).map(this::userToUserDTO).toList(); + } + + public UserDTO userToUserDTO(User user) { + return new UserDTO(user); + } + + public List usersToAdminUserDTOs(List users) { + return users.stream().filter(Objects::nonNull).map(this::userToAdminUserDTO).toList(); + } + + public AdminUserDTO userToAdminUserDTO(User user) { + return new AdminUserDTO(user); + } + + public List userDTOsToUsers(List userDTOs) { + return userDTOs.stream().filter(Objects::nonNull).map(this::userDTOToUser).toList(); + } + + public User userDTOToUser(AdminUserDTO userDTO) { + if (userDTO == null) { + return null; + } else { + User user = new User(); + user.setId(userDTO.getId()); + user.setLogin(userDTO.getLogin()); + user.setFirstName(userDTO.getFirstName()); + user.setLastName(userDTO.getLastName()); + user.setEmail(userDTO.getEmail()); + user.setImageUrl(userDTO.getImageUrl()); + user.setCreatedBy(userDTO.getCreatedBy()); + user.setCreatedDate(userDTO.getCreatedDate()); + user.setLastModifiedBy(userDTO.getLastModifiedBy()); + user.setLastModifiedDate(userDTO.getLastModifiedDate()); + user.setActivated(userDTO.isActivated()); + user.setLangKey(userDTO.getLangKey()); + Set authorities = this.authoritiesFromStrings(userDTO.getAuthorities()); + user.setAuthorities(authorities); + return user; + } + } + + private Set authoritiesFromStrings(Set authoritiesAsString) { + Set authorities = new HashSet<>(); + + if (authoritiesAsString != null) { + authorities = authoritiesAsString + .stream() + .map(string -> { + Authority auth = new Authority(); + auth.setName(string); + return auth; + }) + .collect(Collectors.toSet()); + } + + return authorities; + } + + public User userFromId(Long id) { + if (id == null) { + return null; + } + User user = new User(); + user.setId(id); + return user; + } + + @Named("id") + @BeanMapping(ignoreByDefault = true) + @Mapping(target = "id", source = "id") + public UserDTO toDtoId(User user) { + if (user == null) { + return null; + } + UserDTO userDto = new UserDTO(); + userDto.setId(user.getId()); + return userDto; + } + + @Named("idSet") + @BeanMapping(ignoreByDefault = true) + @Mapping(target = "id", source = "id") + public Set toDtoIdSet(Set users) { + if (users == null) { + return Set.of(); + } + + Set userSet = new HashSet<>(); + for (User userEntity : users) { + userSet.add(this.toDtoId(userEntity)); + } + + return userSet; + } + + @Named("login") + @BeanMapping(ignoreByDefault = true) + @Mapping(target = "id", source = "id") + @Mapping(target = "login", source = "login") + public UserDTO toDtoLogin(User user) { + if (user == null) { + return null; + } + UserDTO userDto = new UserDTO(); + userDto.setId(user.getId()); + userDto.setLogin(user.getLogin()); + return userDto; + } + + @Named("loginSet") + @BeanMapping(ignoreByDefault = true) + @Mapping(target = "id", source = "id") + @Mapping(target = "login", source = "login") + public Set toDtoLoginSet(Set users) { + if (users == null) { + return Set.of(); + } + + Set userSet = new HashSet<>(); + for (User userEntity : users) { + userSet.add(this.toDtoLogin(userEntity)); + } + + return userSet; + } +} diff --git a/src/main/java/com/example/questionbank/service/mapper/package-info.java b/src/main/java/com/example/questionbank/service/mapper/package-info.java new file mode 100644 index 0000000..2c345af --- /dev/null +++ b/src/main/java/com/example/questionbank/service/mapper/package-info.java @@ -0,0 +1,4 @@ +/** + * Data transfer objects mappers. + */ +package com.example.questionbank.service.mapper; diff --git a/src/main/java/com/example/questionbank/service/package-info.java b/src/main/java/com/example/questionbank/service/package-info.java new file mode 100644 index 0000000..69637a5 --- /dev/null +++ b/src/main/java/com/example/questionbank/service/package-info.java @@ -0,0 +1,4 @@ +/** + * Service layer. + */ +package com.example.questionbank.service; diff --git a/src/main/java/com/example/questionbank/web/filter/SpaWebFilter.java b/src/main/java/com/example/questionbank/web/filter/SpaWebFilter.java new file mode 100644 index 0000000..83b4c94 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/filter/SpaWebFilter.java @@ -0,0 +1,33 @@ +package com.example.questionbank.web.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.web.filter.OncePerRequestFilter; + +public class SpaWebFilter extends OncePerRequestFilter { + + /** + * Forwards any unmapped paths (except those containing a period) to the client {@code index.html}. + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // Request URI includes the contextPath if any, removed it. + String path = request.getRequestURI().substring(request.getContextPath().length()); + if ( + !path.startsWith("/api") && + !path.startsWith("/management") && + !path.startsWith("/v3/api-docs") && + !path.contains(".") && + path.matches("/(.*)") + ) { + request.getRequestDispatcher("/index.html").forward(request, response); + return; + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/example/questionbank/web/filter/package-info.java b/src/main/java/com/example/questionbank/web/filter/package-info.java new file mode 100644 index 0000000..2ca428e --- /dev/null +++ b/src/main/java/com/example/questionbank/web/filter/package-info.java @@ -0,0 +1,4 @@ +/** + * Request chain filters. + */ +package com.example.questionbank.web.filter; diff --git a/src/main/java/com/example/questionbank/web/rest/AccountResource.java b/src/main/java/com/example/questionbank/web/rest/AccountResource.java new file mode 100644 index 0000000..657ffd9 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/AccountResource.java @@ -0,0 +1,204 @@ +package com.example.questionbank.web.rest; + +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.security.SecurityUtils; +import com.example.questionbank.service.MailService; +import com.example.questionbank.service.UserService; +import com.example.questionbank.service.dto.AdminUserDTO; +import com.example.questionbank.service.dto.PasswordChangeDTO; +import com.example.questionbank.web.rest.errors.*; +import com.example.questionbank.web.rest.vm.KeyAndPasswordVM; +import com.example.questionbank.web.rest.vm.ManagedUserVM; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import java.util.*; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +/** + * REST controller for managing the current user's account. + */ +@RestController +@RequestMapping("/api") +@Validated +public class AccountResource { + + @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "Account resource request invalid") + private static class AccountResourceException extends RuntimeException { + + private AccountResourceException(String message) { + super(message); + } + } + + private static final Logger LOG = LoggerFactory.getLogger(AccountResource.class); + + private final UserRepository userRepository; + + private final UserService userService; + + private final MailService mailService; + + private final PasswordEncoder passwordEncoder; + + public AccountResource( + UserRepository userRepository, + UserService userService, + MailService mailService, + PasswordEncoder passwordEncoder + ) { + this.userRepository = userRepository; + this.userService = userService; + this.mailService = mailService; + this.passwordEncoder = passwordEncoder; + } + + /** + * {@code POST /register} : register the user. + * + * @param managedUserVM the managed user View Model. + * @throws InvalidPasswordException {@code 400 (Bad Request)} if the password is incorrect. + * @throws EmailAlreadyUsedException {@code 400 (Bad Request)} if the email is already used. + * @throws LoginAlreadyUsedException {@code 400 (Bad Request)} if the login is already used. + */ + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public void registerAccount(@Valid @RequestBody ManagedUserVM managedUserVM) { + LOG.debug("REST request to register account"); + if (isPasswordLengthInvalid(managedUserVM.getPassword())) { + throw new InvalidPasswordException(); + } + User user = userService.registerUser(managedUserVM, managedUserVM.getPassword()); + mailService.sendActivationEmail(user); + } + + /** + * {@code GET /activate} : activate the registered user. + * + * @param key the activation key. + * @throws RuntimeException {@code 500 (Internal Server Error)} if the user couldn't be activated. + */ + @GetMapping("/activate") + public void activateAccount(@RequestParam(value = "key") String key) { + LOG.debug("REST request to activate account"); + Optional user = userService.activateRegistration(key); + if (user.isEmpty()) { + throw new AccountResourceException("No user was found for this activation key"); + } + } + + /** + * {@code GET /account} : get the current user. + * + * @return the current user. + * @throws RuntimeException {@code 500 (Internal Server Error)} if the user couldn't be returned. + */ + @GetMapping("/account") + public AdminUserDTO getAccount() { + LOG.debug("REST request to get account"); + return userService + .getUserWithAuthorities() + .map(AdminUserDTO::new) + .orElseThrow(() -> new AccountResourceException("User could not be found")); + } + + /** + * {@code POST /account} : update the current user information. + * + * @param userDTO the current user information. + * @throws EmailAlreadyUsedException {@code 400 (Bad Request)} if the email is already used. + * @throws RuntimeException {@code 500 (Internal Server Error)} if the user login wasn't found. + */ + @PostMapping("/account") + public void saveAccount(@Valid @RequestBody AdminUserDTO userDTO) { + LOG.debug("REST request to save account"); + String userLogin = SecurityUtils.getCurrentUserLogin().orElseThrow(() -> + new AccountResourceException("Current user login not found") + ); + Optional existingUser = userRepository.findOneByEmailIgnoreCase(userDTO.getEmail()); + if (existingUser.isPresent() && !existingUser.orElseThrow().getLogin().equalsIgnoreCase(userLogin)) { + throw new EmailAlreadyUsedException(); + } + Optional user = userRepository.findOneByLogin(userLogin); + if (user.isEmpty()) { + throw new AccountResourceException("User could not be found"); + } + userService.updateUser( + userDTO.getFirstName(), + userDTO.getLastName(), + userDTO.getEmail(), + userDTO.getLangKey(), + userDTO.getImageUrl() + ); + } + + /** + * {@code POST /account/change-password} : changes the current user's password. + * + * @param passwordChangeDto current and new password. + * @throws InvalidPasswordException {@code 400 (Bad Request)} if the new password is incorrect. + */ + @PostMapping(path = "/account/change-password") + public void changePassword(@RequestBody PasswordChangeDTO passwordChangeDto) { + LOG.debug("REST request to change password"); + if (isPasswordLengthInvalid(passwordChangeDto.getNewPassword())) { + throw new InvalidPasswordException(); + } + userService.changePassword(passwordChangeDto.getCurrentPassword(), passwordChangeDto.getNewPassword()); + } + + /** + * {@code POST /account/reset-password/init} : Send an email to reset the password of the user. + * + * @param mail the mail of the user. + */ + @PostMapping(path = "/account/reset-password/init") + public void requestPasswordReset(@RequestBody @Email @Size(min = 5, max = 254) String mail) { + LOG.debug("REST request to request password reset"); + Optional user = userService.requestPasswordReset(mail); + if (user.isPresent()) { + mailService.sendPasswordResetMail(user.orElseThrow()); + } else { + // Pretend the request has been successful to prevent checking which emails really exist + // but log that an invalid attempt has been made + LOG.warn("Password reset requested for non existing mail"); + } + } + + /** + * {@code POST /account/reset-password/finish} : Finish to reset the password of the user. + * + * @param keyAndPassword the generated key and the new password. + * @throws InvalidPasswordException {@code 400 (Bad Request)} if the password is incorrect. + * @throws RuntimeException {@code 500 (Internal Server Error)} if the password could not be reset. + */ + @PostMapping(path = "/account/reset-password/finish") + public void finishPasswordReset(@RequestBody KeyAndPasswordVM keyAndPassword) { + if (isPasswordLengthInvalid(keyAndPassword.getNewPassword())) { + throw new InvalidPasswordException(); + } + Optional user = userService.completePasswordReset(keyAndPassword.getNewPassword(), keyAndPassword.getKey()); + + if (user.isEmpty()) { + // Dummy hash to prevent reset-key enumeration via response-time timing attack: + // mirrors the bcrypt cost of a successful path so both branches take equal time. + passwordEncoder.encode(keyAndPassword.getNewPassword()); + throw new AccountResourceException("No user was found for this reset key"); + } + } + + private static boolean isPasswordLengthInvalid(String password) { + return ( + StringUtils.isEmpty(password) || + password.length() < ManagedUserVM.PASSWORD_MIN_LENGTH || + password.length() > ManagedUserVM.PASSWORD_MAX_LENGTH + ); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/AuthenticateController.java b/src/main/java/com/example/questionbank/web/rest/AuthenticateController.java new file mode 100644 index 0000000..10afacd --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/AuthenticateController.java @@ -0,0 +1,125 @@ +package com.example.questionbank.web.rest; + +import static com.example.questionbank.security.SecurityUtils.AUTHORITIES_CLAIM; +import static com.example.questionbank.security.SecurityUtils.JWT_ALGORITHM; +import static com.example.questionbank.security.SecurityUtils.USER_ID_CLAIM; + +import com.example.questionbank.security.DomainUserDetailsService.UserWithId; +import com.example.questionbank.web.rest.vm.LoginVM; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import java.security.Principal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.web.bind.annotation.*; + +/** + * Controller to authenticate users. + */ +@RestController +@RequestMapping("/api") +public class AuthenticateController { + + private static final Logger LOG = LoggerFactory.getLogger(AuthenticateController.class); + + private final JwtEncoder jwtEncoder; + + @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds:0}") + private long tokenValidityInSeconds; + + @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me:0}") + private long tokenValidityInSecondsForRememberMe; + + private final AuthenticationManagerBuilder authenticationManagerBuilder; + + public AuthenticateController(JwtEncoder jwtEncoder, AuthenticationManagerBuilder authenticationManagerBuilder) { + this.jwtEncoder = jwtEncoder; + this.authenticationManagerBuilder = authenticationManagerBuilder; + } + + @PostMapping("/authenticate") + public ResponseEntity authorize(@Valid @RequestBody LoginVM loginVM) { + var authenticationToken = new UsernamePasswordAuthenticationToken(loginVM.getUsername(), loginVM.getPassword()); + + var authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + String jwt = this.createToken(authentication, loginVM.isRememberMe()); + var httpHeaders = new HttpHeaders(); + httpHeaders.setBearerAuth(jwt); + return new ResponseEntity<>(new JWTToken(jwt), httpHeaders, HttpStatus.OK); + } + + /** + * {@code GET /authenticate} : check if the user is authenticated. + * + * @return the {@link ResponseEntity} with status {@code 204 (No Content)}, + * or with status {@code 401 (Unauthorized)} if not authenticated. + */ + @GetMapping("/authenticate") + public ResponseEntity isAuthenticated(Principal principal) { + LOG.debug("REST request to check if the current user is authenticated"); + return ResponseEntity.status(principal == null ? HttpStatus.UNAUTHORIZED : HttpStatus.NO_CONTENT).build(); + } + + public String createToken(Authentication authentication, boolean rememberMe) { + String authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(" ")); + + var now = Instant.now(); + Instant validity; + if (rememberMe) { + validity = now.plus(this.tokenValidityInSecondsForRememberMe, ChronoUnit.SECONDS); + } else { + validity = now.plus(this.tokenValidityInSeconds, ChronoUnit.SECONDS); + } + + // @formatter:off + JwtClaimsSet.Builder builder = JwtClaimsSet.builder() + .issuedAt(now) + .expiresAt(validity) + .subject(authentication.getName()) + .claim(AUTHORITIES_CLAIM, authorities); + if (authentication.getPrincipal() instanceof UserWithId user) { + builder.claim(USER_ID_CLAIM, user.getId()); + } + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, builder.build())).getTokenValue(); + } + + /** + * Object to return as body in JWT Authentication. + */ + static class JWTToken { + + private String idToken; + + JWTToken(String idToken) { + this.idToken = idToken; + } + + @JsonProperty("id_token") + String getIdToken() { + return idToken; + } + + void setIdToken(String idToken) { + this.idToken = idToken; + } + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/AuthorityResource.java b/src/main/java/com/example/questionbank/web/rest/AuthorityResource.java new file mode 100644 index 0000000..739a3e7 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/AuthorityResource.java @@ -0,0 +1,103 @@ +package com.example.questionbank.web.rest; + +import com.example.questionbank.domain.Authority; +import com.example.questionbank.repository.AuthorityRepository; +import com.example.questionbank.web.rest.errors.BadRequestAlertException; +import jakarta.validation.Valid; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import tech.jhipster.web.util.HeaderUtil; +import tech.jhipster.web.util.ResponseUtil; + +/** + * REST controller for managing {@link com.example.questionbank.domain.Authority}. + */ +@RestController +@RequestMapping("/api/authorities") +@Transactional(rollbackFor = Exception.class) +public class AuthorityResource { + + private static final Logger LOG = LoggerFactory.getLogger(AuthorityResource.class); + + private static final String ENTITY_NAME = "adminAuthority"; + + @Value("${jhipster.clientApp.name:questionBank}") + private String applicationName; + + private final AuthorityRepository authorityRepository; + + public AuthorityResource(AuthorityRepository authorityRepository) { + this.authorityRepository = authorityRepository; + } + + /** + * {@code POST /authorities} : Create a new authority. + * + * @param authority the authority to create. + * @return the {@link ResponseEntity} with status {@code 201 (Created)} and with body the new authority, or with status {@code 400 (Bad Request)} if the authority has already an ID. + * @throws URISyntaxException if the Location URI syntax is incorrect. + */ + @PostMapping("") + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN')") + public ResponseEntity createAuthority(@Valid @RequestBody Authority authority) throws URISyntaxException { + LOG.debug("REST request to save Authority : {}", authority); + if (authorityRepository.existsById(authority.getName())) { + throw new BadRequestAlertException("authority already exists", ENTITY_NAME, "idexists"); + } + authority = authorityRepository.save(authority); + return ResponseEntity.created(new URI("/api/authorities/" + authority.getName())) + .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, authority.getName())) + .body(authority); + } + + /** + * {@code GET /authorities} : get all the Authorities. + * + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and the list of Authorities in body. + */ + @GetMapping("") + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN')") + public List getAllAuthorities() { + LOG.debug("REST request to get all Authorities"); + return authorityRepository.findAll(); + } + + /** + * {@code GET /authorities/:id} : get the "id" authority. + * + * @param id the id of the authority to retrieve. + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and with body the authority, or with status {@code 404 (Not Found)}. + */ + @GetMapping("/{id}") + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN')") + public ResponseEntity getAuthority(@PathVariable("id") String id) { + LOG.debug("REST request to get Authority : {}", id); + Optional authority = authorityRepository.findById(id); + return ResponseUtil.wrapOrNotFound(authority); + } + + /** + * {@code DELETE /authorities/:id} : delete the "id" authority. + * + * @param id the id of the authority to delete. + * @return the {@link ResponseEntity} with status {@code 204 (NO_CONTENT)}. + */ + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyAuthority('ROLE_ADMIN')") + public ResponseEntity deleteAuthority(@PathVariable("id") String id) { + LOG.debug("REST request to delete Authority : {}", id); + authorityRepository.deleteById(id); + return ResponseEntity.noContent() + .headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id)) + .build(); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/PublicUserResource.java b/src/main/java/com/example/questionbank/web/rest/PublicUserResource.java new file mode 100644 index 0000000..1144b39 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/PublicUserResource.java @@ -0,0 +1,61 @@ +package com.example.questionbank.web.rest; + +import com.example.questionbank.service.UserService; +import com.example.questionbank.service.dto.UserDTO; +import java.util.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import tech.jhipster.web.util.PaginationUtil; + +@RestController +@RequestMapping("/api") +public class PublicUserResource { + + private static final List ALLOWED_ORDERED_PROPERTIES = List.of( + "id", + "login", + "firstName", + "lastName", + "email", + "activated", + "langKey" + ); + + private static final Logger LOG = LoggerFactory.getLogger(PublicUserResource.class); + + private final UserService userService; + + public PublicUserResource(UserService userService) { + this.userService = userService; + } + + /** + * {@code GET /users} : get all users with only public information - calling this method is allowed for anyone. + * + * @param pageable the pagination information. + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and with body all users. + */ + @GetMapping("/users") + public ResponseEntity> getAllPublicUsers(@org.springdoc.core.annotations.ParameterObject Pageable pageable) { + LOG.debug("REST request to get all public User names"); + if (!onlyContainsAllowedProperties(pageable)) { + return ResponseEntity.badRequest().build(); + } + + final Page page = userService.getAllPublicUsers(pageable); + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); + return new ResponseEntity<>(page.getContent(), headers, HttpStatus.OK); + } + + private boolean onlyContainsAllowedProperties(Pageable pageable) { + return pageable.getSort().stream().map(Sort.Order::getProperty).allMatch(ALLOWED_ORDERED_PROPERTIES::contains); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/UserResource.java b/src/main/java/com/example/questionbank/web/rest/UserResource.java new file mode 100644 index 0000000..567963e --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/UserResource.java @@ -0,0 +1,208 @@ +package com.example.questionbank.web.rest; + +import com.example.questionbank.config.Constants; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.security.AuthoritiesConstants; +import com.example.questionbank.service.MailService; +import com.example.questionbank.service.UserService; +import com.example.questionbank.service.dto.AdminUserDTO; +import com.example.questionbank.web.rest.errors.BadRequestAlertException; +import com.example.questionbank.web.rest.errors.EmailAlreadyUsedException; +import com.example.questionbank.web.rest.errors.LoginAlreadyUsedException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import tech.jhipster.web.util.HeaderUtil; +import tech.jhipster.web.util.PaginationUtil; +import tech.jhipster.web.util.ResponseUtil; + +/** + * REST controller for managing users. + *

+ * This class accesses the {@link com.example.questionbank.domain.User} entity, and needs to fetch its collection of authorities. + *

+ * For a normal use-case, it would be better to have an eager relationship between User and Authority, + * and send everything to the client side: there would be no View Model and DTO, a lot less code, and an outer-join + * which would be good for performance. + *

+ * We use a View Model and a DTO for 3 reasons: + *

    + *
  • We want to keep a lazy association between the user and the authorities, because people will + * quite often do relationships with the user, and we don't want them to get the authorities all + * the time for nothing (for performance reasons). This is the #1 goal: we should not impact our users' + * application because of this use-case.
  • + *
  • Not having an outer join causes n+1 requests to the database. This is not a real issue as + * we have by default a second-level cache. This means on the first HTTP call we do the n+1 requests, + * but then all authorities come from the cache, so in fact it's much better than doing an outer join + * (which will get lots of data from the database, for each HTTP call).
  • + *
  • As this manages users, for security reasons, we'd rather have a DTO layer.
  • + *
+ *

+ * Another option would be to have a specific JPA entity graph to handle this case. + */ +@RestController +@RequestMapping("/api/admin") +public class UserResource { + + private static final List ALLOWED_ORDERED_PROPERTIES = List.of( + "id", + "login", + "firstName", + "lastName", + "email", + "activated", + "langKey", + "createdBy", + "createdDate", + "lastModifiedBy", + "lastModifiedDate" + ); + + private static final Logger LOG = LoggerFactory.getLogger(UserResource.class); + + @Value("${jhipster.clientApp.name:questionBank}") + private String applicationName; + + private final UserService userService; + + private final UserRepository userRepository; + + private final MailService mailService; + + public UserResource(UserService userService, UserRepository userRepository, MailService mailService) { + this.userService = userService; + this.userRepository = userRepository; + this.mailService = mailService; + } + + /** + * {@code POST /admin/users} : Creates a new user. + *

+ * Creates a new user if the login and email are not already used, and sends a + * mail with an activation link. + * The user needs to be activated on creation. + * + * @param userDTO the user to create. + * @return the {@link ResponseEntity} with status {@code 201 (Created)} and with body the new user, or with status {@code 400 (Bad Request)} if the login or email is already in use. + * @throws URISyntaxException if the Location URI syntax is incorrect. + * @throws BadRequestAlertException {@code 400 (Bad Request)} if the login or email is already in use. + */ + @PostMapping("/users") + @PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")") + public ResponseEntity createUser(@Valid @RequestBody AdminUserDTO userDTO) throws URISyntaxException { + LOG.debug("REST request to save User : {}", userDTO); + + if (userDTO.getId() != null) { + throw new BadRequestAlertException("A new user cannot already have an ID", "userManagement", "idexists"); + // Lowercase the user login before comparing with database + } else if (userRepository.findOneByLogin(userDTO.getLogin().toLowerCase()).isPresent()) { + throw new LoginAlreadyUsedException(); + } else if (userRepository.findOneByEmailIgnoreCase(userDTO.getEmail()).isPresent()) { + throw new EmailAlreadyUsedException(); + } else { + User newUser = userService.createUser(userDTO); + mailService.sendCreationEmail(newUser); + return ResponseEntity.created(new URI("/api/admin/users/" + newUser.getLogin())) + .headers(HeaderUtil.createAlert(applicationName, "userManagement.created", newUser.getLogin())) + .body(newUser); + } + } + + /** + * {@code PUT /admin/users} : Updates an existing User. + * + * @param userDTO the user to update. + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and with body the updated user. + * @throws EmailAlreadyUsedException {@code 400 (Bad Request)} if the email is already in use. + * @throws LoginAlreadyUsedException {@code 400 (Bad Request)} if the login is already in use. + */ + @PutMapping({ "/users", "/users/{login}" }) + @PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")") + public ResponseEntity updateUser( + @PathVariable(name = "login", required = false) @Pattern(regexp = Constants.LOGIN_REGEX) String login, + @Valid @RequestBody AdminUserDTO userDTO + ) { + LOG.debug("REST request to update User : {}", userDTO); + Optional existingUser = userRepository.findOneByEmailIgnoreCase(userDTO.getEmail()); + if (existingUser.isPresent() && !existingUser.orElseThrow().getId().equals(userDTO.getId())) { + throw new EmailAlreadyUsedException(); + } + existingUser = userRepository.findOneByLogin(userDTO.getLogin().toLowerCase()); + if (existingUser.isPresent() && !existingUser.orElseThrow().getId().equals(userDTO.getId())) { + throw new LoginAlreadyUsedException(); + } + Optional updatedUser = userService.updateUser(userDTO); + + return ResponseUtil.wrapOrNotFound( + updatedUser, + HeaderUtil.createAlert(applicationName, "userManagement.updated", userDTO.getLogin()) + ); + } + + /** + * {@code GET /admin/users} : get all users with all the details - calling this are only allowed for the administrators. + * + * @param pageable the pagination information. + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and with body all users. + */ + @GetMapping("/users") + @PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")") + public ResponseEntity> getAllUsers(@org.springdoc.core.annotations.ParameterObject Pageable pageable) { + LOG.debug("REST request to get all User for an admin"); + if (!onlyContainsAllowedProperties(pageable)) { + return ResponseEntity.badRequest().build(); + } + + final Page page = userService.getAllManagedUsers(pageable); + HttpHeaders headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), page); + return new ResponseEntity<>(page.getContent(), headers, HttpStatus.OK); + } + + private boolean onlyContainsAllowedProperties(Pageable pageable) { + return pageable.getSort().stream().map(Sort.Order::getProperty).allMatch(ALLOWED_ORDERED_PROPERTIES::contains); + } + + /** + * {@code GET /admin/users/:login} : get the "login" user. + * + * @param login the login of the user to find. + * @return the {@link ResponseEntity} with status {@code 200 (OK)} and with body the "login" user, or with status {@code 404 (Not Found)}. + */ + @GetMapping("/users/{login}") + @PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")") + public ResponseEntity getUser(@PathVariable("login") @Pattern(regexp = Constants.LOGIN_REGEX) String login) { + LOG.debug("REST request to get User : {}", login); + return ResponseUtil.wrapOrNotFound(userService.getUserWithAuthoritiesByLogin(login).map(AdminUserDTO::new)); + } + + /** + * {@code DELETE /admin/users/:login} : delete the "login" User. + * + * @param login the login of the user to delete. + * @return the {@link ResponseEntity} with status {@code 204 (NO_CONTENT)}. + */ + @DeleteMapping("/users/{login}") + @PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")") + public ResponseEntity deleteUser(@PathVariable("login") @Pattern(regexp = Constants.LOGIN_REGEX) String login) { + LOG.debug("REST request to delete User: {}", login); + userService.deleteUser(login); + return ResponseEntity.noContent() + .headers(HeaderUtil.createAlert(applicationName, "userManagement.deleted", login)) + .build(); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/BadRequestAlertException.java b/src/main/java/com/example/questionbank/web/rest/errors/BadRequestAlertException.java new file mode 100644 index 0000000..c42ac3e --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/BadRequestAlertException.java @@ -0,0 +1,51 @@ +package com.example.questionbank.web.rest.errors; + +import java.io.Serial; +import java.net.URI; +import org.springframework.http.HttpStatus; +import org.springframework.web.ErrorResponseException; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause.ProblemDetailWithCauseBuilder; + +@SuppressWarnings("java:S110") // Inheritance tree of classes should not be too deep +public class BadRequestAlertException extends ErrorResponseException { + + @Serial + private static final long serialVersionUID = 1L; + + private final String entityName; + + private final String errorKey; + + public BadRequestAlertException(String defaultMessage, String entityName, String errorKey) { + this(ErrorConstants.DEFAULT_TYPE, defaultMessage, entityName, errorKey); + } + + public BadRequestAlertException(URI type, String defaultMessage, String entityName, String errorKey) { + super( + HttpStatus.BAD_REQUEST, + ProblemDetailWithCauseBuilder.instance() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withType(type) + .withTitle(defaultMessage) + .withProperty("message", "error." + errorKey) + .withProperty("params", entityName) + .build(), + null + ); + this.entityName = entityName; + this.errorKey = errorKey; + } + + public String getEntityName() { + return entityName; + } + + public String getErrorKey() { + return errorKey; + } + + public ProblemDetailWithCause getProblemDetailWithCause() { + return (ProblemDetailWithCause) this.getBody(); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/EmailAlreadyUsedException.java b/src/main/java/com/example/questionbank/web/rest/errors/EmailAlreadyUsedException.java new file mode 100644 index 0000000..fd4359d --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/EmailAlreadyUsedException.java @@ -0,0 +1,14 @@ +package com.example.questionbank.web.rest.errors; + +import java.io.Serial; + +@SuppressWarnings("java:S110") // Inheritance tree of classes should not be too deep +public class EmailAlreadyUsedException extends BadRequestAlertException { + + @Serial + private static final long serialVersionUID = 1L; + + public EmailAlreadyUsedException() { + super(ErrorConstants.EMAIL_ALREADY_USED_TYPE, "Email is already in use!", "userManagement", "emailexists"); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/ErrorConstants.java b/src/main/java/com/example/questionbank/web/rest/errors/ErrorConstants.java new file mode 100644 index 0000000..237f128 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/ErrorConstants.java @@ -0,0 +1,17 @@ +package com.example.questionbank.web.rest.errors; + +import java.net.URI; + +public final class ErrorConstants { + + public static final String ERR_CONCURRENCY_FAILURE = "error.concurrencyFailure"; + public static final String ERR_VALIDATION = "error.validation"; + public static final String PROBLEM_BASE_URL = "https://www.jhipster.tech/problem"; + public static final URI DEFAULT_TYPE = URI.create(PROBLEM_BASE_URL + "/problem-with-message"); + public static final URI CONSTRAINT_VIOLATION_TYPE = URI.create(PROBLEM_BASE_URL + "/constraint-violation"); + public static final URI INVALID_PASSWORD_TYPE = URI.create(PROBLEM_BASE_URL + "/invalid-password"); + public static final URI EMAIL_ALREADY_USED_TYPE = URI.create(PROBLEM_BASE_URL + "/email-already-used"); + public static final URI LOGIN_ALREADY_USED_TYPE = URI.create(PROBLEM_BASE_URL + "/login-already-used"); + + private ErrorConstants() {} +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/ExceptionTranslator.java b/src/main/java/com/example/questionbank/web/rest/errors/ExceptionTranslator.java new file mode 100644 index 0000000..266f316 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/ExceptionTranslator.java @@ -0,0 +1,275 @@ +package com.example.questionbank.web.rest.errors; + +import static org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import java.net.URI; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.dao.ConcurrencyFailureException; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.ErrorResponse; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import tech.jhipster.config.JHipsterConstants; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause.ProblemDetailWithCauseBuilder; +import tech.jhipster.web.util.HeaderUtil; + +/** + * Controller advice to translate the server side exceptions to client-friendly json structures. + * The error response follows RFC7807 - Problem Details for HTTP APIs (https://tools.ietf.org/html/rfc7807). + */ +@ControllerAdvice +public class ExceptionTranslator extends ResponseEntityExceptionHandler { + + private static final String FIELD_ERRORS_KEY = "fieldErrors"; + private static final String MESSAGE_KEY = "message"; + private static final String PATH_KEY = "path"; + private static final boolean CAUSAL_CHAIN_ENABLED = false; + + private static final Logger LOG = LoggerFactory.getLogger(ExceptionTranslator.class); + + @Value("${jhipster.clientApp.name:questionBank}") + private String applicationName; + + private final Environment env; + + public ExceptionTranslator(Environment env) { + this.env = env; + } + + @ExceptionHandler + public ResponseEntity handleAnyException(Throwable ex, NativeWebRequest request) { + LOG.debug("Converting Exception to Problem Details:", ex); + ProblemDetailWithCause pdCause = wrapAndCustomizeProblem(ex, request); + return handleExceptionInternal((Exception) ex, pdCause, buildHeaders(ex), HttpStatusCode.valueOf(pdCause.getStatus()), request); + } + + @SuppressWarnings("java:S2638") + @Nullable + @Override + protected ResponseEntity handleExceptionInternal( + Exception ex, + @Nullable Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest request + ) { + body = body == null ? wrapAndCustomizeProblem(ex, (NativeWebRequest) request) : body; + return super.handleExceptionInternal(ex, body, headers, statusCode, request); + } + + protected ProblemDetailWithCause wrapAndCustomizeProblem(Throwable ex, NativeWebRequest request) { + return customizeProblem(getProblemDetailWithCause(ex), ex, request); + } + + private ProblemDetailWithCause getProblemDetailWithCause(Throwable ex) { + if ( + ex instanceof com.example.questionbank.service.UsernameAlreadyUsedException + ) return (ProblemDetailWithCause) new LoginAlreadyUsedException().getBody(); + if ( + ex instanceof com.example.questionbank.service.EmailAlreadyUsedException + ) return (ProblemDetailWithCause) new EmailAlreadyUsedException().getBody(); + if ( + ex instanceof com.example.questionbank.service.InvalidPasswordException + ) return (ProblemDetailWithCause) new InvalidPasswordException().getBody(); + + if ( + ex instanceof ErrorResponseException exp && exp.getBody() instanceof ProblemDetailWithCause problemDetailWithCause + ) return problemDetailWithCause; + return ProblemDetailWithCauseBuilder.instance().withStatus(toStatus(ex).value()).build(); + } + + protected ProblemDetailWithCause customizeProblem(ProblemDetailWithCause problem, Throwable err, NativeWebRequest request) { + if (problem.getStatus() <= 0) problem.setStatus(toStatus(err)); + + if (problem.getType() == null || problem.getType().equals(URI.create("about:blank"))) problem.setType(getMappedType(err)); + + // higher precedence to Custom/ResponseStatus types + String title = extractTitle(err, problem.getStatus()); + String problemTitle = problem.getTitle(); + if (problemTitle == null || !problemTitle.equals(title)) { + problem.setTitle(title); + } + + if (problem.getDetail() == null) { + // higher precedence to cause + problem.setDetail(getCustomizedErrorDetails(err)); + } + + Map problemProperties = problem.getProperties(); + if (problemProperties == null || !problemProperties.containsKey(MESSAGE_KEY)) problem.setProperty( + MESSAGE_KEY, + getMappedMessageKey(err) != null ? getMappedMessageKey(err) : "error.http." + problem.getStatus() + ); + + if (problemProperties == null || !problemProperties.containsKey(PATH_KEY)) problem.setProperty(PATH_KEY, getPathValue(request)); + + if ( + err instanceof MethodArgumentNotValidException fieldException && + (problemProperties == null || !problemProperties.containsKey(FIELD_ERRORS_KEY)) + ) problem.setProperty(FIELD_ERRORS_KEY, getFieldErrors(fieldException)); + + problem.setCause(buildCause(err.getCause(), request).orElse(null)); + + return problem; + } + + private String extractTitle(Throwable err, int statusCode) { + return getCustomizedTitle(err) != null ? getCustomizedTitle(err) : extractTitleForResponseStatus(err, statusCode); + } + + private List getFieldErrors(MethodArgumentNotValidException ex) { + return ex + .getBindingResult() + .getFieldErrors() + .stream() + .map(f -> + new FieldErrorVM( + f.getObjectName().replaceFirst("DTO$", ""), + f.getField(), + StringUtils.isNotBlank(f.getDefaultMessage()) ? f.getDefaultMessage() : f.getCode() + ) + ) + .toList(); + } + + private String extractTitleForResponseStatus(Throwable err, int statusCode) { + var specialStatus = extractResponseStatus(err); + return specialStatus == null ? HttpStatus.valueOf(statusCode).getReasonPhrase() : specialStatus.reason(); + } + + private String extractURI(NativeWebRequest request) { + HttpServletRequest nativeRequest = request.getNativeRequest(HttpServletRequest.class); + return nativeRequest != null ? nativeRequest.getRequestURI() : StringUtils.EMPTY; + } + + private HttpStatus toStatus(final Throwable throwable) { + // Let the ErrorResponse take this responsibility + if (throwable instanceof ErrorResponse err) return HttpStatus.valueOf(err.getBody().getStatus()); + + return Optional.ofNullable(getMappedStatus(throwable)).orElse( + Optional.ofNullable(resolveResponseStatus(throwable)).map(ResponseStatus::value).orElse(HttpStatus.INTERNAL_SERVER_ERROR) + ); + } + + private ResponseStatus extractResponseStatus(final Throwable throwable) { + return resolveResponseStatus(throwable); + } + + private ResponseStatus resolveResponseStatus(final Throwable type) { + final ResponseStatus candidate = findMergedAnnotation(type.getClass(), ResponseStatus.class); + return candidate == null && type.getCause() != null ? resolveResponseStatus(type.getCause()) : candidate; + } + + private URI getMappedType(Throwable err) { + if ( + err instanceof MethodArgumentNotValidException || err instanceof ConstraintViolationException + ) return ErrorConstants.CONSTRAINT_VIOLATION_TYPE; + return ErrorConstants.DEFAULT_TYPE; + } + + private String getMappedMessageKey(Throwable err) { + if (err instanceof MethodArgumentNotValidException || err instanceof ConstraintViolationException) { + return ErrorConstants.ERR_VALIDATION; + } else if (err instanceof ConcurrencyFailureException || err.getCause() instanceof ConcurrencyFailureException) { + return ErrorConstants.ERR_CONCURRENCY_FAILURE; + } + return null; + } + + private String getCustomizedTitle(Throwable err) { + if ( + err instanceof MethodArgumentNotValidException || err instanceof ConstraintViolationException + ) return "Method argument not valid"; + return null; + } + + private String getCustomizedErrorDetails(Throwable err) { + Collection activeProfiles = List.of(env.getActiveProfiles()); + if (activeProfiles.contains(JHipsterConstants.SPRING_PROFILE_PRODUCTION)) { + if (err instanceof HttpMessageConversionException) return "Unable to convert http message"; + if (err instanceof DataAccessException) return "Failure during data access"; + if (containsPackageName(err.getMessage())) return "Unexpected runtime exception"; + } + return err.getCause() != null ? err.getCause().getMessage() : err.getMessage(); + } + + private HttpStatus getMappedStatus(Throwable err) { + // Where we disagree with Spring defaults + if (err instanceof AccessDeniedException) return HttpStatus.FORBIDDEN; + if (err instanceof ConcurrencyFailureException) return HttpStatus.CONFLICT; + if (err instanceof BadCredentialsException) return HttpStatus.UNAUTHORIZED; + if (err instanceof ConstraintViolationException) return HttpStatus.BAD_REQUEST; + return null; + } + + private URI getPathValue(NativeWebRequest request) { + if (request == null) return URI.create("about:blank"); + return URI.create(extractURI(request)); + } + + private HttpHeaders buildHeaders(Throwable err) { + return err instanceof BadRequestAlertException badRequestAlertException + ? HeaderUtil.createFailureAlert( + applicationName, + true, + badRequestAlertException.getEntityName(), + badRequestAlertException.getErrorKey(), + badRequestAlertException.getMessage() + ) + : null; + } + + public Optional buildCause(final Throwable throwable, NativeWebRequest request) { + if (throwable != null && isCausalChainEnabled()) { + return Optional.of(customizeProblem(getProblemDetailWithCause(throwable), throwable, request)); + } + return Optional.ofNullable(null); + } + + private boolean isCausalChainEnabled() { + // Customize as per the needs + return CAUSAL_CHAIN_ENABLED; + } + + private boolean containsPackageName(String message) { + // This list is for sure not complete + return Strings.CS.containsAny( + message, + "org.", + "java.", + "net.", + "jakarta.", + "javax.", + "com.", + "io.", + "de.", + "com.example.questionbank" + ); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/FieldErrorVM.java b/src/main/java/com/example/questionbank/web/rest/errors/FieldErrorVM.java new file mode 100644 index 0000000..b8cf0c5 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/FieldErrorVM.java @@ -0,0 +1,34 @@ +package com.example.questionbank.web.rest.errors; + +import java.io.Serial; +import java.io.Serializable; + +public class FieldErrorVM implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private final String objectName; + + private final String field; + + private final String message; + + public FieldErrorVM(String dto, String field, String message) { + this.objectName = dto; + this.field = field; + this.message = message; + } + + public String getObjectName() { + return objectName; + } + + public String getField() { + return field; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/InvalidPasswordException.java b/src/main/java/com/example/questionbank/web/rest/errors/InvalidPasswordException.java new file mode 100644 index 0000000..84f2e0e --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/InvalidPasswordException.java @@ -0,0 +1,25 @@ +package com.example.questionbank.web.rest.errors; + +import java.io.Serial; +import org.springframework.http.HttpStatus; +import org.springframework.web.ErrorResponseException; +import tech.jhipster.web.rest.errors.ProblemDetailWithCause.ProblemDetailWithCauseBuilder; + +@SuppressWarnings("java:S110") // Inheritance tree of classes should not be too deep +public class InvalidPasswordException extends ErrorResponseException { + + @Serial + private static final long serialVersionUID = 1L; + + public InvalidPasswordException() { + super( + HttpStatus.BAD_REQUEST, + ProblemDetailWithCauseBuilder.instance() + .withStatus(HttpStatus.BAD_REQUEST.value()) + .withType(ErrorConstants.INVALID_PASSWORD_TYPE) + .withTitle("Incorrect password") + .build(), + null + ); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/LoginAlreadyUsedException.java b/src/main/java/com/example/questionbank/web/rest/errors/LoginAlreadyUsedException.java new file mode 100644 index 0000000..ee06808 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/LoginAlreadyUsedException.java @@ -0,0 +1,14 @@ +package com.example.questionbank.web.rest.errors; + +import java.io.Serial; + +@SuppressWarnings("java:S110") // Inheritance tree of classes should not be too deep +public class LoginAlreadyUsedException extends BadRequestAlertException { + + @Serial + private static final long serialVersionUID = 1L; + + public LoginAlreadyUsedException() { + super(ErrorConstants.LOGIN_ALREADY_USED_TYPE, "Login name already used!", "userManagement", "userexists"); + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/errors/package-info.java b/src/main/java/com/example/questionbank/web/rest/errors/package-info.java new file mode 100644 index 0000000..2acb4ba --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/errors/package-info.java @@ -0,0 +1,4 @@ +/** + * Rest layer error handling. + */ +package com.example.questionbank.web.rest.errors; diff --git a/src/main/java/com/example/questionbank/web/rest/package-info.java b/src/main/java/com/example/questionbank/web/rest/package-info.java new file mode 100644 index 0000000..c54b9c5 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/package-info.java @@ -0,0 +1,4 @@ +/** + * Rest layer. + */ +package com.example.questionbank.web.rest; diff --git a/src/main/java/com/example/questionbank/web/rest/vm/KeyAndPasswordVM.java b/src/main/java/com/example/questionbank/web/rest/vm/KeyAndPasswordVM.java new file mode 100644 index 0000000..db55109 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/vm/KeyAndPasswordVM.java @@ -0,0 +1,27 @@ +package com.example.questionbank.web.rest.vm; + +/** + * View Model object for storing the user's key and password. + */ +public class KeyAndPasswordVM { + + private String key; + + private String newPassword; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/vm/LoginVM.java b/src/main/java/com/example/questionbank/web/rest/vm/LoginVM.java new file mode 100644 index 0000000..b04d351 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/vm/LoginVM.java @@ -0,0 +1,53 @@ +package com.example.questionbank.web.rest.vm; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * View Model object for storing a user's credentials. + */ +public class LoginVM { + + @NotNull + @Size(min = 1, max = 254) + private String username; + + @NotNull + @Size(min = 4, max = 100) + private String password; + + private boolean rememberMe; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean isRememberMe() { + return rememberMe; + } + + public void setRememberMe(boolean rememberMe) { + this.rememberMe = rememberMe; + } + + // prettier-ignore + @Override + public String toString() { + return "LoginVM{" + + "username='" + username + '\'' + + ", rememberMe=" + rememberMe + + '}'; + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/vm/ManagedUserVM.java b/src/main/java/com/example/questionbank/web/rest/vm/ManagedUserVM.java new file mode 100644 index 0000000..95d430f --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/vm/ManagedUserVM.java @@ -0,0 +1,35 @@ +package com.example.questionbank.web.rest.vm; + +import com.example.questionbank.service.dto.AdminUserDTO; +import jakarta.validation.constraints.Size; + +/** + * View Model extending the AdminUserDTO, which is meant to be used in the user management UI. + */ +public class ManagedUserVM extends AdminUserDTO { + + public static final int PASSWORD_MIN_LENGTH = 4; + + public static final int PASSWORD_MAX_LENGTH = 100; + + @Size(min = PASSWORD_MIN_LENGTH, max = PASSWORD_MAX_LENGTH) + private String password; + + public ManagedUserVM() { + // Empty constructor needed for Jackson. + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + // prettier-ignore + @Override + public String toString() { + return "ManagedUserVM{" + super.toString() + "} "; + } +} diff --git a/src/main/java/com/example/questionbank/web/rest/vm/package-info.java b/src/main/java/com/example/questionbank/web/rest/vm/package-info.java new file mode 100644 index 0000000..1bc8ee7 --- /dev/null +++ b/src/main/java/com/example/questionbank/web/rest/vm/package-info.java @@ -0,0 +1,4 @@ +/** + * Rest layer visual models. + */ +package com.example.questionbank.web.rest.vm; diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..a731cf4 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,10 @@ + + ${AnsiColor.GREEN} ██╗${AnsiColor.BLUE} ██╗ ██╗ ████████╗ ███████╗ ██████╗ ████████╗ ████████╗ ████${AnsiColor.GREEN}███╗ + ${AnsiColor.GREEN} ██║ ██║ ${AnsiColor.BLUE} ██║ ╚══██╔══╝ ██╔═══██╗ ██╔════╝ ╚══██╔══╝ ██╔══${AnsiColor.GREEN}═══╝ ██╔═══██╗ + ${AnsiColor.GREEN} ██║ ████████║${AnsiColor.BLUE} ██║ ███████╔╝ ╚█████╗ ██║ ${AnsiColor.GREEN} ██████╗ ███████╔╝ + ${AnsiColor.GREEN}██╗ ██║ ██╔═══██║ ██║ ${AnsiColor.BLUE} ██╔════╝ ╚═══██╗ ${AnsiColor.GREEN}██║ ██╔═══╝ ██╔══██║ + ${AnsiColor.GREEN}╚██████╔╝ ██║ ██║ ████████╗ ██║ ${AnsiColor.BLUE} ██████${AnsiColor.GREEN}╔╝ ██║ ████████╗ ██║ ╚██╗ + ${AnsiColor.GREEN} ╚═════╝ ╚═╝ ╚═╝ ╚═══════╝ ╚═╝ ${AnsiColor.BLUE} ╚═${AnsiColor.GREEN}════╝ ╚═╝ ╚═══════╝ ╚═╝ ╚═╝ + +${AnsiColor.BRIGHT_BLUE}:: JHipster 🤓 :: Running Spring Boot ${spring-boot.version} :: Startup profile(s) ${spring.profiles.active} :: +:: https://www.jhipster.tech ::${AnsiColor.DEFAULT} diff --git a/src/main/resources/config/application-dev.yml b/src/main/resources/config/application-dev.yml new file mode 100644 index 0000000..c764467 --- /dev/null +++ b/src/main/resources/config/application-dev.yml @@ -0,0 +1,97 @@ +# =================================================================== +# Spring Boot configuration for the "dev" profile. +# +# This configuration overrides the application.yml file. +# +# More information on profiles: https://www.jhipster.tech/profiles/ +# More information on configuration properties: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# =================================================================== +# Standard Spring Boot properties. +# Full reference is available at: +# https://docs.spring.io/spring-boot/appendix/application-properties/index.html +# =================================================================== + +logging: + level: + ROOT: DEBUG + tech.jhipster: DEBUG + org.hibernate.SQL: DEBUG + com.example.questionbank: DEBUG + +spring: + devtools: + restart: + enabled: true + additional-exclude: static/** + livereload: + enabled: false # we use Webpack dev server + BrowserSync for livereload + jackson: + serialization: + indent-output: true + datasource: + type: com.zaxxer.hikari.HikariDataSource + url: jdbc:postgresql://localhost:5432/questionBank + hikari: + poolName: Hikari + auto-commit: false + liquibase: + # Remove 'faker' if you do not want the sample data to be loaded automatically + contexts: dev, faker + mail: + host: localhost + port: 25 + messages: + cache-duration: PT1S # 1 second, see the ISO 8601 standard + thymeleaf: + cache: false + +server: + port: 8080 + # make sure requests the proxy uri instead of the server one + forward-headers-strategy: native + +# =================================================================== +# JHipster specific properties +# +# Full reference is available at: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +jhipster: + # CORS is only enabled by default with the "dev" profile + cors: + # Allow Ionic for JHipster by default (* no longer allowed in Spring Boot 2.4+) + allowed-origins: 'http://localhost:8100,https://localhost:8100,http://localhost:9000,https://localhost:9000' + # Enable CORS when running in GitHub Codespaces + allowed-origin-patterns: 'https://*.githubpreview.dev' + allowed-methods: '*' + allowed-headers: '*' + exposed-headers: 'Authorization,Link,X-Total-Count,X-${jhipster.clientApp.name}-alert,X-${jhipster.clientApp.name}-error,X-${jhipster.clientApp.name}-params' + allow-credentials: true + max-age: 1800 + security: + authentication: + jwt: + # Token is valid 24 hours + token-validity-in-seconds: 86400 + token-validity-in-seconds-for-remember-me: 2592000 + mail: # specific JHipster mail property, for standard properties see MailProperties + base-url: http://127.0.0.1:8080 + logging: + use-json-format: false # By default, logs are not in Json format + logstash: # Forward logs to logstash over a socket, used by LoggingConfiguration + enabled: false + host: localhost + port: 5000 + ring-buffer-size: 512 +# =================================================================== +# Application specific properties +# Add your own application properties here, see the ApplicationProperties class +# to have type-safe configuration, like in the JHipsterProperties above +# +# More documentation is available at: +# https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# application: diff --git a/src/main/resources/config/application-prod.yml b/src/main/resources/config/application-prod.yml new file mode 100644 index 0000000..3269a26 --- /dev/null +++ b/src/main/resources/config/application-prod.yml @@ -0,0 +1,110 @@ +# =================================================================== +# Spring Boot configuration for the "prod" profile. +# +# This configuration overrides the application.yml file. +# +# More information on profiles: https://www.jhipster.tech/profiles/ +# More information on configuration properties: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# =================================================================== +# Standard Spring Boot properties. +# Full reference is available at: +# https://docs.spring.io/spring-boot/appendix/application-properties/index.html +# =================================================================== + +logging: + level: + ROOT: INFO + tech.jhipster: INFO + com.example.questionbank: INFO + +management: + prometheus: + metrics: + export: + enabled: false + +spring: + devtools: + restart: + enabled: false + livereload: + enabled: false + datasource: + type: com.zaxxer.hikari.HikariDataSource + url: jdbc:postgresql://localhost:5432/questionBank + hikari: + poolName: Hikari + auto-commit: false + # Replace by 'prod, faker' to add the faker context and have sample data loaded in production + liquibase: + contexts: prod + mail: + host: localhost + port: 25 + thymeleaf: + cache: true + +# =================================================================== +# To enable TLS in production, generate a certificate using: +# keytool -genkey -alias questionbank -storetype PKCS12 -keyalg RSA -keysize 2048 -keystore keystore.p12 -validity 3650 +# +# You can also use Let's Encrypt: +# See details in topic "Create a Java Keystore (.JKS) from Let's Encrypt Certificates" on https://maximilian-boehm.com/en-gb/blog +# +# Then, modify the server.ssl properties so your "server" configuration looks like: +# +# server: +# port: 443 +# ssl: +# key-store: classpath:config/tls/keystore.p12 +# key-store-password: password +# key-store-type: PKCS12 +# key-alias: selfsigned +# # The ciphers suite enforce the security by deactivating some old and deprecated SSL cipher, this list was tested against SSL Labs (https://www.ssllabs.com/ssltest/) +# ciphers: TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 ,TLS_DHE_RSA_WITH_AES_128_GCM_SHA256 ,TLS_DHE_RSA_WITH_AES_256_GCM_SHA384 ,TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,TLS_DHE_RSA_WITH_AES_128_CBC_SHA256,TLS_DHE_RSA_WITH_AES_128_CBC_SHA,TLS_DHE_RSA_WITH_AES_256_CBC_SHA256,TLS_DHE_RSA_WITH_AES_256_CBC_SHA,TLS_RSA_WITH_AES_128_GCM_SHA256,TLS_RSA_WITH_AES_256_GCM_SHA384,TLS_RSA_WITH_AES_128_CBC_SHA256,TLS_RSA_WITH_AES_256_CBC_SHA256,TLS_RSA_WITH_AES_128_CBC_SHA,TLS_RSA_WITH_AES_256_CBC_SHA,TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA,TLS_RSA_WITH_CAMELLIA_256_CBC_SHA,TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA,TLS_RSA_WITH_CAMELLIA_128_CBC_SHA +# =================================================================== +server: + port: 8080 + shutdown: graceful # see https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html#web.graceful-shutdown + compression: + enabled: true + mime-types: text/html,text/xml,text/plain,text/css,application/javascript,application/json,image/svg+xml + min-response-size: 1024 + +# =================================================================== +# JHipster specific properties +# +# Full reference is available at: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +jhipster: + http: + cache: # Used by the CachingHttpHeadersFilter + timeToLiveInDays: 1461 + security: + authentication: + jwt: + # Token is valid 24 hours + token-validity-in-seconds: 86400 + token-validity-in-seconds-for-remember-me: 2592000 + mail: # specific JHipster mail property, for standard properties see MailProperties + base-url: http://my-server-url-to-change # Modify according to your server's URL + logging: + use-json-format: false # By default, logs are not in Json format + logstash: # Forward logs to logstash over a socket, used by LoggingConfiguration + enabled: false + host: localhost + port: 5000 + ring-buffer-size: 512 +# =================================================================== +# Application specific properties +# Add your own application properties here, see the ApplicationProperties class +# to have type-safe configuration, like in the JHipsterProperties above +# +# More documentation is available at: +# https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# application: diff --git a/src/main/resources/config/application-secret-samples.yml b/src/main/resources/config/application-secret-samples.yml new file mode 100644 index 0000000..bdb3478 --- /dev/null +++ b/src/main/resources/config/application-secret-samples.yml @@ -0,0 +1,23 @@ +# =================================================================== +# Spring Boot configuration for the "secret-samples" profile. +# +# This configuration overrides the application.yml file. +# +# More information on profiles: https://www.jhipster.tech/profiles/ +# More information on configuration properties: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# =================================================================== +# Samples for required secrets to be used in development and ci using prod profile. +# =================================================================== + +spring: + datasource: + username: questionBank + password: + +jhipster: + security: + authentication: + jwt: + base64-secret: MzY1ZDU0MDM3M2Y5YWRiMDYxYjBmYzZlOTMxMWFiNmYyMDEyZTUzZjYxOWFjNjhlNjU2NzcxYjkwY2Y0MGIwOGUyNTc5NWQyOGY3OGJkMjExZjhkYzU3ZGU4ZDc0ZmZiMTYwMzY0NGM4ZWZmODRhZTZjNzBlNjk5MWJkYjM5YjI= diff --git a/src/main/resources/config/application-tls.yml b/src/main/resources/config/application-tls.yml new file mode 100644 index 0000000..07bb104 --- /dev/null +++ b/src/main/resources/config/application-tls.yml @@ -0,0 +1,27 @@ +# =================================================================== +# Activate this profile to enable TLS and HTTP/2. +# +# JHipster has generated a self-signed certificate, which will be used to encrypt traffic. +# As your browser will not understand this certificate, you will need to import it. +# +# Another (easiest) solution with Chrome is to enable the "allow-insecure-localhost" flag +# at chrome://flags/#allow-insecure-localhost +# =================================================================== +server: + ssl: + key-store: classpath:config/tls/keystore.p12 + key-store-password: password + key-store-type: PKCS12 + key-alias: selfsigned + ciphers: + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA + - TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA + - TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA + enabled-protocols: TLSv1.2 + http2: + enabled: true diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml new file mode 100644 index 0000000..28a83d0 --- /dev/null +++ b/src/main/resources/config/application.yml @@ -0,0 +1,229 @@ +# =================================================================== +# Spring Boot configuration. +# +# This configuration will be overridden by the Spring profile you use, +# for example application-dev.yml if you use the "dev" profile. +# +# More information on profiles: https://www.jhipster.tech/profiles/ +# More information on configuration properties: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# =================================================================== +# Standard Spring Boot properties. +# Full reference is available at: +# https://docs.spring.io/spring-boot/appendix/application-properties/index.html +# =================================================================== + +--- +# Conditionally disable springdoc on missing api-docs profile +spring: + config: + activate: + on-profile: '!api-docs' +springdoc: + api-docs: + enabled: false +--- +management: + endpoints: + web: + base-path: /management + exposure: + include: + - configprops + - env + - health + - info + - jhimetrics + - jhiopenapigroups + - logfile + - loggers + - prometheus + - threaddump + - liquibase + endpoint: + health: + show-details: when_authorized + roles: 'ROLE_ADMIN' + probes: + enabled: true + group: + liveness: + include: livenessState + readiness: + include: readinessState,db + jhimetrics: + access: read-only + info: + git: + mode: full + env: + enabled: true + health: + mail: + enabled: false # When using the MailService, configure an SMTP server and set this to true + prometheus: + metrics: + export: + enabled: true + step: 60 + observations: + key-values: + application: ${spring.application.name} + metrics: + enable: + http: true + jvm: true + logback: true + process: true + system: true + distribution: + percentiles-histogram: + all: true + percentiles: + all: 0, 0.5, 0.75, 0.95, 0.99, 1.0 + data: + repository: + autotime: + enabled: true + tags: + application: ${spring.application.name} + +spring: + application: + name: questionBank + docker: + compose: + enabled: true + lifecycle-management: start-only + file: src/main/docker/services.yml + profiles: + # The commented value for `active` can be replaced with valid Spring profiles to load. + # Otherwise, it will be filled in by maven when building the JAR file + # Either way, it can be overridden by `--spring.profiles.active` value passed in the commandline or `-Dspring.profiles.active` set in `JAVA_OPTS` + active: '@spring.profiles.active@' + group: + dev: + - secret-samples + - api-docs + # Uncomment to activate TLS for the dev profile + #- tls + jmx: + enabled: false + data: + jpa: + repositories: + bootstrap-mode: deferred + jpa: + open-in-view: false + properties: + hibernate.jdbc.time_zone: UTC + hibernate.timezone.default_storage: NORMALIZE + hibernate.type.preferred_instant_jdbc_type: TIMESTAMP + hibernate.id.new_generator_mappings: true + hibernate.connection.provider_disables_autocommit: true + hibernate.cache.use_second_level_cache: false + hibernate.cache.use_query_cache: false + hibernate.generate_statistics: false + # modify batch size as necessary + hibernate.jdbc.batch_size: 25 + hibernate.order_inserts: true + hibernate.order_updates: true + hibernate.query.fail_on_pagination_over_collection_fetch: true + hibernate.query.in_clause_parameter_padding: true + hibernate: + ddl-auto: none + naming: + physical-strategy: org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy + implicit-strategy: org.springframework.boot.hibernate.SpringImplicitNamingStrategy + messages: + basename: i18n/messages + main: + allow-bean-definition-overriding: true + mvc: + problemdetails: + enabled: true + security: + oauth2: + resourceserver: + jwt: + authority-prefix: '' + authorities-claim-name: auth + task: + execution: + thread-name-prefix: question-bank-task- + pool: + core-size: 2 + max-size: 50 + queue-capacity: 10000 + scheduling: + thread-name-prefix: question-bank-scheduling- + pool: + size: 2 + thymeleaf: + mode: HTML + output: + ansi: + console-available: true + +server: + servlet: + session: + cookie: + http-only: true + +springdoc: + show-actuator: true + +# Properties to be exposed on the /info management endpoint +info: + # Comma separated list of profiles that will trigger the ribbon to show + display-ribbon-on-profiles: 'dev' + +# =================================================================== +# JHipster specific properties +# +# Full reference is available at: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +jhipster: + clientApp: + name: 'questionBankApp' + # By default CORS is disabled. Uncomment to enable. + # cors: + # allowed-origins: "http://localhost:8100,http://localhost:9000" + # allowed-methods: "*" + # allowed-headers: "*" + # exposed-headers: "Authorization,Link,X-Total-Count,X-${jhipster.clientApp.name}-alert,X-${jhipster.clientApp.name}-error,X-${jhipster.clientApp.name}-params" + # allow-credentials: true + # max-age: 1800 + mail: + from: questionBank@localhost + api-docs: + default-include-pattern: /api/** + management-include-pattern: /management/** + title: Question Bank API + description: Question Bank API documentation + version: 0.0.1 + terms-of-service-url: + contact-name: + contact-url: + contact-email: + license: unlicensed + license-url: + security: + content-security-policy: "default-src 'self'; frame-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://storage.googleapis.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:" + + +# jhipster-needle-add-application-yaml-document +--- +# =================================================================== +# Application specific properties +# Add your own application properties here, see the ApplicationProperties class +# to have type-safe configuration, like in the JHipsterProperties above +# +# More documentation is available at: +# https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# application: diff --git a/src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml b/src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml new file mode 100644 index 0000000..8ee1b04 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/00000000000000_initial_schema.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/data/authority.csv b/src/main/resources/config/liquibase/data/authority.csv new file mode 100644 index 0000000..af5c6df --- /dev/null +++ b/src/main/resources/config/liquibase/data/authority.csv @@ -0,0 +1,3 @@ +name +ROLE_ADMIN +ROLE_USER diff --git a/src/main/resources/config/liquibase/data/user.csv b/src/main/resources/config/liquibase/data/user.csv new file mode 100644 index 0000000..fbc52da --- /dev/null +++ b/src/main/resources/config/liquibase/data/user.csv @@ -0,0 +1,3 @@ +id;login;password_hash;first_name;last_name;email;image_url;activated;lang_key;created_by;last_modified_by +1;admin;$2a$10$gSAhZrxMllrbgj/kkK9UceBPpChGWJA7SYIb1Mqo.n5aNLq1/oRrC;Administrator;Administrator;admin@localhost;;true;en;system;system +2;user;$2a$10$VEjxo0jq2YG9Rbk2HmX9S.k1uZBGYUHdUcid3g/vfiEl7lwWgOH/K;User;User;user@localhost;;true;en;system;system diff --git a/src/main/resources/config/liquibase/data/user_authority.csv b/src/main/resources/config/liquibase/data/user_authority.csv new file mode 100644 index 0000000..01dbdef --- /dev/null +++ b/src/main/resources/config/liquibase/data/user_authority.csv @@ -0,0 +1,4 @@ +user_id;authority_name +1;ROLE_ADMIN +1;ROLE_USER +2;ROLE_USER diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml new file mode 100644 index 0000000..315d9ec --- /dev/null +++ b/src/main/resources/config/liquibase/master.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/tls/keystore.p12 b/src/main/resources/config/tls/keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..c868a3397c31fce1881f32aae7f23a52697d98bf GIT binary patch literal 2768 zcma)8c{J3G8lD+5mO-|m7+X#9nJm9aG`5P|K_tpn_GOHbEwZI7V+m!eEU7g3vV;uE zzNCr9E-{LaED?rbj3u&M_uPB>&i(7&Ki>14_gUUQpYsAFcsB&h0g&K9oIL0_!?;a; zFc+9if)k)5xa$G!2#{da|0Tf+p(I%T0sZ1&o^ta3C&dQ`L&zl9`2$iLaQ~fv@dMWY z?B6RNa1x4`5Kn_E$SIFIGLV!G+J*U(SEvaPh`b;KEDP{)a{v1xga-x!q&Rt;;tauf z4k#E66{O0s*X|p^WK9nn9E<1GPlb}8{Z0##pFcRkAz6NFcKFek97l8KsEj2t=2ZXL zw3xmd9OL01PBmqaIpOH(KIF6WWy7(yxwL^vzbEv&QnM-OZzX<~#1@drG4p*%&ac{8lk-Dl+9 zJln>rw&X>uaFj^z1d84}nwV#WC?q9DgxaeouJ{ge3#a$`5$Gufj_-1`o4%za7~ZNK3gt-e)4-=(ykSq5VbSIcL$3j@{Q{=sqJxf$LHDd zFG7=c#+4U5l8pa?)*7qDV4y0F#goHIAI%hf!Ev)vbtuX3MJ$3)pv9jAUHgvjoP)7)n3og0ro7 zzP_p*)3&}~A^C2)5lh0 z+008Jd{YqIZQS4j;Wo-@Ijkd%z7+Puy#DuhFZN_~wyX*2Wpq@|F7K?ck{EwC2qHgmaW&|Dp0%>m{vq zX~!FM)I14N{o%eYOJ}4!$h+kxS;(u&h0009x^TZSst#S5`<2)I#syLQ&f7Qt_*QRo zC5+kZw#Dj7Ut#YLhd(J{?{juZdVt9dk6-cHXw?_7DCr?E9WgZ5r7xy-Tq%mJhxR|~ zY~>0wfhKj-nzXb?T57|im-T;jY(!z7nGGH}5|X_oA(K%w>p7OK#Z|@_@bMC|wc#|< zw!VXRU1=3n9r$9&0hbkCuiL6(N9BE1;$2C;;C zp7~-QZ)>V8_B$^&?sSx5dM&m+i1EblGd=_tlr*Wc$hN1d6bzn*0plt6oB1EX=X4E^k-H}azr z+(66iSYkWm54-Coi9dNZ4L(^!dL0rii_PA0I9fLFxJT&H=SNc?*l1MWLeACl^h?JL0AG^Xt=I!c@7K0QWsHUM{XmM7i94{&;DflMp4YF}cZB>k-y})L{_6BpfN_$m-?d-d`n#lYL zgMeSY_CGVWnLo9haM7bhD6!A!#Wl@P8t$plvX+gp&;IUA=oDOZpRKD9ynU3h328ds7OXAg{`xQ)PN#*B;mb&LB=`#+o2IhTM z&4M61k=$|`x4YB@s|nm5{%5`@tdGNg$wz;E55-F#8j`Ut;Z9pFre2F~=AoKpF32BQ zmXN!#W7e{0=wm*P2UO4LufaEDP@mh-9O8R>2>%KDbMiNp1165k>F&8MK~B(@>iw}V z%;TIB7ha|zsuAlvMPA+F)3ffgpYAwB36>7o z-u|O~h&o-(Q)^q-lEe#o)$_GFnMl7F{3d7C{?Lz%R_f*XPTQ*kUgzPcS`(Mv!e!=F zZKqw!iyUp{MpkECUDs;Km61UUyr`zy1QX%aPNu&U}&}o@E>z&WV zcmKVqbqwp!4#LG3Un)Dd5CHQ`|q;RhX-qz zM3ctG8QPJNGms$qbK|mWg;Tz0C#A~;&h?SEl0wc)0pjO&G!olpY&IQTHlqP{Wc*%K={5WAtpS5Tq<-_zk z)P`+T%*EW&4jJq*)vCPq6XfLbk>RfsoLPiYJ1kQw{P}6Y^rs#_GCT$4&CTDASvGbz zJQsRNP84?WA`B?3Ve$dqMvMTD*0Orf-8YAw>V>(E=o*HVDhs<14}aMn{V}lti@^o) zo@H&&QtXu1TLl%XvK-u^-Y<2&UcQf~ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html new file mode 100644 index 0000000..21c5f7e --- /dev/null +++ b/src/main/resources/templates/error.html @@ -0,0 +1,94 @@ + + + + + + Your request cannot be processed + + + +
+

Your request cannot be processed :(

+ +

Sorry, an error has occurred.

+ + Status:  ()
+ + Message: 
+
+
+ + diff --git a/src/main/resources/templates/mail/activationEmail.html b/src/main/resources/templates/mail/activationEmail.html new file mode 100644 index 0000000..6be1e4f --- /dev/null +++ b/src/main/resources/templates/mail/activationEmail.html @@ -0,0 +1,20 @@ + + + + JHipster activation + + + + +

Dear

+

Your JHipster account has been created, please click on the URL below to activate it:

+

+ Activation link +

+

+ Regards, +
+ JHipster. +

+ + diff --git a/src/main/resources/templates/mail/creationEmail.html b/src/main/resources/templates/mail/creationEmail.html new file mode 100644 index 0000000..ab46621 --- /dev/null +++ b/src/main/resources/templates/mail/creationEmail.html @@ -0,0 +1,20 @@ + + + + JHipster creation + + + + +

Dear

+

Your JHipster account has been created, please click on the URL below to access it:

+

+ Login link +

+

+ Regards, +
+ JHipster. +

+ + diff --git a/src/main/resources/templates/mail/passwordResetEmail.html b/src/main/resources/templates/mail/passwordResetEmail.html new file mode 100644 index 0000000..eb74196 --- /dev/null +++ b/src/main/resources/templates/mail/passwordResetEmail.html @@ -0,0 +1,22 @@ + + + + JHipster password reset + + + + +

Dear

+

+ For your JHipster account a password reset was requested, please click on the URL below to reset it: +

+

+ Login link +

+

+ Regards, +
+ JHipster. +

+ + diff --git a/src/main/webapp/404.html b/src/main/webapp/404.html new file mode 100644 index 0000000..f061c66 --- /dev/null +++ b/src/main/webapp/404.html @@ -0,0 +1,57 @@ + + + + + Page Not Found + + + + + +

Page Not Found

+

Sorry, but the page you were trying to view does not exist.

+ + diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..f1611b5 --- /dev/null +++ b/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,13 @@ + + + + + html + text/html;charset=utf-8 + + + diff --git a/src/main/webapp/app/account/account.service.spec.ts b/src/main/webapp/app/account/account.service.spec.ts new file mode 100644 index 0000000..f725014 --- /dev/null +++ b/src/main/webapp/app/account/account.service.spec.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestingPinia } from '@pinia/testing'; +import axios from 'axios'; + +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; +import { type AccountStore, useStore } from '@/store'; + +import AccountService from './account.service'; + +const resetStore = (store: AccountStore) => { + store.logout(); + store.authenticate(null); + store.profilesLoaded = false; + store.activeProfiles = ''; + store.ribbonOnProfiles = ''; +}; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +createTestingPinia({ stubActions: false }); +const store = useStore(); + +describe('Account Service test suite', () => { + let accountService: AccountService; + + beforeEach(() => { + localStorage.clear(); + localStorage.setItem(AUTHENTICATION_TOKEN_KEY, 'token'); + + axiosStub.get.mockReset(); + resetStore(store); + }); + + it('should init service and do not retrieve account', async () => { + axiosStub.get.mockImplementation((...args: any[]) => { + if (args[0] === 'management/info') { + return Promise.resolve({ status: 200, data: { 'display-ribbon-on-profiles': 'dev', activeProfiles: ['dev', 'test'] } }); + } + return Promise.resolve({}); + }); + + accountService = new AccountService(store); + await accountService.update(); + + expect(store.logon).toBeNull(); + expect(accountService.authenticated).toBe(false); + expect(store.account).toBeNull(); + expect(axiosStub.get).toHaveBeenCalledWith('management/info'); + expect(store.activeProfiles[0]).toBe('dev'); + expect(store.activeProfiles[1]).toBe('test'); + expect(store.ribbonOnProfiles).toBe('dev'); + }); + + it('should init service and retrieve profiles if already logged in before but no account found', async () => { + axiosStub.get.mockResolvedValue({}); + accountService = new AccountService(store); + await accountService.update(); + + expect(store.logon).toBeNull(); + expect(accountService.authenticated).toBe(false); + expect(store.account).toBeNull(); + expect(axiosStub.get).toHaveBeenCalledWith('management/info'); + }); + + it('should init service and retrieve profiles if already logged in before but exception occurred and should be logged out', async () => { + axiosStub.get.mockImplementation((...args: any[]) => { + if (args[0] === 'api/account') return Promise.reject(new Error()); + return Promise.resolve({}); + }); + accountService = new AccountService(store); + await accountService.update(); + + expect(accountService.authenticated).toBe(false); + expect(store.account).toBeNull(); + expect(axiosStub.get).toHaveBeenCalledWith('management/info'); + }); + + it('should init service and check for authority after retrieving account but getAccount failed', async () => { + axiosStub.get.mockRejectedValue(new Error()); + accountService = new AccountService(store); + await accountService.update(); + + return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => { + expect(value).toBe(false); + }); + }); + + it('should init service and check for authority after retrieving account', async () => { + axiosStub.get.mockResolvedValue({ status: 200, data: { authorities: ['USER'], langKey: 'en', login: 'ADMIN' } }); + accountService = new AccountService(store); + await accountService.update(); + + return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => { + expect(value).toBe(true); + }); + }); + + it('should init service as not authenticated and not return any authorities admin and not retrieve account', async () => { + axiosStub.get.mockRejectedValue(new Error()); + accountService = new AccountService(store); + await accountService.update(); + + return accountService.hasAnyAuthorityAndCheckAuth('ADMIN').then((value: boolean) => { + expect(value).toBe(false); + }); + }); + + it('should init service as not authenticated and not return authority user', async () => { + axiosStub.get.mockRejectedValue(new Error()); + accountService = new AccountService(store); + await accountService.update(); + + return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => { + expect(value).toBe(false); + }); + }); +}); diff --git a/src/main/webapp/app/account/account.service.ts b/src/main/webapp/app/account/account.service.ts new file mode 100644 index 0000000..17b311d --- /dev/null +++ b/src/main/webapp/app/account/account.service.ts @@ -0,0 +1,87 @@ +import axios from 'axios'; + +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; +import { type AccountStore } from '@/store'; + +export default class AccountService { + constructor(private readonly store: AccountStore) {} + + async update(): Promise { + if (!this.store.profilesLoaded) { + await this.retrieveProfiles(); + this.store.setProfilesLoaded(); + } + await this.loadAccount(); + } + + async retrieveProfiles(): Promise { + try { + const res = await axios.get('management/info'); + if (res.data?.activeProfiles) { + this.store.setRibbonOnProfiles(res.data['display-ribbon-on-profiles']); + this.store.setActiveProfiles(res.data.activeProfiles); + } + return true; + } catch { + return false; + } + } + + async retrieveAccount(): Promise { + try { + const response = await axios.get('api/account'); + if (response.status === 200 && response.data?.login) { + const account = response.data; + this.store.setAuthentication(account); + return true; + } + } catch { + // Ignore error + } + + this.store.logout(); + return false; + } + + async loadAccount() { + if (this.store.logon) { + return this.store.logon; + } + const token = localStorage.getItem(AUTHENTICATION_TOKEN_KEY) ?? sessionStorage.getItem(AUTHENTICATION_TOKEN_KEY); + if (this.authenticated && this.userAuthorities && token) { + return; + } + + const promise = this.retrieveAccount(); + this.store.authenticate(promise); + promise.then(() => this.store.authenticate(null)); + await promise; + } + + async hasAnyAuthorityAndCheckAuth(authorities: any): Promise { + if (typeof authorities === 'string') { + authorities = [authorities]; + } + + return this.checkAuthorities(authorities); + } + + get authenticated(): boolean { + return this.store.authenticated; + } + + get userAuthorities(): string[] { + return this.store.account?.authorities; + } + + private checkAuthorities(authorities: string[]): boolean { + if (this.userAuthorities) { + for (const authority of authorities) { + if (this.userAuthorities.includes(authority)) { + return true; + } + } + } + return false; + } +} diff --git a/src/main/webapp/app/account/activate/activate.component.spec.ts b/src/main/webapp/app/account/activate/activate.component.spec.ts new file mode 100644 index 0000000..62844c2 --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.component.spec.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestingPinia } from '@pinia/testing'; +import { type ComponentMountingOptions, shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import Activate from './activate.vue'; + +type ActivateComponentType = InstanceType; + +const route = { query: { key: 'key' } }; + +vi.mock('vue-router', () => ({ + useRoute: () => route, +})); + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('Activate Component', () => { + let activate: ActivateComponentType; + let mountOptions: ComponentMountingOptions; + + beforeEach(() => { + mountOptions = { + global: { + plugins: [createTestingPinia()], + }, + }; + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + it('should display error when activation fails', async () => { + axiosStub.get.mockRejectedValue({}); + + const wrapper = shallowMount(Activate, mountOptions); + activate = wrapper.vm; + await activate.$nextTick(); + + expect(activate.error).toBeTruthy(); + expect(activate.success).toBeFalsy(); + }); + + it('should display success when activation succeeds', async () => { + axiosStub.get.mockResolvedValue({}); + + const wrapper = shallowMount(Activate, mountOptions); + activate = wrapper.vm; + await activate.$nextTick(); + + expect(activate.error).toBeFalsy(); + expect(activate.success).toBeTruthy(); + }); +}); diff --git a/src/main/webapp/app/account/activate/activate.component.ts b/src/main/webapp/app/account/activate/activate.component.ts new file mode 100644 index 0000000..7877a2a --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.component.ts @@ -0,0 +1,38 @@ +import { type Ref, defineComponent, inject, onMounted, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRoute } from 'vue-router'; + +import { useLoginModal } from '@/account/login-modal'; + +import ActivateService from './activate.service'; + +export default defineComponent({ + setup() { + const activateService = inject('activateService', () => new ActivateService(), true); + const { showLogin } = useLoginModal(); + const route = useRoute(); + + const success: Ref = ref(false); + const error: Ref = ref(false); + + onMounted(async () => { + const key = Array.isArray(route.query.key) ? route.query.key[0] : route.query.key; + try { + await activateService.activateAccount(key); + success.value = true; + error.value = false; + } catch { + error.value = true; + success.value = false; + } + }); + + return { + activateService, + showLogin, + success, + error, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/account/activate/activate.service.ts b/src/main/webapp/app/account/activate/activate.service.ts new file mode 100644 index 0000000..dee5d0b --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.service.ts @@ -0,0 +1,13 @@ +import axios, { type AxiosInstance } from 'axios'; + +export default class ActivateService { + private readonly axios: AxiosInstance; + + constructor() { + this.axios = axios; + } + + activateAccount(key: string): Promise { + return this.axios.get(`api/activate?key=${key}`); + } +} diff --git a/src/main/webapp/app/account/activate/activate.vue b/src/main/webapp/app/account/activate/activate.vue new file mode 100644 index 0000000..a902f51 --- /dev/null +++ b/src/main/webapp/app/account/activate/activate.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/main/webapp/app/account/change-password/change-password.component.spec.ts b/src/main/webapp/app/account/change-password/change-password.component.spec.ts new file mode 100644 index 0000000..228adf7 --- /dev/null +++ b/src/main/webapp/app/account/change-password/change-password.component.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { computed } from 'vue'; + +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import ChangePassword from './change-password.vue'; + +type ChangePasswordComponentType = InstanceType; + +const pinia = createTestingPinia(); + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('ChangePassword Component', () => { + let changePassword: ChangePasswordComponentType; + + beforeEach(() => { + axiosStub.get.mockResolvedValue({}); + axiosStub.post.mockReset(); + + const wrapper = shallowMount(ChangePassword, { + global: { + plugins: [pinia], + provide: { + currentUsername: computed(() => 'username'), + }, + }, + }); + changePassword = wrapper.vm; + }); + + it('should show error if passwords do not match', () => { + // GIVEN + changePassword.resetPassword = { newPassword: 'password1', confirmPassword: 'password2' }; + // WHEN + changePassword.changePassword(); + // THEN + expect(changePassword.doNotMatch).toBe('ERROR'); + expect(changePassword.error).toBeNull(); + expect(changePassword.success).toBeNull(); + }); + + it('should call Auth.changePassword when passwords match and set success to OK upon success', async () => { + // GIVEN + changePassword.resetPassword = { currentPassword: 'password1', newPassword: 'password1', confirmPassword: 'password1' }; + axiosStub.post.mockResolvedValue({}); + + // WHEN + changePassword.changePassword(); + await changePassword.$nextTick(); + + // THEN + expect(axiosStub.post).toHaveBeenCalledWith('api/account/change-password', { + currentPassword: 'password1', + newPassword: 'password1', + }); + + expect(changePassword.doNotMatch).toBeNull(); + expect(changePassword.error).toBeNull(); + expect(changePassword.success).toBe('OK'); + }); + + it('should notify of error if change password fails', async () => { + // GIVEN + changePassword.resetPassword = { currentPassword: 'password1', newPassword: 'password1', confirmPassword: 'password1' }; + axiosStub.post.mockRejectedValue({}); + + // WHEN + changePassword.changePassword(); + await changePassword.$nextTick(); + + // THEN + expect(changePassword.doNotMatch).toBeNull(); + expect(changePassword.success).toBeNull(); + await changePassword.$nextTick(); + expect(changePassword.error).toBe('ERROR'); + }); +}); diff --git a/src/main/webapp/app/account/change-password/change-password.component.ts b/src/main/webapp/app/account/change-password/change-password.component.ts new file mode 100644 index 0000000..b3424cc --- /dev/null +++ b/src/main/webapp/app/account/change-password/change-password.component.ts @@ -0,0 +1,71 @@ +import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useVuelidate } from '@vuelidate/core'; +import { maxLength, minLength, required, sameAs } from '@vuelidate/validators'; +import axios from 'axios'; + +export default defineComponent({ + validations() { + return { + resetPassword: { + currentPassword: { + required, + }, + newPassword: { + required, + minLength: minLength(4), + maxLength: maxLength(254), + }, + confirmPassword: { + sameAsPassword: sameAs(this.resetPassword.newPassword), + }, + }, + }; + }, + setup() { + const username = inject>('currentUsername'); + const success: Ref = ref(null); + const error: Ref = ref(null); + const doNotMatch: Ref = ref(null); + const resetPassword: Ref = ref({ + currentPassword: null, + newPassword: null, + confirmPassword: null, + }); + + return { + username, + success, + error, + doNotMatch, + resetPassword, + v$: useVuelidate(), + t$: useI18n().t, + }; + }, + methods: { + changePassword(): void { + if (this.resetPassword.newPassword !== this.resetPassword.confirmPassword) { + this.error = null; + this.success = null; + this.doNotMatch = 'ERROR'; + } else { + this.doNotMatch = null; + axios + .post('api/account/change-password', { + currentPassword: this.resetPassword.currentPassword, + newPassword: this.resetPassword.newPassword, + }) + .then(() => { + this.success = 'OK'; + this.error = null; + }) + .catch(() => { + this.success = null; + this.error = 'ERROR'; + }); + } + }, + }, +}); diff --git a/src/main/webapp/app/account/change-password/change-password.vue b/src/main/webapp/app/account/change-password/change-password.vue new file mode 100644 index 0000000..123d705 --- /dev/null +++ b/src/main/webapp/app/account/change-password/change-password.vue @@ -0,0 +1,92 @@ + + + diff --git a/src/main/webapp/app/account/login-form/login-form.component.spec.ts b/src/main/webapp/app/account/login-form/login-form.component.spec.ts new file mode 100644 index 0000000..df5310b --- /dev/null +++ b/src/main/webapp/app/account/login-form/login-form.component.spec.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type RouteLocation } from 'vue-router'; + +import { createTestingPinia } from '@pinia/testing'; +import { type MountingOptions, shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; +import { useStore } from '@/store'; +import AccountService from '../account.service'; + +import LoginForm from './login-form.vue'; + +type LoginFormComponentType = InstanceType; + +let route: Partial; +const routerGoMock = vi.fn(); +vi.mock('vue-router', () => ({ + useRoute: () => route, + useRouter: () => ({ go: routerGoMock }), +})); + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('LoginForm Component', () => { + let loginForm: LoginFormComponentType; + + beforeEach(() => { + route = {}; + axiosStub.get.mockResolvedValue({}); + axiosStub.post.mockReset(); + + const pinia = createTestingPinia(); + const store = useStore(); + + const globalOptions: MountingOptions['global'] = { + stubs: { + 'b-alert': true, + 'b-button': true, + 'b-form': true, + 'b-form-input': true, + 'b-form-group': true, + 'b-form-checkbox': true, + 'b-link': true, + }, + plugins: [pinia], + provide: { + accountService: new AccountService(store), + }, + }; + const wrapper = shallowMount(LoginForm, { global: globalOptions }); + + loginForm = wrapper.vm; + }); + + it('should not store token if authentication is KO', async () => { + // GIVEN + loginForm.login = 'login'; + loginForm.password = 'pwd'; + loginForm.rememberMe = true; + axiosStub.post.mockRejectedValue(new Error()); + + // WHEN + loginForm.doLogin(); + await loginForm.$nextTick(); + + // THEN + expect(axiosStub.post).toHaveBeenCalledWith('api/authenticate', { + username: 'login', + password: 'pwd', + rememberMe: true, + }); + await loginForm.$nextTick(); + expect(loginForm.authenticationError).toBeTruthy(); + }); + + it('should store token if authentication is OK', async () => { + // GIVEN + loginForm.login = 'login'; + loginForm.password = 'pwd'; + loginForm.rememberMe = true; + const jwtSecret = 'jwt-secret'; + axiosStub.post.mockResolvedValue({ headers: { authorization: `Bearer ${jwtSecret}` } }); + + // WHEN + loginForm.doLogin(); + await loginForm.$nextTick(); + + // THEN + expect(axiosStub.post).toHaveBeenCalledWith('api/authenticate', { + username: 'login', + password: 'pwd', + rememberMe: true, + }); + + expect(loginForm.authenticationError).toBeFalsy(); + expect(localStorage.getItem(AUTHENTICATION_TOKEN_KEY)).toEqual(jwtSecret); + }); + + it('should store token if authentication is OK in session', async () => { + // GIVEN + loginForm.login = 'login'; + loginForm.password = 'pwd'; + loginForm.rememberMe = false; + const jwtSecret = 'jwt-secret'; + axiosStub.post.mockResolvedValue({ headers: { authorization: `Bearer ${jwtSecret}` } }); + + // WHEN + loginForm.doLogin(); + await loginForm.$nextTick(); + + // THEN + expect(axiosStub.post).toHaveBeenCalledWith('api/authenticate', { + username: 'login', + password: 'pwd', + rememberMe: false, + }); + + expect(loginForm.authenticationError).toBeFalsy(); + expect(sessionStorage.getItem(AUTHENTICATION_TOKEN_KEY)).toEqual(jwtSecret); + }); +}); diff --git a/src/main/webapp/app/account/login-form/login-form.component.ts b/src/main/webapp/app/account/login-form/login-form.component.ts new file mode 100644 index 0000000..6a41dae --- /dev/null +++ b/src/main/webapp/app/account/login-form/login-form.component.ts @@ -0,0 +1,62 @@ +import { type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRoute, useRouter } from 'vue-router'; + +import axios from 'axios'; + +import { useLoginModal } from '@/account/login-modal'; +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; +import type AccountService from '../account.service'; + +export default defineComponent({ + setup() { + const authenticationError: Ref = ref(false); + const login: Ref = ref(null); + const password: Ref = ref(null); + const rememberMe: Ref = ref(false); + + const { hideLogin } = useLoginModal(); + const route = useRoute(); + const router = useRouter(); + + const previousState = () => router.go(-1); + + const accountService = inject('accountService'); + + const doLogin = async () => { + const data = { username: login.value, password: password.value, rememberMe: rememberMe.value }; + try { + const result = await axios.post('api/authenticate', data); + const bearerToken = result.headers.authorization; + if (bearerToken?.startsWith('Bearer ')) { + const jwt = bearerToken.slice(7, bearerToken.length); + if (rememberMe.value) { + localStorage.setItem(AUTHENTICATION_TOKEN_KEY, jwt); + sessionStorage.removeItem(AUTHENTICATION_TOKEN_KEY); + } else { + sessionStorage.setItem(AUTHENTICATION_TOKEN_KEY, jwt); + localStorage.removeItem(AUTHENTICATION_TOKEN_KEY); + } + } + + authenticationError.value = false; + hideLogin(); + await accountService.retrieveAccount(); + if (route.path === '/forbidden') { + previousState(); + } + } catch { + authenticationError.value = true; + } + }; + return { + authenticationError, + login, + password, + rememberMe, + accountService, + doLogin, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/account/login-form/login-form.vue b/src/main/webapp/app/account/login-form/login-form.vue new file mode 100644 index 0000000..73ed27a --- /dev/null +++ b/src/main/webapp/app/account/login-form/login-form.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/main/webapp/app/account/login-modal.ts b/src/main/webapp/app/account/login-modal.ts new file mode 100644 index 0000000..240b8ea --- /dev/null +++ b/src/main/webapp/app/account/login-modal.ts @@ -0,0 +1,21 @@ +import { ref } from 'vue'; + +import { defineStore } from 'pinia'; + +export const useLoginModal = defineStore('login', () => { + const loginModalOpen = ref(false); + + function showLogin() { + loginModalOpen.value = true; + } + + function hideLogin() { + loginModalOpen.value = false; + } + + return { + loginModalOpen, + showLogin, + hideLogin, + }; +}); diff --git a/src/main/webapp/app/account/register/register.component.spec.ts b/src/main/webapp/app/account/register/register.component.spec.ts new file mode 100644 index 0000000..d2acd54 --- /dev/null +++ b/src/main/webapp/app/account/register/register.component.spec.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { computed } from 'vue'; + +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import { useLoginModal } from '@/account/login-modal'; +import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from '@/shared/jhipster/error.constants'; + +import Register from './register.vue'; + +type RegisterComponentType = InstanceType; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('Register Component', () => { + let register: RegisterComponentType; + const filledRegisterAccount = { + email: 'jhi@pster.net', + langKey: 'en', + login: 'jhi', + password: 'jhipster', + }; + + beforeEach(() => { + axiosStub.get.mockResolvedValue({}); + axiosStub.post.mockReset(); + + const wrapper = shallowMount(Register, { + global: { + plugins: [createTestingPinia()], + provide: { + currentLanguage: computed(() => 'en'), + }, + }, + }); + register = wrapper.vm; + }); + + it('should set all default values correctly', () => { + expect(register.success).toBe(false); + expect(register.error).toBe(''); + expect(register.errorEmailExists).toBe(''); + expect(register.errorUserExists).toBe(''); + expect(register.confirmPassword).toBeNull(); + expect(register.registerAccount.login).toBeUndefined(); + expect(register.registerAccount.password).toBeUndefined(); + expect(register.registerAccount.email).toBeUndefined(); + }); + + it('should open login modal when asked to', () => { + const login = useLoginModal(); + register.showLogin(); + expect(login.showLogin).toHaveBeenCalledOnce(); + }); + + it('should register when password match', async () => { + axiosStub.post.mockResolvedValue(undefined); + register.registerAccount = filledRegisterAccount; + register.confirmPassword = filledRegisterAccount.password; + register.register(); + await register.$nextTick(); + + expect(axiosStub.post).toHaveBeenCalledWith('api/register', { + email: 'jhi@pster.net', + langKey: 'en', + login: 'jhi', + password: 'jhipster', + }); + expect(register.success).toBe(true); + expect(register.error).toBeNull(); + expect(register.errorEmailExists).toBeNull(); + expect(register.errorUserExists).toBeNull(); + }); + + it('should register when password match but throw error when login already exist', async () => { + const error = { response: { status: 400, data: { type: LOGIN_ALREADY_USED_TYPE } } }; + axiosStub.post.mockRejectedValue(error); + register.registerAccount = filledRegisterAccount; + register.confirmPassword = filledRegisterAccount.password; + register.register(); + await register.$nextTick(); + + expect(axiosStub.post).toHaveBeenCalledWith('api/register', { + email: 'jhi@pster.net', + langKey: 'en', + login: 'jhi', + password: 'jhipster', + }); + await register.$nextTick(); + expect(register.success).toBeNull(); + expect(register.error).toBeNull(); + expect(register.errorEmailExists).toBeNull(); + expect(register.errorUserExists).toBe('ERROR'); + }); + + it('should register when password match but throw error when email already used', async () => { + const error = { response: { status: 400, data: { type: EMAIL_ALREADY_USED_TYPE } } }; + axiosStub.post.mockRejectedValue(error); + register.registerAccount = filledRegisterAccount; + register.confirmPassword = filledRegisterAccount.password; + register.register(); + await register.$nextTick(); + + expect(axiosStub.post).toHaveBeenCalledWith('api/register', { + email: 'jhi@pster.net', + langKey: 'en', + login: 'jhi', + password: 'jhipster', + }); + await register.$nextTick(); + expect(register.success).toBeNull(); + expect(register.error).toBeNull(); + expect(register.errorEmailExists).toBe('ERROR'); + expect(register.errorUserExists).toBeNull(); + }); + + it('should register when password match but throw error', async () => { + const error = { response: { status: 400, data: { type: 'unknown' } } }; + axiosStub.post.mockRejectedValue(error); + register.registerAccount = filledRegisterAccount; + register.confirmPassword = filledRegisterAccount.password; + register.register(); + await register.$nextTick(); + + expect(axiosStub.post).toHaveBeenCalledWith('api/register', { + email: 'jhi@pster.net', + langKey: 'en', + login: 'jhi', + password: 'jhipster', + }); + await register.$nextTick(); + expect(register.success).toBeNull(); + expect(register.errorEmailExists).toBeNull(); + expect(register.errorUserExists).toBeNull(); + expect(register.error).toBe('ERROR'); + }); +}); diff --git a/src/main/webapp/app/account/register/register.component.ts b/src/main/webapp/app/account/register/register.component.ts new file mode 100644 index 0000000..85fbe52 --- /dev/null +++ b/src/main/webapp/app/account/register/register.component.ts @@ -0,0 +1,98 @@ +import { type Ref, computed, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useVuelidate } from '@vuelidate/core'; +import { email, helpers, maxLength, minLength, required, sameAs } from '@vuelidate/validators'; + +import { useLoginModal } from '@/account/login-modal'; +import RegisterService from '@/account/register/register.service'; +import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from '@/shared/jhipster/error.constants'; + +const loginPattern = helpers.regex(/^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$/); + +export default defineComponent({ + name: 'Register', + validations() { + return { + registerAccount: { + login: { + required, + minLength: minLength(1), + maxLength: maxLength(50), + pattern: loginPattern, + }, + email: { + required, + minLength: minLength(5), + maxLength: maxLength(254), + email, + }, + password: { + required, + minLength: minLength(4), + maxLength: maxLength(254), + }, + }, + confirmPassword: { + required, + minLength: minLength(4), + maxLength: maxLength(50), + sameAsPassword: sameAs(this.registerAccount.password), + }, + }; + }, + setup() { + const { showLogin } = useLoginModal(); + const registerService = inject('registerService', () => new RegisterService(), true); + const currentLanguage = inject('currentLanguage', () => computed(() => navigator.language ?? 'en'), true); + + const error: Ref = ref(''); + const errorEmailExists: Ref = ref(''); + const errorUserExists: Ref = ref(''); + const success: Ref = ref(false); + + const confirmPassword: Ref = ref(null); + const registerAccount: Ref = ref({ + login: undefined, + email: undefined, + password: undefined, + }); + + return { + showLogin, + currentLanguage, + registerService, + error, + errorEmailExists, + errorUserExists, + success, + confirmPassword, + registerAccount, + v$: useVuelidate(), + t$: useI18n().t, + }; + }, + methods: { + register(): void { + this.error = null; + this.errorUserExists = null; + this.errorEmailExists = null; + this.registerAccount.langKey = this.currentLanguage; + this.registerService + .processRegistration(this.registerAccount) + .then(() => { + this.success = true; + }) + .catch(error => { + this.success = null; + if (error.response.status === 400 && error.response.data.type === LOGIN_ALREADY_USED_TYPE) { + this.errorUserExists = 'ERROR'; + } else if (error.response.status === 400 && error.response.data.type === EMAIL_ALREADY_USED_TYPE) { + this.errorEmailExists = 'ERROR'; + } else { + this.error = 'ERROR'; + } + }); + }, + }, +}); diff --git a/src/main/webapp/app/account/register/register.service.ts b/src/main/webapp/app/account/register/register.service.ts new file mode 100644 index 0000000..77b7cdc --- /dev/null +++ b/src/main/webapp/app/account/register/register.service.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; + +export default class RegisterService { + processRegistration(account: any): Promise { + return axios.post('api/register', account); + } +} diff --git a/src/main/webapp/app/account/register/register.vue b/src/main/webapp/app/account/register/register.vue new file mode 100644 index 0000000..130d468 --- /dev/null +++ b/src/main/webapp/app/account/register/register.vue @@ -0,0 +1,152 @@ + + + diff --git a/src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.spec.ts b/src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.spec.ts new file mode 100644 index 0000000..732f50b --- /dev/null +++ b/src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.spec.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import ResetPasswordFinish from './reset-password-finish.vue'; + +type ResetPasswordFinishComponentType = InstanceType; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('ResetPasswordFinish Component', () => { + let resetPasswordFinish: ResetPasswordFinishComponentType; + + beforeEach(() => { + axiosStub.post.mockReset(); + const wrapper = shallowMount(ResetPasswordFinish, { + global: { + plugins: [createTestingPinia()], + }, + }); + resetPasswordFinish = wrapper.vm; + }); + + it('should reset finish be a success', async () => { + // Given + axiosStub.post.mockResolvedValue(undefined); + + // When + await resetPasswordFinish.finishReset(); + + // Then + expect(resetPasswordFinish.success).toBeTruthy(); + }); + + it('should reset request fail as an error', async () => { + // Given + axiosStub.post.mockRejectedValue({ + response: { + status: null, + data: { + type: null, + }, + }, + }); + + // When + await resetPasswordFinish.finishReset(); + await resetPasswordFinish.$nextTick(); + + // Then + expect(resetPasswordFinish.success).toBeNull(); + expect(resetPasswordFinish.error).toEqual('ERROR'); + }); +}); diff --git a/src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.ts b/src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.ts new file mode 100644 index 0000000..d350d3d --- /dev/null +++ b/src/main/webapp/app/account/reset-password/finish/reset-password-finish.component.ts @@ -0,0 +1,77 @@ +import { type Ref, defineComponent, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useVuelidate } from '@vuelidate/core'; +import { maxLength, minLength, required, sameAs } from '@vuelidate/validators'; +import axios from 'axios'; + +import { useLoginModal } from '@/account/login-modal'; + +export default defineComponent({ + name: 'ResetPasswordFinish', + validations() { + return { + resetAccount: { + newPassword: { + required, + minLength: minLength(4), + maxLength: maxLength(254), + }, + confirmPassword: { + sameAsPassword: sameAs(this.resetAccount.newPassword), + }, + }, + }; + }, + setup() { + const { showLogin } = useLoginModal(); + + const doNotMatch: Ref = ref(null); + const success: Ref = ref(null); + const error: Ref = ref(null); + const keyMissing: Ref = ref(false); + const key: Ref = ref(null); + const resetAccount: Ref = ref({ + newPassword: null, + confirmPassword: null, + }); + + return { + showLogin, + doNotMatch, + success, + error, + keyMissing, + key, + resetAccount, + v$: useVuelidate(), + t$: useI18n().t, + }; + }, + created(): void { + if (this.$route?.query?.key !== undefined) { + this.key = this.$route.query.key; + } + this.keyMissing = !this.key; + }, + methods: { + finishReset() { + this.doNotMatch = null; + this.success = null; + this.error = null; + if (this.resetAccount.newPassword !== this.resetAccount.confirmPassword) { + this.doNotMatch = 'ERROR'; + } else { + return axios + .post('api/account/reset-password/finish', { key: this.key, newPassword: this.resetAccount.newPassword }) + .then(() => { + this.success = 'OK'; + }) + .catch(() => { + this.success = null; + this.error = 'ERROR'; + }); + } + }, + }, +}); diff --git a/src/main/webapp/app/account/reset-password/finish/reset-password-finish.vue b/src/main/webapp/app/account/reset-password/finish/reset-password-finish.vue new file mode 100644 index 0000000..2140b24 --- /dev/null +++ b/src/main/webapp/app/account/reset-password/finish/reset-password-finish.vue @@ -0,0 +1,85 @@ + + + diff --git a/src/main/webapp/app/account/reset-password/init/reset-password-init.component.spec.ts b/src/main/webapp/app/account/reset-password/init/reset-password-init.component.spec.ts new file mode 100644 index 0000000..ffc6c58 --- /dev/null +++ b/src/main/webapp/app/account/reset-password/init/reset-password-init.component.spec.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import ResetPasswordInit from './reset-password-init.vue'; + +type ResetPasswordInitComponentType = InstanceType; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('ResetPasswordInit Component', () => { + let resetPasswordInit: ResetPasswordInitComponentType; + + beforeEach(() => { + axiosStub.post.mockReset(); + const wrapper = shallowMount(ResetPasswordInit, {}); + resetPasswordInit = wrapper.vm; + }); + + it('should reset request be a success', async () => { + // Given + axiosStub.post.mockResolvedValue(undefined); + + // When + await resetPasswordInit.requestReset(); + + // Then + expect(resetPasswordInit.success).toBeTruthy(); + }); + + it('should reset request fail as an error', async () => { + // Given + axiosStub.post.mockRejectedValue({ + response: { + status: null, + data: { + type: null, + }, + }, + }); + + // When + await resetPasswordInit.requestReset(); + await resetPasswordInit.$nextTick(); + + // Then + expect(resetPasswordInit.success).toBe(false); + expect(resetPasswordInit.error).toEqual('ERROR'); + }); +}); diff --git a/src/main/webapp/app/account/reset-password/init/reset-password-init.component.ts b/src/main/webapp/app/account/reset-password/init/reset-password-init.component.ts new file mode 100644 index 0000000..34dd377 --- /dev/null +++ b/src/main/webapp/app/account/reset-password/init/reset-password-init.component.ts @@ -0,0 +1,60 @@ +import { type Ref, defineComponent, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useVuelidate } from '@vuelidate/core'; +import { email, maxLength, minLength, required } from '@vuelidate/validators'; +import axios from 'axios'; + +const validations = { + resetAccount: { + email: { + required, + minLength: minLength(5), + maxLength: maxLength(254), + email, + }, + }, +}; + +interface ResetAccount { + email: string | null; +} + +export default defineComponent({ + name: 'ResetPasswordInit', + validations, + setup() { + const error: Ref = ref(null); + const success: Ref = ref(false); + const resetAccount: Ref = ref({ + email: null, + }); + + return { + error, + success, + resetAccount, + v$: useVuelidate(), + t$: useI18n().t, + }; + }, + methods: { + async requestReset(): Promise { + this.error = null; + this.success = false; + await axios + .post('api/account/reset-password/init', this.resetAccount.email, { + headers: { + 'content-type': 'text/plain', + }, + }) + .then(() => { + this.success = true; + }) + .catch(() => { + this.success = false; + this.error = 'ERROR'; + }); + }, + }, +}); diff --git a/src/main/webapp/app/account/reset-password/init/reset-password-init.vue b/src/main/webapp/app/account/reset-password/init/reset-password-init.vue new file mode 100644 index 0000000..60a7476 --- /dev/null +++ b/src/main/webapp/app/account/reset-password/init/reset-password-init.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/main/webapp/app/account/settings/settings.component.spec.ts b/src/main/webapp/app/account/settings/settings.component.spec.ts new file mode 100644 index 0000000..93c1823 --- /dev/null +++ b/src/main/webapp/app/account/settings/settings.component.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import { EMAIL_ALREADY_USED_TYPE } from '@/shared/jhipster/error.constants'; +import { useStore } from '@/store'; + +import Settings from './settings.vue'; + +type SettingsComponentType = InstanceType; + +const pinia = createTestingPinia({ stubActions: false }); + +const store = useStore(); + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('Settings Component', () => { + let settings: SettingsComponentType; + const account = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@jhipster.org', + }; + + beforeEach(() => { + axiosStub.get.mockResolvedValue({}); + axiosStub.post.mockReset(); + + store.setAuthentication(account); + const wrapper = shallowMount(Settings, { + global: { + plugins: [pinia], + }, + }); + settings = wrapper.vm; + }); + + it('should send the current identity upon save', async () => { + // GIVEN + axiosStub.post.mockResolvedValue({}); + + // WHEN + await settings.save(); + await settings.$nextTick(); + + // THEN + expect(axiosStub.post).toHaveBeenCalledWith('api/account', account); + }); + + it('should notify of success upon successful save', async () => { + // GIVEN + axiosStub.post.mockResolvedValue(account); + + // WHEN + await settings.save(); + await settings.$nextTick(); + + // THEN + expect(settings.error).toBeNull(); + expect(settings.success).toBe('OK'); + }); + + it('should notify of error upon failed save', async () => { + // GIVEN + const error = { response: { status: 417 } }; + axiosStub.post.mockRejectedValue(error); + + // WHEN + await settings.save(); + await settings.$nextTick(); + + // THEN + expect(settings.error).toEqual('ERROR'); + expect(settings.errorEmailExists).toBeNull(); + expect(settings.success).toBeNull(); + }); + + it('should notify of error upon error 400', async () => { + // GIVEN + const error = { response: { status: 400, data: {} } }; + axiosStub.post.mockRejectedValue(error); + + // WHEN + await settings.save(); + await settings.$nextTick(); + + // THEN + expect(settings.error).toEqual('ERROR'); + expect(settings.errorEmailExists).toBeNull(); + expect(settings.success).toBeNull(); + }); + + it('should notify of error upon email already used', async () => { + // GIVEN + const error = { response: { status: 400, data: { type: EMAIL_ALREADY_USED_TYPE } } }; + axiosStub.post.mockRejectedValue(error); + + // WHEN + await settings.save(); + await settings.$nextTick(); + + // THEN + expect(settings.errorEmailExists).toEqual('ERROR'); + expect(settings.error).toBeNull(); + expect(settings.success).toBeNull(); + }); +}); diff --git a/src/main/webapp/app/account/settings/settings.component.ts b/src/main/webapp/app/account/settings/settings.component.ts new file mode 100644 index 0000000..353ef77 --- /dev/null +++ b/src/main/webapp/app/account/settings/settings.component.ts @@ -0,0 +1,78 @@ +import { type ComputedRef, type Ref, computed, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useVuelidate } from '@vuelidate/core'; +import { email, maxLength, minLength, required } from '@vuelidate/validators'; +import axios from 'axios'; + +import languages from '@/shared/config/languages'; +import { EMAIL_ALREADY_USED_TYPE } from '@/shared/jhipster/error.constants'; +import { useStore } from '@/store'; + +const validations = { + settingsAccount: { + firstName: { + required, + minLength: minLength(1), + maxLength: maxLength(50), + }, + lastName: { + required, + minLength: minLength(1), + maxLength: maxLength(50), + }, + email: { + required, + email, + minLength: minLength(5), + maxLength: maxLength(254), + }, + }, +}; + +export default defineComponent({ + name: 'Settings', + validations, + setup() { + const store = useStore(); + + const success: Ref = ref(null); + const error: Ref = ref(null); + const errorEmailExists: Ref = ref(null); + + const settingsAccount = computed(() => store.account); + const username = inject>('currentUsername', () => computed(() => store.account?.login), true); + + return { + success, + error, + errorEmailExists, + settingsAccount, + username, + v$: useVuelidate(), + languages: languages(), + t$: useI18n().t, + }; + }, + methods: { + save() { + this.error = null; + this.errorEmailExists = null; + return axios + .post('api/account', this.settingsAccount) + .then(() => { + this.error = null; + this.success = 'OK'; + this.errorEmailExists = null; + }) + .catch(ex => { + this.success = null; + this.error = 'ERROR'; + if (ex.response.status === 400 && ex.response.data.type === EMAIL_ALREADY_USED_TYPE) { + this.errorEmailExists = 'ERROR'; + this.error = null; + } + }); + }, + }, +}); diff --git a/src/main/webapp/app/account/settings/settings.vue b/src/main/webapp/app/account/settings/settings.vue new file mode 100644 index 0000000..70dc555 --- /dev/null +++ b/src/main/webapp/app/account/settings/settings.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/main/webapp/app/admin/configuration/configuration.component.spec.ts b/src/main/webapp/app/admin/configuration/configuration.component.spec.ts new file mode 100644 index 0000000..120d867 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.spec.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import Configuration from './configuration.vue'; + +type ConfigurationComponentType = InstanceType; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), +}; + +describe('Configuration Component', () => { + let configuration: ConfigurationComponentType; + + beforeEach(() => { + axiosStub.get.mockReset(); + axiosStub.get.mockResolvedValue({ + data: { contexts: [{ beans: [{ prefix: 'A' }, { prefix: 'B' }] }], propertySources: [{ properties: { key1: { value: 'value' } } }] }, + }); + const wrapper = shallowMount(Configuration, { + global: { + stubs: { + 'jhi-sort-indicator': true, + }, + }, + }); + configuration = wrapper.vm; + }); + + describe('OnRouteEnter', () => { + it('should set all default values correctly', () => { + expect(configuration.configKeys).toEqual([]); + expect(configuration.filtered).toBe(''); + expect(configuration.orderProp).toBe('prefix'); + expect(configuration.reverse).toBe(false); + }); + it('Should call load all on init', async () => { + // WHEN + configuration.init(); + await configuration.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith('management/env'); + expect(axiosStub.get).toHaveBeenCalledWith('management/configprops'); + }); + }); + + describe('keys method', () => { + it('should return the keys of an Object', () => { + // GIVEN + const data = { + key1: 'test', + key2: 'test2', + }; + + // THEN + expect(configuration.keys(data)).toEqual(['key1', 'key2']); + expect(configuration.keys(undefined)).toEqual([]); + }); + }); + + describe('changeOrder function', () => { + it('should change order', () => { + // GIVEN + const rev = configuration.reverse; + + // WHEN + configuration.changeOrder('prefix'); + + // THEN + expect(configuration.orderProp).toBe('prefix'); + expect(configuration.reverse).toBe(!rev); + }); + }); +}); diff --git a/src/main/webapp/app/admin/configuration/configuration.component.ts b/src/main/webapp/app/admin/configuration/configuration.component.ts new file mode 100644 index 0000000..fd478ce --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.component.ts @@ -0,0 +1,67 @@ +import { type Ref, computed, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { orderAndFilterBy } from '@/shared/computables'; + +import ConfigurationService from './configuration.service'; + +export default defineComponent({ + name: 'JhiConfiguration', + setup() { + const configurationService = inject('configurationService', () => new ConfigurationService(), true); + + const orderProp = ref('prefix'); + const reverse = ref(false); + const allConfiguration: Ref = ref({}); + const configuration: Ref = ref([]); + const configKeys: Ref = ref([]); + const filtered = ref(''); + + const filteredConfiguration = computed(() => + orderAndFilterBy(configuration.value, { + filterByTerm: filtered.value, + orderByProp: orderProp.value, + reverse: reverse.value, + }), + ); + + return { + configurationService, + orderProp, + reverse, + allConfiguration, + configuration, + configKeys, + filtered, + filteredConfiguration, + t$: useI18n().t, + }; + }, + mounted() { + this.init(); + }, + methods: { + init(): void { + this.configurationService.loadConfiguration().then(res => { + this.configuration = res; + + for (const config of this.configuration) { + if (config.properties !== undefined) { + this.configKeys.push(Object.keys(config.properties)); + } + } + }); + + this.configurationService.loadEnvConfiguration().then(res => { + this.allConfiguration = res; + }); + }, + changeOrder(prop: string): void { + this.orderProp = prop; + this.reverse = !this.reverse; + }, + keys(dict: any): string[] { + return dict === undefined ? [] : Object.keys(dict); + }, + }, +}); diff --git a/src/main/webapp/app/admin/configuration/configuration.service.ts b/src/main/webapp/app/admin/configuration/configuration.service.ts new file mode 100644 index 0000000..14a3424 --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.service.ts @@ -0,0 +1,55 @@ +import axios from 'axios'; + +export default class ConfigurationService { + async loadConfiguration(): Promise { + const res = await axios.get('management/configprops'); + const properties = []; + const propertiesObject = this.getConfigPropertiesObjects(res.data); + for (const key in propertiesObject) { + if (Object.hasOwn(propertiesObject, key)) { + properties.push(propertiesObject[key]); + } + } + + properties.sort((propertyA, propertyB) => { + const comparePrefix = propertyA.prefix < propertyB.prefix ? -1 : 1; + return propertyA.prefix === propertyB.prefix ? 0 : comparePrefix; + }); + return properties; + } + + async loadEnvConfiguration(): Promise { + const res = await axios.get('management/env'); + const properties = {}; + const { propertySources } = res.data; + + for (const propertyObject of propertySources) { + const { name } = propertyObject; + const detailProperties = propertyObject.properties; + const vals = []; + for (const keyDetail in detailProperties) { + if (Object.hasOwn(detailProperties, keyDetail)) { + vals.push({ key: keyDetail, val: detailProperties[keyDetail].value }); + } + } + properties[name] = vals; + } + return properties; + } + + private getConfigPropertiesObjects(res): any { + // This code is for Spring Boot 2 + if (res.contexts !== undefined) { + for (const key in res.contexts) { + // If the key is not bootstrap, it will be the ApplicationContext Id + // For default app, it is baseName + // For microservice, it is baseName-1 + if (!key.startsWith('bootstrap')) { + return res.contexts[key].beans; + } + } + } + // by default, use the default ApplicationContext Id + return res.contexts.questionBank.beans; + } +} diff --git a/src/main/webapp/app/admin/configuration/configuration.vue b/src/main/webapp/app/admin/configuration/configuration.vue new file mode 100644 index 0000000..5bd4d2a --- /dev/null +++ b/src/main/webapp/app/admin/configuration/configuration.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/main/webapp/app/admin/docs/docs.component.ts b/src/main/webapp/app/admin/docs/docs.component.ts new file mode 100644 index 0000000..d8c2d10 --- /dev/null +++ b/src/main/webapp/app/admin/docs/docs.component.ts @@ -0,0 +1,5 @@ +import { defineComponent } from 'vue'; + +export default defineComponent({ + name: 'JhiDocs', +}); diff --git a/src/main/webapp/app/admin/docs/docs.vue b/src/main/webapp/app/admin/docs/docs.vue new file mode 100644 index 0000000..d8fbc4d --- /dev/null +++ b/src/main/webapp/app/admin/docs/docs.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/main/webapp/app/admin/health/health-modal.component.spec.ts b/src/main/webapp/app/admin/health/health-modal.component.spec.ts new file mode 100644 index 0000000..e32c724 --- /dev/null +++ b/src/main/webapp/app/admin/health/health-modal.component.spec.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { shallowMount } from '@vue/test-utils'; + +import HealthModal from './health-modal.vue'; + +type HealthModalComponentType = InstanceType; + +const healthService = { getBaseName: vi.fn(), getSubSystemName: vi.fn() }; + +describe('Health Modal Component', () => { + let healthModal: HealthModalComponentType; + + beforeEach(() => { + const wrapper = shallowMount(HealthModal, { + propsData: { + currentHealth: {}, + }, + global: { + stubs: { + 'font-awesome-icon': true, + }, + provide: { + healthService, + }, + }, + }); + healthModal = wrapper.vm; + }); + + describe('baseName and subSystemName', () => { + it('should use healthService to get base name', () => { + healthModal.baseName('base'); + + expect(healthService.getBaseName).toHaveBeenCalled(); + }); + + it('should use healthService to get sub system name', () => { + healthModal.subSystemName('base'); + + expect(healthService.getSubSystemName).toHaveBeenCalled(); + }); + }); + + describe('readableValue should transform data', () => { + it('to string when is an object', () => { + const result = healthModal.readableValue({ data: 1000 }); + + expect(result).toBe('{"data":1000}'); + }); + + it('to string when is a string', () => { + const result = healthModal.readableValue('data'); + + expect(result).toBe('data'); + }); + }); +}); + +describe('Health Modal Component for diskSpace', () => { + let healthModal: HealthModalComponentType; + + beforeEach(() => { + const wrapper = shallowMount(HealthModal, { + propsData: { + currentHealth: { name: 'diskSpace' }, + }, + global: { + provide: { + healthService, + }, + }, + }); + healthModal = wrapper.vm; + }); + + describe('readableValue should transform data', () => { + it('to GB when needed', () => { + const result = healthModal.readableValue(2147483648); + + expect(result).toBe('2.00 GB'); + }); + + it('to MB when needed', () => { + const result = healthModal.readableValue(214748); + + expect(result).toBe('0.20 MB'); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/health-modal.component.ts b/src/main/webapp/app/admin/health/health-modal.component.ts new file mode 100644 index 0000000..1db1e64 --- /dev/null +++ b/src/main/webapp/app/admin/health/health-modal.component.ts @@ -0,0 +1,46 @@ +import { defineComponent, inject } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import HealthService from './health.service'; + +export default defineComponent({ + name: 'JhiHealthModal', + props: { + currentHealth: { + type: Object, + default: () => ({}), + }, + }, + setup() { + const healthService = inject('healthService', () => new HealthService(), true); + + return { + healthService, + t$: useI18n().t, + }; + }, + methods: { + baseName(name: string): any { + return this.healthService.getBaseName(name); + }, + subSystemName(name: string): any { + return this.healthService.getSubSystemName(name); + }, + readableValue(value: any): string { + if (this.currentHealth.name === 'diskSpace') { + // Should display storage space in a human readable unit + const val = value / 1073741824; + if (val > 1) { + // Value + return `${val.toFixed(2)} GB`; + } + return `${(value / 1048576).toFixed(2)} MB`; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + return value.toString(); + }, + }, +}); diff --git a/src/main/webapp/app/admin/health/health-modal.vue b/src/main/webapp/app/admin/health/health-modal.vue new file mode 100644 index 0000000..333377f --- /dev/null +++ b/src/main/webapp/app/admin/health/health-modal.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/main/webapp/app/admin/health/health.component.spec.ts b/src/main/webapp/app/admin/health/health.component.spec.ts new file mode 100644 index 0000000..9842c6c --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.spec.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import HealthService from './health.service'; +import Health from './health.vue'; + +type HealthComponentType = InstanceType; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), +}; + +describe('Health Component', () => { + let health: HealthComponentType; + + beforeEach(() => { + axiosStub.get.mockResolvedValue({}); + const wrapper = shallowMount(Health, { + global: { + stubs: { + bModal: true, + 'font-awesome-icon': true, + 'health-modal': true, + }, + directives: { + 'b-modal': {}, + }, + provide: { + healthService: new HealthService(), + }, + }, + }); + health = wrapper.vm; + }); + + describe('baseName and subSystemName', () => { + it('should return the basename when it has no sub system', () => { + expect(health.baseName('base')).toBe('base'); + }); + + it('should return the basename when it has sub systems', () => { + expect(health.baseName('base.subsystem.system')).toBe('base'); + }); + + it('should return the sub system name', () => { + expect(health.subSystemName('subsystem')).toBe(''); + }); + + it('should return the subsystem when it has multiple keys', () => { + expect(health.subSystemName('subsystem.subsystem.system')).toBe(' - subsystem.system'); + }); + }); + + describe('getBadgeClass', () => { + it('should get badge class', () => { + const upBadgeClass = health.getBadgeClass('UP'); + const downBadgeClass = health.getBadgeClass('DOWN'); + expect(upBadgeClass).toEqual('bg-success'); + expect(downBadgeClass).toEqual('bg-danger'); + }); + }); + + describe('refresh', () => { + it('should call refresh on init', async () => { + // GIVEN + axiosStub.get.mockResolvedValue({}); + + // WHEN + health.refresh(); + await health.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith('management/health'); + await health.$nextTick(); + expect(health.updatingHealth).toEqual(false); + }); + it('should handle a 503 on refreshing health data', async () => { + // GIVEN + axiosStub.get.mockRejectedValue({}); + + // WHEN + health.refresh(); + await health.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith('management/health'); + await health.$nextTick(); + expect(health.updatingHealth).toEqual(false); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/health.component.ts b/src/main/webapp/app/admin/health/health.component.ts new file mode 100644 index 0000000..1b0a08f --- /dev/null +++ b/src/main/webapp/app/admin/health/health.component.ts @@ -0,0 +1,63 @@ +import { type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import JhiHealthModal from './health-modal.vue'; +import HealthService from './health.service'; + +export default defineComponent({ + name: 'JhiHealth', + components: { + 'health-modal': JhiHealthModal, + }, + setup() { + const healthService = inject('healthService', () => new HealthService(), true); + + const healthData: Ref = ref(null); + const currentHealth: Ref = ref(null); + const updatingHealth = ref(false); + + return { + healthService, + healthData, + currentHealth, + updatingHealth, + t$: useI18n().t, + }; + }, + mounted(): void { + this.refresh(); + }, + methods: { + baseName(name: any): any { + return this.healthService.getBaseName(name); + }, + getBadgeClass(statusState: any): string { + if (statusState === 'UP') { + return 'bg-success'; + } + return 'bg-danger'; + }, + refresh(): void { + this.updatingHealth = true; + this.healthService + .checkHealth() + .then(res => { + this.healthData = this.healthService.transformHealthData(res.data); + this.updatingHealth = false; + }) + .catch(error => { + if (error.status === 503) { + this.healthData = this.healthService.transformHealthData(error.error); + } + this.updatingHealth = false; + }); + }, + showHealth(health: any): void { + this.currentHealth = health; + (this.$refs.healthModal as any).show(); + }, + subSystemName(name: string): string { + return this.healthService.getSubSystemName(name); + }, + }, +}); diff --git a/src/main/webapp/app/admin/health/health.service.spec.ts b/src/main/webapp/app/admin/health/health.service.spec.ts new file mode 100644 index 0000000..b5544fc --- /dev/null +++ b/src/main/webapp/app/admin/health/health.service.spec.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import HealthService from './health.service'; + +describe('Health Service', () => { + let healthService: HealthService; + + beforeEach(() => { + healthService = new HealthService(); + }); + + describe('transformHealthData', () => { + it('should flatten empty health data', () => { + const data = {}; + const expected = []; + expect(healthService.transformHealthData(data)).toEqual(expected); + }); + + it('should flatten health data with no subsystems', () => { + const data = { + components: { + status: 'UP', + db: { + status: 'UP', + database: 'H2', + hello: '1', + }, + mail: { + status: 'UP', + error: 'mail.a.b.c', + }, + }, + }; + const expected = [ + { + name: 'db', + status: 'UP', + details: { + database: 'H2', + hello: '1', + }, + }, + { + name: 'mail', + error: 'mail.a.b.c', + status: 'UP', + }, + ]; + expect(healthService.transformHealthData(data)).toEqual(expected); + }); + + it('should flatten health data with subsystems at level 1, main system has no additional information', () => { + const data = { + components: { + status: 'UP', + db: { + status: 'UP', + database: 'H2', + hello: '1', + }, + mail: { + status: 'UP', + error: 'mail.a.b.c', + }, + system: { + status: 'DOWN', + subsystem1: { + status: 'UP', + property1: 'system.subsystem1.property1', + }, + subsystem2: { + status: 'DOWN', + error: 'system.subsystem1.error', + property2: 'system.subsystem2.property2', + }, + }, + }, + }; + const expected = [ + { + name: 'db', + status: 'UP', + details: { + database: 'H2', + hello: '1', + }, + }, + { + name: 'mail', + error: 'mail.a.b.c', + status: 'UP', + }, + { + name: 'system.subsystem1', + status: 'UP', + details: { + property1: 'system.subsystem1.property1', + }, + }, + { + name: 'system.subsystem2', + error: 'system.subsystem1.error', + status: 'DOWN', + details: { + property2: 'system.subsystem2.property2', + }, + }, + ]; + expect(healthService.transformHealthData(data)).toEqual(expected); + }); + + it('should flatten health data with subsystems at level 1, main system has additional information', () => { + const data = { + components: { + status: 'UP', + db: { + status: 'UP', + database: 'H2', + hello: '1', + }, + mail: { + status: 'UP', + error: 'mail.a.b.c', + }, + system: { + status: 'DOWN', + property1: 'system.property1', + subsystem1: { + status: 'UP', + property1: 'system.subsystem1.property1', + }, + subsystem2: { + status: 'DOWN', + error: 'system.subsystem1.error', + property2: 'system.subsystem2.property2', + }, + }, + }, + }; + const expected = [ + { + name: 'db', + status: 'UP', + details: { + database: 'H2', + hello: '1', + }, + }, + { + name: 'mail', + error: 'mail.a.b.c', + status: 'UP', + }, + { + name: 'system', + status: 'DOWN', + details: { + property1: 'system.property1', + }, + }, + { + name: 'system.subsystem1', + status: 'UP', + details: { + property1: 'system.subsystem1.property1', + }, + }, + { + name: 'system.subsystem2', + error: 'system.subsystem1.error', + status: 'DOWN', + details: { + property2: 'system.subsystem2.property2', + }, + }, + ]; + expect(healthService.transformHealthData(data)).toEqual(expected); + }); + + it('should flatten health data with subsystems at level 1, main system has additional error', () => { + const data = { + components: { + status: 'UP', + db: { + status: 'UP', + database: 'H2', + hello: '1', + }, + mail: { + status: 'UP', + error: 'mail.a.b.c', + }, + system: { + status: 'DOWN', + error: 'show me', + subsystem1: { + status: 'UP', + property1: 'system.subsystem1.property1', + }, + subsystem2: { + status: 'DOWN', + error: 'system.subsystem1.error', + property2: 'system.subsystem2.property2', + }, + }, + }, + }; + const expected = [ + { + name: 'db', + status: 'UP', + details: { + database: 'H2', + hello: '1', + }, + }, + { + name: 'mail', + error: 'mail.a.b.c', + status: 'UP', + }, + { + name: 'system', + error: 'show me', + status: 'DOWN', + }, + { + name: 'system.subsystem1', + status: 'UP', + details: { + property1: 'system.subsystem1.property1', + }, + }, + { + name: 'system.subsystem2', + error: 'system.subsystem1.error', + status: 'DOWN', + details: { + property2: 'system.subsystem2.property2', + }, + }, + ]; + expect(healthService.transformHealthData(data)).toEqual(expected); + }); + }); +}); diff --git a/src/main/webapp/app/admin/health/health.service.ts b/src/main/webapp/app/admin/health/health.service.ts new file mode 100644 index 0000000..9781578 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.service.ts @@ -0,0 +1,118 @@ +import axios, { type AxiosPromise } from 'axios'; + +export default class HealthService { + separator = '.'; + + checkHealth(): AxiosPromise { + return axios.get('management/health'); + } + + transformHealthData(data: any): any { + const response = []; + this.flattenHealthData(response, null, data.components); + return response; + } + + getBaseName(name: string): string { + if (name) { + const split = name.split('.'); + return split[0]; + } + } + + getSubSystemName(name: string): string { + if (name) { + const split = name.split('.'); + split.splice(0, 1); + const remainder = split.join('.'); + return remainder ? ` - ${remainder}` : ''; + } + } + + addHealthObject(result: any, isLeaf: boolean, healthObject: any, name: string) { + const healthData = { + name, + details: undefined, + error: undefined, + }; + + const details = {}; + let hasDetails = false; + + for (const key in healthObject) { + if (Object.hasOwn(healthObject, key)) { + const value = healthObject[key]; + if (key === 'status' || key === 'error') { + healthData[key] = value; + } else if (!this.isHealthObject(value)) { + details[key] = value; + hasDetails = true; + } + } + } + + // Add the details + if (hasDetails) { + healthData.details = details; + } + + // Only add nodes if they provide additional information + if (isLeaf || hasDetails || healthData.error) { + result.push(healthData); + } + return healthData; + } + + flattenHealthData(result: any, path: any, data: any): any { + for (const key in data) { + if (Object.hasOwn(data, key)) { + const value = data[key]; + if (this.isHealthObject(value)) { + if (this.hasSubSystem(value)) { + this.addHealthObject(result, false, value, this.getModuleName(path, key)); + this.flattenHealthData(result, this.getModuleName(path, key), value); + } else { + this.addHealthObject(result, true, value, this.getModuleName(path, key)); + } + } + } + } + return result; + } + + getModuleName(path: any, name: string) { + if (path && name) { + return path + this.separator + name; + } else if (path) { + return path; + } else if (name) { + return name; + } + return ''; + } + + hasSubSystem(healthObject: any): any { + let result = false; + + for (const key in healthObject) { + if (Object.hasOwn(healthObject, key)) { + const value = healthObject[key]; + if (value?.status) { + result = true; + } + } + } + return result; + } + + isHealthObject(healthObject: any): any { + let result = false; + + for (const key in healthObject) { + if (Object.hasOwn(healthObject, key) && key === 'status') { + result = true; + } + } + return result; + } +} diff --git a/src/main/webapp/app/admin/health/health.vue b/src/main/webapp/app/admin/health/health.vue new file mode 100644 index 0000000..7e525a7 --- /dev/null +++ b/src/main/webapp/app/admin/health/health.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/main/webapp/app/admin/logs/logs.component.spec.ts b/src/main/webapp/app/admin/logs/logs.component.spec.ts new file mode 100644 index 0000000..ebcc54b --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.spec.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import Logs from './logs.vue'; + +type LogsComponentType = InstanceType; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), +}; + +describe('Logs Component', () => { + let logs: LogsComponentType; + + beforeEach(() => { + axiosStub.get.mockResolvedValue({}); + const wrapper = shallowMount(Logs, { + global: { + stubs: { + 'jhi-sort-indicator': true, + BButton: true, + BButtonGroup: true, + }, + }, + }); + logs = wrapper.vm; + }); + + describe('OnInit', () => { + it('should set all default values correctly', () => { + expect(logs.filtered).toBe(''); + expect(logs.orderProp).toBe('name'); + expect(logs.reverse).toBe(false); + }); + + it('Should call load all on init', async () => { + // WHEN + logs.init(); + await logs.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith('management/loggers'); + }); + }); + + describe('change log level', () => { + it('should change log level correctly', async () => { + axiosStub.post.mockResolvedValue({}); + + // WHEN + logs.updateLevel('main', 'ERROR'); + await logs.$nextTick(); + + // THEN + expect(axiosStub.post).toHaveBeenCalledWith('management/loggers/main', { configuredLevel: 'ERROR' }); + expect(axiosStub.get).toHaveBeenCalledWith('management/loggers'); + }); + }); + + describe('change order', () => { + it('should change order and invert reverse', () => { + // WHEN + logs.changeOrder('dummy-order'); + + // THEN + expect(logs.orderProp).toEqual('dummy-order'); + expect(logs.reverse).toBe(true); + }); + }); +}); diff --git a/src/main/webapp/app/admin/logs/logs.component.ts b/src/main/webapp/app/admin/logs/logs.component.ts new file mode 100644 index 0000000..aed5cd5 --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.component.ts @@ -0,0 +1,63 @@ +import { type Ref, computed, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { orderAndFilterBy } from '@/shared/computables'; + +import LogsService from './logs.service'; + +export default defineComponent({ + name: 'JhiLogs', + setup() { + const logsService = inject('logsService', () => new LogsService(), true); + + const loggers: Ref = ref([]); + const filtered = ref(''); + const orderProp = ref('name'); + const reverse = ref(false); + const filteredLoggers = computed(() => + orderAndFilterBy(loggers.value, { + filterByTerm: filtered.value, + orderByProp: orderProp.value, + reverse: reverse.value, + }), + ); + + return { + logsService, + loggers, + filtered, + orderProp, + reverse, + filteredLoggers, + t$: useI18n().t, + }; + }, + mounted() { + this.init(); + }, + methods: { + init(): void { + this.logsService.findAll().then(response => { + this.extractLoggers(response); + }); + }, + updateLevel(name: string, level: string): void { + this.logsService.changeLevel(name, level).then(() => { + this.init(); + }); + }, + changeOrder(orderProp: string): void { + this.orderProp = orderProp; + this.reverse = !this.reverse; + }, + extractLoggers(response) { + this.loggers = []; + if (response.data) { + for (const key of Object.keys(response.data.loggers)) { + const logger = response.data.loggers[key]; + this.loggers.push({ name: key, level: logger.effectiveLevel }); + } + } + }, + }, +}); diff --git a/src/main/webapp/app/admin/logs/logs.service.ts b/src/main/webapp/app/admin/logs/logs.service.ts new file mode 100644 index 0000000..5f2568f --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.service.ts @@ -0,0 +1,11 @@ +import axios, { type AxiosPromise } from 'axios'; + +export default class LogsService { + changeLevel(name: string, configuredLevel: string): AxiosPromise { + return axios.post(`management/loggers/${name}`, { configuredLevel }); + } + + findAll(): AxiosPromise { + return axios.get('management/loggers'); + } +} diff --git a/src/main/webapp/app/admin/logs/logs.vue b/src/main/webapp/app/admin/logs/logs.vue new file mode 100644 index 0000000..0121be2 --- /dev/null +++ b/src/main/webapp/app/admin/logs/logs.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/main/webapp/app/admin/metrics/metrics-modal.component.spec.ts b/src/main/webapp/app/admin/metrics/metrics-modal.component.spec.ts new file mode 100644 index 0000000..1eab453 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics-modal.component.spec.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { shallowMount } from '@vue/test-utils'; + +import MetricsModal from './metrics-modal.vue'; + +type MetricsModalComponentType = InstanceType; + +describe('MetricsModal', () => { + let metricsModal: MetricsModalComponentType; + + beforeEach(() => { + const wrapper = shallowMount(MetricsModal, { + propsData: { + threadDump: [ + { name: 'test1', threadState: 'RUNNABLE' }, + { name: 'test2', threadState: 'WAITING' }, + { name: 'test3', threadState: 'TIMED_WAITING' }, + { name: 'test4', threadState: 'BLOCKED' }, + { name: 'test5', threadState: 'BLOCKED' }, + { name: 'test5', threadState: 'NONE' }, + ], + }, + }); + metricsModal = wrapper.vm; + }); + + describe('init', () => { + it('should count the numbers of each thread type', () => { + expect(metricsModal.threadDumpData.threadDumpRunnable).toBe(1); + expect(metricsModal.threadDumpData.threadDumpWaiting).toBe(1); + expect(metricsModal.threadDumpData.threadDumpTimedWaiting).toBe(1); + expect(metricsModal.threadDumpData.threadDumpBlocked).toBe(2); + expect(metricsModal.threadDumpData.threadDumpAll).toBe(5); + }); + }); + + describe('getBadgeClass', () => { + it('should return bg-success for RUNNABLE', () => { + expect(metricsModal.getBadgeClass('RUNNABLE')).toBe('bg-success'); + }); + + it('should return bg-info for WAITING', () => { + expect(metricsModal.getBadgeClass('WAITING')).toBe('bg-info'); + }); + + it('should return bg-warning for TIMED_WAITING', () => { + expect(metricsModal.getBadgeClass('TIMED_WAITING')).toBe('bg-warning'); + }); + + it('should return bg-danger for BLOCKED', () => { + expect(metricsModal.getBadgeClass('BLOCKED')).toBe('bg-danger'); + }); + + it('should return an empty string for anything else', () => { + expect(metricsModal.getBadgeClass('')).toBe(''); + }); + }); +}); diff --git a/src/main/webapp/app/admin/metrics/metrics-modal.component.ts b/src/main/webapp/app/admin/metrics/metrics-modal.component.ts new file mode 100644 index 0000000..4f50a3d --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics-modal.component.ts @@ -0,0 +1,64 @@ +import { type PropType, type Ref, computed, defineComponent, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { filterBy } from '@/shared/computables'; + +export default defineComponent({ + name: 'JhiMetricsModal', + props: { + threadDump: { + type: Array as PropType, + default: () => [], + }, + }, + setup(props) { + const threadDumpFilter: Ref = ref(''); + const filteredThreadDump = computed(() => filterBy(props.threadDump, { filterByTerm: threadDumpFilter.value })); + + const threadDumpData = computed(() => { + const data = { + threadDumpAll: 0, + threadDumpBlocked: 0, + threadDumpRunnable: 0, + threadDumpTimedWaiting: 0, + threadDumpWaiting: 0, + }; + if (props.threadDump) { + props.threadDump.forEach(value => { + if (value.threadState === 'RUNNABLE') { + data.threadDumpRunnable += 1; + } else if (value.threadState === 'WAITING') { + data.threadDumpWaiting += 1; + } else if (value.threadState === 'TIMED_WAITING') { + data.threadDumpTimedWaiting += 1; + } else if (value.threadState === 'BLOCKED') { + data.threadDumpBlocked += 1; + } + }); + data.threadDumpAll = data.threadDumpRunnable + data.threadDumpWaiting + data.threadDumpTimedWaiting + data.threadDumpBlocked; + } + return data; + }); + + return { + threadDumpFilter, + threadDumpData, + filteredThreadDump, + t$: useI18n().t, + }; + }, + methods: { + getBadgeClass(threadState: string): string { + if (threadState === 'RUNNABLE') { + return 'bg-success'; + } else if (threadState === 'WAITING') { + return 'bg-info'; + } else if (threadState === 'TIMED_WAITING') { + return 'bg-warning'; + } else if (threadState === 'BLOCKED') { + return 'bg-danger'; + } + return ''; + }, + }, +}); diff --git a/src/main/webapp/app/admin/metrics/metrics-modal.vue b/src/main/webapp/app/admin/metrics/metrics-modal.vue new file mode 100644 index 0000000..bc7bc36 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics-modal.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/main/webapp/app/admin/metrics/metrics.component.spec.ts b/src/main/webapp/app/admin/metrics/metrics.component.spec.ts new file mode 100644 index 0000000..70f2bcb --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.spec.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import MetricsService from './metrics.service'; +import Metrics from './metrics.vue'; + +type MetricsComponentType = InstanceType; + +const axiosStub = { + get: vi.spyOn(axios, 'get'), +}; + +describe('Metrics Component', () => { + let metricsComponent: MetricsComponentType; + const response = { + jvm: { + 'PS Eden Space': { + committed: 5.57842432e8, + max: 6.49592832e8, + used: 4.20828184e8, + }, + 'Code Cache': { + committed: 2.3461888e7, + max: 2.5165824e8, + used: 2.2594368e7, + }, + 'Compressed Class Space': { + committed: 1.2320768e7, + max: 1.073741824e9, + used: 1.1514008e7, + }, + 'PS Survivor Space': { + committed: 1.5204352e7, + max: 1.5204352e7, + used: 1.2244376e7, + }, + 'PS Old Gen': { + committed: 1.10624768e8, + max: 1.37887744e9, + used: 4.1390776e7, + }, + Metaspace: { + committed: 9.170944e7, + max: -1.0, + used: 8.7377552e7, + }, + }, + databases: { + min: { + value: 10.0, + }, + max: { + value: 10.0, + }, + idle: { + value: 10.0, + }, + usage: { + '0.0': 0.0, + '1.0': 0.0, + max: 0.0, + totalTime: 4210.0, + mean: 701.6666666666666, + '0.5': 0.0, + count: 6, + '0.99': 0.0, + '0.75': 0.0, + '0.95': 0.0, + }, + pending: { + value: 0.0, + }, + active: { + value: 0.0, + }, + acquire: { + '0.0': 0.0, + '1.0': 0.0, + max: 0.0, + totalTime: 0.884426, + mean: 0.14740433333333333, + '0.5': 0.0, + count: 6, + '0.99': 0.0, + '0.75': 0.0, + '0.95': 0.0, + }, + creation: { + '0.0': 0.0, + '1.0': 0.0, + max: 0.0, + totalTime: 27.0, + mean: 3.0, + '0.5': 0.0, + count: 9, + '0.99': 0.0, + '0.75': 0.0, + '0.95': 0.0, + }, + connections: { + value: 10.0, + }, + }, + 'http.server.requests': { + all: { + count: 5, + }, + percode: { + '200': { + max: 0.0, + mean: 298.9012628, + count: 5, + }, + }, + }, + cache: { + usersByEmail: { + 'cache.gets.miss': 0.0, + 'cache.puts': 0.0, + 'cache.gets.hit': 0.0, + 'cache.removals': 0.0, + 'cache.evictions': 0.0, + }, + usersByLogin: { + 'cache.gets.miss': 1.0, + 'cache.puts': 1.0, + 'cache.gets.hit': 1.0, + 'cache.removals': 0.0, + 'cache.evictions': 0.0, + }, + 'tech.jhipster.domain.Authority': { + 'cache.gets.miss': 0.0, + 'cache.puts': 2.0, + 'cache.gets.hit': 0.0, + 'cache.removals': 0.0, + 'cache.evictions': 0.0, + }, + 'tech.jhipster.domain.User.authorities': { + 'cache.gets.miss': 0.0, + 'cache.puts': 1.0, + 'cache.gets.hit': 0.0, + 'cache.removals': 0.0, + 'cache.evictions': 0.0, + }, + 'tech.jhipster.domain.User': { + 'cache.gets.miss': 0.0, + 'cache.puts': 1.0, + 'cache.gets.hit': 0.0, + 'cache.removals': 0.0, + 'cache.evictions': 0.0, + }, + }, + garbageCollector: { + 'jvm.gc.max.data.size': 1.37887744e9, + 'jvm.gc.pause': { + '0.0': 0.0, + '1.0': 0.0, + max: 0.0, + totalTime: 242.0, + mean: 242.0, + '0.5': 0.0, + count: 1, + '0.99': 0.0, + '0.75': 0.0, + '0.95': 0.0, + }, + 'jvm.gc.memory.promoted': 2.992732e7, + 'jvm.gc.memory.allocated': 1.26362872e9, + classesLoaded: 17393.0, + 'jvm.gc.live.data.size': 3.1554408e7, + classesUnloaded: 0.0, + }, + services: { + '/management/info': { + GET: { + max: 0.0, + mean: 104.952893, + count: 1, + }, + }, + '/api/authenticate': { + POST: { + max: 0.0, + mean: 909.53003, + count: 1, + }, + }, + '/api/account': { + GET: { + max: 0.0, + mean: 141.209628, + count: 1, + }, + }, + '/**': { + GET: { + max: 0.0, + mean: 169.4068815, + count: 2, + }, + }, + }, + processMetrics: { + 'system.load.average.1m': 3.63, + 'system.cpu.usage': 0.5724934148485453, + 'system.cpu.count': 4.0, + 'process.start.time': 1.548140811306e12, + 'process.files.open': 205.0, + 'process.cpu.usage': 0.003456347568026252, + 'process.uptime': 88404.0, + 'process.files.max': 1048576.0, + }, + threads: [{ name: 'test1', threadState: 'RUNNABLE' }], + }; + + beforeEach(() => { + axiosStub.get.mockResolvedValue({ data: { timers: [], gauges: [] } }); + const wrapper = shallowMount(Metrics, { + global: { + stubs: { + bModal: true, + bProgress: true, + bProgressBar: true, + 'font-awesome-icon': true, + 'metrics-modal': true, + }, + directives: { + 'b-modal': {}, + 'b-progress': {}, + 'b-progress-bar': {}, + }, + provide: { + metricsService: new MetricsService(), + }, + }, + }); + metricsComponent = wrapper.vm; + }); + + describe('refresh', () => { + it('should call refresh on init', async () => { + // GIVEN + axiosStub.get.mockResolvedValue({ data: response }); + + // WHEN + await metricsComponent.refresh(); + await metricsComponent.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith('management/jhimetrics'); + expect(axiosStub.get).toHaveBeenCalledWith('management/threaddump'); + expect(metricsComponent.metrics).toHaveProperty('jvm'); + expect(metricsComponent.metrics).toEqual(response); + expect(metricsComponent.threadStats).toEqual({ + threadDumpRunnable: 1, + threadDumpWaiting: 0, + threadDumpTimedWaiting: 0, + threadDumpBlocked: 0, + threadDumpAll: 1, + }); + }); + }); + + describe('isNan', () => { + it('should return if a variable is NaN', () => { + expect(metricsComponent.filterNaN(1)).toBe(1); + expect(metricsComponent.filterNaN('test')).toBe(0); + }); + }); +}); diff --git a/src/main/webapp/app/admin/metrics/metrics.component.ts b/src/main/webapp/app/admin/metrics/metrics.component.ts new file mode 100644 index 0000000..af94cd4 --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.component.ts @@ -0,0 +1,131 @@ +import { type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useDateFormat } from '@/shared/composables'; + +import JhiMetricsModal from './metrics-modal.vue'; +import MetricsService from './metrics.service'; + +export default defineComponent({ + name: 'JhiMetrics', + components: { + 'metrics-modal': JhiMetricsModal, + }, + setup() { + const { formatDate } = useDateFormat(); + const metricsService = inject('metricsService', () => new MetricsService(), true); + + const metrics: Ref = ref({}); + const threadData: Ref = ref(null); + const threadStats: Ref = ref({}); + const updatingMetrics = ref(true); + + return { + metricsService, + metrics, + threadData, + threadStats, + updatingMetrics, + formatDate, + t$: useI18n().t, + }; + }, + mounted(): void { + this.refresh(); + }, + methods: { + refresh() { + return this.metricsService + .getMetrics() + .then(resultsMetrics => { + this.metrics = resultsMetrics.data; + this.metricsService + .retrieveThreadDump() + .then(res => { + this.updatingMetrics = true; + this.threadData = res.data.threads; + + this.threadStats = { + threadDumpRunnable: 0, + threadDumpWaiting: 0, + threadDumpTimedWaiting: 0, + threadDumpBlocked: 0, + threadDumpAll: 0, + }; + + this.threadData.forEach(value => { + if (value.threadState === 'RUNNABLE') { + this.threadStats.threadDumpRunnable += 1; + } else if (value.threadState === 'WAITING') { + this.threadStats.threadDumpWaiting += 1; + } else if (value.threadState === 'TIMED_WAITING') { + this.threadStats.threadDumpTimedWaiting += 1; + } else if (value.threadState === 'BLOCKED') { + this.threadStats.threadDumpBlocked += 1; + } + }); + + this.threadStats.threadDumpAll = + this.threadStats.threadDumpRunnable + + this.threadStats.threadDumpWaiting + + this.threadStats.threadDumpTimedWaiting + + this.threadStats.threadDumpBlocked; + + this.updatingMetrics = false; + }) + .catch(() => { + this.updatingMetrics = true; + }); + }) + .catch(() => { + this.updatingMetrics = true; + }); + }, + openModal(): void { + if ((this.$refs.metricsModal as any).show) { + (this.$refs.metricsModal as any).show(); + } + }, + filterNaN(input: any): any { + if (isNaN(input)) { + return 0; + } + return input; + }, + formatNumber1(value: any): any { + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value); + }, + formatNumber2(value: any): any { + return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); + }, + convertMillisecondsToDuration(ms) { + const times = { + year: 31557600000, + month: 2629746000, + day: 86400000, + hour: 3600000, + minute: 60000, + second: 1000, + }; + let time_string = ''; + const plural = ''; + for (const key in times) { + if (Math.floor(ms / times[key]) > 0) { + let plural = ''; + if (Math.floor(ms / times[key]) > 1) { + plural = 's'; + } + time_string += `${Math.floor(ms / times[key])} ${key}${plural} `; + ms = ms - times[key] * Math.floor(ms / times[key]); + } + } + return time_string; + }, + isObjectExisting(metrics: any, key: string): boolean { + return metrics?.[key]; + }, + isObjectExistingAndNotEmpty(metrics: any, key: string): boolean { + return this.isObjectExisting(metrics, key) && JSON.stringify(metrics[key]) !== '{}'; + }, + }, +}); diff --git a/src/main/webapp/app/admin/metrics/metrics.service.ts b/src/main/webapp/app/admin/metrics/metrics.service.ts new file mode 100644 index 0000000..c1d3d5b --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.service.ts @@ -0,0 +1,11 @@ +import axios, { type AxiosPromise } from 'axios'; + +export default class MetricsService { + getMetrics(): AxiosPromise { + return axios.get('management/jhimetrics'); + } + + retrieveThreadDump(): AxiosPromise { + return axios.get('management/threaddump'); + } +} diff --git a/src/main/webapp/app/admin/metrics/metrics.vue b/src/main/webapp/app/admin/metrics/metrics.vue new file mode 100644 index 0000000..889055c --- /dev/null +++ b/src/main/webapp/app/admin/metrics/metrics.vue @@ -0,0 +1,371 @@ + + + diff --git a/src/main/webapp/app/admin/user-management/user-management-edit.component.spec.ts b/src/main/webapp/app/admin/user-management/user-management-edit.component.spec.ts new file mode 100644 index 0000000..11cc5ed --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management-edit.component.spec.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type RouteLocation } from 'vue-router'; + +import { type MountingOptions, shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import AlertService from '@/shared/alert/alert.service'; +import { MESSAGE_ALERT_HEADER_NAME, MESSAGE_PARAM_HEADER_NAME } from '@/shared/jhipster/constants'; + +import UserManagementEdit from './user-management-edit.vue'; + +type UserManagementEditComponentType = InstanceType; + +let route: Partial; +const routerGoMock = vi.fn(); + +vi.mock('vue-router', () => ({ + useRoute: () => route, + useRouter: () => ({ go: routerGoMock }), +})); + +describe('UserManagementEdit Component', () => { + const axiosStub = { + get: vi.spyOn(axios, 'get'), + post: vi.spyOn(axios, 'post'), + put: vi.spyOn(axios, 'put'), + }; + let mountOptions: MountingOptions['global']; + let alertService: AlertService; + + beforeEach(() => { + route = {}; + alertService = new AlertService({ + i18n: { t: vi.fn() } as any, + toast: { + create: vi.fn(), + } as any, + }); + + mountOptions = { + stubs: { + 'font-awesome-icon': true, + }, + provide: { + alertService, + }, + }; + + axiosStub.get.mockReset(); + axiosStub.post.mockReset(); + axiosStub.put.mockReset(); + }); + + describe('init', () => { + it('Should load user', async () => { + // GIVEN + axiosStub.get.mockImplementation((url?: string) => { + if (url === `api/admin/users/${123}`) return Promise.resolve({}); + if (url === 'api/authorities') return Promise.resolve({ data: [] }); + return Promise.resolve({}); + }); + route = { + params: { + userId: `${123}`, + }, + }; + const wrapper = shallowMount(UserManagementEdit, { global: mountOptions }); + const userManagementEdit: UserManagementEditComponentType = wrapper.vm; + + // WHEN + await userManagementEdit.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith('api/authorities'); + expect(axiosStub.get).toHaveBeenCalledWith(`api/admin/users/${123}`); + }); + it('Should open create user', async () => { + // GIVEN + axiosStub.get.mockImplementation((url?: string) => { + if (url === 'api/authorities') return Promise.resolve({ data: [] }); + return Promise.resolve({}); + }); + route = { + params: {}, + }; + const wrapper = shallowMount(UserManagementEdit, { global: mountOptions }); + const userManagementEdit: UserManagementEditComponentType = wrapper.vm; + + // WHEN + await userManagementEdit.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith('api/authorities'); + expect(axiosStub.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('save', () => { + it('Should call update service on save for existing user', async () => { + // GIVEN + axiosStub.put.mockResolvedValue({ + headers: { + [MESSAGE_ALERT_HEADER_NAME]: '', + [MESSAGE_PARAM_HEADER_NAME]: '', + }, + }); + axiosStub.get.mockImplementation((url?: string) => { + if (url === `api/admin/users/${123}`) return Promise.resolve({ data: { id: 123, authorities: [] } }); + if (url === 'api/authorities') return Promise.resolve({ data: [] }); + return Promise.resolve({}); + }); + route = { + params: { + userId: `${123}`, + }, + }; + const wrapper = shallowMount(UserManagementEdit, { global: mountOptions }); + const userManagementEdit: UserManagementEditComponentType = wrapper.vm; + await userManagementEdit.$nextTick(); + + // WHEN + userManagementEdit.save(); + await userManagementEdit.$nextTick(); + + // THEN + expect(axiosStub.put).toHaveBeenCalledWith('api/admin/users', { id: 123, authorities: [] }); + expect(userManagementEdit.isSaving).toEqual(false); + }); + + it('Should call create service on save for new user', async () => { + // GIVEN + axiosStub.post.mockResolvedValue({ + headers: { + [MESSAGE_ALERT_HEADER_NAME]: '', + [MESSAGE_PARAM_HEADER_NAME]: '', + }, + }); + axiosStub.get.mockImplementation((url?: string) => { + if (url === 'api/authorities') return Promise.resolve({ data: [] }); + return Promise.resolve({}); + }); + route = { + params: {}, + }; + const wrapper = shallowMount(UserManagementEdit, { global: mountOptions }); + const userManagementEdit: UserManagementEditComponentType = wrapper.vm; + await userManagementEdit.$nextTick(); + userManagementEdit.userAccount = { authorities: [] }; + + // WHEN + userManagementEdit.save(); + await userManagementEdit.$nextTick(); + + // THEN + expect(axiosStub.post).toHaveBeenCalledWith('api/admin/users', { + authorities: [], + }); + expect(userManagementEdit.isSaving).toEqual(false); + }); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/user-management-edit.component.ts b/src/main/webapp/app/admin/user-management/user-management-edit.component.ts new file mode 100644 index 0000000..0eda160 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management-edit.component.ts @@ -0,0 +1,125 @@ +import { type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRoute, useRouter } from 'vue-router'; + +import { useVuelidate } from '@vuelidate/core'; +import { email, maxLength, minLength, required } from '@vuelidate/validators'; + +import { useAlertService } from '@/shared/alert/alert.service'; +import languages from '@/shared/config/languages'; +import { getMessageFromHeaders } from '@/shared/jhipster/headers'; +import { type IUser, User } from '@/shared/model/user.model'; + +import UserManagementService from './user-management.service'; + +const loginValidator = (value: string) => { + if (!value) { + return true; + } + return /^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$/.test(value); +}; + +const validations: any = { + userAccount: { + login: { + required, + maxLength: maxLength(254), + pattern: loginValidator, + }, + firstName: { + maxLength: maxLength(50), + }, + lastName: { + maxLength: maxLength(50), + }, + email: { + required, + email, + minLength: minLength(5), + maxLength: maxLength(50), + }, + }, +}; + +export default defineComponent({ + name: 'JhiUserManagementEdit', + validations, + setup() { + const route = useRoute(); + const router = useRouter(); + + const alertService = inject('alertService', () => useAlertService(), true); + const userManagementService = inject('userManagementService', () => new UserManagementService(), true); + const previousState = () => router.go(-1); + + const userAccount: Ref = ref({ ...new User(), activated: true, authorities: [] }); + const isSaving: Ref = ref(false); + const authorities: Ref = ref([]); + + const initAuthorities = async () => { + const response = await userManagementService.retrieveAuthorities(); + authorities.value = response.data; + }; + + const loadUser = async (userId: string) => { + const response = await userManagementService.get(userId); + userAccount.value = response.data; + }; + + initAuthorities(); + const userId = route.params?.userId; + if (userId) { + loadUser(userId); + } + + return { + alertService, + userAccount, + isSaving, + authorities, + userManagementService, + previousState, + v$: useVuelidate(), + languages: languages(), + t$: useI18n().t, + }; + }, + methods: { + save(): void { + this.isSaving = true; + if (this.userAccount.id) { + this.userManagementService + .update(this.userAccount) + .then(res => { + this.returnToList(); + this.alertService.showInfo(this.getToastMessageFromHeader(res)); + }) + .catch(error => { + this.isSaving = true; + this.alertService.showHttpError(error.response); + }); + } else { + this.userManagementService + .create(this.userAccount) + .then(res => { + this.returnToList(); + this.alertService.showSuccess(this.getToastMessageFromHeader(res)); + }) + .catch(error => { + this.isSaving = true; + this.alertService.showHttpError(error.response); + }); + } + }, + + returnToList(): void { + this.isSaving = false; + this.previousState(); + }, + + getToastMessageFromHeader(res: any): string { + const message = getMessageFromHeaders(res.headers); + return message.alertKey ? this.t$(message.alertKey, { param: message.param }) : message.alertMessage; + }, + }, +}); diff --git a/src/main/webapp/app/admin/user-management/user-management-edit.vue b/src/main/webapp/app/admin/user-management/user-management-edit.vue new file mode 100644 index 0000000..e425a82 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management-edit.vue @@ -0,0 +1,143 @@ + + + diff --git a/src/main/webapp/app/admin/user-management/user-management-view.component.spec.ts b/src/main/webapp/app/admin/user-management/user-management-view.component.spec.ts new file mode 100644 index 0000000..ed59f42 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management-view.component.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type RouteLocation } from 'vue-router'; + +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import AlertService from '@/shared/alert/alert.service'; +import { Authority } from '@/shared/jhipster/constants'; + +import UserManagementView from './user-management-view.vue'; + +let route: Partial; + +vi.mock('vue-router', () => ({ + useRoute: () => route, +})); + +const axiosStub = { + get: vi.spyOn(axios, 'get'), +}; + +describe('UserManagementView Component', () => { + let alertService: AlertService; + + beforeEach(() => { + route = {}; + alertService = new AlertService({ + i18n: { t: vi.fn() } as any, + toast: { + create: vi.fn(), + } as any, + }); + }); + + describe('OnInit', () => { + it('Should call load all on init', async () => { + // GIVEN + const userData = { + id: 1, + login: 'user', + firstName: 'first', + lastName: 'last', + email: 'first@last.com', + activated: true, + langKey: 'en', + authorities: [Authority.USER], + createdBy: 'admin', + createdDate: null, + lastModifiedBy: null, + lastModifiedDate: null, + password: null, + }; + axiosStub.get.mockResolvedValue({ data: userData }); + + route = { + params: { + userId: `${123}`, + }, + }; + + const wrapper = shallowMount(UserManagementView, { + global: { + stubs: { + 'b-badge': true, + 'router-link': true, + 'font-awesome-icon': true, + }, + provide: { + alertService, + }, + }, + }); + const userManagementView = wrapper.vm; + + // WHEN + await userManagementView.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith(`api/admin/users/${123}`); + expect(userManagementView.user).toEqual(userData); + }); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/user-management-view.component.ts b/src/main/webapp/app/admin/user-management/user-management-view.component.ts new file mode 100644 index 0000000..5acb1a0 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management-view.component.ts @@ -0,0 +1,40 @@ +import { type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRoute } from 'vue-router'; + +import { useAlertService } from '@/shared/alert/alert.service'; +import { useDateFormat } from '@/shared/composables'; + +import UserManagementService from './user-management.service'; + +export default defineComponent({ + name: 'JhiUserManagementView', + setup() { + const route = useRoute(); + const { formatDateLong: formatDate } = useDateFormat(); + + const alertService = inject('alertService', () => useAlertService(), true); + const userManagementService = inject('userManagementService', () => new UserManagementService(), true); + + const user: Ref = ref(null); + + async function loadUser(userId: string) { + try { + const response = await userManagementService.get(userId); + user.value = response.data; + } catch (error) { + alertService.showHttpError(error.response); + } + } + + loadUser(route.params?.userId); + + return { + formatDate, + alertService, + userManagementService, + user, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/admin/user-management/user-management-view.vue b/src/main/webapp/app/admin/user-management/user-management-view.vue new file mode 100644 index 0000000..5432eba --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management-view.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/main/webapp/app/admin/user-management/user-management.component.spec.ts b/src/main/webapp/app/admin/user-management/user-management.component.spec.ts new file mode 100644 index 0000000..f4e442a --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management.component.spec.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ref } from 'vue'; + +import { shallowMount } from '@vue/test-utils'; +import axios from 'axios'; + +import AlertService from '@/shared/alert/alert.service'; +import { MESSAGE_ALERT_HEADER_NAME, MESSAGE_PARAM_HEADER_NAME } from '@/shared/jhipster/constants'; + +import UserManagement from './user-management.vue'; + +type UserManagementComponentType = InstanceType; + +const axiosStub = { + delete: vi.spyOn(axios, 'delete'), + get: vi.spyOn(axios, 'get'), + put: vi.spyOn(axios, 'put'), +}; + +describe('UserManagement Component', () => { + let userManagement: UserManagementComponentType; + let alertService: AlertService; + + beforeEach(() => { + axiosStub.put.mockReset(); + axiosStub.get.mockReset(); + axiosStub.get.mockResolvedValue({ headers: {} }); + + alertService = new AlertService({ + i18n: { t: vi.fn() } as any, + toast: { + create: vi.fn(), + } as any, + }); + + const wrapper = shallowMount(UserManagement, { + global: { + stubs: { + bPagination: true, + jhiItemCount: true, + bModal: true, + 'router-link': true, + 'jhi-sort-indicator': true, + 'font-awesome-icon': true, + 'b-button': true, + }, + directives: { + 'b-modal': {}, + }, + provide: { + alertService, + currentUsername: ref(''), + }, + }, + }); + userManagement = wrapper.vm; + }); + + describe('OnInit', () => { + it('Should call load all on init', async () => { + // WHEN + userManagement.loadAll(); + await userManagement.$nextTick(); + + // THEN + expect(axiosStub.get).toHaveBeenCalledWith(`api/admin/users?sort=id,asc&page=0&size=20`); + }); + }); + + describe('setActive', () => { + it('Should update user and call load all', async () => { + // GIVEN + axiosStub.put.mockResolvedValue({}); + + // WHEN + userManagement.setActive({ id: 123 }, true); + await userManagement.$nextTick(); + + // THEN + expect(axiosStub.put).toHaveBeenCalledWith(`api/admin/users`, { id: 123, activated: true }); + expect(axiosStub.get).toHaveBeenCalledWith(`api/admin/users?sort=id,asc&page=0&size=20`); + }); + }); + + describe('confirmDelete', () => { + it('Should call delete service on confirmDelete', async () => { + // GIVEN + axiosStub.delete.mockResolvedValue({ + headers: { + [MESSAGE_ALERT_HEADER_NAME]: '', + [MESSAGE_PARAM_HEADER_NAME]: '', + }, + }); + + // WHEN + userManagement.prepareRemove({ login: 123 }); + userManagement.deleteUser(); + await userManagement.$nextTick(); + + // THEN + expect(axiosStub.delete).toHaveBeenCalledWith(`api/admin/users/${123}`); + expect(axiosStub.get).toHaveBeenCalledWith(`api/admin/users?sort=id,asc&page=0&size=20`); + }); + }); + + describe('change order', () => { + it('should change order and invert reverse', () => { + // WHEN + userManagement.changeOrder('dummy-order'); + + // THEN + expect(userManagement.propOrder).toEqual('dummy-order'); + expect(userManagement.reverse).toBe(true); + }); + }); +}); diff --git a/src/main/webapp/app/admin/user-management/user-management.component.ts b/src/main/webapp/app/admin/user-management/user-management.component.ts new file mode 100644 index 0000000..e037564 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management.component.ts @@ -0,0 +1,141 @@ +import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useAlertService } from '@/shared/alert/alert.service'; +import { useDateFormat } from '@/shared/composables'; +import { getMessageFromHeaders } from '@/shared/jhipster/headers'; + +import UserManagementService from './user-management.service'; + +export default defineComponent({ + name: 'JhiUserManagementComponent', + setup() { + const alertService = inject('alertService', () => useAlertService(), true); + const { formatDateShort: formatDate } = useDateFormat(); + const userManagementService = inject('userManagementService', () => new UserManagementService(), true); + const username = inject>('currentUsername'); + + const error = ref(''); + const success = ref(''); + const itemsPerPage = ref(20); + const page = ref(1); + const previousPage = ref(1); + const propOrder = ref('id'); + const reverse = ref(false); + const isLoading = ref(false); + const removeId: Ref = ref(null); + const users: Ref = ref([]); + const totalItems = ref(0); + const queryCount: Ref = ref(null); + + return { + formatDate, + userManagementService, + alertService, + error, + success, + itemsPerPage, + page, + previousPage, + propOrder, + reverse, + isLoading, + removeId, + users, + username, + totalItems, + queryCount, + t$: useI18n().t, + }; + }, + mounted(): void { + this.loadAll(); + }, + methods: { + setActive(user, isActivated): void { + user.activated = isActivated; + this.userManagementService + .update(user) + .then(() => { + this.error = null; + this.success = 'OK'; + this.loadAll(); + }) + .catch(() => { + this.success = null; + this.error = 'ERROR'; + user.activated = false; + }); + }, + loadAll(): void { + this.isLoading = true; + + this.userManagementService + .retrieve({ + sort: this.sort(), + page: this.page - 1, + size: this.itemsPerPage, + }) + .then(res => { + this.isLoading = false; + this.users = res.data; + this.totalItems = Number(res.headers['x-total-count']); + this.queryCount = this.totalItems; + }) + .catch(() => { + this.isLoading = false; + }); + }, + handleSyncList(): void { + this.loadAll(); + }, + sort(): any { + const result = [`${this.propOrder},${this.reverse ? 'desc' : 'asc'}`]; + if (this.propOrder !== 'id') { + result.push('id'); + } + return result; + }, + loadPage(page: number): void { + if (page !== this.previousPage) { + this.previousPage = page; + this.transition(); + } + }, + transition(): void { + this.loadAll(); + }, + changeOrder(propOrder: string): void { + this.propOrder = propOrder; + this.reverse = !this.reverse; + this.transition(); + }, + deleteUser(): void { + this.userManagementService + .remove(this.removeId) + .then(res => { + const message = getMessageFromHeaders(res.headers); + this.alertService.showInfo(message.alertKey ? this.t$(message.alertKey, { param: message.param }) : message.alertMessage, { + variant: 'danger', + }); + this.removeId = null; + this.loadAll(); + this.closeDialog(); + }) + .catch(error => { + this.alertService.showHttpError(error.response); + }); + }, + prepareRemove(instance): void { + this.removeId = instance.login; + if (this.$refs.removeUser as any) { + (this.$refs.removeUser as any).show(); + } + }, + closeDialog(): void { + if (this.$refs.removeUser as any) { + (this.$refs.removeUser as any).hide(); + } + }, + }, +}); diff --git a/src/main/webapp/app/admin/user-management/user-management.service.ts b/src/main/webapp/app/admin/user-management/user-management.service.ts new file mode 100644 index 0000000..b26cb8f --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management.service.ts @@ -0,0 +1,33 @@ +import axios from 'axios'; + +import { type IUser } from '@/shared/model/user.model'; +import buildPaginationQueryOpts from '@/shared/sort/sorts'; + +export default class UserManagementService { + get(userId: string): Promise { + return axios.get(`api/admin/users/${userId}`); + } + + create(user: IUser): Promise { + return axios.post('api/admin/users', user); + } + + update(user: IUser): Promise { + return axios.put('api/admin/users', user); + } + + remove(userId: number): Promise { + return axios.delete(`api/admin/users/${userId}`); + } + + retrieve(req?: any): Promise { + return axios.get(`api/admin/users?${buildPaginationQueryOpts(req)}`); + } + + retrieveAuthorities(): Promise { + return axios.get('api/authorities').then(response => { + response.data = response.data.map(authority => authority.name); + return response; + }); + } +} diff --git a/src/main/webapp/app/admin/user-management/user-management.vue b/src/main/webapp/app/admin/user-management/user-management.vue new file mode 100644 index 0000000..77b8e35 --- /dev/null +++ b/src/main/webapp/app/admin/user-management/user-management.vue @@ -0,0 +1,152 @@ + + + diff --git a/src/main/webapp/app/app.component.ts b/src/main/webapp/app/app.component.ts new file mode 100644 index 0000000..d3163e4 --- /dev/null +++ b/src/main/webapp/app/app.component.ts @@ -0,0 +1,33 @@ +import { type Component, defineComponent, provide } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { BApp } from 'bootstrap-vue-next'; +import { storeToRefs } from 'pinia'; + +import LoginForm from '@/account/login-form/login-form.vue'; +import { useLoginModal } from '@/account/login-modal'; +import JhiFooter from '@/core/jhi-footer/jhi-footer.vue'; +import JhiNavbar from '@/core/jhi-navbar/jhi-navbar.vue'; +import Ribbon from '@/core/ribbon/ribbon.vue'; +import { useAlertService } from '@/shared/alert/alert.service'; +import '@/shared/config/dayjs'; + +export default defineComponent({ + name: 'App', + components: { + BApp: BApp as Component, + Ribbon, + JhiNavbar, + LoginForm, + JhiFooter, + }, + setup() { + provide('alertService', useAlertService()); + const { loginModalOpen } = storeToRefs(useLoginModal()); + + return { + loginModalOpen, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/app.vue b/src/main/webapp/app/app.vue new file mode 100644 index 0000000..54a90de --- /dev/null +++ b/src/main/webapp/app/app.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/main/webapp/app/core/error/error.component.spec.ts b/src/main/webapp/app/core/error/error.component.spec.ts new file mode 100644 index 0000000..cefe4c9 --- /dev/null +++ b/src/main/webapp/app/core/error/error.component.spec.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Ref, ref } from 'vue'; +import { type RouteLocation } from 'vue-router'; + +import { createTestingPinia } from '@pinia/testing'; +import { type ComponentMountingOptions, shallowMount } from '@vue/test-utils'; + +import { useLoginModal } from '@/account/login-modal'; + +import Error from './error.vue'; + +type ErrorComponentType = InstanceType; + +let route: Partial; + +vi.mock('vue-router', () => ({ + useRoute: () => route, +})); + +const customErrorMsg = 'An error occurred.'; + +describe('Error component', () => { + let error: ErrorComponentType; + let login: ReturnType; + let authenticated: Ref; + let mountOptions: ComponentMountingOptions; + + beforeEach(() => { + route = {}; + authenticated = ref(false); + mountOptions = { + global: { + plugins: [createTestingPinia()], + provide: { + authenticated, + }, + }, + }; + }); + + it('should have retrieve custom error on routing', () => { + route = { + path: '/custom-error', + name: 'CustomMessage', + meta: { errorMessage: customErrorMsg }, + }; + const wrapper = shallowMount(Error, mountOptions); + error = wrapper.vm; + login = useLoginModal(); + + expect(error.errorMessage).toBe(customErrorMsg); + expect(error.error403).toBeFalsy(); + expect(error.error404).toBeFalsy(); + expect(login.showLogin).not.toHaveBeenCalled(); + }); + + it('should have set forbidden error on routing', () => { + route = { + meta: { error403: true }, + }; + const wrapper = shallowMount(Error, mountOptions); + error = wrapper.vm; + login = useLoginModal(); + + expect(error.errorMessage).toBeNull(); + expect(error.error403).toBeTruthy(); + expect(error.error404).toBeFalsy(); + expect(login.showLogin).toHaveBeenCalled(); + }); + + it('should have set not found error on routing', () => { + route = { + meta: { error404: true }, + }; + const wrapper = shallowMount(Error, mountOptions); + error = wrapper.vm; + login = useLoginModal(); + + expect(error.errorMessage).toBeNull(); + expect(error.error403).toBeFalsy(); + expect(error.error404).toBeTruthy(); + expect(login.showLogin).not.toHaveBeenCalled(); + }); + + it('should have set default on no error', () => { + const wrapper = shallowMount(Error, mountOptions); + error = wrapper.vm; + login = useLoginModal(); + + expect(error.errorMessage).toBeNull(); + expect(error.error403).toBeFalsy(); + expect(error.error404).toBeFalsy(); + expect(login.showLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/webapp/app/core/error/error.component.ts b/src/main/webapp/app/core/error/error.component.ts new file mode 100644 index 0000000..adcf3d2 --- /dev/null +++ b/src/main/webapp/app/core/error/error.component.ts @@ -0,0 +1,33 @@ +import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRoute } from 'vue-router'; + +import { useLoginModal } from '@/account/login-modal'; + +export default defineComponent({ + name: 'Error', + setup() { + const { showLogin } = useLoginModal(); + const authenticated = inject>('authenticated'); + const errorMessage: Ref = ref(null); + const error403: Ref = ref(false); + const error404: Ref = ref(false); + const route = useRoute(); + + if (route.meta) { + errorMessage.value = route.meta.errorMessage ?? null; + error403.value = route.meta.error403 ?? false; + error404.value = route.meta.error404 ?? false; + if (!authenticated.value && error403.value) { + showLogin(); + } + } + + return { + errorMessage, + error403, + error404, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/core/error/error.vue b/src/main/webapp/app/core/error/error.vue new file mode 100644 index 0000000..79fd0dc --- /dev/null +++ b/src/main/webapp/app/core/error/error.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/main/webapp/app/core/home/home.component.spec.ts b/src/main/webapp/app/core/home/home.component.spec.ts new file mode 100644 index 0000000..91f21ab --- /dev/null +++ b/src/main/webapp/app/core/home/home.component.spec.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { ref } from 'vue'; + +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; + +import { useLoginModal } from '@/account/login-modal'; + +import Home from './home.vue'; + +type HomeComponentType = InstanceType; + +describe('Home', () => { + let home: HomeComponentType; + let authenticated; + let currentUsername; + let login: ReturnType; + + beforeEach(() => { + authenticated = ref(false); + currentUsername = ref(''); + const wrapper = shallowMount(Home, { + global: { + plugins: [createTestingPinia()], + stubs: { + 'router-link': true, + }, + provide: { + authenticated, + currentUsername, + }, + }, + }); + home = wrapper.vm; + login = useLoginModal(); + }); + + it('should not have user data set', () => { + expect(home.authenticated).toBeFalsy(); + expect(home.username).toBe(''); + }); + + it('should have user data set after authentication', () => { + authenticated.value = true; + currentUsername.value = 'test'; + + expect(home.authenticated).toBeTruthy(); + expect(home.username).toBe('test'); + }); + + it('should use login service', () => { + home.showLogin(); + expect(login.showLogin).toHaveBeenCalled(); + }); +}); diff --git a/src/main/webapp/app/core/home/home.component.ts b/src/main/webapp/app/core/home/home.component.ts new file mode 100644 index 0000000..363ea0d --- /dev/null +++ b/src/main/webapp/app/core/home/home.component.ts @@ -0,0 +1,19 @@ +import { type ComputedRef, defineComponent, inject } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useLoginModal } from '@/account/login-modal'; + +export default defineComponent({ + setup() { + const { showLogin } = useLoginModal(); + const authenticated = inject>('authenticated'); + const username = inject>('currentUsername'); + + return { + authenticated, + username, + showLogin, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/core/home/home.vue b/src/main/webapp/app/core/home/home.vue new file mode 100644 index 0000000..1eeab6c --- /dev/null +++ b/src/main/webapp/app/core/home/home.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/main/webapp/app/core/jhi-footer/jhi-footer.component.ts b/src/main/webapp/app/core/jhi-footer/jhi-footer.component.ts new file mode 100644 index 0000000..77090d0 --- /dev/null +++ b/src/main/webapp/app/core/jhi-footer/jhi-footer.component.ts @@ -0,0 +1,11 @@ +import { defineComponent } from 'vue'; +import { useI18n } from 'vue-i18n'; + +export default defineComponent({ + name: 'JhiFooter', + setup() { + return { + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/core/jhi-footer/jhi-footer.vue b/src/main/webapp/app/core/jhi-footer/jhi-footer.vue new file mode 100644 index 0000000..78e5ae7 --- /dev/null +++ b/src/main/webapp/app/core/jhi-footer/jhi-footer.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.spec.ts b/src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.spec.ts new file mode 100644 index 0000000..8d149ed --- /dev/null +++ b/src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.spec.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { computed } from 'vue'; +import { type Router } from 'vue-router'; + +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; + +import { useLoginModal } from '@/account/login-modal'; +import { createRouter } from '@/router'; +import { useStore } from '@/store'; + +import JhiNavbar from './jhi-navbar.vue'; + +type JhiNavbarComponentType = InstanceType; + +const pinia = createTestingPinia({ stubActions: false }); +const store = useStore(); + +describe('JhiNavbar', () => { + let jhiNavbar: JhiNavbarComponentType; + let login: ReturnType; + const accountService = { hasAnyAuthorityAndCheckAuth: vi.fn().mockImplementation(() => Promise.resolve(true)) }; + const changeLanguage = vi.fn(); + let router: Router; + + beforeEach(() => { + router = createRouter(); + const wrapper = shallowMount(JhiNavbar, { + global: { + plugins: [pinia, router], + stubs: { + 'font-awesome-icon': true, + 'b-navbar': true, + 'b-navbar-nav': true, + 'b-dropdown-item': true, + 'b-collapse': true, + 'b-nav-item': true, + 'b-nav-item-dropdown': true, + 'b-navbar-toggle': true, + 'b-navbar-brand': true, + }, + provide: { + currentLanguage: computed(() => 'foo'), + changeLanguage, + accountService, + }, + }, + }); + jhiNavbar = wrapper.vm; + login = useLoginModal(); + }); + + it('should not have user data set', () => { + expect(jhiNavbar.authenticated).toBeFalsy(); + expect(jhiNavbar.openAPIEnabled).toBeFalsy(); + expect(jhiNavbar.inProduction).toBeFalsy(); + }); + + it('should have user data set after authentication', () => { + store.setAuthentication({ login: 'test' }); + + expect(jhiNavbar.authenticated).toBeTruthy(); + }); + + it('should have profile info set after info retrieved', () => { + store.setActiveProfiles(['prod', 'api-docs']); + + expect(jhiNavbar.openAPIEnabled).toBeTruthy(); + expect(jhiNavbar.inProduction).toBeTruthy(); + }); + + it('should use login service', () => { + jhiNavbar.showLogin(); + + expect(login.showLogin).toHaveBeenCalled(); + }); + + it('should use account service', () => { + jhiNavbar.hasAnyAuthority('auth'); + + expect(accountService.hasAnyAuthorityAndCheckAuth).toHaveBeenCalled(); + }); + + it('logout should clear credentials and return to the home page when no provider URL is returned', async () => { + store.setAuthentication({ login: 'test' }); + + await jhiNavbar.logout(); + + expect(jhiNavbar.authenticated).toBeFalsy(); + }); + + it('should determine active route', async () => { + await router.push('/forbidden'); + + expect(jhiNavbar.subIsActive('/titi')).toBeFalsy(); + expect(jhiNavbar.subIsActive('/forbidden')).toBeTruthy(); + expect(jhiNavbar.subIsActive(['/forbidden', 'forbidden'])).toBeTruthy(); + }); + + it('should call translationService when changing language', () => { + jhiNavbar.changeLanguage('fr'); + + expect(changeLanguage).toHaveBeenCalled(); + }); + + it('should check for correct language', () => { + expect(jhiNavbar.isActiveLanguage('en')).toBeFalsy(); + expect(jhiNavbar.isActiveLanguage('foo')).toBeTruthy(); + }); +}); diff --git a/src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.ts b/src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.ts new file mode 100644 index 0000000..c30ca35 --- /dev/null +++ b/src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.ts @@ -0,0 +1,79 @@ +import { type Ref, computed, defineComponent, inject, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRouter } from 'vue-router'; + +import { storeToRefs } from 'pinia'; + +import type AccountService from '@/account/account.service'; +import { useLoginModal } from '@/account/login-modal'; +import EntitiesMenu from '@/entities/entities-menu.vue'; +import languages from '@/shared/config/languages'; +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; +import { useStore } from '@/store'; + +export default defineComponent({ + name: 'JhiNavbar', + components: { + 'entities-menu': EntitiesMenu, + }, + setup() { + const { showLogin } = useLoginModal(); + const accountService = inject('accountService'); + const currentLanguage = inject('currentLanguage', () => computed(() => navigator.language ?? 'en'), true); + const changeLanguage = inject<(string) => Promise>('changeLanguage'); + + const isActiveLanguage = (key: string) => key === currentLanguage.value; + + const router = useRouter(); + const store = useStore(); + + const version = `v${APP_VERSION}`; + const hasAnyAuthorityValues: Ref = ref({}); + + const openAPIEnabled = computed(() => store.activeProfiles.includes('api-docs')); + const inProduction = computed(() => store.activeProfiles.includes('prod')); + const { authenticated } = storeToRefs(store); + + const subIsActive = (input: string | string[]) => { + const paths = Array.isArray(input) ? input : [input]; + // current path starts with this path string + return paths.some(path => router.currentRoute.value.path.startsWith(path)); + }; + + const logout = async () => { + localStorage.removeItem(AUTHENTICATION_TOKEN_KEY); + sessionStorage.removeItem(AUTHENTICATION_TOKEN_KEY); + store.logout(); + if (router.currentRoute.value.path !== '/') { + await router.push('/'); + } + }; + + return { + logout, + subIsActive, + accountService, + showLogin, + changeLanguage, + languages: languages(), + isActiveLanguage, + version, + currentLanguage, + hasAnyAuthorityValues, + openAPIEnabled, + inProduction, + authenticated, + t$: useI18n().t, + }; + }, + methods: { + hasAnyAuthority(authorities: any): boolean { + this.accountService.hasAnyAuthorityAndCheckAuth(authorities).then(value => { + if (this.hasAnyAuthorityValues[authorities] !== value) { + this.hasAnyAuthorityValues = { ...this.hasAnyAuthorityValues, [authorities]: value }; + } + }); + return this.hasAnyAuthorityValues[authorities] ?? false; + }, + }, +}); diff --git a/src/main/webapp/app/core/jhi-navbar/jhi-navbar.vue b/src/main/webapp/app/core/jhi-navbar/jhi-navbar.vue new file mode 100644 index 0000000..4b8dc84 --- /dev/null +++ b/src/main/webapp/app/core/jhi-navbar/jhi-navbar.vue @@ -0,0 +1,193 @@ + + + + + + diff --git a/src/main/webapp/app/core/ribbon/ribbon.component.spec.ts b/src/main/webapp/app/core/ribbon/ribbon.component.spec.ts new file mode 100644 index 0000000..49e2a79 --- /dev/null +++ b/src/main/webapp/app/core/ribbon/ribbon.component.spec.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createTestingPinia } from '@pinia/testing'; +import { shallowMount } from '@vue/test-utils'; + +import { type AccountStore, useStore } from '@/store'; + +import Ribbon from './ribbon.vue'; + +type RibbonComponentType = InstanceType; + +const pinia = createTestingPinia({ stubActions: false }); + +describe('Ribbon', () => { + let ribbon: RibbonComponentType; + let store: AccountStore; + + beforeEach(async () => { + const wrapper = shallowMount(Ribbon, { + global: { + plugins: [pinia], + }, + }); + ribbon = wrapper.vm; + await ribbon.$nextTick(); + store = useStore(); + store.setRibbonOnProfiles(null); + }); + + it('should not have ribbonEnabled when no data', () => { + expect(ribbon.ribbonEnabled).toBeFalsy(); + }); + + it('should have ribbonEnabled set to value in store', () => { + const profile = 'dev'; + store.setActiveProfiles(['foo', profile, 'bar']); + store.setRibbonOnProfiles(profile); + expect(ribbon.ribbonEnabled).toBeTruthy(); + }); + + it('should not have ribbonEnabled when profile not activated', () => { + const profile = 'dev'; + store.setActiveProfiles(['foo', 'bar']); + store.setRibbonOnProfiles(profile); + expect(ribbon.ribbonEnabled).toBeFalsy(); + }); +}); diff --git a/src/main/webapp/app/core/ribbon/ribbon.component.ts b/src/main/webapp/app/core/ribbon/ribbon.component.ts new file mode 100644 index 0000000..a829c52 --- /dev/null +++ b/src/main/webapp/app/core/ribbon/ribbon.component.ts @@ -0,0 +1,19 @@ +import { computed, defineComponent } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { useStore } from '@/store'; + +export default defineComponent({ + name: 'Ribbon', + setup() { + const store = useStore(); + const ribbonEnv = computed(() => store.ribbonOnProfiles); + const ribbonEnabled = computed(() => store.ribbonOnProfiles && store.activeProfiles.indexOf(store.ribbonOnProfiles) > -1); + + return { + ribbonEnv, + ribbonEnabled, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/core/ribbon/ribbon.vue b/src/main/webapp/app/core/ribbon/ribbon.vue new file mode 100644 index 0000000..1d75e35 --- /dev/null +++ b/src/main/webapp/app/core/ribbon/ribbon.vue @@ -0,0 +1,43 @@ + + + + + + diff --git a/src/main/webapp/app/declarations.d.ts b/src/main/webapp/app/declarations.d.ts new file mode 100644 index 0000000..f8306dc --- /dev/null +++ b/src/main/webapp/app/declarations.d.ts @@ -0,0 +1,6 @@ +// These constants are injected via webpack environment variables. +// You can add more variables in webpack.common.js or in profile specific webpack..js files. +// If you change the values in the webpack config files, you need to re run webpack to update the application + +declare const SERVER_API_URL: string; +declare const APP_VERSION: string; diff --git a/src/main/webapp/app/entities/entities-menu.component.ts b/src/main/webapp/app/entities/entities-menu.component.ts new file mode 100644 index 0000000..fab94eb --- /dev/null +++ b/src/main/webapp/app/entities/entities-menu.component.ts @@ -0,0 +1,12 @@ +import { defineComponent } from 'vue'; +import { useI18n } from 'vue-i18n'; + +export default defineComponent({ + name: 'EntitiesMenu', + setup() { + const i18n = useI18n(); + return { + t$: i18n.t, + }; + }, +}); diff --git a/src/main/webapp/app/entities/entities-menu.vue b/src/main/webapp/app/entities/entities-menu.vue new file mode 100644 index 0000000..22b4569 --- /dev/null +++ b/src/main/webapp/app/entities/entities-menu.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/main/webapp/app/entities/entities.component.ts b/src/main/webapp/app/entities/entities.component.ts new file mode 100644 index 0000000..330630b --- /dev/null +++ b/src/main/webapp/app/entities/entities.component.ts @@ -0,0 +1,12 @@ +import { defineComponent, provide } from 'vue'; + +import UserService from '@/entities/user/user.service'; +// jhipster-needle-add-entity-service-to-entities-component-import - JHipster will import entities services here + +export default defineComponent({ + name: 'Entities', + setup() { + provide('userService', () => new UserService()); + // jhipster-needle-add-entity-service-to-entities-component - JHipster will import entities services here + }, +}); diff --git a/src/main/webapp/app/entities/entities.vue b/src/main/webapp/app/entities/entities.vue new file mode 100644 index 0000000..b22f96f --- /dev/null +++ b/src/main/webapp/app/entities/entities.vue @@ -0,0 +1,5 @@ + + + diff --git a/src/main/webapp/app/entities/user/user.service.ts b/src/main/webapp/app/entities/user/user.service.ts new file mode 100644 index 0000000..91736e4 --- /dev/null +++ b/src/main/webapp/app/entities/user/user.service.ts @@ -0,0 +1,9 @@ +import axios from 'axios'; + +const baseApiUrl = 'api/users'; + +export default class UserService { + retrieve(): Promise { + return axios.get(baseApiUrl); + } +} diff --git a/src/main/webapp/app/locale/translation.service.ts b/src/main/webapp/app/locale/translation.service.ts new file mode 100644 index 0000000..a6ed3e3 --- /dev/null +++ b/src/main/webapp/app/locale/translation.service.ts @@ -0,0 +1,40 @@ +import { type Composer } from 'vue-i18n'; + +import axios from 'axios'; +import dayjs from 'dayjs'; + +import languages from '@/shared/config/languages'; + +export default class TranslationService { + private readonly i18n: Composer; + private readonly languages = languages(); + private readonly loadedLanguages: Set = new Set(); + + constructor(i18n: Composer) { + this.i18n = i18n; + } + + async refreshTranslation(newLanguage: string) { + if (this.i18n && !this.loadedLanguages.has(newLanguage)) { + const translations = await import(`../../i18n/${newLanguage}/${newLanguage}.js`); + this.i18n.setLocaleMessage(newLanguage, translations.default); + + this.loadedLanguages.add(newLanguage); + } + } + + setLocale(lang: string) { + dayjs.locale(lang); + this.i18n.locale.value = lang; + axios.defaults.headers.common['Accept-Language'] = lang; + document.querySelector('html').setAttribute('lang', lang); + } + + isLanguageSupported(lang: string) { + return Boolean(this.languages[lang]); + } + + getLocalStoreLanguage(): string | null { + return localStorage.getItem('currentLanguage'); + } +} diff --git a/src/main/webapp/app/main.ts b/src/main/webapp/app/main.ts new file mode 100644 index 0000000..a0d9c1b --- /dev/null +++ b/src/main/webapp/app/main.ts @@ -0,0 +1,132 @@ +// The Vue build version to load with the `import` command +// (runtime-only or standalone) has been set in webpack.common with an alias. +import { computed, createApp, onMounted, provide, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { START_LOCATION } from 'vue-router'; + +import { createPinia, storeToRefs } from 'pinia'; + +import AccountService from '@/account/account.service'; +import { useLoginModal } from '@/account/login-modal'; +import TranslationService from '@/locale/translation.service'; +import { setupAxiosInterceptors } from '@/shared/config/axios-interceptor'; +import { initFortAwesome, initI18N } from '@/shared/config/config'; +import { initBootstrapVue } from '@/shared/config/config-bootstrap-vue'; +import JhiItemCount from '@/shared/jhi-item-count.vue'; +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; +import JhiSortIndicator from '@/shared/sort/jhi-sort-indicator.vue'; +import { useStore, useTranslationStore } from '@/store'; + +import App from './app.vue'; +import router from './router'; + +import '../content/scss/global.scss'; +import '../content/scss/vendor.scss'; + +const pinia = createPinia(); + +// jhipster-needle-add-entity-service-to-main-import - JHipster will import entities services here + +const i18n = initI18N(); + +const app = createApp({ + components: { App }, + setup() { + const { hideLogin, showLogin } = useLoginModal(); + const store = useStore(); + const accountService = new AccountService(store); + const i18n = useI18n(); + const translationStore = useTranslationStore(); + const translationService = new TranslationService(i18n); + + const changeLanguage = async (newLanguage: string) => { + if (newLanguage && i18n.locale.value !== newLanguage) { + await translationService.refreshTranslation(newLanguage); + translationStore.setCurrentLanguage(newLanguage); + } + }; + + provide('currentLanguage', i18n.locale); + provide('changeLanguage', changeLanguage); + + watch( + () => store.account, + async value => { + if (!translationService.getLocalStoreLanguage()) { + await changeLanguage(value?.langKey); + } + }, + ); + + watch( + () => translationStore.currentLanguage, + value => { + translationService.setLocale(value); + }, + ); + + onMounted(async () => { + const lang = [translationService.getLocalStoreLanguage(), store.account?.langKey, navigator.language, 'en'].find( + lang => lang && translationService.isLanguageSupported(lang), + ); + await changeLanguage(lang); + }); + + router.beforeResolve(async (to, from) => { + // Make sure login modal is closed when navigating. + // The initial navigation may resolve after the user opened the login modal, don't close it. + if (from !== START_LOCATION) { + hideLogin(); + } + + if (!store.authenticated) { + await accountService.update(); + } + if (to.meta?.authorities && to.meta.authorities.length > 0) { + const value = await accountService.hasAnyAuthorityAndCheckAuth(to.meta.authorities); + if (!value && from.path !== '/forbidden') { + return { path: '/forbidden' }; + } + } + return true; + }); + + setupAxiosInterceptors( + error => { + const url = error.response?.config?.url; + const status = error.status || error.response?.status; + if (status === 401) { + // Store logged out state. + sessionStorage.removeItem(AUTHENTICATION_TOKEN_KEY); + localStorage.removeItem(AUTHENTICATION_TOKEN_KEY); + store.logout(); + if (!url?.endsWith('api/account') && !url?.endsWith('api/authenticate')) { + // Ask for a new authentication + showLogin(); + return; + } + } + return Promise.reject(error); + }, + error => Promise.reject(error), + ); + + const { authenticated } = storeToRefs(store); + provide('authenticated', authenticated); + provide( + 'currentUsername', + computed(() => store.account?.login), + ); + + provide('translationService', translationService); + provide('accountService', accountService); + // jhipster-needle-add-entity-service-to-main - JHipster will import entities services here + }, + template: '', +}); + +initFortAwesome(app); + +initBootstrapVue(app); + +app.component('JhiItemCount', JhiItemCount).component('JhiSortIndicator', JhiSortIndicator).use(router).use(pinia).use(i18n).mount('#app'); diff --git a/src/main/webapp/app/router/account.ts b/src/main/webapp/app/router/account.ts new file mode 100644 index 0000000..903d187 --- /dev/null +++ b/src/main/webapp/app/router/account.ts @@ -0,0 +1,43 @@ +import { Authority } from '@/shared/jhipster/constants'; + +const Register = () => import('@/account/register/register.vue'); +const Activate = () => import('@/account/activate/activate.vue'); +const ResetPasswordInit = () => import('@/account/reset-password/init/reset-password-init.vue'); +const ResetPasswordFinish = () => import('@/account/reset-password/finish/reset-password-finish.vue'); +const ChangePassword = () => import('@/account/change-password/change-password.vue'); +const Settings = () => import('@/account/settings/settings.vue'); + +export default [ + { + path: '/register', + name: 'Register', + component: Register, + }, + { + path: '/account/activate', + name: 'Activate', + component: Activate, + }, + { + path: '/account/reset/request', + name: 'ResetPasswordInit', + component: ResetPasswordInit, + }, + { + path: '/account/reset/finish', + name: 'ResetPasswordFinish', + component: ResetPasswordFinish, + }, + { + path: '/account/password', + name: 'ChangePassword', + component: ChangePassword, + meta: { authorities: [Authority.USER] }, + }, + { + path: '/account/settings', + name: 'Settings', + component: Settings, + meta: { authorities: [Authority.USER] }, + }, +]; diff --git a/src/main/webapp/app/router/admin.ts b/src/main/webapp/app/router/admin.ts new file mode 100644 index 0000000..5af2259 --- /dev/null +++ b/src/main/webapp/app/router/admin.ts @@ -0,0 +1,67 @@ +import { Authority } from '@/shared/jhipster/constants'; + +const JhiUserManagementComponent = () => import('@/admin/user-management/user-management.vue'); +const JhiUserManagementViewComponent = () => import('@/admin/user-management/user-management-view.vue'); +const JhiUserManagementEditComponent = () => import('@/admin/user-management/user-management-edit.vue'); +const JhiDocsComponent = () => import('@/admin/docs/docs.vue'); +const JhiConfigurationComponent = () => import('@/admin/configuration/configuration.vue'); +const JhiHealthComponent = () => import('@/admin/health/health.vue'); +const JhiLogsComponent = () => import('@/admin/logs/logs.vue'); +const JhiMetricsComponent = () => import('@/admin/metrics/metrics.vue'); + +export default [ + { + path: '/admin/user-management', + name: 'JhiUser', + component: JhiUserManagementComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/user-management/new', + name: 'JhiUserCreate', + component: JhiUserManagementEditComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/user-management/:userId/edit', + name: 'JhiUserEdit', + component: JhiUserManagementEditComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/user-management/:userId/view', + name: 'JhiUserView', + component: JhiUserManagementViewComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/docs', + name: 'JhiDocsComponent', + component: JhiDocsComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/health', + name: 'JhiHealthComponent', + component: JhiHealthComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/logs', + name: 'JhiLogsComponent', + component: JhiLogsComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/metrics', + name: 'JhiMetricsComponent', + component: JhiMetricsComponent, + meta: { authorities: [Authority.ADMIN] }, + }, + { + path: '/admin/configuration', + name: 'JhiConfigurationComponent', + component: JhiConfigurationComponent, + meta: { authorities: [Authority.ADMIN] }, + }, +]; diff --git a/src/main/webapp/app/router/entities.ts b/src/main/webapp/app/router/entities.ts new file mode 100644 index 0000000..451fe99 --- /dev/null +++ b/src/main/webapp/app/router/entities.ts @@ -0,0 +1,11 @@ +const Entities = () => import('@/entities/entities.vue'); + +// jhipster-needle-add-entity-to-router-import - JHipster will import entities to the router here + +export default { + path: '/', + component: Entities, + children: [ + // jhipster-needle-add-entity-to-router - JHipster will add entities to the router here + ], +}; diff --git a/src/main/webapp/app/router/index.ts b/src/main/webapp/app/router/index.ts new file mode 100644 index 0000000..9979b1a --- /dev/null +++ b/src/main/webapp/app/router/index.ts @@ -0,0 +1,47 @@ +import { createRouter as createVueRouter, createWebHistory } from 'vue-router'; + +const Home = () => import('@/core/home/home.vue'); +const Error = () => import('@/core/error/error.vue'); +import account from '@/router/account'; +import admin from '@/router/admin'; +import entities from '@/router/entities'; +import pages from '@/router/pages'; + +export const createRouter = () => + createVueRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'Home', + component: Home, + }, + { + path: '/forbidden', + name: 'Forbidden', + component: Error, + meta: { error403: true }, + }, + { + path: '/not-found', + name: 'NotFound', + component: Error, + meta: { error404: true }, + }, + ...account, + ...admin, + entities, + ...pages, + ], + }); + +const router = createRouter(); + +router.beforeResolve(async to => { + if (!to.matched.length) { + return { path: '/not-found' }; + } + return true; +}); + +export default router; diff --git a/src/main/webapp/app/router/pages.ts b/src/main/webapp/app/router/pages.ts new file mode 100644 index 0000000..80c2e55 --- /dev/null +++ b/src/main/webapp/app/router/pages.ts @@ -0,0 +1,5 @@ +// jhipster-needle-add-entity-to-router-import - JHipster will import entities to the router here + +export default [ + // jhipster-needle-add-entity-to-router - JHipster will add entities to the router here +]; diff --git a/src/main/webapp/app/shared/alert/alert.service.spec.ts b/src/main/webapp/app/shared/alert/alert.service.spec.ts new file mode 100644 index 0000000..b4eed2d --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert.service.spec.ts @@ -0,0 +1,188 @@ +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MESSAGE_ERROR_HEADER_NAME, MESSAGE_PARAM_HEADER_NAME } from '@/shared/jhipster/constants'; + +import AlertService from './alert.service'; + +describe('Alert Service test suite', () => { + let translationStub: Mock; + let toastStub: Mock; + let alertService: AlertService; + + beforeEach(() => { + translationStub = vi.fn(); + toastStub = vi.fn(); + alertService = new AlertService({ + i18n: { t: translationStub } as any, + toast: { + create: toastStub, + } as any, + }); + }); + + it('should show error toast with translation/message', () => { + const message = 'translatedMessage'; + + // WHEN + alertService.showError(message); + + // THEN + expect(toastStub).toHaveBeenCalledExactlyOnceWith({ + body: message, + modelValue: 5000, + position: 'top-end', + title: 'Error', + variant: 'danger', + solid: true, + }); + }); + + it('should show not reachable toast when http status = 0', () => { + const translationKey = 'error.server.not.reachable'; + const message = 'Server not reachable'; + const httpErrorResponse = { + status: 0, + }; + // GIVEN + translationStub.mockReturnValueOnce(message); + + // WHEN + alertService.showHttpError(httpErrorResponse); + + // THEN + expect(translationStub).toHaveBeenCalledExactlyOnceWith(translationKey); + expect(toastStub).toHaveBeenCalledExactlyOnceWith({ + body: expect.any(String), + modelValue: 5000, + position: 'top-end', + solid: true, + title: 'Error', + variant: 'danger', + }); + }); + + it('should show parameterized error toast when http status = 400 and entity headers', () => { + const translationKey = 'error.update'; + const message = 'Updation Error'; + const httpErrorResponse = { + status: 400, + headers: { + [MESSAGE_ERROR_HEADER_NAME]: translationKey, + [MESSAGE_PARAM_HEADER_NAME]: 'dummyEntity', + }, + }; + // GIVEN + translationStub.mockImplementation(key => { + if (key === translationKey) { + return message; + } + if (key === 'global.menu.entities.dummyEntity') { + return 'DummyEntity'; + } + throw new Error(); + }); + + // WHEN + alertService.showHttpError(httpErrorResponse); + + // THEN + expect(translationStub).toHaveBeenCalledTimes(2); + expect(translationStub).toHaveBeenCalledWith(translationKey, { entityName: 'DummyEntity' }); + expect(translationStub).toHaveBeenCalledWith('global.menu.entities.dummyEntity'); + expect(toastStub).toHaveBeenCalledWith({ + body: expect.any(String), + modelValue: 5000, + position: 'top-end', + solid: true, + title: 'Error', + variant: 'danger', + }); + }); + + it('should show error toast with data.message when http status = 400 and entity headers', () => { + const message = 'Validation error'; + const httpErrorResponse = { + status: 400, + headers: { + [`${MESSAGE_ERROR_HEADER_NAME}400`]: 'error', + [`${MESSAGE_PARAM_HEADER_NAME}400`]: 'dummyEntity', + }, + data: { + message, + fieldErrors: { + field1: 'error1', + }, + }, + }; + + // GIVEN + translationStub.mockReturnValueOnce('DummyEntity'); + translationStub.mockReturnValueOnce(message); + + // WHEN + alertService.showHttpError(httpErrorResponse); + + // THEN + expect(translationStub).toHaveBeenCalledExactlyOnceWith(message); + expect(toastStub).toHaveBeenCalledExactlyOnceWith({ + body: expect.any(String), + modelValue: 5000, + position: 'top-end', + solid: true, + title: 'Error', + variant: 'danger', + }); + }); + + it('should show error toast when http status = 404', () => { + const translationKey = 'error.http.404'; + const message = 'The page does not exist.'; + const httpErrorResponse = { + status: 404, + }; + + // GIVEN + translationStub.mockReturnValueOnce(message); + + // WHEN + alertService.showHttpError(httpErrorResponse); + + // THEN + expect(translationStub).toHaveBeenCalledExactlyOnceWith(translationKey); + expect(toastStub).toHaveBeenCalledExactlyOnceWith({ + body: expect.any(String), + modelValue: 5000, + position: 'top-end', + solid: true, + title: 'Error', + variant: 'danger', + }); + }); + + it('should show error toast when http status != 400,404', () => { + const message = 'Error 500'; + const httpErrorResponse = { + status: 500, + data: { + message, + }, + }; + + // GIVEN + translationStub.mockReturnValueOnce(message); + + // WHEN + alertService.showHttpError(httpErrorResponse); + + // THEN + expect(translationStub).toHaveBeenCalledExactlyOnceWith(message); + expect(toastStub).toHaveBeenCalledExactlyOnceWith({ + body: expect.any(String), + modelValue: 5000, + position: 'top-end', + solid: true, + title: 'Error', + variant: 'danger', + }); + }); +}); diff --git a/src/main/webapp/app/shared/alert/alert.service.ts b/src/main/webapp/app/shared/alert/alert.service.ts new file mode 100644 index 0000000..e370fb7 --- /dev/null +++ b/src/main/webapp/app/shared/alert/alert.service.ts @@ -0,0 +1,86 @@ +import { type Composer, useI18n } from 'vue-i18n'; + +import { type BToastProps, useToast } from 'bootstrap-vue-next'; + +import { getMessageFromHeaders } from '@/shared/jhipster/headers'; + +export const useAlertService = () => { + const toast = useToast(); + if (!toast) { + throw new Error('BootstrapVue toast component was not found'); + } + const i18n = useI18n(); + return new AlertService({ + toast, + i18n, + }); +}; + +export default class AlertService { + private readonly toast: ReturnType; + private readonly i18n: Composer; + + constructor({ toast, i18n }: { toast: ReturnType; i18n: Composer }) { + this.toast = toast; + this.i18n = i18n; + } + + showInfo(toastMessage: string, props: BToastProps = {}) { + this.toast.create({ + modelValue: 5000, + position: 'top-end', + title: 'Info', + variant: 'info', + solid: true, + body: toastMessage, + ...props, + }); + } + + showSuccess(toastMessage: string) { + this.showInfo(toastMessage, { + title: 'Success', + variant: 'success', + }); + } + + showError(toastMessage: string) { + this.showInfo(toastMessage, { + title: 'Error', + variant: 'danger', + }); + } + + showHttpError({ data, status, headers }: any) { + let errorMessage: string | null = null; + switch (status) { + case 0: + errorMessage = this.i18n.t('error.server.not.reachable').toString(); + break; + + case 400: { + const message = getMessageFromHeaders(headers); + if (message.errorKey && message.param) { + errorMessage = this.i18n.t(message.errorKey, { entityName: this.i18n.t(`global.menu.entities.${message.param}`) }).toString(); + } else if (message.errorKey) { + errorMessage = this.i18n.t(message.errorKey).toString(); + } else if (message.errorMessage) { + errorMessage = message.errorMessage; + } else if (data.message) { + errorMessage = this.i18n.t(data.message).toString(); + } else if (data?.fieldErrors) { + errorMessage = 'Validation error'; + } + break; + } + + case 404: + errorMessage = this.i18n.t('error.http.404').toString(); + break; + + default: + errorMessage = this.i18n.t(data.message).toString(); + } + this.showError(errorMessage!); + } +} diff --git a/src/main/webapp/app/shared/composables/date-format.ts b/src/main/webapp/app/shared/composables/date-format.ts new file mode 100644 index 0000000..860c2f9 --- /dev/null +++ b/src/main/webapp/app/shared/composables/date-format.ts @@ -0,0 +1,51 @@ +import { type Ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import dayjs from 'dayjs'; + +export const DATE_FORMAT = 'YYYY-MM-DD'; +export const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'; + +export const DATE_TIME_LONG_FORMAT = 'YYYY-MM-DDTHH:mm'; + +export const useDateFormat = ({ entityRef }: { entityRef?: Ref> } = {}) => { + const formatDate = value => (value ? dayjs(value).format(DATE_TIME_FORMAT) : ''); + const dateFormatUtils = { + convertDateTimeFromServer: (date: Date): string => (date && dayjs(date).isValid() ? dayjs(date).format(DATE_TIME_LONG_FORMAT) : null), + formatDate, + formatDuration: value => (value ? (dayjs.duration(value).humanize() ?? value) : ''), + }; + const entityUtils = entityRef + ? { + ...dateFormatUtils, + updateInstantField: (field: string, event: any) => { + if (event.target?.value) { + entityRef.value[field] = dayjs(event.target.value, DATE_TIME_LONG_FORMAT); + } else { + entityRef.value[field] = null; + } + }, + updateZonedDateTimeField: (field: string, event: any) => { + if (event.target?.value) { + entityRef.value[field] = dayjs(event.target.value, DATE_TIME_LONG_FORMAT); + } else { + entityRef.value[field] = null; + } + }, + } + : {}; + + const i18n = useI18n(); + const formatDateI18N = (date, format = 'short') => (date ? i18n.d(Date.parse(date), format) : null); + const i18nUtils = { + formatDateI18N, + formatDateLong: date => formatDateI18N(date, 'long'), + formatDateShort: date => formatDateI18N(date, 'short'), + }; + + return { + ...dateFormatUtils, + ...entityUtils, + ...i18nUtils, + }; +}; diff --git a/src/main/webapp/app/shared/composables/index.ts b/src/main/webapp/app/shared/composables/index.ts new file mode 100644 index 0000000..3040a56 --- /dev/null +++ b/src/main/webapp/app/shared/composables/index.ts @@ -0,0 +1,2 @@ +export { useDateFormat } from './date-format'; +export { useValidation } from './validation'; diff --git a/src/main/webapp/app/shared/composables/validation.ts b/src/main/webapp/app/shared/composables/validation.ts new file mode 100644 index 0000000..76f2048 --- /dev/null +++ b/src/main/webapp/app/shared/composables/validation.ts @@ -0,0 +1,14 @@ +import { decimal, helpers, integer, maxLength, maxValue, minLength, minValue, required, sameAs } from '@vuelidate/validators'; + +export const useValidation = () => { + return { + required: (message: string) => helpers.withMessage(message, required), + decimal: (message: string) => helpers.withMessage(message, decimal), + integer: (message: string) => helpers.withMessage(message, integer), + sameAs: (message: string, ...args: Parameters) => helpers.withMessage(message, sameAs(...args)), + minLength: (message: string, ...args: Parameters) => helpers.withMessage(message, minLength(...args)), + maxLength: (message: string, ...args: Parameters) => helpers.withMessage(message, maxLength(...args)), + minValue: (message: string, ...args: Parameters) => helpers.withMessage(message, minValue(...args)), + maxValue: (message: string, ...args: Parameters) => helpers.withMessage(message, maxValue(...args)), + }; +}; diff --git a/src/main/webapp/app/shared/computables/arrays.ts b/src/main/webapp/app/shared/computables/arrays.ts new file mode 100644 index 0000000..45e3a3c --- /dev/null +++ b/src/main/webapp/app/shared/computables/arrays.ts @@ -0,0 +1,58 @@ +const compareString = (a: string, b: string): number => { + if (b == null) return 1; + if (a == null) return -1; + + return a.localeCompare(b); +}; + +const asString = (val): string => (typeof val === 'string' ? val : `${val}`); + +const compareAny = (a, b): number => { + if (b == null) return 1; + if (a == null) return -1; + + return a - b; +}; + +export type OrderByOptions = { orderByProp: string; reverse?: boolean }; + +export const orderBy = (array: any[], opts: OrderByOptions) => { + if (!Array.isArray(array)) return array; + + const { orderByProp, reverse = false } = opts; + let sorted: any[]; + if (array.some(el => typeof el[orderByProp] === 'string')) { + sorted = array.sort((a, b) => compareString(asString(a), asString(b))); + } else { + sorted = array.sort((a, b) => compareAny(a, b)); + } + if (reverse) { + return sorted.slice().reverse(); + } + return sorted; +}; + +export type FilterByOptions = { filterByTerm: string; filterMaxDepth?: number }; + +const filterObject = (val: any, opts: FilterByOptions): boolean => { + const { filterByTerm, filterMaxDepth = 2 } = opts; + if (typeof val === 'string') { + return val.toLocaleLowerCase().startsWith(filterByTerm); + } + if (typeof val === 'object') { + if (filterMaxDepth < 0) return false; + for (const value of Object.values(val)) { + if (filterObject(value, { filterByTerm, filterMaxDepth: filterMaxDepth - 1 })) return true; + } + return false; + } + return `${val}`.toLocaleLowerCase().startsWith(filterByTerm); +}; + +export const filterBy = (array: any, opts: FilterByOptions) => { + return Array.isArray(array) && opts.filterByTerm + ? array.filter(el => filterObject(el, { ...opts, filterByTerm: opts.filterByTerm.toLocaleLowerCase() })) + : array; +}; + +export const orderAndFilterBy = (array: any, opts: FilterByOptions & OrderByOptions) => orderBy(filterBy(array, opts), opts); diff --git a/src/main/webapp/app/shared/computables/index.ts b/src/main/webapp/app/shared/computables/index.ts new file mode 100644 index 0000000..4bcce8e --- /dev/null +++ b/src/main/webapp/app/shared/computables/index.ts @@ -0,0 +1 @@ +export * from './arrays'; diff --git a/src/main/webapp/app/shared/config/axios-interceptor.spec.ts b/src/main/webapp/app/shared/config/axios-interceptor.spec.ts new file mode 100644 index 0000000..576c4c9 --- /dev/null +++ b/src/main/webapp/app/shared/config/axios-interceptor.spec.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; + +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; + +import * as setupAxiosConfig from './axios-interceptor'; + +const mock = new MockAdapter(axios); + +describe('Axios interceptor', () => { + beforeEach(() => { + axios.interceptors.request.clear(); + axios.interceptors.response.clear(); + }); + + it('should use localStorage to provide bearer', () => { + localStorage.setItem(AUTHENTICATION_TOKEN_KEY, 'auth'); + const result = setupAxiosConfig.onRequestSuccess(() => console.log('A problem occurred')); + + expect(result.headers.Authorization).toBe('Bearer auth'); + expect(result.url.indexOf(SERVER_API_URL)).toBeGreaterThan(-1); + }); + + it('should use sessionStorage to provide bearer', () => { + sessionStorage.setItem(AUTHENTICATION_TOKEN_KEY, 'auth'); + const result = setupAxiosConfig.onRequestSuccess(() => console.log('A problem occurred')); + + expect(result.headers.Authorization).toBe('Bearer auth'); + expect(result.url.indexOf(SERVER_API_URL)).toBeGreaterThan(-1); + }); +}); + +describe('Axios errors interceptor', () => { + it('should use callback on 401, 403 errors', async () => { + const callback = vi.fn(); + + setupAxiosConfig.setupAxiosInterceptors(callback, () => {}); + + try { + mock.onGet().reply(401); + await axios('/api/test'); + } catch { + expect(callback).toHaveBeenCalled(); + } + }); + it('should use callback on 50x errors', async () => { + const callback = vi.fn(); + + setupAxiosConfig.setupAxiosInterceptors(() => {}, callback); + + try { + mock.onGet().reply(500); + await axios('/api/test'); + } catch { + expect(callback).toHaveBeenCalled(); + } + }); + it('should not use callback for errors different 50x, 401, 403', async () => { + const callback = vi.fn(); + + setupAxiosConfig.setupAxiosInterceptors(() => {}, callback); + + try { + mock.onGet().reply(402); + await axios('/api/test'); + } catch { + expect(callback).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/main/webapp/app/shared/config/axios-interceptor.ts b/src/main/webapp/app/shared/config/axios-interceptor.ts new file mode 100644 index 0000000..caa3708 --- /dev/null +++ b/src/main/webapp/app/shared/config/axios-interceptor.ts @@ -0,0 +1,36 @@ +import axios from 'axios'; + +import { AUTHENTICATION_TOKEN_KEY } from '@/shared/jhipster/constants'; + +const TIMEOUT = 1 * 60 * 1000; +const onRequestSuccess = config => { + const token = localStorage.getItem(AUTHENTICATION_TOKEN_KEY) ?? sessionStorage.getItem(AUTHENTICATION_TOKEN_KEY); + if (token) { + if (!config.headers) { + config.headers = {}; + } + config.headers.Authorization = `Bearer ${token}`; + } + config.timeout = TIMEOUT; + config.url = `${SERVER_API_URL}${config.url}`; + return config; +}; +const setupAxiosInterceptors = (onUnauthenticated, onServerError) => { + const onResponseError = err => { + const status = err.status || err.response?.status; + if (status === 403 || status === 401) { + return onUnauthenticated(err); + } + if (status >= 500) { + return onServerError(err); + } + return Promise.reject(err); + }; + + if (axios.interceptors) { + axios.interceptors.request.use(onRequestSuccess); + axios.interceptors.response.use(res => res, onResponseError); + } +}; + +export { onRequestSuccess, setupAxiosInterceptors }; diff --git a/src/main/webapp/app/shared/config/config-bootstrap-vue.ts b/src/main/webapp/app/shared/config/config-bootstrap-vue.ts new file mode 100644 index 0000000..67ae951 --- /dev/null +++ b/src/main/webapp/app/shared/config/config-bootstrap-vue.ts @@ -0,0 +1,52 @@ +import type { App } from 'vue'; + +import { + BAlert, + BBadge, + BButton, + BCollapse, + BDropdownItem, + BForm, + BFormCheckbox, + BFormGroup, + BFormInput, + BLink, + BModal, + BNavItem, + BNavItemDropdown, + BNavbar, + BNavbarBrand, + BNavbarNav, + BNavbarToggle, + BPagination, + BProgress, + BProgressBar, + createBootstrap, + vBModal, +} from 'bootstrap-vue-next'; + +export function initBootstrapVue(vue: App) { + vue.use(createBootstrap()); + + vue.component('BBadge', BBadge); + vue.component('BDropdownItem', BDropdownItem); + vue.component('BLink', BLink); + vue.component('BAlert', BAlert); + vue.component('BButton', BButton); + vue.component('BNavbar', BNavbar); + vue.component('BNavbarNav', BNavbarNav); + vue.component('BNavbarBrand', BNavbarBrand); + vue.component('BNavbarToggle', BNavbarToggle); + vue.component('BPagination', BPagination); + vue.component('BProgress', BProgress); + vue.component('BProgressBar', BProgressBar); + vue.component('BForm', BForm); + vue.component('BFormInput', BFormInput); + vue.component('BFormGroup', BFormGroup); + vue.component('BFormCheckbox', BFormCheckbox); + vue.component('BCollapse', BCollapse); + vue.component('BNavItem', BNavItem); + vue.component('BNavItemDropdown', BNavItemDropdown); + vue.component('BModal', BModal); + vue.directive('b-modal', vBModal); +} diff --git a/src/main/webapp/app/shared/config/config.ts b/src/main/webapp/app/shared/config/config.ts new file mode 100644 index 0000000..bf16d2c --- /dev/null +++ b/src/main/webapp/app/shared/config/config.ts @@ -0,0 +1,105 @@ +import type { App } from 'vue'; +import { type IntlDateTimeFormats, createI18n } from 'vue-i18n'; + +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft'; +import { faAsterisk } from '@fortawesome/free-solid-svg-icons/faAsterisk'; +import { faBan } from '@fortawesome/free-solid-svg-icons/faBan'; +import { faBars } from '@fortawesome/free-solid-svg-icons/faBars'; +import { faBell } from '@fortawesome/free-solid-svg-icons/faBell'; +import { faBook } from '@fortawesome/free-solid-svg-icons/faBook'; +import { faCloud } from '@fortawesome/free-solid-svg-icons/faCloud'; +import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs'; +import { faDatabase } from '@fortawesome/free-solid-svg-icons/faDatabase'; +import { faEye } from '@fortawesome/free-solid-svg-icons/faEye'; +import { faFlag } from '@fortawesome/free-solid-svg-icons/faFlag'; +import { faHeart } from '@fortawesome/free-solid-svg-icons/faHeart'; +import { faHome } from '@fortawesome/free-solid-svg-icons/faHome'; +import { faList } from '@fortawesome/free-solid-svg-icons/faList'; +import { faLock } from '@fortawesome/free-solid-svg-icons/faLock'; +import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt'; +import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus'; +import { faRoad } from '@fortawesome/free-solid-svg-icons/faRoad'; +import { faSave } from '@fortawesome/free-solid-svg-icons/faSave'; +import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'; +import { faSignInAlt } from '@fortawesome/free-solid-svg-icons/faSignInAlt'; +import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons/faSignOutAlt'; +import { faSort } from '@fortawesome/free-solid-svg-icons/faSort'; +import { faSortDown } from '@fortawesome/free-solid-svg-icons/faSortDown'; +import { faSortUp } from '@fortawesome/free-solid-svg-icons/faSortUp'; +import { faSync } from '@fortawesome/free-solid-svg-icons/faSync'; +import { faTachometerAlt } from '@fortawesome/free-solid-svg-icons/faTachometerAlt'; +import { faTasks } from '@fortawesome/free-solid-svg-icons/faTasks'; +import { faThList } from '@fortawesome/free-solid-svg-icons/faThList'; +import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; +import { faTimesCircle } from '@fortawesome/free-solid-svg-icons/faTimesCircle'; +import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash'; +import { faUser } from '@fortawesome/free-solid-svg-icons/faUser'; +import { faUserPlus } from '@fortawesome/free-solid-svg-icons/faUserPlus'; +import { faUsers } from '@fortawesome/free-solid-svg-icons/faUsers'; +import { faUsersCog } from '@fortawesome/free-solid-svg-icons/faUsersCog'; +import { faWrench } from '@fortawesome/free-solid-svg-icons/faWrench'; +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; + +const datetimeFormats: IntlDateTimeFormats = { + en: { + short: { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }, + medium: { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short', hour: 'numeric', minute: 'numeric' }, + long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: 'numeric', minute: 'numeric' }, + }, + // jhipster-needle-i18n-language-date-time-format - JHipster will add/remove format options in this object +}; + +export function initFortAwesome(vue: App) { + vue.component('FontAwesomeIcon', FontAwesomeIcon); + + library.add( + faArrowLeft, + faAsterisk, + faBan, + faBars, + faBell, + faBook, + faCloud, + faCogs, + faDatabase, + faEye, + faFlag, + faHeart, + faHome, + faList, + faLock, + faPencilAlt, + faPlus, + faRoad, + faSave, + faSearch, + faSignInAlt, + faSignOutAlt, + faSort, + faSortDown, + faSortUp, + faSync, + faTachometerAlt, + faTasks, + faThList, + faTimes, + faTimesCircle, + faTrash, + faUser, + faUserPlus, + faUsers, + faUsersCog, + faWrench, + ); +} +export function initI18N(opts: any = {}) { + return createI18n({ + missingWarn: false, + fallbackWarn: false, + legacy: false, + datetimeFormats, + silentTranslationWarn: true, + ...opts, + }); +} diff --git a/src/main/webapp/app/shared/config/dayjs.ts b/src/main/webapp/app/shared/config/dayjs.ts new file mode 100644 index 0000000..3010283 --- /dev/null +++ b/src/main/webapp/app/shared/config/dayjs.ts @@ -0,0 +1,11 @@ +import dayjs from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; +import duration from 'dayjs/plugin/duration'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +import 'dayjs/locale/en'; +// jhipster-needle-i18n-language-dayjs-imports - JHipster will import languages from dayjs here + +dayjs.extend(customParseFormat); +dayjs.extend(duration); +dayjs.extend(relativeTime); diff --git a/src/main/webapp/app/shared/config/languages.ts b/src/main/webapp/app/shared/config/languages.ts new file mode 100644 index 0000000..598a2d3 --- /dev/null +++ b/src/main/webapp/app/shared/config/languages.ts @@ -0,0 +1,6 @@ +const languages = () => ({ + en: { name: 'English' }, + // jhipster-needle-i18n-language-key-pipe - JHipster will add/remove languages in this object +}); + +export default languages; diff --git a/src/main/webapp/app/shared/config/store/account-store.ts b/src/main/webapp/app/shared/config/store/account-store.ts new file mode 100644 index 0000000..55862bb --- /dev/null +++ b/src/main/webapp/app/shared/config/store/account-store.ts @@ -0,0 +1,59 @@ +import { computed, ref } from 'vue'; + +import { defineStore } from 'pinia'; + +export const useAccountStore = defineStore('main', () => { + const userIdentity = ref(null); + const logon = ref(null); + const profilesLoaded = ref(false); + const activeProfiles = ref(''); + const ribbonOnProfiles = ref(''); + + const account = computed(() => userIdentity.value); + const authenticated = computed(() => !!userIdentity.value); + + function authenticate(promise) { + logon.value = promise; + } + + function setAuthentication(identity) { + userIdentity.value = identity; + logon.value = null; + } + + function logout() { + userIdentity.value = null; + logon.value = null; + } + + function setProfilesLoaded() { + profilesLoaded.value = true; + } + + function setActiveProfiles(profile) { + activeProfiles.value = profile; + } + + function setRibbonOnProfiles(ribbon) { + ribbonOnProfiles.value = ribbon; + } + + return { + // State + userIdentity, + logon, + profilesLoaded, + activeProfiles, + ribbonOnProfiles, + // Getters + account, + authenticated, + // Actions + authenticate, + setAuthentication, + logout, + setProfilesLoaded, + setActiveProfiles, + setRibbonOnProfiles, + }; +}); diff --git a/src/main/webapp/app/shared/config/store/translation-store.ts b/src/main/webapp/app/shared/config/store/translation-store.ts new file mode 100644 index 0000000..8a8555f --- /dev/null +++ b/src/main/webapp/app/shared/config/store/translation-store.ts @@ -0,0 +1,17 @@ +import { defineStore } from 'pinia'; + +interface TranslationState { + currentLanguage: string; +} + +export const useTranslationStore = defineStore('translationStore', { + state: (): TranslationState => ({ + currentLanguage: undefined, + }), + actions: { + setCurrentLanguage(newLanguage) { + this.currentLanguage = newLanguage; + localStorage.setItem('currentLanguage', newLanguage); + }, + }, +}); diff --git a/src/main/webapp/app/shared/data/data-utils.service.spec.ts b/src/main/webapp/app/shared/data/data-utils.service.spec.ts new file mode 100644 index 0000000..1d5dc2b --- /dev/null +++ b/src/main/webapp/app/shared/data/data-utils.service.spec.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { endsWith, paddingSize } from '@/shared/jhipster/data-utils'; + +import useDataUtils from './data-utils.service'; + +describe('DataUtils Service', () => { + let dataUtilsService: ReturnType; + + beforeEach(() => { + dataUtilsService = useDataUtils(); + }); + + it('should not abbreviate text shorter than 30 characters', () => { + const result = dataUtilsService.abbreviate('JHipster JHipster'); + + expect(result).toBe('JHipster JHipster'); + }); + + it('should abbreviate text longer than 30 characters', () => { + const result = dataUtilsService.abbreviate('JHipster JHipster JHipster JHipster JHipster'); + + expect(result).toBe('JHipster JHipst...r JHipster'); + }); + + it('should retrieve byteSize', () => { + const result = dataUtilsService.byteSize('JHipster rocks!'); + + expect(result).toBe('11.25 bytes'); + }); + + it('should clear input entity', () => { + const entity = { field: 'key', value: 'value' }; + dataUtilsService.clearInputImage(entity, null, 'field', 'value', 1); + + expect(entity.field).toBeNull(); + expect(entity.value).toBeNull(); + }); + + it('should open file', () => { + window.open = vi.fn().mockReturnValue({}); + const objectURL = 'blob:http://localhost:9000/xxx'; + URL.createObjectURL = vi.fn().mockImplementationOnce(() => { + return objectURL; + }); + + dataUtilsService.openFile('text', 'data'); + + expect(window.open).toHaveBeenCalledWith(objectURL); + }); + + it('should check text ends with suffix', () => { + const result = endsWith('rocky', 'JHipster rocks!'); + + expect(result).toBe(false); + }); + + it('should paddingSize to 0', () => { + const result = paddingSize('toto'); + + expect(result).toBe(0); + }); + + it('should paddingSize to 1', () => { + const result = paddingSize('toto='); + + expect(result).toBe(1); + }); + + it('should paddingSize to 2', () => { + const result = paddingSize('toto=='); + + expect(result).toBe(2); + }); + + it('should parse links', () => { + const result = dataUtilsService.parseLinks( + '; rel="next",; rel="last",; rel="first"', + ); + + expect(result.last).toBe(2); + }); + + it('should return empty JSON object for empty string', () => { + const result = dataUtilsService.parseLinks(''); + + expect(result).toStrictEqual({}); + }); + + it('should return empty JSON object for text with no link header', () => { + const result = dataUtilsService.parseLinks('JHipster rocks!'); + + expect(result).toStrictEqual({}); + }); + + it('should return empty JSON object for text without >;', () => { + const result = dataUtilsService.parseLinks( + ' rel="next", rel="last", rel="first"', + ); + + expect(result).toStrictEqual({}); + }); + + it('should return empty JSON object for text with no comma separated link header', () => { + const result = dataUtilsService.parseLinks( + '; rel="next"; rel="last"; rel="first"', + ); + + expect(result).toStrictEqual({}); + }); +}); diff --git a/src/main/webapp/app/shared/data/data-utils.service.ts b/src/main/webapp/app/shared/data/data-utils.service.ts new file mode 100644 index 0000000..e7bfefd --- /dev/null +++ b/src/main/webapp/app/shared/data/data-utils.service.ts @@ -0,0 +1,94 @@ +import { byteSize, openFile, toBase64 } from '@/shared/jhipster/data-utils'; +import { getPageNumberFromLinkHeader } from '@/shared/jhipster/link-header'; + +/** + * A composable utility for data. + */ +const useDataUtils = () => ({ + /** + * Method to abbreviate the text given + */ + abbreviate(text, append = '...') { + if (text.length < 30) { + return text; + } + return text ? text.substring(0, 15) + append + text.slice(-10) : ''; + }, + + /** + * Method to find the byte size of the string provides + */ + byteSize(base64String) { + return byteSize(base64String); + }, + + /** + * Method to open file + */ + openFile(contentType, data) { + openFile(data, contentType); + }, + + /** + * Method to clear the input + */ + clearInputImage(entity, elementRef, field, fieldContentType, idInput) { + if (entity && field && fieldContentType) { + if (Object.hasOwn(entity, field)) { + entity[field] = null; + } + if (Object.hasOwn(entity, fieldContentType)) { + entity[fieldContentType] = null; + } + if (elementRef && idInput && elementRef.nativeElement.querySelector(`#${idInput}`)) { + elementRef.nativeElement.querySelector(`#${idInput}`).value = null; + } + } + }, + + setFileData(event, entity, field, isImage) { + if (event?.target.files?.[0]) { + const file = event.target.files[0]; + if (isImage && !file.type.startsWith('image/')) { + return; + } + toBase64(file, base64Data => { + entity[field] = base64Data; + entity[`${field}ContentType`] = file.type; + }); + } + }, + + /** + * Method to download file + */ + downloadFile(contentType, data, fileName) { + const byteCharacters = atob(data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { + type: contentType, + }); + const tempLink = document.createElement('a'); + tempLink.href = window.URL.createObjectURL(blob); + tempLink.download = fileName; + tempLink.target = '_blank'; + tempLink.click(); + }, + + /** + * Method to parse header links + */ + parseLinks(header) { + try { + return getPageNumberFromLinkHeader(header); + } catch { + return {}; + } + }, +}); + +export default useDataUtils; diff --git a/src/main/webapp/app/shared/jhi-item-count.component.ts b/src/main/webapp/app/shared/jhi-item-count.component.ts new file mode 100644 index 0000000..aa423ca --- /dev/null +++ b/src/main/webapp/app/shared/jhi-item-count.component.ts @@ -0,0 +1,29 @@ +import { computed, defineComponent } from 'vue'; +import { useI18n } from 'vue-i18n'; + +export default defineComponent({ + props: { + page: { + type: Number, + default: 1, + }, + total: { + type: Number, + default: 0, + }, + itemsPerPage: { + type: Number, + default: 20, + }, + }, + setup(props) { + const first = computed(() => ((props.page - 1) * props.itemsPerPage === 0 ? 1 : (props.page - 1) * props.itemsPerPage + 1)); + const second = computed(() => Math.min(props.page * props.itemsPerPage, props.total)); + + return { + first, + second, + t$: useI18n().t, + }; + }, +}); diff --git a/src/main/webapp/app/shared/jhi-item-count.vue b/src/main/webapp/app/shared/jhi-item-count.vue new file mode 100644 index 0000000..7b8b9a0 --- /dev/null +++ b/src/main/webapp/app/shared/jhi-item-count.vue @@ -0,0 +1,7 @@ + + + diff --git a/src/main/webapp/app/shared/jhipster/constants.ts b/src/main/webapp/app/shared/jhipster/constants.ts new file mode 100644 index 0000000..6ad5071 --- /dev/null +++ b/src/main/webapp/app/shared/jhipster/constants.ts @@ -0,0 +1,10 @@ +export const MESSAGE_ALERT_HEADER_NAME = 'x-questionbankapp-alert'; +export const MESSAGE_ERROR_HEADER_NAME = 'x-questionbankapp-error'; +export const MESSAGE_PARAM_HEADER_NAME = 'x-questionbankapp-params'; + +export const AUTHENTICATION_TOKEN_KEY = 'jhi-authenticationToken'; + +export enum Authority { + ADMIN = 'ROLE_ADMIN', + USER = 'ROLE_USER', +} diff --git a/src/main/webapp/app/shared/jhipster/data-utils.ts b/src/main/webapp/app/shared/jhipster/data-utils.ts new file mode 100644 index 0000000..e7530d4 --- /dev/null +++ b/src/main/webapp/app/shared/jhipster/data-utils.ts @@ -0,0 +1,47 @@ +const formatAsBytes = (size: number): string => `${size.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')} bytes`; // NOSONAR + +export const endsWith = (suffix: string, str: string): boolean => str.includes(suffix, str.length - suffix.length); + +export const paddingSize = (value: string): number => { + if (endsWith('==', value)) { + return 2; + } + if (endsWith('=', value)) { + return 1; + } + return 0; +}; + +const size = (value: string): number => (value.length / 4) * 3 - paddingSize(value); + +export const toBase64 = (file: File, callback: (base64Data: string) => void): void => { + const fileReader: FileReader = new FileReader(); + fileReader.onload = (e: ProgressEvent) => { + if (typeof e.target?.result === 'string') { + const base64Data: string = e.target.result.substring(e.target.result.indexOf('base64,') + 'base64,'.length); + callback(base64Data); + } + }; + fileReader.readAsDataURL(file); +}; + +export const openFile = (data: string, contentType: string | null | undefined): void => { + contentType ??= ''; + + const byteCharacters = atob(data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.codePointAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { + type: contentType, + }); + const fileURL = globalThis.URL.createObjectURL(blob); + const win = globalThis.open(fileURL); + if (win) { + win.onload = () => URL.revokeObjectURL(fileURL); + } +}; + +export const byteSize = (base64String: string): string => formatAsBytes(size(base64String)); diff --git a/src/main/webapp/app/shared/jhipster/error.constants.ts b/src/main/webapp/app/shared/jhipster/error.constants.ts new file mode 100644 index 0000000..e335293 --- /dev/null +++ b/src/main/webapp/app/shared/jhipster/error.constants.ts @@ -0,0 +1,4 @@ +export const PROBLEM_BASE_URL = 'https://www.jhipster.tech/problem'; +export const EMAIL_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/email-already-used`; +export const LOGIN_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/login-already-used`; +export const INVALID_PASSWORD_TYPE = `${PROBLEM_BASE_URL}/invalid-password`; diff --git a/src/main/webapp/app/shared/jhipster/headers.ts b/src/main/webapp/app/shared/jhipster/headers.ts new file mode 100644 index 0000000..8fb5572 --- /dev/null +++ b/src/main/webapp/app/shared/jhipster/headers.ts @@ -0,0 +1,62 @@ +import { MESSAGE_ALERT_HEADER_NAME, MESSAGE_ERROR_HEADER_NAME, MESSAGE_PARAM_HEADER_NAME } from './constants'; + +export type HeaderMessage = { + /** Success translation key or message */ + alert?: string; + /** Success translation key */ + alertKey?: string; + /** Success message */ + alertMessage?: string; + + /** Error translation key or message */ + error?: string; + /** Error translation key */ + errorKey?: string; + /** Error message */ + errorMessage?: string; + /** Entity id for success messages. Entity name for error messages. */ + param?: string; +}; + +const headerToString = (headerValue: any): string | undefined => { + if (headerValue === undefined) { + return undefined; + } + if (Array.isArray(headerValue)) { + if (headerValue.length > 1) { + throw new TypeError('Multiple header values found'); + } + headerValue = headerValue[0]; + } + if (typeof headerValue !== 'string') { + throw new TypeError('Header value is not a string'); + } + return headerValue; +}; + +const decodeHeaderValue = (headerValue?: string): string | undefined => + headerValue ? decodeURIComponent(headerValue.replaceAll('+', ' ')) : headerValue; + +export const getMessageFromHeaders = (headers: Record): HeaderMessage => { + const alertHeader = headerToString(headers[MESSAGE_ALERT_HEADER_NAME]); + // Try to determine if the alertHeader is a key or a message + const alertIsKey = /^[A-Za-z0-9](?:[A-Za-z0-9.]*[A-Za-z0-9])?$/.test(alertHeader ?? ''); + const alertKey = alertIsKey ? alertHeader : undefined; + const alertMessage = alertIsKey ? undefined : alertHeader; + + const errorHeader = headerToString(headers[MESSAGE_ERROR_HEADER_NAME]); + // Try to determine if the errorHeader is a key or a message + const errorIsKey = /^[A-Za-z0-9](?:[A-Za-z0-9.]*[A-Za-z0-9])?$/.test(errorHeader ?? ''); + const errorKey = errorIsKey ? errorHeader : undefined; + const errorMessage = errorIsKey ? undefined : errorHeader; + + return { + alert: alertHeader, + alertKey, + alertMessage, + error: errorHeader, + errorKey, + errorMessage, + param: decodeHeaderValue(headerToString(headers[MESSAGE_PARAM_HEADER_NAME])), + }; +}; diff --git a/src/main/webapp/app/shared/jhipster/link-header.ts b/src/main/webapp/app/shared/jhipster/link-header.ts new file mode 100644 index 0000000..f4b49e4 --- /dev/null +++ b/src/main/webapp/app/shared/jhipster/link-header.ts @@ -0,0 +1,55 @@ +/** + * Method to parse the link header into an object + * @example + * link: ; rel="last",; rel="first" + * will return { last: { page: '0', size: '20' }, first: { page: '0', size: '20' } } + */ +export function parseLinkHeader(header: string): Record | undefined> { + if (header.length === 0) { + throw new Error('input must not be of zero length'); + } + + // Split parts by comma + const parts: string[] = header.split(','); + + // Parse each part into a named link + return Object.fromEntries( + parts.map(p => { + const section: string[] = p.split(';'); + + if (section.length !== 2) { + throw new Error('section could not be split on ";"'); + } + + const url: string = section[0].replace(/<(.*)>/, '$1').trim(); // NOSONAR + const queryString: Record = {}; + + url.replaceAll(/([^?=&]+)(=([^&]*))?/g, (_$0: string, $1: string | undefined, _$2: string | undefined, $3: string | undefined) => { + if ($1 !== undefined && $3 !== undefined) { + queryString[$1] = decodeURIComponent($3); + } + return $3 ?? ''; + }); + + const name: string = section[1].replace(/rel="(.*)"/, '$1').trim(); + return [name, queryString]; + }), + ); +} + +/** + * Method to parse the link header + * @example + * link: ; rel="last",; rel="first" + * will return { last: 0, first: 0 } + */ +export function getPageNumberFromLinkHeader(header: string): Record { + const sections = parseLinkHeader(header); + const pages: Record = {}; + for (const [name, queryParams] of Object.entries(sections)) { + if (queryParams?.page !== undefined) { + pages[name] = Number.parseInt(queryParams.page, 10); + } + } + return pages; +} diff --git a/src/main/webapp/app/shared/jhipster/problem-details.ts b/src/main/webapp/app/shared/jhipster/problem-details.ts new file mode 100644 index 0000000..06bb5c2 --- /dev/null +++ b/src/main/webapp/app/shared/jhipster/problem-details.ts @@ -0,0 +1,35 @@ +/** + * Problem details defined in RFC 7807. + * @see https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 + */ +export type ProblemDetails = { + /** A URI reference that identifies the problem type */ + type: string; + /** A short, human-readable summary of the problem type */ + title: string; + /** The HTTP status code */ + status: number; + /** A human-readable explanation specific to this occurrence of the problem */ + detail: string; + /** A URI reference that identifies the specific occurrence of the problem */ + instance: string; +}; + +export const ProblemWithMessageType = 'https://www.jhipster.tech/problem/problem-with-message'; + +export type FieldErrorVM = { message: string; objectName: string; field: string }; + +export type ProblemWithMessage = ProblemDetails & { + type: typeof ProblemWithMessageType; + + /** Translation message key */ + message?: string; + /** Translation message parameter */ + params?: string; + /** Request path */ + path?: string; + /** Field errors */ + fieldErrors?: FieldErrorVM[]; +}; + +export const isProblemWithMessage = (data: any): data is ProblemWithMessage => data?.type === ProblemWithMessageType; diff --git a/src/main/webapp/app/shared/model/user.model.ts b/src/main/webapp/app/shared/model/user.model.ts new file mode 100644 index 0000000..e7a786f --- /dev/null +++ b/src/main/webapp/app/shared/model/user.model.ts @@ -0,0 +1,33 @@ +export interface IUser { + id?: any; + login?: string; + firstName?: string; + lastName?: string; + email?: string; + activated?: boolean; + langKey?: string; + authorities?: any[]; + createdBy?: string; + createdDate?: Date; + lastModifiedBy?: string; + lastModifiedDate?: Date; + password?: string; +} + +export class User implements IUser { + constructor( + public id?: any, + public login?: string, + public firstName?: string, + public lastName?: string, + public email?: string, + public activated?: boolean, + public langKey?: string, + public authorities?: any[], + public createdBy?: string, + public createdDate?: Date, + public lastModifiedBy?: string, + public lastModifiedDate?: Date, + public password?: string, + ) {} +} diff --git a/src/main/webapp/app/shared/sort/jhi-sort-indicator.component.ts b/src/main/webapp/app/shared/sort/jhi-sort-indicator.component.ts new file mode 100644 index 0000000..59994d3 --- /dev/null +++ b/src/main/webapp/app/shared/sort/jhi-sort-indicator.component.ts @@ -0,0 +1,16 @@ +import { defineComponent } from 'vue'; + +export default defineComponent({ + name: 'JhiSortIndicatorComponent', + props: { + currentOrder: { + type: String, + default: '', + }, + fieldName: { + type: String, + default: '', + }, + reverse: Boolean, + }, +}); diff --git a/src/main/webapp/app/shared/sort/jhi-sort-indicator.vue b/src/main/webapp/app/shared/sort/jhi-sort-indicator.vue new file mode 100644 index 0000000..b136dd4 --- /dev/null +++ b/src/main/webapp/app/shared/sort/jhi-sort-indicator.vue @@ -0,0 +1,5 @@ + + + diff --git a/src/main/webapp/app/shared/sort/sorts.spec.ts b/src/main/webapp/app/shared/sort/sorts.spec.ts new file mode 100644 index 0000000..bfdf676 --- /dev/null +++ b/src/main/webapp/app/shared/sort/sorts.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import buildPaginationQueryOpts from './sorts'; + +describe('Sort', () => { + it('should return an empty string if there is no pagination', () => { + const result = buildPaginationQueryOpts(undefined); + + expect(result).toBe(''); + }); +}); diff --git a/src/main/webapp/app/shared/sort/sorts.ts b/src/main/webapp/app/shared/sort/sorts.ts new file mode 100644 index 0000000..0753c7f --- /dev/null +++ b/src/main/webapp/app/shared/sort/sorts.ts @@ -0,0 +1,13 @@ +export default function buildPaginationQueryOpts(paginationQuery) { + if (paginationQuery) { + return Object.entries(paginationQuery) + .map(([paramName, paramValue]) => { + if (Array.isArray(paramValue)) { + return paramValue.map(eachValue => `${paramName}=${eachValue}`).join('&'); + } + return `${paramName}=${paramValue}`; + }) + .join('&'); + } + return ''; +} diff --git a/src/main/webapp/app/shims-vue.d.ts b/src/main/webapp/app/shims-vue.d.ts new file mode 100644 index 0000000..5e264cc --- /dev/null +++ b/src/main/webapp/app/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import { type DefineComponent } from 'vue'; + const component: DefineComponent & any; + export default component; +} diff --git a/src/main/webapp/app/store.ts b/src/main/webapp/app/store.ts new file mode 100644 index 0000000..2c584eb --- /dev/null +++ b/src/main/webapp/app/store.ts @@ -0,0 +1,5 @@ +import { useAccountStore as useStore } from '@/shared/config/store/account-store'; +export type AccountStore = ReturnType; +export { useStore }; + +export { useTranslationStore } from '@/shared/config/store/translation-store'; diff --git a/src/main/webapp/app/test-setup.ts b/src/main/webapp/app/test-setup.ts new file mode 100644 index 0000000..1e07607 --- /dev/null +++ b/src/main/webapp/app/test-setup.ts @@ -0,0 +1,22 @@ +import { beforeAll } from 'vitest'; +import { createI18n } from 'vue-i18n'; + +import { config } from '@vue/test-utils'; +import axios from 'axios'; + +beforeAll(() => { + globalThis.location.href = 'https://jhipster.tech/'; + + // Make sure axios is never executed. + axios.interceptors.request.use(request => { + throw new Error(`Error axios should be mocked ${request.url}`); + }); + + config.global.plugins.push( + createI18n({ + legacy: false, + missingWarn: false, + fallbackWarn: false, + }), + ); +}); diff --git a/src/main/webapp/content/css/loading.css b/src/main/webapp/content/css/loading.css new file mode 100644 index 0000000..fa99866 --- /dev/null +++ b/src/main/webapp/content/css/loading.css @@ -0,0 +1,152 @@ +@keyframes lds-pacman-1 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 50% { + -webkit-transform: rotate(-45deg); + transform: rotate(-45deg); + } + 100% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } +} +@-webkit-keyframes lds-pacman-1 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 50% { + -webkit-transform: rotate(-45deg); + transform: rotate(-45deg); + } + 100% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } +} +@keyframes lds-pacman-2 { + 0% { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); + } + 50% { + -webkit-transform: rotate(225deg); + transform: rotate(225deg); + } + 100% { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); + } +} +@-webkit-keyframes lds-pacman-2 { + 0% { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); + } + 50% { + -webkit-transform: rotate(225deg); + transform: rotate(225deg); + } + 100% { + -webkit-transform: rotate(180deg); + transform: rotate(180deg); + } +} +@keyframes lds-pacman-3 { + 0% { + -webkit-transform: translate(190px, 0); + transform: translate(190px, 0); + opacity: 0; + } + 20% { + opacity: 1; + } + 100% { + -webkit-transform: translate(70px, 0); + transform: translate(70px, 0); + opacity: 1; + } +} +@-webkit-keyframes lds-pacman-3 { + 0% { + -webkit-transform: translate(190px, 0); + transform: translate(190px, 0); + opacity: 0; + } + 20% { + opacity: 1; + } + 100% { + -webkit-transform: translate(70px, 0); + transform: translate(70px, 0); + opacity: 1; + } +} + +.app-loading { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + top: 10em; +} +.app-loading p { + display: block; + font-size: 1.17em; + margin-inline-start: 0; + margin-inline-end: 0; + font-weight: normal; +} + +.app-loading .lds-pacman { + position: relative; + margin: auto; + width: 200px !important; + height: 200px !important; + -webkit-transform: translate(-100px, -100px) scale(1) translate(100px, 100px); + transform: translate(-100px, -100px) scale(1) translate(100px, 100px); +} +.app-loading .lds-pacman > div:nth-child(2) div { + position: absolute; + top: 40px; + left: 40px; + width: 120px; + height: 60px; + border-radius: 120px 120px 0 0; + background: #bbcedd; + -webkit-animation: lds-pacman-1 1s linear infinite; + animation: lds-pacman-1 1s linear infinite; + -webkit-transform-origin: 60px 60px; + transform-origin: 60px 60px; +} +.app-loading .lds-pacman > div:nth-child(2) div:nth-child(2) { + -webkit-animation: lds-pacman-2 1s linear infinite; + animation: lds-pacman-2 1s linear infinite; +} +.app-loading .lds-pacman > div:nth-child(1) div { + position: absolute; + top: 97px; + left: -8px; + width: 24px; + height: 10px; + background-image: url('/content/images/logo-jhipster.png'); + background-size: contain; + -webkit-animation: lds-pacman-3 1s linear infinite; + animation: lds-pacman-3 1.5s linear infinite; +} +.app-loading .lds-pacman > div:nth-child(1) div:nth-child(1) { + -webkit-animation-delay: -0.67s; + animation-delay: -1s; +} +.app-loading .lds-pacman > div:nth-child(1) div:nth-child(2) { + -webkit-animation-delay: -0.33s; + animation-delay: -0.5s; +} +.app-loading .lds-pacman > div:nth-child(1) div:nth-child(3) { + -webkit-animation-delay: 0s; + animation-delay: 0s; +} diff --git a/src/main/webapp/content/images/jhipster_family_member_0.svg b/src/main/webapp/content/images/jhipster_family_member_0.svg new file mode 100644 index 0000000..d6df83c --- /dev/null +++ b/src/main/webapp/content/images/jhipster_family_member_0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/webapp/content/images/jhipster_family_member_0_head-192.png b/src/main/webapp/content/images/jhipster_family_member_0_head-192.png new file mode 100644 index 0000000000000000000000000000000000000000..6d90ab36d37914257d5f3d0356e7895ccc0596f2 GIT binary patch literal 13439 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_0_head-256.png b/src/main/webapp/content/images/jhipster_family_member_0_head-256.png new file mode 100644 index 0000000000000000000000000000000000000000..8e99bfe66d5a0e4831fcdc4031c1a57ba9b9cb1a GIT binary patch literal 7037 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_0_head-384.png b/src/main/webapp/content/images/jhipster_family_member_0_head-384.png new file mode 100644 index 0000000000000000000000000000000000000000..c7ca46022548477085d9c10966c94131e7290df6 GIT binary patch literal 10350 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_1.svg b/src/main/webapp/content/images/jhipster_family_member_1.svg new file mode 100644 index 0000000..e3a0f3d --- /dev/null +++ b/src/main/webapp/content/images/jhipster_family_member_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/webapp/content/images/jhipster_family_member_1_head-192.png b/src/main/webapp/content/images/jhipster_family_member_1_head-192.png new file mode 100644 index 0000000000000000000000000000000000000000..ac5f2a00ac809809f7e48628d79e04849ebad98b GIT binary patch literal 7046 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 S!t<8`Kvhvo0VZc1_WuB}nB+SE literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_1_head-256.png b/src/main/webapp/content/images/jhipster_family_member_1_head-256.png new file mode 100644 index 0000000000000000000000000000000000000000..443822e8ea1f8db01adc11f33e8685fb14b997dc GIT binary patch literal 9505 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_1_head-384.png b/src/main/webapp/content/images/jhipster_family_member_1_head-384.png new file mode 100644 index 0000000000000000000000000000000000000000..4a5e9fe4775b79347fcf33c97a7ad10e3f693b2c GIT binary patch literal 15054 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 O0c0c;#cM>30{;*Cash(? literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_1_head-512.png b/src/main/webapp/content/images/jhipster_family_member_1_head-512.png new file mode 100644 index 0000000000000000000000000000000000000000..66c625c10626011b5e142ce32ca009cbaea5f0bb GIT binary patch literal 16456 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_2.svg b/src/main/webapp/content/images/jhipster_family_member_2.svg new file mode 100644 index 0000000..51c6a5a --- /dev/null +++ b/src/main/webapp/content/images/jhipster_family_member_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/webapp/content/images/jhipster_family_member_2_head-192.png b/src/main/webapp/content/images/jhipster_family_member_2_head-192.png new file mode 100644 index 0000000000000000000000000000000000000000..24baf78ce8326c3c2756f10ebed90829d0bddf30 GIT binary patch literal 5423 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 X%hVz6s@WHxK!C2Mp+=25A@Y9!nF9~R literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_2_head-256.png b/src/main/webapp/content/images/jhipster_family_member_2_head-256.png new file mode 100644 index 0000000000000000000000000000000000000000..7b25f52f93d109da2df18f8442ef2104a6e14b8f GIT binary patch literal 6687 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 y!w`2PZFekY^= literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_2_head-384.png b/src/main/webapp/content/images/jhipster_family_member_2_head-384.png new file mode 100644 index 0000000000000000000000000000000000000000..e89e120c2f015ccb140678ffff8ccbacb1ae1f20 GIT binary patch literal 9682 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 V^p!DYUi_f~=xZBm)oD1z{4Y|2cgz3) literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_2_head-512.png b/src/main/webapp/content/images/jhipster_family_member_2_head-512.png new file mode 100644 index 0000000000000000000000000000000000000000..3c0e6cb9af31e2e46d9e9b6c8cec99c0d4c5e5c6 GIT binary patch literal 10514 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 literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_3.svg b/src/main/webapp/content/images/jhipster_family_member_3.svg new file mode 100644 index 0000000..cc0d01f --- /dev/null +++ b/src/main/webapp/content/images/jhipster_family_member_3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/webapp/content/images/jhipster_family_member_3_head-192.png b/src/main/webapp/content/images/jhipster_family_member_3_head-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b1e4fb3c8654570012950a3c888f11cb92c23bdd GIT binary patch literal 6148 zcmX9?byyT#7vEiKfu&PA1Q9_{DGBLrMClFz0i}^#xGtI{Ae>CR4$78EFu+6l0FtIR&y4#K=GJKPfaC2Az;5Iy*i7$J$w0QlZAg7#)oMIR^0Gzyuhh(_{1` z28rHZ*P_R;&Q6Xo`j~Nie6oR2LmyytHvU%gRC)-ricUNau7pE8Jr#q<{_ji}$VtAML_d>zlN5{wfF2(0Jcjs4E zh^>94xIUSvo`Zb5qlNKtVc-D@y|s^;d;5@1i6pmsSu?SJKgwp#?Ahtg+6i*WA$OG9 zr(^HC;n~sQ?Aq?w()LMr)>M|qlrmN07!q1B{E8Q!y6+%T`zM@GAZ21n%Qqn z<3YuJ`-QRDh8m(4{IeQ#tnczD&9b&jlLFp*yMO*@6>O~GA`jpOOv8#ZVd?QdV)spz zYBgUVHR&+b+0bQP)p&4J9lR5$x-LhAP^0-*Q&lQVrM6x(p|YJ1GwiqQ1$V=Aj%q_^ zBC0R$lZA`(+ljB3*ndG^2SZclRxmb+ZogR{4xSI0vvO!OD zGz3FE3|{4f@V{1Es%QESscxt>3qIy?!3{~z8ZZw;j}lTTZ^k1qbSu%&}QE~eAaG| z+Ag0KJ4biML$tsGA_o&S}pHmUHTt)pGk9}2LqJFR3w@?SKN}#@%2vm z&=gj;vN{-Gl;(wK&kU(2QhkE3^E{!F^Jexll{wBbh)&rMFO8s`sD{K9o6_$|m!>O- z2UMt=jocC~fLe~WC)NN%)gBkr^l-;Q*a>JT!`ufnrT3q-697v2Vinox2qSjhY=iv8 zf#2fuw_G?JWZSHRM6;q_2|*YM?cZ32Mdhyb6A)gu{Ka)2zQM17vpK)FAuwL~q$b-& z{;~p}9*}3KFA^)tv2j6%169O+;~Oh~@}xSnt_|;!)yrrvUt{-kHLa85R}|b0mz221RTip1I?W(BL=! z3%^p%`PxeF29Qf{Y2PWw4}M&4r)<{!t1h5hh;yXoTROP^i^23=OH!#E*5%=3Tuj$> z4gOE8Aw^V%pw5h(+gxS4HQHG{N2=?th#||{i3rO z57Fa1=F}0quMb*-V$s@i)t7@EVw?rw6H34M?F6Cr>yY&0sHnB(HSe;# z@Kvra>@$;ZVz@P&Y?`W(=5?j@`>g7D(-HzoE!8q|Gtnv!lcQ7Mcx?L{pXtqiMC+Kr zjnx)=@ff;012y_N0w>?9ZoE7m9Ml;RcSyGogv3V787=#^8f>%~I8?H?T^d8@2ComU zZv#Jo=XP*{!a&I=D7*_#f0FgUXe@r-rFk?>UGlBTA{@NpDFrrai6)ye5h-t@qoRj? zyvANoc{K|fePN}BUtF=mNj5*b1SfNrr*s^rB;y>6dx#t4%otR=s~f|~$@#TaoS*;W zr{7mOB{NNKy%eh zIiSXiKS*^)MRoPvQUEW`sd7$wKcjbT6b^8SD{YXzk)t;CNm>KNJWl~{jYolHbomKZ z{{~Lk4@t)iJP61~H2642ZhS=cMpy3Wrh)jYXz(GR=kod7gi)jVFNl7*0;?jsLFZ*C;*XvuSEKx~AaP(y;4PgC&f6re~NT<5LN?(0hJCXxLJ7?-JT zoJL8|%XTBDPZG83qeIHZ7VfZa>}OFI!Yl@~T92aCZwZ}K(}A|gOQ{vsU8B$%rpD)c zhtgOpeL+mrK{BQIK%r|Ffp2LMyL|?bm0A z=ZPK@45?$(Cn}0~$!*u)BFY1?$sMEYu{ZrZKO8Y?G6lFF#|kZpW`>6xN`YQaYBQ8T zrZNXY(FA(7Ac8R5x6+*`#0to#Jr#$qu2E?D(v903rbSDV$RH6x7#7S8wZM`!78>kL z7%54U_Pn%=peLiowy^Tk0<-YqoYp-1`CRtxBEB_BZL4(o@q0?35@Ru0h94V^EX2%6EZ?+8GR?<()vFQ5?gbrF z$04ZEBk6168a>__$z7TZ)YFvABXkwS9l5MVto6H0yN0;l{-RzJ+HLyctcII2OYED^ zng366T`ijeou;rf2O)m;`Ren?HQ%T&UDU6&_ZeQEd($)uH5ixZ+S*d5rGB{2|J`xK zJHmOuq_>6&T$PfO&Ht`vrjspx{Y}Z>ew^U;h~{v~B$nIj2@h!y;V-lkXO9rX%44vy zrpj4}VOPue!svr0;s>_7Sf+u2sbOY@K?eS^Vv9T}DPTsx>BE#8kzvElSv_z_d}_wv-qDyr!98gMVMmB{qq_3@?bCYha zW!~=#zw0$!wg(bCLrmP?@f>z#-=U8sCBIqI0uaR3B=QG5@DIy}SCyX449+>Mwi~Z6 zo#L|ZZs)9R%(C{72?ft^aX{18(dI4X`#XKiW=sS_iqM}*59#6*x;hbjVBxz{3UP54 zFlDUVL|_YND)NK1E=T_I?9;6K#KR*K*B>7)IdFQrQcg!0_xAQ$8~Me^2`oUIZfgp- zOj+h!qGCdV?K(p-<6K(Q1-2ED?*^F}8JP`6gqH=xEPg8#k;0o>u~zDqXmt|{~n$l*`)FzFM_#reYW?M!-2z3Og5>j%89kcWB?!@K(YDOwdv2< z4Kv~mg|2i~^6w_u#21NgnOr!?0-TD6@)!}n_Qs+ZSkrd)H-V$05E$F8-J93i;88Ew zoKeA_*@5FuQgLHem?)ANW#wPf7yWWp#f_gPcZ9PIm&(N92Z@$Z%EvA%Fk%{v4c`Cd zGr~pf+x%jdX3xlo=}d3?nE6b<0v_`zp7$u?k)nZiBU2ytf#*EHBS$6iljNi~-OA6C zQpzNa@m1*k&-P9y?7>YrZEga-Bz$N}qdVGoI$>g%6q}YVl=XAKd|B?FN~{15c<;AY zd6_hgU5jG3IVyT~xH2>%`3M#eY6O=f{kp-o?&K5(LLBp%A~lxJ1o4M|OFs(; z0`3W~O6qi!FY%2HhRYQ37)fwLQIsHB zLLh%J6{sp@u6@b|r<26@rN)pA4l$Nr3V^_04krh%n9@-08Eh6WxW)ncXJI7Yf)w}t z8dkIbck-AnAE7O4m~DU}7QC|nU=t?+lHW3km|BjeBP=m)twbFA%a)M+{RjM3?5E%M0QD*U&u&0#x z-cXcWcQO`%xIsb5b25>0GpeMC0h~^_bUPscexh7^sbTR3J>sh_DZ&QLuXZ;_?EbXn_b_!u~b%n zFjUD)F8BH5yZfZEP=V$Qm|-o@Hi8Up911bF1wHKmXM1Mz*x%Q50@R&zN(QuEYJy>* zS$aovj(6*V`G4$+#MCVd`l?!#e)Mb4s%^U6^d_|M$BkF^oYG4978^hM8#Kh#{EmIY z^c^i@FLCJvgt}gl2EYnGb9rEzDDqu@+{*D;BPkXmuen#E8ifpUNlS+WZc!vLYUNAW z5@Vgh!V&o?${Z`firZiwg7dKi2$P6jlxOp={zKFB+(^VP49{|aJ;?S+#J=|W(=U(F zB0VYtjQiU_Kz1%bip1QRwk1Z+vRy5=avjX)0Thje1PkS0QuvqfMH1y6`w{(&2QX7; zKpDJ{KWp;vz0zwP>`i%RtOZwxjBi|;SdCvSyb|TmyIwEdm$8H=YQ=U$bINS?o_^s% zBlt&FTog0~lWVyC6BJ60QrtR2dMYsI#8(sr1Fe1CGa)(mKt1vmyFj8|XcmNltF0N3 zi-TcDW~ilHPP|SHGf>itI}xX+HfFDcaD7a~B9J4ysQ*&Bnukby3?{HAHn0A9Jya|ut( zGW8s=MSW33jgM|mFK|hmCL8nCNkspZf4tP+si0U@S?ddc=ujpH%UGT7k|47NfcLwROCn5f@nso#rr04BJhX zU@OG;RP4|he9)fH-?h*QX$Ip3-$Jm86w4I9!AIZudu*5i=)SA?x&uGsqFsnhPPg#j zi?(i~JU04*rVs>7l=6(Lq~-TtvYLq2$v>TCOj$xLyT&}G^h^hBo|WTFZGtjjmYfl> zTt=9_`l|tIzPD-l<6HGy`(tPUR=CN9HmqC>$G4@gXY+p@SlYHE^HQo@-;%-$&q;^XkP06Dsu3k7F22dL zCTfmVbD(=%_(6{Tz-0B;W~wu;rWTP;+|=F{fnS0E&BJjq(-Qhd-~Y3bmif9%D6`|j z&w|SEUlgvsrvW-yUwA}s>Vy+z3%E(ZBNVmK;VMCG^xcxq;59E86Y?djda7}B!{v1I=2#m8&pcs376 z?C#lDXDo%>d0z%U_{@E|zS8E640ucOQS6(T!_qEcRJ*NAct(M>B-7G2zjC38>(ug+ zx@nnIRqdv_udf~odN#LejI;o}GnZ0{&62jeC*+M}8%B_*ErqbT)$+3aiUtz_e|Yu6 z&$!ca36plwMt0{$hlrUe(}k&t`B7^@=ly#;P0so5KKu_hn8Qtgvb=^|g^YRd{{U3N B<5&Oy literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_3_head-256.png b/src/main/webapp/content/images/jhipster_family_member_3_head-256.png new file mode 100644 index 0000000000000000000000000000000000000000..aa058c7a0aba654a4991c8bbd5d3e2d32517acd1 GIT binary patch literal 8028 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 I)*|@-0J^?0w*UYD literal 0 HcmV?d00001 diff --git a/src/main/webapp/content/images/jhipster_family_member_3_head-384.png b/src/main/webapp/content/images/jhipster_family_member_3_head-384.png new file mode 100644 index 0000000000000000000000000000000000000000..1d10bd5da3d4d417aedfd3bf2e8515eff092c506 GIT binary patch literal 11998 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 element. + +$body-bg: #ffffff; + +// Typography: +// Font, line-height, and color for body text, headings, and more. + +$font-size-base: 1rem; + +$dropdown-link-hover-color: white; +$dropdown-link-hover-bg: #343a40; diff --git a/src/main/webapp/content/scss/global.scss b/src/main/webapp/content/scss/global.scss new file mode 100644 index 0000000..97e1fd9 --- /dev/null +++ b/src/main/webapp/content/scss/global.scss @@ -0,0 +1,288 @@ +@import 'bootstrap-variables'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +// Import Bootstrap source files from node_modules +@import 'bootstrap/scss/bootstrap'; +@import 'bootstrap-vue-next/dist/bootstrap-vue-next.css'; + +/* Up to bootstrap-vue-next 0.45 the orchestrator mapped a toast position such as `top-end` to the +Bootstrap utilities `top-0 end-0`. Since 0.46 it sets the raw position as the container class, and no +stylesheet defines it, so toast containers are left unpositioned. Drop this once upstream maps them again. */ +.toast-container { + &.top-start { + top: 0; + left: 0; + } + &.top-center { + top: 0; + left: 50%; + transform: translateX(-50%); + } + &.top-end { + top: 0; + right: 0; + } + &.middle-start { + top: 50%; + left: 0; + transform: translateY(-50%); + } + &.middle-center { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + &.middle-end { + top: 50%; + right: 0; + transform: translateY(-50%); + } + &.bottom-start { + bottom: 0; + left: 0; + } + &.bottom-center { + bottom: 0; + left: 50%; + transform: translateX(-50%); + } + &.bottom-end { + bottom: 0; + right: 0; + } +} + +/* ============================================================== +Bootstrap tweaks +===============================================================*/ + +body, +h1, +h2, +h3, +h4 { + font-weight: 300; +} + +/* Increase contrast of links to get 100% on Lighthouse Accessibility Audit. Override this color if you want to change the link color, or use a Bootswatch theme */ +a { + color: #533f03; + font-weight: bold; +} + +/* override hover color for dropdown-item forced by bootstrap to all a:not([href]):not([tabindex]) elements in _reboot.scss */ +a:not([href]):not([tabindex]):hover.dropdown-item { + color: $dropdown-link-hover-color; +} + +/* override .dropdown-item.active background-color on hover */ +.dropdown-item.active:hover { + background-color: mix($dropdown-link-hover-bg, $dropdown-link-active-bg, 50%); +} + +a:hover { + color: #533f03; + /* make sure browsers use the pointer cursor for anchors, even with no href */ + cursor: pointer; +} + +.dropdown-item:hover { + color: $dropdown-link-hover-color; +} + +/* ========================================================================== +Browser Upgrade Prompt +========================================================================== */ +.browserupgrade { + margin: 0.2em 0; + background: #ccc; + color: #000; + padding: 0.2em 0; +} + +/* ========================================================================== +Main page styles +========================================================================== */ + +.hipster.img-fluid { + display: inline-block; + width: 347px; + height: 497px; + background: url('/content/images/jhipster_family_member_2.svg') no-repeat center top; + background-size: contain; +} + +/* wait autoprefixer update to allow simple generation of high pixel density media query */ +@media only screen and (-webkit-min-device-pixel-ratio: 2), + only screen and (min--moz-device-pixel-ratio: 2), + only screen and (-o-min-device-pixel-ratio: 2/1), + only screen and (min-device-pixel-ratio: 2), + only screen and (min-resolution: 192dpi), + only screen and (min-resolution: 2dppx) { + .hipster { + background: url('/content/images/jhipster_family_member_2.svg') no-repeat center top; + background-size: contain; + } +} + +/* ========================================================================== +Generic styles +========================================================================== */ +.jh-card { + padding: 1.5%; + margin-top: 20px; + border: none; +} + +.error { + color: white; + background-color: red; +} + +.pad { + padding: 10px; +} + +.w-40 { + width: 40% !important; +} + +.w-60 { + width: 60% !important; +} + +.break { + white-space: normal; + word-break: break-all; +} + +.form-control { + background-color: #fff; +} + +.readonly { + background-color: #eee; + opacity: 1; +} + +.footer { + border-top: 1px solid rgba(0, 0, 0, 0.125); +} + +.hand, +[jhisortby] { + cursor: pointer; +} + +/* ========================================================================== +Custom alerts for notification +========================================================================== */ +.alerts { + .alert { + text-overflow: ellipsis; + pre { + background: none; + border: none; + font: inherit; + color: inherit; + padding: 0; + margin: 0; + } + .popover pre { + font-size: 10px; + } + } + .jhi-toast { + position: fixed; + width: 100%; + &.left { + left: 5px; + } + &.right { + right: 5px; + } + &.top { + top: 55px; + } + &.bottom { + bottom: 55px; + } + } +} + +@media screen and (min-width: 480px) { + .alerts .jhi-toast { + width: 50%; + } +} + +/* ========================================================================== +entity list page css +========================================================================== */ + +.table-entities thead th .d-flex > * { + margin: auto 0; +} + +/* ========================================================================== +entity detail page css +========================================================================== */ +.row-md.jh-entity-details { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 10px; + line-height: 1.5; +} + +@media screen and (min-width: 768px) { + .row-md.jh-entity-details > { + dt { + float: left; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0.5em 0; + } + dd { + border-bottom: 1px solid #eee; + padding: 0.5em 0; + margin-left: 0; + } + } +} + +/* ========================================================================== +ui bootstrap tweaks +========================================================================== */ +.nav, +.pagination, +.carousel, +.panel-title a { + cursor: pointer; +} + +.thread-dump-modal-lock { + max-width: 450px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-menu { + padding-left: 0; +} + +// Add space between icon and text in dropdowns +.dropdown-menu svg:first-child, +.btn svg:first-child, +.nav-item span svg:first-child { + margin-right: 0.4em; +} + +th svg:last-child { + margin-left: 0.4em; +} + +/* jhipster-needle-scss-add-main JHipster will add new css style */ diff --git a/src/main/webapp/content/scss/vendor.scss b/src/main/webapp/content/scss/vendor.scss new file mode 100644 index 0000000..25e7e88 --- /dev/null +++ b/src/main/webapp/content/scss/vendor.scss @@ -0,0 +1,8 @@ +/* after changing this file run 'npm run webapp:build' */ + +/*************************** +put Sass variables here: +eg $input-color: red; +****************************/ + +/* jhipster-needle-scss-add-vendor JHipster will add new css style */ diff --git a/src/main/webapp/favicon.ico b/src/main/webapp/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4179874f53b3c3b0ba9e2a401412f814ab6296bd GIT binary patch literal 1574 zcmV+>2HE+EP)000H;Nkl9r4a2J+cS@yzSX6E!^*k!$-D-4;C?DsI4Z2te8^PT@c z=RfD)VMK(b3{GnU7K#)Bt&t+2HBtn$Mv8#eNDUS>&~)z8>N1c(q8rNd{sPjn1MM2QHDA^sG2W;HKsEVpi{%G+8~m}54A zpq~8zXet!#9Gbj-*V8>RHQ@_Oa=ck5fDynmyijTRbXS$xsGFssxLtW3`my8G-!>%7 zn;n<%&U37xG-qc+$&NL}Ic8(r8P8{L>@pz`5wF~FxA(hl3{Q#@0mJ|T!>orWW&$y= zwZ)lV?q9=mOj&=001@Fo=j2<5|CsUovve~87<4?ht)^g4)54xHIM_fH6g9vTH~{$6e3n@@!>>0A((rb8tLK5s#V0 zMm+wn&))p*T>q|hCGR#@SL9}ZK#Tw|V#*5$4}y`?UCz_p4!1wNkO1l#@e&9G#+VYs zEG&vW!|wTsW3iPFM8p#vgQq%eFRm{7prxk1*aAS&G~ti@^w-GQ9%mpd0W^=8Nrc@q z?FmG(O?n~{0D#CgKIQg@lG#5`2LTaLZtu09`*&npbwOKeU4B;jv8(n!ddI?|{BT~F zzml*h^*el9D=mnp(SK|%RkC6{JP5dS>;C22j@;ZLQI=y-twP>a1a%F2w^mvhW1N!C zItRzq%;^AP-WFw5y#6R|j(8Qh9Dt}K_u4&+p(d6Y7r5u2fMIuVYB~yqz^KPR<_)T> zVUZCQ2K>q>IWj4>kGx1p%HD(9OEwu=KUlGU-EF%U2| z6%r-`VTl%YwkK@x(j4)7%q9E||Yz*Vu z-KgXDY^wP1l~c9KAAp;kIku<%ZR4V3H)gcci^L zxuYQ7iwPXJy}sz+_S%}ltQn(|jw6aU+5iCCMBw-}`=x=2s3a#J7eur?P5(n%lfW3; zzojxs0t_(d_}%ME4>VV=%*&kl@mY?4RLIPDrr1%QWBTmX>U-{zUphzI`^LkPoTQkY z^?7=MW3nvEM4$hB{Zyw7M3hi@DgLIIc}3Z#J)0`_Hm$Unjj=qofc>keP`dJ1mj{ks?4R(3kPwGF$MQ4Nw$&8u zY$#b@Zsp>+MfD;l;gbiMsB74J{+6r5=JEI=N`S;Tl0o2i)aJImRADmkV6kfzMM5Yl zb`4GR+C9Ed)T9?ySkhM)WtCdZJg310p5oRacks5uH}YWG9~KQdzS3&iP?nXGu8&`< zB@h73l>ryEsGJ*5{SM`E0!tK2{&F`(Kx?E3XpIyBt&t+2HBtn$Mv8#eNDYour user account has been activated. Please ", + "error": "Your user could not be activated. Please use the registration form to sign up." + } + } +} diff --git a/src/main/webapp/i18n/en/configuration.json b/src/main/webapp/i18n/en/configuration.json new file mode 100644 index 0000000..09c9a1a --- /dev/null +++ b/src/main/webapp/i18n/en/configuration.json @@ -0,0 +1,10 @@ +{ + "configuration": { + "title": "Configuration", + "filter": "Filter (by prefix)", + "table": { + "prefix": "Prefix", + "properties": "Properties" + } + } +} diff --git a/src/main/webapp/i18n/en/en.js b/src/main/webapp/i18n/en/en.js new file mode 100644 index 0000000..77f1471 --- /dev/null +++ b/src/main/webapp/i18n/en/en.js @@ -0,0 +1,3 @@ +import { all } from 'deepmerge'; + +export default all(Object.values(import.meta.glob('./*.json', { import: 'default', eager: true }))); diff --git a/src/main/webapp/i18n/en/error.json b/src/main/webapp/i18n/en/error.json new file mode 100644 index 0000000..d257563 --- /dev/null +++ b/src/main/webapp/i18n/en/error.json @@ -0,0 +1,14 @@ +{ + "error": { + "title": "Error page!", + "http": { + "400": "Bad request.", + "403": "You are not authorized to access this page.", + "404": "The page does not exist.", + "405": "The HTTP verb you used is not supported for this URL.", + "500": "Internal server error." + }, + "concurrencyFailure": "Another user modified this data at the same time as you. Your changes were rejected.", + "validation": "Validation error on the server." + } +} diff --git a/src/main/webapp/i18n/en/global.json b/src/main/webapp/i18n/en/global.json new file mode 100644 index 0000000..cba121e --- /dev/null +++ b/src/main/webapp/i18n/en/global.json @@ -0,0 +1,147 @@ +{ + "global": { + "title": "QuestionBank", + "browsehappy": "You are using an outdated browser. Please upgrade your browser to improve your experience.", + "menu": { + "home": "Home", + "jhipster-needle-menu-add-element": "JHipster will add additional menu entries here (do not translate!)", + "entities": { + "main": "Entities", + "jhipster-needle-menu-add-entry": "JHipster will add additional entities here (do not translate!)" + }, + "account": { + "main": "Account", + "settings": "Settings", + "password": "Password", + "sessions": "Sessions", + "login": "Sign in", + "logout": "Sign out", + "register": "Register" + }, + "admin": { + "main": "Administration", + "userManagement": "User management", + "tracker": "User tracker", + "metrics": "Metrics", + "health": "Health", + "configuration": "Configuration", + "logs": "Logs", + "apidocs": "API", + "database": "Database", + "jhipster-needle-menu-add-admin-element": "JHipster will add additional menu entries here (do not translate!)" + }, + "language": "Language" + }, + "form": { + "username.label": "Username", + "username.placeholder": "Your username", + "currentpassword.label": "Current password", + "currentpassword.placeholder": "Current password", + "newpassword.label": "New password", + "newpassword.placeholder": "New password", + "confirmpassword.label": "New password confirmation", + "confirmpassword.placeholder": "Confirm the new password", + "email.label": "Email", + "email.placeholder": "Your email" + }, + "messages": { + "info": { + "authenticated": { + "prefix": "If you want to ", + "link": "sign in", + "suffix": ", you can try the default accounts:
- Administrator (login=\"admin\" and password=\"admin\")
- User (login=\"user\" and password=\"user\")." + }, + "register": { + "noaccount": "You don't have an account yet?", + "link": "Register a new account" + } + }, + "error": { + "dontmatch": "The password and its confirmation do not match!" + }, + "validate": { + "newpassword": { + "required": "Your password is required.", + "minlength": "Your password is required to be at least 4 characters.", + "maxlength": "Your password cannot be longer than 50 characters.", + "strength": "Password strength:" + }, + "confirmpassword": { + "required": "Your confirmation password is required.", + "minlength": "Your confirmation password is required to be at least 4 characters.", + "maxlength": "Your confirmation password cannot be longer than 50 characters." + }, + "email": { + "required": "Your email is required.", + "invalid": "Your email is invalid.", + "minlength": "Your email is required to be at least 5 characters.", + "maxlength": "Your email cannot be longer than 50 characters." + } + } + }, + "field": { + "id": "ID" + }, + "ribbon": { + "dev": "Development" + }, + "item-count": "Showing { first } - { second } of { total } items." + }, + "entity": { + "action": { + "addblob": "Add blob", + "addimage": "Add image", + "back": "Back", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "open": "Open", + "save": "Save", + "view": "View", + "show": "Show { otherEntity }" + }, + "detail": { + "field": "Field", + "value": "Value" + }, + "delete": { + "title": "Confirm delete operation" + }, + "validation": { + "required": "This field is required.", + "minlength": "This field is required to be at least { min } characters.", + "maxlength": "This field cannot be longer than { max } characters.", + "min": "This field should be at least { min }.", + "max": "This field cannot be more than { max }.", + "minbytes": "This field should be at least { min } bytes.", + "maxbytes": "This field cannot be more than { max } bytes.", + "pattern": "This field should follow pattern for { pattern }.", + "number": "This field should be a number.", + "datetimelocal": "This field should be a date and time.", + "patternLogin": "This field can only contain letters, digits and e-mail addresses." + }, + "filters": { + "set": "Following filters are set", + "clear": "Clear filter", + "clearAll": "Clear all filters" + } + }, + "error": { + "internalServerError": "Internal server error", + "server.not.reachable": "Server not reachable", + "url.not.found": "Not found", + "NotNull": "Field { fieldName } cannot be empty!", + "Size": "Field { fieldName } does not meet min/max size requirements!", + "userexists": "Login name already used!", + "emailexists": "Email is already in use!", + "idexists": "A new { entityName } cannot already have an ID", + "idnull": "Invalid ID", + "idinvalid": "Invalid ID", + "idnotfound": "ID cannot be found", + "file": { + "could.not.extract": "Could not extract file", + "not.image": "File was expected to be an image but was found to be \"{ fileType }\"" + } + }, + "footer": "This is your footer" +} diff --git a/src/main/webapp/i18n/en/health.json b/src/main/webapp/i18n/en/health.json new file mode 100644 index 0000000..0a0a4d4 --- /dev/null +++ b/src/main/webapp/i18n/en/health.json @@ -0,0 +1,32 @@ +{ + "health": { + "title": "Health Checks", + "refresh.button": "Refresh", + "stacktrace": "Stacktrace", + "details": { + "details": "Details", + "properties": "Properties", + "name": "Name", + "value": "Value", + "error": "Error" + }, + "indicator": { + "diskSpace": "Disk space", + "ssl": "SSL", + "mail": "Email", + "livenessState": "Liveness state", + "readinessState": "Readiness state", + "ping": "Application", + "db": "Database" + }, + "table": { + "service": "Service name", + "status": "Status" + }, + "status": { + "UNKNOWN": "UNKNOWN", + "UP": "UP", + "DOWN": "DOWN" + } + } +} diff --git a/src/main/webapp/i18n/en/home.json b/src/main/webapp/i18n/en/home.json new file mode 100644 index 0000000..bcae837 --- /dev/null +++ b/src/main/webapp/i18n/en/home.json @@ -0,0 +1,19 @@ +{ + "home": { + "title": "Welcome, Java Hipster!", + "subtitle": "This is your homepage", + "logged": { + "message": "You are logged in as user \"{ username }\"." + }, + "question": "If you have any questions on JHipster:", + "link": { + "homepage": "JHipster homepage", + "stackoverflow": "JHipster on Stack Overflow", + "bugtracker": "JHipster bug tracker", + "chat": "JHipster public chat room", + "follow": "follow {'@'}jhipster on Twitter" + }, + "like": "If you like JHipster, don't forget to give us a star on", + "github": "GitHub" + } +} diff --git a/src/main/webapp/i18n/en/login.json b/src/main/webapp/i18n/en/login.json new file mode 100644 index 0000000..4667958 --- /dev/null +++ b/src/main/webapp/i18n/en/login.json @@ -0,0 +1,19 @@ +{ + "login": { + "title": "Sign in", + "form": { + "password": "Password", + "password.placeholder": "Your password", + "rememberme": "Remember me", + "button": "Sign in" + }, + "messages": { + "error": { + "authentication": "Failed to sign in! Please check your credentials and try again." + } + }, + "password": { + "forgot": "Did you forget your password?" + } + } +} diff --git a/src/main/webapp/i18n/en/logs.json b/src/main/webapp/i18n/en/logs.json new file mode 100644 index 0000000..638e6d0 --- /dev/null +++ b/src/main/webapp/i18n/en/logs.json @@ -0,0 +1,11 @@ +{ + "logs": { + "title": "Logs", + "nbloggers": "There are { total } loggers.", + "filter": "Filter", + "table": { + "name": "Name", + "level": "Level" + } + } +} diff --git a/src/main/webapp/i18n/en/metrics.json b/src/main/webapp/i18n/en/metrics.json new file mode 100644 index 0000000..e192762 --- /dev/null +++ b/src/main/webapp/i18n/en/metrics.json @@ -0,0 +1,102 @@ +{ + "metrics": { + "title": "Application Metrics", + "refresh.button": "Refresh", + "updating": "Updating...", + "jvm": { + "title": "JVM Metrics", + "memory": { + "title": "Memory", + "total": "Total Memory", + "heap": "Heap Memory", + "nonheap": "Non-Heap Memory" + }, + "threads": { + "title": "Threads", + "all": "All", + "runnable": "Runnable", + "timedwaiting": "Timed waiting", + "waiting": "Waiting", + "blocked": "Blocked", + "dump": { + "title": "Threads dump", + "id": "Id: ", + "blockedtime": "Blocked Time", + "blockedcount": "Blocked Count", + "waitedtime": "Waited Time", + "waitedcount": "Waited Count", + "lockname": "Lock name", + "stacktrace": "Stacktrace", + "show": "Show Stacktrace", + "hide": "Hide Stacktrace" + } + }, + "gc": { + "title": "Garbage collections", + "marksweepcount": "Mark Sweep count", + "marksweeptime": "Mark Sweep time", + "scavengecount": "Scavenge count", + "scavengetime": "Scavenge time" + }, + "http": { + "title": "HTTP requests (time in milliseconds)", + "active": "Active requests:", + "total": "Total requests:", + "table": { + "code": "Code", + "count": "Count", + "mean": "Mean", + "average": "Average", + "max": "Max" + }, + "code": { + "ok": "Ok", + "notfound": "Not found", + "servererror": "Server Error" + } + } + }, + "servicesstats": { + "title": "Services statistics (time in milliseconds)", + "table": { + "name": "Service name", + "count": "Count", + "mean": "Mean", + "min": "Min", + "max": "Max", + "p50": "p50", + "p75": "p75", + "p95": "p95", + "p99": "p99" + } + }, + "cache": { + "title": "Cache statistics", + "cachename": "Cache name", + "hits": "Cache Hits", + "misses": "Cache Misses", + "gets": "Cache Gets", + "puts": "Cache Puts", + "removals": "Cache Removals", + "evictions": "Cache Evictions", + "hitPercent": "Cache Hit %", + "missPercent": "Cache Miss %", + "averageGetTime": "Average get time (µs)", + "averagePutTime": "Average put time (µs)", + "averageRemoveTime": "Average remove time (µs)" + }, + "datasource": { + "usage": "Connection Pool Usage", + "title": "DataSource statistics (time in milliseconds)", + "name": "Pool usage", + "count": "Count", + "mean": "Mean", + "min": "Min", + "max": "Max", + "p50": "p50", + "p75": "p75", + "p95": "p95", + "p99": "p99" + } + } +} diff --git a/src/main/webapp/i18n/en/password.json b/src/main/webapp/i18n/en/password.json new file mode 100644 index 0000000..cc7196a --- /dev/null +++ b/src/main/webapp/i18n/en/password.json @@ -0,0 +1,12 @@ +{ + "password": { + "title": "Password for [{ username }]", + "form": { + "button": "Save" + }, + "messages": { + "error": "An error has occurred! The password could not be changed.", + "success": "Password changed!" + } + } +} diff --git a/src/main/webapp/i18n/en/register.json b/src/main/webapp/i18n/en/register.json new file mode 100644 index 0000000..1a0aeec --- /dev/null +++ b/src/main/webapp/i18n/en/register.json @@ -0,0 +1,24 @@ +{ + "register": { + "title": "Registration", + "form": { + "button": "Register" + }, + "messages": { + "validate": { + "login": { + "required": "Your username is required.", + "minlength": "Your username is required to be at least 1 character.", + "maxlength": "Your username cannot be longer than 50 characters.", + "pattern": "Your username is invalid." + } + }, + "success": "Registration saved! Please check your email for confirmation.", + "error": { + "fail": "Registration failed! Please try again later.", + "userexists": "Login name already registered! Please choose another one.", + "emailexists": "Email is already in use! Please choose another one." + } + } + } +} diff --git a/src/main/webapp/i18n/en/reset.json b/src/main/webapp/i18n/en/reset.json new file mode 100644 index 0000000..062d3e7 --- /dev/null +++ b/src/main/webapp/i18n/en/reset.json @@ -0,0 +1,26 @@ +{ + "reset": { + "request": { + "title": "Reset your password", + "form": { + "button": "Reset password" + }, + "messages": { + "info": "Enter the email address you used to register", + "success": "Check your email for details on how to reset your password." + } + }, + "finish": { + "title": "Reset password", + "form": { + "button": "Validate new password" + }, + "messages": { + "info": "Choose a new password", + "success": "Your password has been reset. Please ", + "keymissing": "The reset key is missing.", + "error": "Your password couldn't be reset. Remember a password request is only valid for 24 hours." + } + } + } +} diff --git a/src/main/webapp/i18n/en/sessions.json b/src/main/webapp/i18n/en/sessions.json new file mode 100644 index 0000000..ff7a284 --- /dev/null +++ b/src/main/webapp/i18n/en/sessions.json @@ -0,0 +1,15 @@ +{ + "sessions": { + "title": "Active sessions for [{ username }]", + "table": { + "ipaddress": "IP address", + "useragent": "User Agent", + "date": "Date", + "button": "Invalidate" + }, + "messages": { + "success": "Session invalidated!", + "error": "An error has occurred! The session could not be invalidated." + } + } +} diff --git a/src/main/webapp/i18n/en/settings.json b/src/main/webapp/i18n/en/settings.json new file mode 100644 index 0000000..c8dd74f --- /dev/null +++ b/src/main/webapp/i18n/en/settings.json @@ -0,0 +1,32 @@ +{ + "settings": { + "title": "User settings for [{ username }]", + "form": { + "firstname": "First Name", + "firstname.placeholder": "Your first name", + "lastname": "Last Name", + "lastname.placeholder": "Your last name", + "language": "Language", + "button": "Save" + }, + "messages": { + "error": { + "fail": "An error has occurred! Settings could not be saved.", + "emailexists": "Email is already in use! Please choose another one." + }, + "success": "Settings saved!", + "validate": { + "firstname": { + "required": "Your first name is required.", + "minlength": "Your first name is required to be at least 1 character", + "maxlength": "Your first name cannot be longer than 50 characters" + }, + "lastname": { + "required": "Your last name is required.", + "minlength": "Your last name is required to be at least 1 character", + "maxlength": "Your last name cannot be longer than 50 characters" + } + } + } + } +} diff --git a/src/main/webapp/i18n/en/user-management.json b/src/main/webapp/i18n/en/user-management.json new file mode 100644 index 0000000..8ea1ea2 --- /dev/null +++ b/src/main/webapp/i18n/en/user-management.json @@ -0,0 +1,34 @@ +{ + "userManagement": { + "home": { + "title": "Users", + "refreshListLabel": "Refresh list", + "createLabel": "Create a new user", + "createOrEditLabel": "Create or edit a user", + "notFound": "No user found" + }, + "created": "A new user is created with identifier { param }", + "updated": "A user is updated with identifier { param }", + "deleted": "A user is deleted with identifier { param }", + "delete": { + "question": "Are you sure you want to delete user { login }?" + }, + "detail": { + "title": "User" + }, + "id": "ID", + "login": "Login", + "firstName": "First name", + "lastName": "Last name", + "email": "Email", + "imageUrl": "Image", + "activated": "Activated", + "deactivated": "Deactivated", + "profiles": "Profiles", + "langKey": "Language", + "createdBy": "Created by", + "createdDate": "Created date", + "lastModifiedBy": "Modified by", + "lastModifiedDate": "Modified date" + } +} diff --git a/src/main/webapp/index.html b/src/main/webapp/index.html new file mode 100644 index 0000000..96ed005 --- /dev/null +++ b/src/main/webapp/index.html @@ -0,0 +1,117 @@ + + + + + questionBank + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + diff --git a/src/main/webapp/manifest.webapp b/src/main/webapp/manifest.webapp new file mode 100644 index 0000000..2ba3dcf --- /dev/null +++ b/src/main/webapp/manifest.webapp @@ -0,0 +1,31 @@ +{ + "name": "QuestionBank", + "short_name": "QuestionBank", + "icons": [ + { + "src": "./content/images/jhipster_family_member_2_head-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./content/images/jhipster_family_member_2_head-256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "./content/images/jhipster_family_member_2_head-384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "./content/images/jhipster_family_member_2_head-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#000000", + "background_color": "#e0e0e0", + "start_url": ".", + "display": "standalone", + "orientation": "portrait" +} diff --git a/src/main/webapp/robots.txt b/src/main/webapp/robots.txt new file mode 100644 index 0000000..04f669b --- /dev/null +++ b/src/main/webapp/robots.txt @@ -0,0 +1,8 @@ +# robotstxt.org/ + +User-agent: * +Disallow: /api/account +Disallow: /api/logs/ +Disallow: /api/users/ +Disallow: /management/ +Disallow: /v3/api-docs/ diff --git a/src/main/webapp/swagger-ui/index.html b/src/main/webapp/swagger-ui/index.html new file mode 100644 index 0000000..4c84751 --- /dev/null +++ b/src/main/webapp/swagger-ui/index.html @@ -0,0 +1,115 @@ + + + + + questionBank - Swagger UI + + + + + + + +
+ + + + + + + + diff --git a/src/test/java/com/example/questionbank/IntegrationTest.java b/src/test/java/com/example/questionbank/IntegrationTest.java new file mode 100644 index 0000000..61c2a4c --- /dev/null +++ b/src/test/java/com/example/questionbank/IntegrationTest.java @@ -0,0 +1,24 @@ +package com.example.questionbank; + +import com.example.questionbank.config.AsyncSyncConfiguration; +import com.example.questionbank.config.DatabaseTestcontainer; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Base composite annotation for integration tests. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest( + classes = { + QuestionBankApp.class, + AsyncSyncConfiguration.class, + com.example.questionbank.config.JacksonHibernateConfiguration.class, + DatabaseTestcontainer.class, + } +) +public @interface IntegrationTest {} diff --git a/src/test/java/com/example/questionbank/TechnicalStructureTest.java b/src/test/java/com/example/questionbank/TechnicalStructureTest.java new file mode 100644 index 0000000..647d073 --- /dev/null +++ b/src/test/java/com/example/questionbank/TechnicalStructureTest.java @@ -0,0 +1,38 @@ +package com.example.questionbank; + +import static com.tngtech.archunit.base.DescribedPredicate.alwaysTrue; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + +import com.tngtech.archunit.core.importer.ImportOption.DoNotIncludeTests; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +@AnalyzeClasses(packagesOf = QuestionBankApp.class, importOptions = DoNotIncludeTests.class) +class TechnicalStructureTest { + + // prettier-ignore + @ArchTest + static final ArchRule respectsTechnicalArchitectureLayers = layeredArchitecture() + .consideringAllDependencies() + .layer("Config").definedBy("..config..") + .layer("Web").definedBy("..web..") + .optionalLayer("Service").definedBy("..service..") + .layer("Security").definedBy("..security..") + .optionalLayer("Persistence").definedBy("..repository..") + .layer("Domain").definedBy("..domain..") + + .whereLayer("Config").mayNotBeAccessedByAnyLayer() + .whereLayer("Web").mayOnlyBeAccessedByLayers("Config") + .whereLayer("Service").mayOnlyBeAccessedByLayers("Web", "Config") + .whereLayer("Security").mayOnlyBeAccessedByLayers("Config", "Service", "Web") + .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service", "Security", "Web", "Config") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Persistence", "Service", "Security", "Web", "Config") + + .ignoreDependency(belongToAnyOf(QuestionBankApp.class), alwaysTrue()) + .ignoreDependency(alwaysTrue(), belongToAnyOf( + com.example.questionbank.config.Constants.class, + com.example.questionbank.config.ApplicationProperties.class + )); +} diff --git a/src/test/java/com/example/questionbank/aop/logging/LoggingAspectTest.java b/src/test/java/com/example/questionbank/aop/logging/LoggingAspectTest.java new file mode 100644 index 0000000..dea68f8 --- /dev/null +++ b/src/test/java/com/example/questionbank/aop/logging/LoggingAspectTest.java @@ -0,0 +1,102 @@ +package com.example.questionbank.aop.logging; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; + +class LoggingAspectTest { + + private static final String DECLARING_TYPE = "com.example.questionbank.service.LoggedService"; + private static final String METHOD = "loggedMethod"; + + private final Environment env = mock(Environment.class); + private final ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + private final ListAppender appender = new ListAppender<>(); + private LoggingAspect aspect; + private Logger logger; + + @BeforeEach + void setup() { + Signature signature = mock(Signature.class); + when(signature.getDeclaringTypeName()).thenReturn(DECLARING_TYPE); + when(signature.getName()).thenReturn(METHOD); + when(joinPoint.getSignature()).thenReturn(signature); + when(joinPoint.getArgs()).thenReturn(new Object[] { "arg" }); + when(env.acceptsProfiles(any(Profiles.class))).thenReturn(true); + + logger = (Logger) LoggerFactory.getLogger(DECLARING_TYPE); + logger.setLevel(Level.DEBUG); + appender.start(); + logger.addAppender(appender); + + aspect = new LoggingAspect(env); + } + + @AfterEach + void teardown() { + logger.detachAppender(appender); + logger.setLevel(null); + } + + private List messages() { + return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList(); + } + + @Test + void logAroundLogsEnterAndExit() throws Throwable { + when(joinPoint.proceed()).thenReturn("result"); + + Object result = aspect.logAround(joinPoint); + + assertThat(result).isEqualTo("result"); + assertThat(messages()).containsExactly( + "Enter: loggedMethod() with argument[s] = [arg]", + "Exit: loggedMethod() with result = result" + ); + } + + @Test + void logAroundRethrowsIllegalArgumentException() throws Throwable { + when(joinPoint.proceed()).thenThrow(new IllegalArgumentException("invalid")); + + assertThatThrownBy(() -> aspect.logAround(joinPoint)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("invalid"); + + assertThat(messages()).contains("Illegal argument: [arg] in loggedMethod()"); + } + + @Test + void logAfterThrowingLogsExceptionWithStackTraceInDevProfile() { + aspect.logAfterThrowing(joinPoint, new IllegalStateException("failed")); + + assertThat(messages()).containsExactly("Exception in loggedMethod() with cause = 'NULL' and exception = 'failed'"); + assertThat(appender.list.get(0).getThrowableProxy()).isNotNull(); + } + + @Test + void logAfterThrowingLogsCauseOnlyOutsideDevProfile() { + when(env.acceptsProfiles(any(Profiles.class))).thenReturn(false); + + aspect.logAfterThrowing(joinPoint, new IllegalStateException("failed", new RuntimeException("root"))); + + assertThat(messages()).containsExactly("Exception in loggedMethod() with cause = java.lang.RuntimeException: root"); + assertThat(appender.list.get(0).getThrowableProxy()).isNull(); + } +} diff --git a/src/test/java/com/example/questionbank/config/AsyncSyncConfiguration.java b/src/test/java/com/example/questionbank/config/AsyncSyncConfiguration.java new file mode 100644 index 0000000..b17b360 --- /dev/null +++ b/src/test/java/com/example/questionbank/config/AsyncSyncConfiguration.java @@ -0,0 +1,15 @@ +package com.example.questionbank.config; + +import java.util.concurrent.Executor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.task.SyncTaskExecutor; + +@TestConfiguration(proxyBeanMethods = false) +public class AsyncSyncConfiguration { + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + return new SyncTaskExecutor(); + } +} diff --git a/src/test/java/com/example/questionbank/config/CRLFLogConverterTest.java b/src/test/java/com/example/questionbank/config/CRLFLogConverterTest.java new file mode 100644 index 0000000..7ffd7ad --- /dev/null +++ b/src/test/java/com/example/questionbank/config/CRLFLogConverterTest.java @@ -0,0 +1,132 @@ +package com.example.questionbank.config; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; +import org.springframework.boot.ansi.AnsiColor; +import org.springframework.boot.ansi.AnsiElement; + +class CRLFLogConverterTest { + + @Test + void transformShouldReturnInputStringWhenMarkerListIsEmpty() { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getMarkerList()).thenReturn(null); + when(event.getLoggerName()).thenReturn("org.hibernate.example.Logger"); + String input = "Test input string"; + CRLFLogConverter converter = new CRLFLogConverter(); + + String result = converter.transform(event, input); + + assertEquals(input, result); + } + + @Test + void transformShouldReturnInputStringWhenMarkersContainCRLFSafeMarker() { + ILoggingEvent event = mock(ILoggingEvent.class); + Marker marker = MarkerFactory.getMarker("CRLF_SAFE"); + List markers = List.of(marker); + when(event.getMarkerList()).thenReturn(markers); + String input = "Test input string"; + CRLFLogConverter converter = new CRLFLogConverter(); + + String result = converter.transform(event, input); + + assertEquals(input, result); + } + + @Test + void transformShouldReturnInputStringWhenMarkersNotContainCRLFSafeMarker() { + ILoggingEvent event = mock(ILoggingEvent.class); + Marker marker = MarkerFactory.getMarker("CRLF_NOT_SAFE"); + List markers = List.of(marker); + when(event.getMarkerList()).thenReturn(markers); + when(event.getLoggerName()).thenReturn("org.hibernate.example.Logger"); + String input = "Test input string"; + CRLFLogConverter converter = new CRLFLogConverter(); + + String result = converter.transform(event, input); + + assertEquals(input, result); + } + + @Test + void transformShouldReturnInputStringWhenLoggerIsSafe() { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getLoggerName()).thenReturn("org.hibernate.example.Logger"); + String input = "Test input string"; + CRLFLogConverter converter = new CRLFLogConverter(); + + String result = converter.transform(event, input); + + assertEquals(input, result); + } + + @Test + void transformShouldReplaceNewlinesAndCarriageReturnsWithUnderscoreWhenMarkersDoNotContainCRLFSafeMarkerAndLoggerIsNotSafe() { + ILoggingEvent event = mock(ILoggingEvent.class); + List markers = List.of(); + when(event.getMarkerList()).thenReturn(markers); + when(event.getLoggerName()).thenReturn("com.example.questionbank.example.Logger"); + String input = "Test\ninput\rstring"; + CRLFLogConverter converter = new CRLFLogConverter(); + + String result = converter.transform(event, input); + + assertEquals("Test_input_string", result); + } + + @Test + void transformShouldReplaceNewlinesAndCarriageReturnsWithAnsiStringWhenMarkersDoNotContainCRLFSafeMarkerAndLoggerIsNotSafeAndAnsiElementIsNotNull() { + ILoggingEvent event = mock(ILoggingEvent.class); + List markers = List.of(); + when(event.getMarkerList()).thenReturn(markers); + when(event.getLoggerName()).thenReturn("com.example.questionbank.example.Logger"); + String input = "Test\ninput\rstring"; + CRLFLogConverter converter = new CRLFLogConverter(); + converter.setOptionList(List.of("red")); + + String result = converter.transform(event, input); + + assertEquals("Test_input_string", result); + } + + @Test + void isLoggerSafeShouldReturnTrueWhenLoggerNameStartsWithSafeLogger() { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getLoggerName()).thenReturn("org.springframework.boot.autoconfigure.example.Logger"); + CRLFLogConverter converter = new CRLFLogConverter(); + + boolean result = converter.isLoggerSafe(event); + + assertTrue(result); + } + + @Test + void isLoggerSafeShouldReturnFalseWhenLoggerNameDoesNotStartWithSafeLogger() { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getLoggerName()).thenReturn("com.example.questionbank.example.Logger"); + CRLFLogConverter converter = new CRLFLogConverter(); + + boolean result = converter.isLoggerSafe(event); + + assertFalse(result); + } + + @Test + void testToAnsiString() { + CRLFLogConverter cut = new CRLFLogConverter(); + AnsiElement ansiElement = AnsiColor.RED; + + String result = cut.toAnsiString("input", ansiElement); + + assertThat(result).isEqualTo("input"); + } +} diff --git a/src/test/java/com/example/questionbank/config/DatabaseTestcontainer.java b/src/test/java/com/example/questionbank/config/DatabaseTestcontainer.java new file mode 100644 index 0000000..624f84e --- /dev/null +++ b/src/test/java/com/example/questionbank/config/DatabaseTestcontainer.java @@ -0,0 +1,24 @@ +package com.example.questionbank.config; + +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class DatabaseTestcontainer { + + private static final PostgreSQLContainer DATABASE_CONTAINER = new PostgreSQLContainer("postgres:18.6") + .withDatabaseName("questionBank") + + .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger(DatabaseTestcontainer.class))) + .withReuse(true); + + @Bean + @ServiceConnection + PostgreSQLContainer databaseContainer() { + return DATABASE_CONTAINER; + } +} diff --git a/src/test/java/com/example/questionbank/config/SpringBootTestClassOrderer.java b/src/test/java/com/example/questionbank/config/SpringBootTestClassOrderer.java new file mode 100644 index 0000000..656c1db --- /dev/null +++ b/src/test/java/com/example/questionbank/config/SpringBootTestClassOrderer.java @@ -0,0 +1,22 @@ +package com.example.questionbank.config; + +import com.example.questionbank.IntegrationTest; +import java.util.Comparator; +import org.junit.jupiter.api.ClassDescriptor; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.ClassOrdererContext; + +public class SpringBootTestClassOrderer implements ClassOrderer { + + @Override + public void orderClasses(ClassOrdererContext context) { + context.getClassDescriptors().sort(Comparator.comparingInt(SpringBootTestClassOrderer::getOrder)); + } + + private static int getOrder(ClassDescriptor classDescriptor) { + if (classDescriptor.findAnnotation(IntegrationTest.class).isPresent()) { + return 2; + } + return 1; + } +} diff --git a/src/test/java/com/example/questionbank/config/StaticResourcesWebConfigurerTest.java b/src/test/java/com/example/questionbank/config/StaticResourcesWebConfigurerTest.java new file mode 100644 index 0000000..84beca9 --- /dev/null +++ b/src/test/java/com/example/questionbank/config/StaticResourcesWebConfigurerTest.java @@ -0,0 +1,76 @@ +package com.example.questionbank.config; + +import static com.example.questionbank.config.StaticResourcesWebConfiguration.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.CacheControl; +import org.springframework.mock.web.MockServletContext; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import tech.jhipster.config.JHipsterDefaults; +import tech.jhipster.config.JHipsterProperties; + +class StaticResourcesWebConfigurerTest { + + public static final int MAX_AGE_TEST = 5; + public StaticResourcesWebConfiguration staticResourcesWebConfiguration; + private ResourceHandlerRegistry resourceHandlerRegistry; + private MockServletContext servletContext; + private WebApplicationContext applicationContext; + private JHipsterProperties props; + + @BeforeEach + void setUp() { + servletContext = spy(new MockServletContext()); + applicationContext = mock(WebApplicationContext.class); + resourceHandlerRegistry = spy(new ResourceHandlerRegistry(applicationContext, servletContext)); + props = new JHipsterProperties(); + staticResourcesWebConfiguration = spy(new StaticResourcesWebConfiguration(props)); + } + + @Test + void shouldAppendResourceHandlerAndInitializeIt() { + staticResourcesWebConfiguration.addResourceHandlers(resourceHandlerRegistry); + + verify(resourceHandlerRegistry, times(1)).addResourceHandler(RESOURCE_PATHS); + verify(staticResourcesWebConfiguration, times(1)).initializeResourceHandler(any(ResourceHandlerRegistration.class)); + for (String testingPath : RESOURCE_PATHS) { + assertThat(resourceHandlerRegistry.hasMappingForPattern(testingPath)).isTrue(); + } + } + + @Test + void shouldInitializeResourceHandlerWithCacheControlAndLocations() { + CacheControl ccExpected = CacheControl.maxAge(5, TimeUnit.DAYS).cachePublic(); + when(staticResourcesWebConfiguration.getCacheControl()).thenReturn(ccExpected); + ResourceHandlerRegistration resourceHandlerRegistration = spy(new ResourceHandlerRegistration(RESOURCE_PATHS)); + + staticResourcesWebConfiguration.initializeResourceHandler(resourceHandlerRegistration); + + verify(staticResourcesWebConfiguration, times(1)).getCacheControl(); + verify(resourceHandlerRegistration, times(1)).setCacheControl(ccExpected); + verify(resourceHandlerRegistration, times(1)).addResourceLocations(RESOURCE_LOCATIONS); + } + + @Test + void shouldCreateCacheControlBasedOnJhipsterDefaultProperties() { + CacheControl cacheExpected = CacheControl.maxAge(JHipsterDefaults.Http.Cache.timeToLiveInDays, TimeUnit.DAYS).cachePublic(); + assertThat(staticResourcesWebConfiguration.getCacheControl()) + .extracting(CacheControl::getHeaderValue) + .isEqualTo(cacheExpected.getHeaderValue()); + } + + @Test + void shouldCreateCacheControlWithSpecificConfigurationInProperties() { + props.getHttp().getCache().setTimeToLiveInDays(MAX_AGE_TEST); + CacheControl cacheExpected = CacheControl.maxAge(MAX_AGE_TEST, TimeUnit.DAYS).cachePublic(); + assertThat(staticResourcesWebConfiguration.getCacheControl()) + .extracting(CacheControl::getHeaderValue) + .isEqualTo(cacheExpected.getHeaderValue()); + } +} diff --git a/src/test/java/com/example/questionbank/config/WebConfigurerTest.java b/src/test/java/com/example/questionbank/config/WebConfigurerTest.java new file mode 100644 index 0000000..36aec7f --- /dev/null +++ b/src/test/java/com/example/questionbank/config/WebConfigurerTest.java @@ -0,0 +1,113 @@ +package com.example.questionbank.config; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.servlet.*; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.web.MockServletContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import tech.jhipster.config.JHipsterProperties; + +/** + * Unit tests for the {@link WebConfigurer} class. + */ +class WebConfigurerTest { + + private WebConfigurer webConfigurer; + + private MockServletContext servletContext; + + private MockEnvironment env; + + private JHipsterProperties props; + + @BeforeEach + void setup() { + servletContext = spy(new MockServletContext()); + doReturn(mock(FilterRegistration.Dynamic.class)).when(servletContext).addFilter(anyString(), any(Filter.class)); + doReturn(mock(ServletRegistration.Dynamic.class)).when(servletContext).addServlet(anyString(), any(Servlet.class)); + + env = new MockEnvironment(); + props = new JHipsterProperties(); + + webConfigurer = new WebConfigurer(env, props); + } + + @Test + void shouldCorsFilterOnApiPath() throws Exception { + props.getCors().setAllowedOrigins(List.of("other.domain.com")); + props.getCors().setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); + props.getCors().setAllowedHeaders(List.of("*")); + props.getCors().setMaxAge(1800L); + props.getCors().setAllowCredentials(true); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new WebConfigurerTestController()).addFilters(webConfigurer.corsFilter()).build(); + + mockMvc + .perform( + options("/api/test-cors") + .header(HttpHeaders.ORIGIN, "other.domain.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + ) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "other.domain.com")) + .andExpect(header().string(HttpHeaders.VARY, "Origin")) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET,POST,PUT,DELETE")) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "1800")); + + mockMvc + .perform(get("/api/test-cors").header(HttpHeaders.ORIGIN, "other.domain.com")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "other.domain.com")); + } + + @Test + void shouldCorsFilterOnOtherPath() throws Exception { + props.getCors().setAllowedOrigins(List.of("*")); + props.getCors().setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE")); + props.getCors().setAllowedHeaders(List.of("*")); + props.getCors().setMaxAge(1800L); + props.getCors().setAllowCredentials(true); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new WebConfigurerTestController()).addFilters(webConfigurer.corsFilter()).build(); + + mockMvc + .perform(get("/test/test-cors").header(HttpHeaders.ORIGIN, "other.domain.com")) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + + @Test + void shouldCorsFilterDeactivatedForNullAllowedOrigins() throws Exception { + props.getCors().setAllowedOrigins(null); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new WebConfigurerTestController()).addFilters(webConfigurer.corsFilter()).build(); + + mockMvc + .perform(get("/api/test-cors").header(HttpHeaders.ORIGIN, "other.domain.com")) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + + @Test + void shouldCorsFilterDeactivatedForEmptyAllowedOrigins() throws Exception { + props.getCors().setAllowedOrigins(new ArrayList<>()); + + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new WebConfigurerTestController()).addFilters(webConfigurer.corsFilter()).build(); + + mockMvc + .perform(get("/api/test-cors").header(HttpHeaders.ORIGIN, "other.domain.com")) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + } +} diff --git a/src/test/java/com/example/questionbank/config/WebConfigurerTestController.java b/src/test/java/com/example/questionbank/config/WebConfigurerTestController.java new file mode 100644 index 0000000..b9df727 --- /dev/null +++ b/src/test/java/com/example/questionbank/config/WebConfigurerTestController.java @@ -0,0 +1,18 @@ +package com.example.questionbank.config; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class WebConfigurerTestController { + + @GetMapping("/api/test-cors") + public void testCorsOnApiPath() { + // empty method + } + + @GetMapping("/test/test-cors") + public void testCorsOnOtherPath() { + // empty method + } +} diff --git a/src/test/java/com/example/questionbank/config/timezone/HibernateTimeZoneIT.java b/src/test/java/com/example/questionbank/config/timezone/HibernateTimeZoneIT.java new file mode 100644 index 0000000..b91dc83 --- /dev/null +++ b/src/test/java/com/example/questionbank/config/timezone/HibernateTimeZoneIT.java @@ -0,0 +1,171 @@ +package com.example.questionbank.config.timezone; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.repository.timezone.DateTimeWrapper; +import com.example.questionbank.repository.timezone.DateTimeWrapperRepository; +import java.time.*; +import java.time.format.DateTimeFormatter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.rowset.SqlRowSet; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration tests for verifying the behavior of Hibernate in the context of storing various date and time types across different databases. + * The tests focus on ensuring that the stored values are correctly transformed and stored according to the configured timezone. + * Timezone is environment specific, and can be adjusted according to your needs. + * + * For more context, refer to: + * - GitHub Issue: https://github.com/jhipster/generator-jhipster/issues/22579 + * - Pull Request: https://github.com/jhipster/generator-jhipster/pull/22946 + */ +@IntegrationTest +class HibernateTimeZoneIT { + + @Autowired + private DateTimeWrapperRepository dateTimeWrapperRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${spring.jpa.properties.hibernate.jdbc.time_zone:UTC}") + private String zoneId; + + private DateTimeWrapper dateTimeWrapper; + private DateTimeFormatter dateTimeFormatter; + private DateTimeFormatter timeFormatter; + private DateTimeFormatter offsetTimeFormatter; + private DateTimeFormatter dateFormatter; + + @BeforeEach + void setup() { + dateTimeWrapper = new DateTimeWrapper(); + dateTimeWrapper.setInstant(Instant.parse("2014-11-12T05:10:00.0Z")); + dateTimeWrapper.setLocalDateTime(LocalDateTime.parse("2014-11-12T07:20:00.0")); + dateTimeWrapper.setOffsetDateTime(OffsetDateTime.parse("2011-12-14T08:30:00.0Z")); + dateTimeWrapper.setZonedDateTime(ZonedDateTime.parse("2011-12-14T08:40:00.0Z")); + dateTimeWrapper.setLocalTime(LocalTime.parse("14:50:00")); + dateTimeWrapper.setOffsetTime(OffsetTime.parse("14:00:00+02:00")); + dateTimeWrapper.setLocalDate(LocalDate.parse("2016-09-10")); + + dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.S").withZone(ZoneId.of(zoneId)); + timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.of(zoneId)); + offsetTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); + dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + } + + @Test + @Transactional + void storeInstantWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { + dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); + + String request = generateSqlRequest("instant", dateTimeWrapper.getId()); + SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); + String expectedValue = dateTimeFormatter.format(dateTimeWrapper.getInstant()); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); + } + + @Test + @Transactional + void storeLocalDateTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { + dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); + + String request = generateSqlRequest("local_date_time", dateTimeWrapper.getId()); + SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); + String expectedValue = dateTimeWrapper.getLocalDateTime().atZone(ZoneId.systemDefault()).format(dateTimeFormatter); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); + } + + @Test + @Transactional + void storeOffsetDateTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { + dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); + + String request = generateSqlRequest("offset_date_time", dateTimeWrapper.getId()); + SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); + String expectedValue = dateTimeWrapper.getOffsetDateTime().format(dateTimeFormatter); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); + } + + @Test + @Transactional + void storeZoneDateTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZone() { + dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); + + String request = generateSqlRequest("zoned_date_time", dateTimeWrapper.getId()); + SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); + String expectedValue = dateTimeWrapper.getZonedDateTime().format(dateTimeFormatter); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); + } + + @Test + @Transactional + void storeLocalTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZoneAccordingToHis1stJan1970Value() { + dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); + + String request = generateSqlRequest("local_time", dateTimeWrapper.getId()); + SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); + String expectedValue = dateTimeWrapper + .getLocalTime() + .atDate(LocalDate.of(1970, Month.JANUARY, 1)) + .atZone(ZoneId.systemDefault()) + .format(timeFormatter); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); + } + + @Test + @Transactional + void storeOffsetTimeWithZoneIdConfigShouldBeStoredOnConfiguredTimeZoneAccordingToHis1stJan1970Value() { + dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); + + String request = generateSqlRequest("offset_time", dateTimeWrapper.getId()); + SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); + String expectedValue = dateTimeWrapper + .getOffsetTime() + // Convert to configured timezone + .withOffsetSameInstant(ZoneId.of(zoneId).getRules().getOffset(Instant.now())) + // Normalize to System TimeZone. + // this behavior looks a bug, refer to https://github.com/jhipster/generator-jhipster/issues/22579. + .withOffsetSameLocal(OffsetDateTime.ofInstant(Instant.EPOCH, ZoneId.systemDefault()).getOffset()) + // Convert the normalized value to configured timezone + .withOffsetSameInstant(ZoneId.of(zoneId).getRules().getOffset(Instant.EPOCH)) + .format(offsetTimeFormatter); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); + } + + @Test + @Transactional + void storeLocalDateWithZoneIdConfigShouldBeStoredWithoutTransformation() { + dateTimeWrapperRepository.saveAndFlush(dateTimeWrapper); + + String request = generateSqlRequest("local_date", dateTimeWrapper.getId()); + SqlRowSet resultSet = jdbcTemplate.queryForRowSet(request); + String expectedValue = dateTimeWrapper.getLocalDate().format(dateFormatter); + + assertThatValueFromSqlRowSetIsEqualToExpectedValue(resultSet, expectedValue); + } + + private String generateSqlRequest(String fieldName, long id) { + return "SELECT %s FROM jhi_date_time_wrapper where id=%d".formatted(fieldName, id); + } + + private void assertThatValueFromSqlRowSetIsEqualToExpectedValue(SqlRowSet sqlRowSet, String expectedValue) { + while (sqlRowSet.next()) { + String dbValue = sqlRowSet.getString(1); + + assertThat(dbValue).isNotNull(); + assertThat(dbValue).isEqualTo(expectedValue); + } + } +} diff --git a/src/test/java/com/example/questionbank/domain/AssertUtils.java b/src/test/java/com/example/questionbank/domain/AssertUtils.java new file mode 100644 index 0000000..1f8183c --- /dev/null +++ b/src/test/java/com/example/questionbank/domain/AssertUtils.java @@ -0,0 +1,15 @@ +package com.example.questionbank.domain; + +import java.math.BigDecimal; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Comparator; + +public class AssertUtils { + + public static Comparator zonedDateTimeSameInstant = Comparator.nullsFirst((e1, a2) -> + e1.withZoneSameInstant(ZoneOffset.UTC).compareTo(a2.withZoneSameInstant(ZoneOffset.UTC)) + ); + + public static Comparator bigDecimalCompareTo = Comparator.nullsFirst(BigDecimal::compareTo); +} diff --git a/src/test/java/com/example/questionbank/domain/AuthorityAsserts.java b/src/test/java/com/example/questionbank/domain/AuthorityAsserts.java new file mode 100644 index 0000000..cf54d49 --- /dev/null +++ b/src/test/java/com/example/questionbank/domain/AuthorityAsserts.java @@ -0,0 +1,60 @@ +package com.example.questionbank.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AuthorityAsserts { + + /** + * Asserts that the entity has all properties (fields/relationships) set. + * + * @param expected the expected entity + * @param actual the actual entity + */ + public static void assertAuthorityAllPropertiesEquals(Authority expected, Authority actual) { + assertAuthorityAutoGeneratedPropertiesEquals(expected, actual); + assertAuthorityAllUpdatablePropertiesEquals(expected, actual); + } + + /** + * Asserts that the entity has all updatable properties (fields/relationships) set. + * + * @param expected the expected entity + * @param actual the actual entity + */ + public static void assertAuthorityAllUpdatablePropertiesEquals(Authority expected, Authority actual) { + assertAuthorityUpdatableFieldsEquals(expected, actual); + assertAuthorityUpdatableRelationshipsEquals(expected, actual); + } + + /** + * Asserts that the entity has all the auto generated properties (fields/relationships) set. + * + * @param expected the expected entity + * @param actual the actual entity + */ + public static void assertAuthorityAutoGeneratedPropertiesEquals(Authority expected, Authority actual) { + // empty method + } + + /** + * Asserts that the entity has all the updatable fields set. + * + * @param expected the expected entity + * @param actual the actual entity + */ + public static void assertAuthorityUpdatableFieldsEquals(Authority expected, Authority actual) { + assertThat(actual) + .as("Verify Authority relevant properties") + .satisfies(a -> assertThat(a.getName()).as("check name").isEqualTo(expected.getName())); + } + + /** + * Asserts that the entity has all the updatable relationships set. + * + * @param expected the expected entity + * @param actual the actual entity + */ + public static void assertAuthorityUpdatableRelationshipsEquals(Authority expected, Authority actual) { + // empty method + } +} diff --git a/src/test/java/com/example/questionbank/domain/AuthorityTest.java b/src/test/java/com/example/questionbank/domain/AuthorityTest.java new file mode 100644 index 0000000..662dd9a --- /dev/null +++ b/src/test/java/com/example/questionbank/domain/AuthorityTest.java @@ -0,0 +1,34 @@ +package com.example.questionbank.domain; + +import static com.example.questionbank.domain.AuthorityTestSamples.*; +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.questionbank.web.rest.TestUtil; +import org.junit.jupiter.api.Test; + +class AuthorityTest { + + @Test + void equalsVerifier() throws Exception { + TestUtil.equalsVerifier(Authority.class); + Authority authority1 = getAuthoritySample1(); + Authority authority2 = new Authority(); + assertThat(authority1).isNotEqualTo(authority2); + + authority2.setName(authority1.getName()); + assertThat(authority1).isEqualTo(authority2); + + authority2 = getAuthoritySample2(); + assertThat(authority1).isNotEqualTo(authority2); + } + + @Test + void hashCodeVerifier() { + Authority authority = new Authority(); + assertThat(authority.hashCode()).isZero(); + + Authority authority1 = getAuthoritySample1(); + authority.setName(authority1.getName()); + assertThat(authority).hasSameHashCodeAs(authority1); + } +} diff --git a/src/test/java/com/example/questionbank/domain/AuthorityTestSamples.java b/src/test/java/com/example/questionbank/domain/AuthorityTestSamples.java new file mode 100644 index 0000000..c37f2d3 --- /dev/null +++ b/src/test/java/com/example/questionbank/domain/AuthorityTestSamples.java @@ -0,0 +1,18 @@ +package com.example.questionbank.domain; + +import java.util.UUID; + +public class AuthorityTestSamples { + + public static Authority getAuthoritySample1() { + return new Authority().name("name1"); + } + + public static Authority getAuthoritySample2() { + return new Authority().name("name2"); + } + + public static Authority getAuthorityRandomSampleGenerator() { + return new Authority().name(UUID.randomUUID().toString()); + } +} diff --git a/src/test/java/com/example/questionbank/management/SecurityMetersServiceTests.java b/src/test/java/com/example/questionbank/management/SecurityMetersServiceTests.java new file mode 100644 index 0000000..23e57c5 --- /dev/null +++ b/src/test/java/com/example/questionbank/management/SecurityMetersServiceTests.java @@ -0,0 +1,70 @@ +package com.example.questionbank.management; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Collection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SecurityMetersServiceTests { + + private static final String INVALID_TOKENS_METER_EXPECTED_NAME = "security.authentication.invalid-tokens"; + + private MeterRegistry meterRegistry; + + private SecurityMetersService securityMetersService; + + @BeforeEach + void setup() { + meterRegistry = new SimpleMeterRegistry(); + + securityMetersService = new SecurityMetersService(meterRegistry); + } + + @Test + void testInvalidTokensCountersByCauseAreCreated() { + meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).counter(); + + meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter(); + + meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "unsupported").counter(); + + meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter(); + + meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter(); + + Collection counters = meterRegistry.find(INVALID_TOKENS_METER_EXPECTED_NAME).counters(); + + assertThat(counters).hasSize(4); + } + + @Test + void testCountMethodsShouldBeBoundToCorrectCounters() { + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count()).isZero(); + + securityMetersService.trackTokenExpired(); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count()).isEqualTo(1); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "unsupported").counter().count()).isZero(); + + securityMetersService.trackTokenUnsupported(); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "unsupported").counter().count()).isEqualTo(1); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count()).isZero(); + + securityMetersService.trackTokenInvalidSignature(); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count()).isEqualTo(1); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isZero(); + + securityMetersService.trackTokenMalformed(); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isEqualTo(1); + } +} diff --git a/src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapper.java b/src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapper.java new file mode 100644 index 0000000..a9727fe --- /dev/null +++ b/src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapper.java @@ -0,0 +1,135 @@ +package com.example.questionbank.repository.timezone; + +import jakarta.persistence.*; +import java.io.Serial; +import java.io.Serializable; +import java.time.*; +import java.util.Objects; + +@Entity +@Table(name = "jhi_date_time_wrapper") +public class DateTimeWrapper implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator") + @SequenceGenerator(name = "sequenceGenerator") + private Long id; + + @Column(name = "instant") + private Instant instant; + + @Column(name = "local_date_time") + private LocalDateTime localDateTime; + + @Column(name = "offset_date_time") + private OffsetDateTime offsetDateTime; + + @Column(name = "zoned_date_time") + private ZonedDateTime zonedDateTime; + + @Column(name = "local_time") + private LocalTime localTime; + + @Column(name = "offset_time") + private OffsetTime offsetTime; + + @Column(name = "local_date") + private LocalDate localDate; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Instant getInstant() { + return instant; + } + + public void setInstant(Instant instant) { + this.instant = instant; + } + + public LocalDateTime getLocalDateTime() { + return localDateTime; + } + + public void setLocalDateTime(LocalDateTime localDateTime) { + this.localDateTime = localDateTime; + } + + public OffsetDateTime getOffsetDateTime() { + return offsetDateTime; + } + + public void setOffsetDateTime(OffsetDateTime offsetDateTime) { + this.offsetDateTime = offsetDateTime; + } + + public ZonedDateTime getZonedDateTime() { + return zonedDateTime; + } + + public void setZonedDateTime(ZonedDateTime zonedDateTime) { + this.zonedDateTime = zonedDateTime; + } + + public LocalTime getLocalTime() { + return localTime; + } + + public void setLocalTime(LocalTime localTime) { + this.localTime = localTime; + } + + public OffsetTime getOffsetTime() { + return offsetTime; + } + + public void setOffsetTime(OffsetTime offsetTime) { + this.offsetTime = offsetTime; + } + + public LocalDate getLocalDate() { + return localDate; + } + + public void setLocalDate(LocalDate localDate) { + this.localDate = localDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + DateTimeWrapper dateTimeWrapper = (DateTimeWrapper) o; + return !(dateTimeWrapper.getId() == null || getId() == null) && Objects.equals(getId(), dateTimeWrapper.getId()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getId()); + } + + // prettier-ignore + @Override + public String toString() { + return "TimeZoneTest{" + + "id=" + id + + ", instant=" + instant + + ", localDateTime=" + localDateTime + + ", offsetDateTime=" + offsetDateTime + + ", zonedDateTime=" + zonedDateTime + + '}'; + } +} diff --git a/src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapperRepository.java b/src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapperRepository.java new file mode 100644 index 0000000..2bc73ab --- /dev/null +++ b/src/test/java/com/example/questionbank/repository/timezone/DateTimeWrapperRepository.java @@ -0,0 +1,10 @@ +package com.example.questionbank.repository.timezone; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for the {@link DateTimeWrapper} entity. + */ +@Repository +public interface DateTimeWrapperRepository extends JpaRepository {} diff --git a/src/test/java/com/example/questionbank/security/DomainUserDetailsServiceIT.java b/src/test/java/com/example/questionbank/security/DomainUserDetailsServiceIT.java new file mode 100644 index 0000000..3b09a8d --- /dev/null +++ b/src/test/java/com/example/questionbank/security/DomainUserDetailsServiceIT.java @@ -0,0 +1,136 @@ +package com.example.questionbank.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.service.UserService; +import java.util.Locale; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integrations tests for {@link DomainUserDetailsService}. + */ +@Transactional +@IntegrationTest +class DomainUserDetailsServiceIT { + + private static final String USER_ONE_LOGIN = "test-user-one"; + private static final String USER_ONE_EMAIL = "test-user-one@localhost"; + private static final String USER_TWO_LOGIN = "test-user-two"; + private static final String USER_TWO_EMAIL = "test-user-two@localhost"; + private static final String USER_THREE_LOGIN = "test-user-three"; + private static final String USER_THREE_EMAIL = "test-user-three@localhost"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + @Qualifier("userDetailsService") + private UserDetailsService domainUserDetailsService; + + public User getUserOne() { + User userOne = new User(); + userOne.setLogin(USER_ONE_LOGIN); + userOne.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + userOne.setActivated(true); + userOne.setEmail(USER_ONE_EMAIL); + userOne.setFirstName("userOne"); + userOne.setLastName("doe"); + userOne.setLangKey("en"); + return userOne; + } + + public User getUserTwo() { + User userTwo = new User(); + userTwo.setLogin(USER_TWO_LOGIN); + userTwo.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + userTwo.setActivated(true); + userTwo.setEmail(USER_TWO_EMAIL); + userTwo.setFirstName("userTwo"); + userTwo.setLastName("doe"); + userTwo.setLangKey("en"); + return userTwo; + } + + public User getUserThree() { + User userThree = new User(); + userThree.setLogin(USER_THREE_LOGIN); + userThree.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + userThree.setActivated(false); + userThree.setEmail(USER_THREE_EMAIL); + userThree.setFirstName("userThree"); + userThree.setLastName("doe"); + userThree.setLangKey("en"); + return userThree; + } + + @BeforeEach + void init() { + userRepository.save(getUserOne()); + userRepository.save(getUserTwo()); + userRepository.save(getUserThree()); + } + + @AfterEach + void cleanup() { + userService.deleteUser(USER_ONE_LOGIN); + userService.deleteUser(USER_TWO_LOGIN); + userService.deleteUser(USER_THREE_LOGIN); + } + + @Test + void assertThatUserCanBeFoundByLogin() { + UserDetails userDetails = domainUserDetailsService.loadUserByUsername(USER_ONE_LOGIN); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.getUsername()).isEqualTo(USER_ONE_LOGIN); + } + + @Test + void assertThatUserCanBeFoundByLoginIgnoreCase() { + UserDetails userDetails = domainUserDetailsService.loadUserByUsername(USER_ONE_LOGIN.toUpperCase(Locale.ENGLISH)); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.getUsername()).isEqualTo(USER_ONE_LOGIN); + } + + @Test + void assertThatUserCanBeFoundByEmail() { + UserDetails userDetails = domainUserDetailsService.loadUserByUsername(USER_TWO_EMAIL); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.getUsername()).isEqualTo(USER_TWO_LOGIN); + } + + @Test + void assertThatUserCanBeFoundByEmailIgnoreCase() { + UserDetails userDetails = domainUserDetailsService.loadUserByUsername(USER_TWO_EMAIL.toUpperCase(Locale.ENGLISH)); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.getUsername()).isEqualTo(USER_TWO_LOGIN); + } + + @Test + void assertThatEmailIsPrioritizedOverLogin() { + UserDetails userDetails = domainUserDetailsService.loadUserByUsername(USER_ONE_EMAIL); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.getUsername()).isEqualTo(USER_ONE_LOGIN); + } + + @Test + void assertThatUserNotActivatedExceptionIsThrownForNotActivatedUsers() { + assertThatExceptionOfType(UserNotActivatedException.class).isThrownBy(() -> + domainUserDetailsService.loadUserByUsername(USER_THREE_LOGIN) + ); + } +} diff --git a/src/test/java/com/example/questionbank/security/SecurityUtilsUnitTest.java b/src/test/java/com/example/questionbank/security/SecurityUtilsUnitTest.java new file mode 100644 index 0000000..19d232d --- /dev/null +++ b/src/test/java/com/example/questionbank/security/SecurityUtilsUnitTest.java @@ -0,0 +1,137 @@ +package com.example.questionbank.security; + +import static com.example.questionbank.security.SecurityUtils.USER_ID_CLAIM; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * Test class for the {@link SecurityUtils} utility class. + */ +class SecurityUtilsUnitTest { + + @BeforeEach + @AfterEach + void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + void testGetCurrentUserLogin() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("admin", "admin")); + SecurityContextHolder.setContext(securityContext); + Optional login = SecurityUtils.getCurrentUserLogin(); + assertThat(login).contains("admin"); + } + + @Test + void testGetCurrentUserLoginForAnonymousUser() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + var authorities = List.of(new SimpleGrantedAuthority(AuthoritiesConstants.ANONYMOUS)); + securityContext.setAuthentication(new AnonymousAuthenticationToken("key", "anonymousUser", authorities)); + SecurityContextHolder.setContext(securityContext); + Optional login = SecurityUtils.getCurrentUserLogin(); + assertThat(login).isEmpty(); + } + + @Test + void testGetCurrentUserJWT() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("admin", "token")); + SecurityContextHolder.setContext(securityContext); + Optional jwt = SecurityUtils.getCurrentUserJWT(); + assertThat(jwt).contains("token"); + } + + @Test + void testGetCurrentUserJWTFromJwtCredentials() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + var now = Instant.now(); + var jwt = Jwt.withTokenValue("token").issuedAt(now).expiresAt(now.plusSeconds(60)).header("Test", "test").build(); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("admin", jwt)); + SecurityContextHolder.setContext(securityContext); + Optional currentUserJwt = SecurityUtils.getCurrentUserJWT(); + assertThat(currentUserJwt).contains("token"); + } + + @Test + void testGetCurrentUserId() { + var userId = 1L; + var securityContext = SecurityContextHolder.createEmptyContext(); + var now = Instant.now(); + var jwt = Jwt.withTokenValue("token") + .issuedAt(now) + .expiresAt(now.plusSeconds(60)) + .claim(USER_ID_CLAIM, userId) + .header("Test", "test") + .build(); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken(jwt, "token")); + SecurityContextHolder.setContext(securityContext); + var contextUserId = SecurityUtils.getCurrentUserId(); + assertThat(contextUserId.orElse(null)).isEqualTo(userId); + } + + @Test + void testIsAuthenticated() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("admin", "admin")); + SecurityContextHolder.setContext(securityContext); + boolean isAuthenticated = SecurityUtils.isAuthenticated(); + assertThat(isAuthenticated).isTrue(); + } + + @Test + void testAnonymousIsNotAuthenticated() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + var authorities = List.of(new SimpleGrantedAuthority(AuthoritiesConstants.ANONYMOUS)); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities)); + SecurityContextHolder.setContext(securityContext); + boolean isAuthenticated = SecurityUtils.isAuthenticated(); + assertThat(isAuthenticated).isFalse(); + } + + @Test + void testHasCurrentUserThisAuthority() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + var authorities = List.of(new SimpleGrantedAuthority(AuthoritiesConstants.USER)); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities)); + SecurityContextHolder.setContext(securityContext); + + assertThat(SecurityUtils.hasCurrentUserThisAuthority(AuthoritiesConstants.USER)).isTrue(); + assertThat(SecurityUtils.hasCurrentUserThisAuthority(AuthoritiesConstants.ADMIN)).isFalse(); + } + + @Test + void testHasCurrentUserAnyOfAuthorities() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + var authorities = List.of(new SimpleGrantedAuthority(AuthoritiesConstants.USER)); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities)); + SecurityContextHolder.setContext(securityContext); + + assertThat(SecurityUtils.hasCurrentUserAnyOfAuthorities(AuthoritiesConstants.USER, AuthoritiesConstants.ADMIN)).isTrue(); + assertThat(SecurityUtils.hasCurrentUserAnyOfAuthorities(AuthoritiesConstants.ANONYMOUS, AuthoritiesConstants.ADMIN)).isFalse(); + } + + @Test + void testHasCurrentUserNoneOfAuthorities() { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + var authorities = List.of(new SimpleGrantedAuthority(AuthoritiesConstants.USER)); + securityContext.setAuthentication(new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities)); + SecurityContextHolder.setContext(securityContext); + + assertThat(SecurityUtils.hasCurrentUserNoneOfAuthorities(AuthoritiesConstants.USER, AuthoritiesConstants.ADMIN)).isFalse(); + assertThat(SecurityUtils.hasCurrentUserNoneOfAuthorities(AuthoritiesConstants.ANONYMOUS, AuthoritiesConstants.ADMIN)).isTrue(); + } +} diff --git a/src/test/java/com/example/questionbank/security/jwt/AuthenticationIntegrationTest.java b/src/test/java/com/example/questionbank/security/jwt/AuthenticationIntegrationTest.java new file mode 100644 index 0000000..18d7086 --- /dev/null +++ b/src/test/java/com/example/questionbank/security/jwt/AuthenticationIntegrationTest.java @@ -0,0 +1,32 @@ +package com.example.questionbank.security.jwt; + +import com.example.questionbank.config.SecurityConfiguration; +import com.example.questionbank.config.SecurityJwtConfiguration; +import com.example.questionbank.config.WebConfigurer; +import com.example.questionbank.management.SecurityMetersService; +import com.example.questionbank.web.rest.AuthenticateController; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.boot.test.context.SpringBootTest; +import tech.jhipster.config.JHipsterProperties; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest( + properties = { + "jhipster.security.authentication.jwt.base64-secret=fd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8", + "jhipster.security.authentication.jwt.token-validity-in-seconds=60000", + }, + classes = { + JHipsterProperties.class, + WebConfigurer.class, + SecurityConfiguration.class, + SecurityJwtConfiguration.class, + SecurityMetersService.class, + AuthenticateController.class, + JwtAuthenticationTestUtils.class, + } +) +public @interface AuthenticationIntegrationTest {} diff --git a/src/test/java/com/example/questionbank/security/jwt/JwtAuthenticationTestUtils.java b/src/test/java/com/example/questionbank/security/jwt/JwtAuthenticationTestUtils.java new file mode 100644 index 0000000..a4e4940 --- /dev/null +++ b/src/test/java/com/example/questionbank/security/jwt/JwtAuthenticationTestUtils.java @@ -0,0 +1,100 @@ +package com.example.questionbank.security.jwt; + +import static com.example.questionbank.security.AuthoritiesConstants.ADMIN; +import static com.example.questionbank.security.SecurityUtils.AUTHORITIES_CLAIM; +import static com.example.questionbank.security.SecurityUtils.JWT_ALGORITHM; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.util.Base64; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.time.Instant; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +public class JwtAuthenticationTestUtils { + + public static final String BEARER = "Bearer "; + + @Bean + private MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + + public static String createValidToken(String jwtKey) { + return createValidTokenForUser(jwtKey, "anonymous"); + } + + public static String createValidTokenForUser(String jwtKey, String user) { + JwtEncoder encoder = jwtEncoder(jwtKey); + + var now = Instant.now(); + + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuedAt(now) + .expiresAt(now.plusSeconds(60)) + .subject(user) + .claims(customClaim -> customClaim.put(AUTHORITIES_CLAIM, List.of(ADMIN))) + .build(); + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return encoder.encode(JwtEncoderParameters.from(jwsHeader, claims)).getTokenValue(); + } + + public static String createTokenWithDifferentSignature() { + JwtEncoder encoder = jwtEncoder("Xfd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8"); + + var now = Instant.now(); + var past = now.plusSeconds(60); + + JwtClaimsSet claims = JwtClaimsSet.builder().issuedAt(now).expiresAt(past).subject("anonymous").build(); + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return encoder.encode(JwtEncoderParameters.from(jwsHeader, claims)).getTokenValue(); + } + + public static String createExpiredToken(String jwtKey) { + JwtEncoder encoder = jwtEncoder(jwtKey); + + var now = Instant.now(); + var past = now.minusSeconds(600); + + JwtClaimsSet claims = JwtClaimsSet.builder().issuedAt(past).expiresAt(past.plusSeconds(1)).subject("anonymous").build(); + + JwsHeader jwsHeader = JwsHeader.with(JWT_ALGORITHM).build(); + return encoder.encode(JwtEncoderParameters.from(jwsHeader, claims)).getTokenValue(); + } + + public static String createInvalidToken(String jwtKey) { + return createValidToken(jwtKey).substring(1); + } + + public static String createSignedInvalidJwt(String jwtKey) throws Exception { + return calculateHMAC("foo", jwtKey); + } + + private static JwtEncoder jwtEncoder(String jwtKey) { + return new NimbusJwtEncoder(new ImmutableSecret<>(getSecretKey(jwtKey))); + } + + private static SecretKey getSecretKey(String jwtKey) { + byte[] keyBytes = Base64.from(jwtKey).decode(); + return new SecretKeySpec(keyBytes, 0, keyBytes.length, JWT_ALGORITHM.getName()); + } + + private static String calculateHMAC(String data, String key) throws Exception { + SecretKeySpec secretKeySpec = new SecretKeySpec(Base64.from(key).decode(), "HmacSHA512"); + Mac mac = Mac.getInstance("HmacSHA512"); + mac.init(secretKeySpec); + return String.copyValueOf(Hex.encode(mac.doFinal(data.getBytes()))); + } +} diff --git a/src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationIT.java b/src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationIT.java new file mode 100644 index 0000000..035620c --- /dev/null +++ b/src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationIT.java @@ -0,0 +1,55 @@ +package com.example.questionbank.security.jwt; + +import static com.example.questionbank.security.jwt.JwtAuthenticationTestUtils.*; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@AutoConfigureMockMvc +@AuthenticationIntegrationTest +class TokenAuthenticationIT { + + @Autowired + private MockMvc mvc; + + @Value("${jhipster.security.authentication.jwt.base64-secret}") + private String jwtKey; + + @Test + void testLoginWithValidToken() throws Exception { + expectOk(createValidToken(jwtKey)); + } + + @Test + void testReturnFalseWhenJWThasInvalidSignature() throws Exception { + expectUnauthorized(createTokenWithDifferentSignature()); + } + + @Test + void testReturnFalseWhenJWTisMalformed() throws Exception { + expectUnauthorized(createSignedInvalidJwt(jwtKey)); + } + + @Test + void testReturnFalseWhenJWTisExpired() throws Exception { + expectUnauthorized(createExpiredToken(jwtKey)); + } + + private void expectOk(String token) throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/authenticate").header(AUTHORIZATION, BEARER + token)).andExpect( + status().isNoContent() + ); + } + + private void expectUnauthorized(String token) throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/authenticate").header(AUTHORIZATION, BEARER + token)).andExpect( + status().isUnauthorized() + ); + } +} diff --git a/src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationSecurityMetersIT.java b/src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationSecurityMetersIT.java new file mode 100644 index 0000000..fc8f9e8 --- /dev/null +++ b/src/test/java/com/example/questionbank/security/jwt/TokenAuthenticationSecurityMetersIT.java @@ -0,0 +1,88 @@ +package com.example.questionbank.security.jwt; + +import static com.example.questionbank.security.jwt.JwtAuthenticationTestUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Collection; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@AutoConfigureMockMvc +@AuthenticationIntegrationTest +class TokenAuthenticationSecurityMetersIT { + + private static final String INVALID_TOKENS_METER_EXPECTED_NAME = "security.authentication.invalid-tokens"; + + @Autowired + private MockMvc mvc; + + @Value("${jhipster.security.authentication.jwt.base64-secret}") + private String jwtKey; + + @Autowired + private MeterRegistry meterRegistry; + + @Test + void testValidTokenShouldNotCountAnything() throws Exception { + Collection counters = meterRegistry.find(INVALID_TOKENS_METER_EXPECTED_NAME).counters(); + + var count = aggregate(counters); + + tryToAuthenticate(createValidToken(jwtKey)); + + assertThat(aggregate(counters)).isEqualTo(count); + } + + @Test + void testTokenExpiredCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count(); + + tryToAuthenticate(createExpiredToken(jwtKey)); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "expired").counter().count()).isEqualTo(count + 1); + } + + @Test + void testTokenSignatureInvalidCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count(); + + tryToAuthenticate(createTokenWithDifferentSignature()); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "invalid-signature").counter().count()).isEqualTo( + count + 1 + ); + } + + @Test + void testTokenMalformedCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count(); + + tryToAuthenticate(createSignedInvalidJwt(jwtKey)); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isEqualTo(count + 1); + } + + @Test + void testTokenInvalidCount() throws Exception { + var count = meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count(); + + tryToAuthenticate(createInvalidToken(jwtKey)); + + assertThat(meterRegistry.get(INVALID_TOKENS_METER_EXPECTED_NAME).tag("cause", "malformed").counter().count()).isEqualTo(count + 1); + } + + private void tryToAuthenticate(String token) throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/api/authenticate").header(AUTHORIZATION, BEARER + token)); + } + + private double aggregate(Collection counters) { + return counters.stream().mapToDouble(Counter::count).sum(); + } +} diff --git a/src/test/java/com/example/questionbank/service/MailServiceIT.java b/src/test/java/com/example/questionbank/service/MailServiceIT.java new file mode 100644 index 0000000..bc70910 --- /dev/null +++ b/src/test/java/com/example/questionbank/service/MailServiceIT.java @@ -0,0 +1,237 @@ +package com.example.questionbank.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.config.Constants; +import com.example.questionbank.domain.User; +import jakarta.mail.Multipart; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; +import java.io.ByteArrayOutputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import tech.jhipster.config.JHipsterProperties; + +/** + * Integration tests for {@link MailService}. + */ +@ExtendWith(MockitoExtension.class) +@IntegrationTest +class MailServiceIT { + + private static final String[] languages = { + // jhipster-needle-i18n-language-constant-start + "en", + // jhipster-needle-i18n-language-constant - JHipster will add/remove languages in this array + }; + private static final Pattern PATTERN_LOCALE_3 = Pattern.compile("([a-z]{2})-([a-zA-Z]{4})-([a-z]{2})"); + private static final Pattern PATTERN_LOCALE_2 = Pattern.compile("([a-z]{2})-([a-z]{2})"); + + @Autowired + private JHipsterProperties jHipsterProperties; + + @MockitoBean + private JavaMailSender javaMailSender; + + @Captor + private ArgumentCaptor messageCaptor; + + @Autowired + private MailService mailService; + + @BeforeEach + void setup() { + doNothing().when(javaMailSender).send(any(MimeMessage.class)); + when(javaMailSender.createMimeMessage()).thenReturn(new MimeMessage((Session) null)); + } + + @Test + void testSendEmail() throws Exception { + mailService.sendEmail("john.doe@example.com", "testSubject", "testContent", false, false); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + assertThat(message.getSubject()).isEqualTo("testSubject"); + assertThat(message.getAllRecipients()[0]).hasToString("john.doe@example.com"); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent()).isInstanceOf(String.class); + assertThat(message.getContent()).hasToString("testContent"); + assertThat(message.getDataHandler().getContentType()).isEqualTo("text/plain; charset=UTF-8"); + } + + @Test + void testSendHtmlEmail() throws Exception { + mailService.sendEmail("john.doe@example.com", "testSubject", "testContent", false, true); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + assertThat(message.getSubject()).isEqualTo("testSubject"); + assertThat(message.getAllRecipients()[0]).hasToString("john.doe@example.com"); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent()).isInstanceOf(String.class); + assertThat(message.getContent()).hasToString("testContent"); + assertThat(message.getDataHandler().getContentType()).isEqualTo("text/html;charset=UTF-8"); + } + + @Test + void testSendMultipartEmail() throws Exception { + mailService.sendEmail("john.doe@example.com", "testSubject", "testContent", true, false); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + MimeMultipart mp = (MimeMultipart) message.getContent(); + MimeBodyPart part = (MimeBodyPart) ((MimeMultipart) mp.getBodyPart(0).getContent()).getBodyPart(0); + ByteArrayOutputStream aos = new ByteArrayOutputStream(); + part.writeTo(aos); + assertThat(message.getSubject()).isEqualTo("testSubject"); + assertThat(message.getAllRecipients()[0]).hasToString("john.doe@example.com"); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent()).isInstanceOf(Multipart.class); + assertThat(aos).hasToString("\r\ntestContent"); + assertThat(part.getDataHandler().getContentType()).isEqualTo("text/plain; charset=UTF-8"); + } + + @Test + void testSendMultipartHtmlEmail() throws Exception { + mailService.sendEmail("john.doe@example.com", "testSubject", "testContent", true, true); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + MimeMultipart mp = (MimeMultipart) message.getContent(); + MimeBodyPart part = (MimeBodyPart) ((MimeMultipart) mp.getBodyPart(0).getContent()).getBodyPart(0); + ByteArrayOutputStream aos = new ByteArrayOutputStream(); + part.writeTo(aos); + assertThat(message.getSubject()).isEqualTo("testSubject"); + assertThat(message.getAllRecipients()[0]).hasToString("john.doe@example.com"); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent()).isInstanceOf(Multipart.class); + assertThat(aos).hasToString("\r\ntestContent"); + assertThat(part.getDataHandler().getContentType()).isEqualTo("text/html;charset=UTF-8"); + } + + @Test + void testSendEmailFromTemplate() throws Exception { + User user = new User(); + user.setLangKey(Constants.DEFAULT_LANGUAGE); + user.setLogin("john"); + user.setEmail("john.doe@example.com"); + mailService.sendEmailFromTemplate(user, "mail/testEmail", "email.test.title"); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + assertThat(message.getSubject()).isEqualTo("test title"); + assertThat(message.getAllRecipients()[0]).hasToString(user.getEmail()); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent().toString()).isEqualToNormalizingNewlines("test title, http://127.0.0.1:8080, john\n"); + assertThat(message.getDataHandler().getContentType()).isEqualTo("text/html;charset=UTF-8"); + } + + @Test + void testSendActivationEmail() throws Exception { + User user = new User(); + user.setLangKey(Constants.DEFAULT_LANGUAGE); + user.setLogin("john"); + user.setEmail("john.doe@example.com"); + mailService.sendActivationEmail(user); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + assertThat(message.getAllRecipients()[0]).hasToString(user.getEmail()); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent().toString()).isNotEmpty(); + assertThat(message.getDataHandler().getContentType()).isEqualTo("text/html;charset=UTF-8"); + } + + @Test + void testCreationEmail() throws Exception { + User user = new User(); + user.setLangKey(Constants.DEFAULT_LANGUAGE); + user.setLogin("john"); + user.setEmail("john.doe@example.com"); + mailService.sendCreationEmail(user); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + assertThat(message.getAllRecipients()[0]).hasToString(user.getEmail()); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent().toString()).isNotEmpty(); + assertThat(message.getDataHandler().getContentType()).isEqualTo("text/html;charset=UTF-8"); + } + + @Test + void testSendPasswordResetMail() throws Exception { + User user = new User(); + user.setLangKey(Constants.DEFAULT_LANGUAGE); + user.setLogin("john"); + user.setEmail("john.doe@example.com"); + mailService.sendPasswordResetMail(user); + verify(javaMailSender).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + assertThat(message.getAllRecipients()[0]).hasToString(user.getEmail()); + assertThat(message.getFrom()[0]).hasToString(jHipsterProperties.getMail().getFrom()); + assertThat(message.getContent().toString()).isNotEmpty(); + assertThat(message.getDataHandler().getContentType()).isEqualTo("text/html;charset=UTF-8"); + } + + @Test + void testSendEmailWithException() { + doThrow(MailSendException.class).when(javaMailSender).send(any(MimeMessage.class)); + assertDoesNotThrow(() -> mailService.sendEmail("john.doe@example.com", "testSubject", "testContent", false, false)); + } + + @Test + void testSendLocalizedEmailForAllSupportedLanguages() throws Exception { + User user = new User(); + user.setLogin("john"); + user.setEmail("john.doe@example.com"); + for (String langKey : languages) { + user.setLangKey(langKey); + mailService.sendEmailFromTemplate(user, "mail/testEmail", "email.test.title"); + verify(javaMailSender, atLeastOnce()).send(messageCaptor.capture()); + MimeMessage message = messageCaptor.getValue(); + + String propertyFilePath = "i18n/messages_" + getMessageSourceSuffixForLanguage(langKey) + ".properties"; + URL resource = this.getClass().getClassLoader().getResource(propertyFilePath); + Path filePath = Path.of(resource.toURI()); + Properties properties = new Properties(); + properties.load(new InputStreamReader(Files.newInputStream(filePath), Charset.forName("UTF-8"))); + + String emailTitle = (String) properties.get("email.test.title"); + assertThat(message.getSubject()).isEqualTo(emailTitle); + assertThat(message.getContent().toString()).isEqualToNormalizingNewlines( + "" + emailTitle + ", http://127.0.0.1:8080, john\n" + ); + } + } + + /** + * Convert a lang key to the Java locale. + */ + private String getMessageSourceSuffixForLanguage(String langKey) { + String javaLangKey = langKey; + Matcher matcher2 = PATTERN_LOCALE_2.matcher(langKey); + if (matcher2.matches()) { + javaLangKey = matcher2.group(1) + "_" + matcher2.group(2).toUpperCase(); + } + Matcher matcher3 = PATTERN_LOCALE_3.matcher(langKey); + if (matcher3.matches()) { + javaLangKey = matcher3.group(1) + "_" + matcher3.group(2) + "_" + matcher3.group(3).toUpperCase(); + } + return javaLangKey; + } +} diff --git a/src/test/java/com/example/questionbank/service/UserServiceIT.java b/src/test/java/com/example/questionbank/service/UserServiceIT.java new file mode 100644 index 0000000..eee49d1 --- /dev/null +++ b/src/test/java/com/example/questionbank/service/UserServiceIT.java @@ -0,0 +1,192 @@ +package com.example.questionbank.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import tech.jhipster.security.RandomUtil; + +/** + * Integration tests for {@link UserService}. + */ +@IntegrationTest +@Transactional +class UserServiceIT { + + private static final String DEFAULT_LOGIN = "johndoe_service"; + + private static final String DEFAULT_EMAIL = "johndoe_service@localhost"; + + private static final String DEFAULT_FIRSTNAME = "john"; + + private static final String DEFAULT_LASTNAME = "doe"; + + private static final String DEFAULT_IMAGEURL = "http://placehold.it/50x50"; + + private static final String DEFAULT_LANGKEY = "dummy"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private AuditingHandler auditingHandler; + + @MockitoBean + private DateTimeProvider dateTimeProvider; + + private User user; + + private Long numberOfUsers; + + @BeforeEach + void init() { + numberOfUsers = userRepository.count(); + user = new User(); + user.setLogin(DEFAULT_LOGIN); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + user.setEmail(DEFAULT_EMAIL); + user.setFirstName(DEFAULT_FIRSTNAME); + user.setLastName(DEFAULT_LASTNAME); + user.setImageUrl(DEFAULT_IMAGEURL); + user.setLangKey(DEFAULT_LANGKEY); + + when(dateTimeProvider.getNow()).thenReturn(Optional.of(LocalDateTime.now())); + auditingHandler.setDateTimeProvider(dateTimeProvider); + } + + @AfterEach + void cleanupAndCheck() { + userService.deleteUser(DEFAULT_LOGIN); + assertThat(userRepository.count()).isEqualTo(numberOfUsers); + numberOfUsers = null; + } + + @Test + @Transactional + void assertThatUserMustExistToResetPassword() { + userRepository.saveAndFlush(user); + Optional maybeUser = userService.requestPasswordReset("invalid.login@localhost"); + assertThat(maybeUser).isNotPresent(); + + maybeUser = userService.requestPasswordReset(user.getEmail()); + assertThat(maybeUser).isPresent(); + assertThat(maybeUser.orElse(null).getEmail()).isEqualTo(user.getEmail()); + assertThat(maybeUser.orElse(null).getResetDate()).isNotNull(); + assertThat(maybeUser.orElse(null).getResetKey()).isNotNull(); + } + + @Test + @Transactional + void assertThatOnlyActivatedUserCanRequestPasswordReset() { + user.setActivated(false); + userRepository.saveAndFlush(user); + + Optional maybeUser = userService.requestPasswordReset(user.getLogin()); + assertThat(maybeUser).isNotPresent(); + userRepository.delete(user); + } + + @Test + @Transactional + void assertThatResetKeyMustNotBeOlderThan24Hours() { + Instant daysAgo = Instant.now().minus(25, ChronoUnit.HOURS); + String resetKey = RandomUtil.generateResetKey(); + user.setActivated(true); + user.setResetDate(daysAgo); + user.setResetKey(resetKey); + userRepository.saveAndFlush(user); + + Optional maybeUser = userService.completePasswordReset("johndoe2", user.getResetKey()); + assertThat(maybeUser).isNotPresent(); + userRepository.delete(user); + } + + @Test + @Transactional + void assertThatResetKeyMustBeValid() { + Instant daysAgo = Instant.now().minus(25, ChronoUnit.HOURS); + user.setActivated(true); + user.setResetDate(daysAgo); + user.setResetKey("1234"); + userRepository.saveAndFlush(user); + + Optional maybeUser = userService.completePasswordReset("johndoe2", user.getResetKey()); + assertThat(maybeUser).isNotPresent(); + userRepository.delete(user); + } + + @Test + @Transactional + void assertThatUserCanResetPassword() { + String oldPassword = user.getPassword(); + Instant daysAgo = Instant.now().minus(2, ChronoUnit.HOURS); + String resetKey = RandomUtil.generateResetKey(); + user.setActivated(true); + user.setResetDate(daysAgo); + user.setResetKey(resetKey); + userRepository.saveAndFlush(user); + + Optional maybeUser = userService.completePasswordReset("johndoe2", user.getResetKey()); + assertThat(maybeUser).isPresent(); + assertThat(maybeUser.orElse(null).getResetDate()).isNull(); + assertThat(maybeUser.orElse(null).getResetKey()).isNull(); + assertThat(maybeUser.orElse(null).getPassword()).isNotEqualTo(oldPassword); + + userRepository.delete(user); + } + + @Test + @Transactional + void assertThatNotActivatedUsersWithNotNullActivationKeyCreatedBefore3DaysAreDeleted() { + Instant now = Instant.now(); + when(dateTimeProvider.getNow()).thenReturn(Optional.of(now.minus(4, ChronoUnit.DAYS))); + user.setActivated(false); + user.setActivationKey(RandomStringUtils.insecure().next(20)); + User dbUser = userRepository.saveAndFlush(user); + dbUser.setCreatedDate(now.minus(4, ChronoUnit.DAYS)); + userRepository.saveAndFlush(user); + Instant threeDaysAgo = now.minus(3, ChronoUnit.DAYS); + List users = userRepository.findAllByActivatedIsFalseAndActivationKeyIsNotNullAndCreatedDateBefore(threeDaysAgo); + assertThat(users).isNotEmpty(); + userService.removeNotActivatedUsers(); + users = userRepository.findAllByActivatedIsFalseAndActivationKeyIsNotNullAndCreatedDateBefore(threeDaysAgo); + assertThat(users).isEmpty(); + } + + @Test + @Transactional + void assertThatNotActivatedUsersWithNullActivationKeyCreatedBefore3DaysAreNotDeleted() { + Instant now = Instant.now(); + when(dateTimeProvider.getNow()).thenReturn(Optional.of(now.minus(4, ChronoUnit.DAYS))); + user.setActivated(false); + User dbUser = userRepository.saveAndFlush(user); + dbUser.setCreatedDate(now.minus(4, ChronoUnit.DAYS)); + userRepository.saveAndFlush(user); + Instant threeDaysAgo = now.minus(3, ChronoUnit.DAYS); + List users = userRepository.findAllByActivatedIsFalseAndActivationKeyIsNotNullAndCreatedDateBefore(threeDaysAgo); + assertThat(users).isEmpty(); + userService.removeNotActivatedUsers(); + Optional maybeDbUser = userRepository.findById(dbUser.getId()); + assertThat(maybeDbUser).contains(dbUser); + } +} diff --git a/src/test/java/com/example/questionbank/service/mapper/UserMapperTest.java b/src/test/java/com/example/questionbank/service/mapper/UserMapperTest.java new file mode 100644 index 0000000..f0082fa --- /dev/null +++ b/src/test/java/com/example/questionbank/service/mapper/UserMapperTest.java @@ -0,0 +1,180 @@ +package com.example.questionbank.service.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.questionbank.domain.Authority; +import com.example.questionbank.domain.User; +import com.example.questionbank.security.AuthoritiesConstants; +import com.example.questionbank.service.dto.AdminUserDTO; +import com.example.questionbank.service.dto.UserDTO; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link UserMapper}. + */ +class UserMapperTest { + + private static final String DEFAULT_LOGIN = "johndoe"; + private static final Long DEFAULT_ID = 1L; + + private UserMapper userMapper; + private User user; + private AdminUserDTO userDto; + + @BeforeEach + void init() { + userMapper = new UserMapper(); + user = new User(); + user.setLogin(DEFAULT_LOGIN); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + user.setEmail("johndoe@localhost"); + user.setFirstName("john"); + user.setLastName("doe"); + user.setImageUrl("image_url"); + Instant creationTimestamp = Instant.ofEpochMilli(1792485949603L); + user.setCreatedBy(DEFAULT_LOGIN); + user.setCreatedDate(creationTimestamp); + user.setLastModifiedBy(DEFAULT_LOGIN); + user.setLastModifiedDate(creationTimestamp); + user.setLangKey("en"); + + Set authorities = new HashSet<>(); + Authority authority = new Authority(); + authority.setName(AuthoritiesConstants.USER); + authorities.add(authority); + user.setAuthorities(authorities); + + userDto = new AdminUserDTO(user); + } + + @Test + void testUserToUserDTO() { + AdminUserDTO convertedUserDto = userMapper.userToAdminUserDTO(user); + + assertThat(convertedUserDto.getId()).isEqualTo(user.getId()); + assertThat(convertedUserDto.getLogin()).isEqualTo(user.getLogin()); + assertThat(convertedUserDto.getFirstName()).isEqualTo(user.getFirstName()); + assertThat(convertedUserDto.getLastName()).isEqualTo(user.getLastName()); + assertThat(convertedUserDto.getEmail()).isEqualTo(user.getEmail()); + assertThat(convertedUserDto.isActivated()).isEqualTo(user.isActivated()); + assertThat(convertedUserDto.getImageUrl()).isEqualTo(user.getImageUrl()); + assertThat(convertedUserDto.getCreatedBy()).isEqualTo(user.getCreatedBy()); + assertThat(convertedUserDto.getCreatedDate()).isEqualTo(user.getCreatedDate()); + assertThat(convertedUserDto.getLastModifiedBy()).isEqualTo(user.getLastModifiedBy()); + assertThat(convertedUserDto.getLastModifiedDate()).isEqualTo(user.getLastModifiedDate()); + assertThat(convertedUserDto.getLangKey()).isEqualTo(user.getLangKey()); + assertThat(convertedUserDto.getAuthorities()).containsExactly(AuthoritiesConstants.USER); + } + + @Test + void testUserDTOtoUser() { + User convertedUser = userMapper.userDTOToUser(userDto); + + assertThat(convertedUser.getId()).isEqualTo(userDto.getId()); + assertThat(convertedUser.getLogin()).isEqualTo(userDto.getLogin()); + assertThat(convertedUser.getFirstName()).isEqualTo(userDto.getFirstName()); + assertThat(convertedUser.getLastName()).isEqualTo(userDto.getLastName()); + assertThat(convertedUser.getEmail()).isEqualTo(userDto.getEmail()); + assertThat(convertedUser.isActivated()).isEqualTo(userDto.isActivated()); + assertThat(convertedUser.getImageUrl()).isEqualTo(userDto.getImageUrl()); + assertThat(convertedUser.getLangKey()).isEqualTo(userDto.getLangKey()); + assertThat(convertedUser.getCreatedBy()).isEqualTo(userDto.getCreatedBy()); + assertThat(convertedUser.getCreatedDate()).isEqualTo(userDto.getCreatedDate()); + assertThat(convertedUser.getLastModifiedBy()).isEqualTo(userDto.getLastModifiedBy()); + assertThat(convertedUser.getLastModifiedDate()).isEqualTo(userDto.getLastModifiedDate()); + assertThat(convertedUser.getAuthorities()).extracting("name").containsExactly(AuthoritiesConstants.USER); + } + + @Test + void usersToUserDTOsShouldMapOnlyNonNullUsers() { + List users = new ArrayList<>(); + users.add(user); + users.add(null); + + List userDTOS = userMapper.usersToUserDTOs(users); + + assertThat(userDTOS).isNotEmpty().size().isEqualTo(1); + } + + @Test + void userDTOsToUsersShouldMapOnlyNonNullUsers() { + List usersDto = new ArrayList<>(); + usersDto.add(userDto); + usersDto.add(null); + + List users = userMapper.userDTOsToUsers(usersDto); + + assertThat(users).isNotEmpty().size().isEqualTo(1); + } + + @Test + void userDTOsToUsersWithAuthoritiesStringShouldMapToUsersWithAuthoritiesDomain() { + Set authoritiesAsString = new HashSet<>(); + authoritiesAsString.add("ADMIN"); + userDto.setAuthorities(authoritiesAsString); + + List usersDto = new ArrayList<>(); + usersDto.add(userDto); + + List users = userMapper.userDTOsToUsers(usersDto); + + assertThat(users).isNotEmpty().size().isEqualTo(1); + assertThat(users.getFirst().getAuthorities()).isNotNull(); + assertThat(users.getFirst().getAuthorities()).isNotEmpty(); + assertThat(users.getFirst().getAuthorities().iterator().next().getName()).isEqualTo("ADMIN"); + } + + @Test + void userDTOsToUsersMapWithNullAuthoritiesStringShouldReturnUserWithEmptyAuthorities() { + userDto.setAuthorities(null); + + List usersDto = new ArrayList<>(); + usersDto.add(userDto); + + List users = userMapper.userDTOsToUsers(usersDto); + + assertThat(users).isNotEmpty().size().isEqualTo(1); + assertThat(users.getFirst().getAuthorities()).isNotNull(); + assertThat(users.getFirst().getAuthorities()).isEmpty(); + } + + @Test + void userDTOToUserMapWithAuthoritiesStringShouldReturnUserWithAuthorities() { + User convertedUser = userMapper.userDTOToUser(userDto); + + assertThat(convertedUser).isNotNull(); + assertThat(convertedUser.getAuthorities()).isNotNull(); + assertThat(convertedUser.getAuthorities()).isNotEmpty(); + assertThat(convertedUser.getAuthorities().iterator().next().getName()).isEqualTo(AuthoritiesConstants.USER); + } + + @Test + void userDTOToUserMapWithNullAuthoritiesStringShouldReturnUserWithEmptyAuthorities() { + userDto.setAuthorities(null); + + User persistUser = userMapper.userDTOToUser(userDto); + + assertThat(persistUser).isNotNull(); + assertThat(persistUser.getAuthorities()).isNotNull(); + assertThat(persistUser.getAuthorities()).isEmpty(); + } + + @Test + void userDTOToUserMapWithNullUserShouldReturnNull() { + assertThat(userMapper.userDTOToUser(null)).isNull(); + } + + @Test + void testUserFromId() { + assertThat(userMapper.userFromId(DEFAULT_ID).getId()).isEqualTo(DEFAULT_ID); + assertThat(userMapper.userFromId(null)).isNull(); + } +} diff --git a/src/test/java/com/example/questionbank/web/filter/SpaWebFilterIT.java b/src/test/java/com/example/questionbank/web/filter/SpaWebFilterIT.java new file mode 100644 index 0000000..9d0d29b --- /dev/null +++ b/src/test/java/com/example/questionbank/web/filter/SpaWebFilterIT.java @@ -0,0 +1,88 @@ +package com.example.questionbank.web.filter; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.security.AuthoritiesConstants; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@AutoConfigureMockMvc +@WithMockUser +@IntegrationTest +class SpaWebFilterIT { + + @Autowired + private MockMvc mockMvc; + + @Test + void testFilterForwardsToIndex() throws Exception { + mockMvc.perform(get("/")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void testFilterDoesNotForwardToIndexForApi() throws Exception { + mockMvc.perform(get("/api/authenticate")).andExpect(status().is2xxSuccessful()).andExpect(forwardedUrl(null)); + } + + @Test + @WithMockUser(authorities = AuthoritiesConstants.ADMIN) + void testFilterDoesNotForwardToIndexForV3ApiDocs() throws Exception { + mockMvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andExpect(forwardedUrl(null)); + } + + @Test + void testFilterDoesNotForwardToIndexForDotFile() throws Exception { + mockMvc.perform(get("/file.js")).andExpect(status().isNotFound()); + } + + @Test + void getBackendEndpoint() throws Exception { + mockMvc.perform(get("/test")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedFirstLevelMapping() throws Exception { + mockMvc.perform(get("/first-level")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedSecondLevelMapping() throws Exception { + mockMvc.perform(get("/first-level/second-level")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedThirdLevelMapping() throws Exception { + mockMvc.perform(get("/first-level/second-level/third-level")).andExpect(status().isOk()).andExpect(forwardedUrl("/index.html")); + } + + @Test + void forwardUnmappedDeepMapping() throws Exception { + mockMvc.perform(get("/1/2/3/4/5/6/7/8/9/10")).andExpect(forwardedUrl("/index.html")); + } + + @Test + void getUnmappedFirstLevelFile() throws Exception { + mockMvc.perform(get("/foo.js")).andExpect(status().isNotFound()); + } + + /** + * This test verifies that any files that aren't permitted by Spring Security will be forbidden. + * If you want to change this to return isNotFound(), you need to add a request mapping that + * allows this file in SecurityConfiguration. + */ + @Test + void getUnmappedSecondLevelFile() throws Exception { + mockMvc.perform(get("/foo/bar.js")).andExpect(status().isForbidden()); + } + + @Test + void getUnmappedThirdLevelFile() throws Exception { + mockMvc.perform(get("/foo/another/bar.js")).andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/AccountResourceIT.java b/src/test/java/com/example/questionbank/web/rest/AccountResourceIT.java new file mode 100644 index 0000000..43903fe --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/AccountResourceIT.java @@ -0,0 +1,790 @@ +package com.example.questionbank.web.rest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.config.Constants; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.AuthorityRepository; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.security.AuthoritiesConstants; +import com.example.questionbank.service.UserService; +import com.example.questionbank.service.dto.AdminUserDTO; +import com.example.questionbank.service.dto.PasswordChangeDTO; +import com.example.questionbank.web.rest.vm.KeyAndPasswordVM; +import com.example.questionbank.web.rest.vm.ManagedUserVM; +import java.time.Instant; +import java.util.*; +import java.util.stream.Stream; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.ObjectMapper; + +/** + * Integration tests for the {@link AccountResource} REST controller. + */ +@AutoConfigureMockMvc +@IntegrationTest +class AccountResourceIT { + + static final String TEST_USER_LOGIN = "test"; + + @Autowired + private ObjectMapper om; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthorityRepository authorityRepository; + + @Autowired + private UserService userService; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private MockMvc restAccountMockMvc; + + private Long numberOfUsers; + + @BeforeEach + void countUsers() { + numberOfUsers = userRepository.count(); + } + + @AfterEach + void cleanupAndCheck() { + assertThat(userRepository.count()).isEqualTo(numberOfUsers); + numberOfUsers = null; + } + + @Test + @WithUnauthenticatedMockUser + void testNonAuthenticatedUser() throws Exception { + restAccountMockMvc.perform(get("/api/authenticate")).andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(TEST_USER_LOGIN) + void testAuthenticatedUser() throws Exception { + restAccountMockMvc.perform(get("/api/authenticate").with(request -> request)).andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(TEST_USER_LOGIN) + void testGetExistingAccount() throws Exception { + Set authorities = new HashSet<>(); + authorities.add(AuthoritiesConstants.ADMIN); + + AdminUserDTO user = new AdminUserDTO(); + user.setLogin(TEST_USER_LOGIN); + user.setFirstName("john"); + user.setLastName("doe"); + user.setEmail("john.doe@jhipster.com"); + user.setImageUrl("http://placehold.it/50x50"); + user.setLangKey("en"); + user.setAuthorities(authorities); + userService.createUser(user); + + restAccountMockMvc + .perform(get("/api/account").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.login").value(TEST_USER_LOGIN)) + .andExpect(jsonPath("$.firstName").value("john")) + .andExpect(jsonPath("$.lastName").value("doe")) + .andExpect(jsonPath("$.email").value("john.doe@jhipster.com")) + .andExpect(jsonPath("$.imageUrl").value("http://placehold.it/50x50")) + .andExpect(jsonPath("$.langKey").value("en")) + .andExpect(jsonPath("$.authorities").value(AuthoritiesConstants.ADMIN)); + + userService.deleteUser(TEST_USER_LOGIN); + } + + @Test + void testGetUnknownAccount() throws Exception { + restAccountMockMvc.perform(get("/api/account").accept(MediaType.APPLICATION_PROBLEM_JSON)).andExpect(status().isUnauthorized()); + } + + @Test + @Transactional + void testRegisterValid() throws Exception { + ManagedUserVM validUser = new ManagedUserVM(); + validUser.setLogin("test-register-valid"); + validUser.setPassword("password"); + validUser.setFirstName("Alice"); + validUser.setLastName("Test"); + validUser.setEmail("test-register-valid@example.com"); + validUser.setImageUrl("http://placehold.it/50x50"); + validUser.setLangKey(Constants.DEFAULT_LANGUAGE); + validUser.setAuthorities(Set.of(AuthoritiesConstants.USER)); + assertThat(userRepository.findOneByLogin("test-register-valid")).isEmpty(); + + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(validUser))) + .andExpect(status().isCreated()); + + assertThat(userRepository.findOneByLogin("test-register-valid")).isPresent(); + + userService.deleteUser("test-register-valid"); + } + + @Test + @Transactional + void testRegisterInvalidLogin() throws Exception { + ManagedUserVM invalidUser = new ManagedUserVM(); + invalidUser.setLogin("funky-log(n"); // <-- invalid + invalidUser.setPassword("password"); + invalidUser.setFirstName("Funky"); + invalidUser.setLastName("One"); + invalidUser.setEmail("funky@example.com"); + invalidUser.setActivated(true); + invalidUser.setImageUrl("http://placehold.it/50x50"); + invalidUser.setLangKey(Constants.DEFAULT_LANGUAGE); + invalidUser.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(invalidUser))) + .andExpect(status().isBadRequest()); + + Optional user = userRepository.findOneByEmailIgnoreCase("funky@example.com"); + assertThat(user).isEmpty(); + } + + static Stream invalidUsers() { + return Stream.of( + createInvalidUser("bob", "password", "Bob", "Green", "invalid", true), // <-- invalid + createInvalidUser("bob", "123", "Bob", "Green", "bob@example.com", true), // password with only 3 digits + createInvalidUser("bob", null, "Bob", "Green", "bob@example.com", true) // invalid null password + ); + } + + @ParameterizedTest + @MethodSource("invalidUsers") + @Transactional + void testRegisterInvalidUsers(ManagedUserVM invalidUser) throws Exception { + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(invalidUser))) + .andExpect(status().isBadRequest()); + + Optional user = userRepository.findOneByLogin("bob"); + assertThat(user).isEmpty(); + } + + private static ManagedUserVM createInvalidUser( + String login, + String password, + String firstName, + String lastName, + String email, + boolean activated + ) { + ManagedUserVM invalidUser = new ManagedUserVM(); + invalidUser.setLogin(login); + invalidUser.setPassword(password); + invalidUser.setFirstName(firstName); + invalidUser.setLastName(lastName); + invalidUser.setEmail(email); + invalidUser.setActivated(activated); + invalidUser.setImageUrl("http://placehold.it/50x50"); + invalidUser.setLangKey(Constants.DEFAULT_LANGUAGE); + invalidUser.setAuthorities(Set.of(AuthoritiesConstants.USER)); + return invalidUser; + } + + @Test + @Transactional + void testRegisterDuplicateLogin() throws Exception { + // First registration + ManagedUserVM firstUser = new ManagedUserVM(); + firstUser.setLogin("alice"); + firstUser.setPassword("password"); + firstUser.setFirstName("Alice"); + firstUser.setLastName("Something"); + firstUser.setEmail("alice@example.com"); + firstUser.setImageUrl("http://placehold.it/50x50"); + firstUser.setLangKey(Constants.DEFAULT_LANGUAGE); + firstUser.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + // Duplicate login, different email + ManagedUserVM secondUser = new ManagedUserVM(); + secondUser.setLogin(firstUser.getLogin()); + secondUser.setPassword(firstUser.getPassword()); + secondUser.setFirstName(firstUser.getFirstName()); + secondUser.setLastName(firstUser.getLastName()); + secondUser.setEmail("alice2@example.com"); + secondUser.setImageUrl(firstUser.getImageUrl()); + secondUser.setLangKey(firstUser.getLangKey()); + secondUser.setCreatedBy(firstUser.getCreatedBy()); + secondUser.setCreatedDate(firstUser.getCreatedDate()); + secondUser.setLastModifiedBy(firstUser.getLastModifiedBy()); + secondUser.setLastModifiedDate(firstUser.getLastModifiedDate()); + secondUser.setAuthorities(new HashSet<>(firstUser.getAuthorities())); + + // First user + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(firstUser))) + .andExpect(status().isCreated()); + + // Second (non activated) user + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(secondUser))) + .andExpect(status().isCreated()); + + Optional testUser = userRepository.findOneByEmailIgnoreCase("alice2@example.com"); + assertThat(testUser).isPresent(); + testUser.orElseThrow().setActivated(true); + userRepository.save(testUser.orElseThrow()); + + // Second (already activated) user + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(secondUser))) + .andExpect(status().is4xxClientError()); + + userService.deleteUser("alice"); + } + + @Test + @Transactional + void testRegisterDuplicateEmail() throws Exception { + // First user + ManagedUserVM firstUser = new ManagedUserVM(); + firstUser.setLogin("test-register-duplicate-email"); + firstUser.setPassword("password"); + firstUser.setFirstName("Alice"); + firstUser.setLastName("Test"); + firstUser.setEmail("test-register-duplicate-email@example.com"); + firstUser.setImageUrl("http://placehold.it/50x50"); + firstUser.setLangKey(Constants.DEFAULT_LANGUAGE); + firstUser.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + // Register first user + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(firstUser))) + .andExpect(status().isCreated()); + + Optional testUser1 = userRepository.findOneByLogin("test-register-duplicate-email"); + assertThat(testUser1).isPresent(); + + // Duplicate email, different login + ManagedUserVM secondUser = new ManagedUserVM(); + secondUser.setLogin("test-register-duplicate-email-2"); + secondUser.setPassword(firstUser.getPassword()); + secondUser.setFirstName(firstUser.getFirstName()); + secondUser.setLastName(firstUser.getLastName()); + secondUser.setEmail(firstUser.getEmail()); + secondUser.setImageUrl(firstUser.getImageUrl()); + secondUser.setLangKey(firstUser.getLangKey()); + secondUser.setAuthorities(new HashSet<>(firstUser.getAuthorities())); + + // Register second (non activated) user + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(secondUser))) + .andExpect(status().isCreated()); + + Optional testUser2 = userRepository.findOneByLogin("test-register-duplicate-email"); + assertThat(testUser2).isEmpty(); + + Optional testUser3 = userRepository.findOneByLogin("test-register-duplicate-email-2"); + assertThat(testUser3).isPresent(); + + // Duplicate email - with uppercase email address + ManagedUserVM userWithUpperCaseEmail = new ManagedUserVM(); + userWithUpperCaseEmail.setId(firstUser.getId()); + userWithUpperCaseEmail.setLogin("test-register-duplicate-email-3"); + userWithUpperCaseEmail.setPassword(firstUser.getPassword()); + userWithUpperCaseEmail.setFirstName(firstUser.getFirstName()); + userWithUpperCaseEmail.setLastName(firstUser.getLastName()); + userWithUpperCaseEmail.setEmail("TEST-register-duplicate-email@example.com"); + userWithUpperCaseEmail.setImageUrl(firstUser.getImageUrl()); + userWithUpperCaseEmail.setLangKey(firstUser.getLangKey()); + userWithUpperCaseEmail.setAuthorities(new HashSet<>(firstUser.getAuthorities())); + + // Register third (not activated) user + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userWithUpperCaseEmail))) + .andExpect(status().isCreated()); + + Optional testUser4 = userRepository.findOneByLogin("test-register-duplicate-email-3"); + assertThat(testUser4).isPresent(); + assertThat(testUser4.orElseThrow().getEmail()).isEqualTo("test-register-duplicate-email@example.com"); + + testUser4.orElseThrow().setActivated(true); + userService.updateUser(new AdminUserDTO(testUser4.orElseThrow())); + + // Register 4th (already activated) user + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(secondUser))) + .andExpect(status().is4xxClientError()); + + userService.deleteUser("test-register-duplicate-email-3"); + } + + @Test + @Transactional + void testRegisterAdminIsIgnored() throws Exception { + ManagedUserVM validUser = new ManagedUserVM(); + validUser.setLogin("badguy"); + validUser.setPassword("password"); + validUser.setFirstName("Bad"); + validUser.setLastName("Guy"); + validUser.setEmail("badguy@example.com"); + validUser.setActivated(true); + validUser.setImageUrl("http://placehold.it/50x50"); + validUser.setLangKey(Constants.DEFAULT_LANGUAGE); + validUser.setAuthorities(Set.of(AuthoritiesConstants.ADMIN)); + + restAccountMockMvc + .perform(post("/api/register").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(validUser))) + .andExpect(status().isCreated()); + + Optional userDup = userRepository.findOneWithAuthoritiesByLogin("badguy"); + assertThat(userDup).isPresent(); + assertThat(userDup.orElseThrow().getAuthorities()) + .hasSize(1) + .containsExactly(authorityRepository.findById(AuthoritiesConstants.USER).orElseThrow()); + + userService.deleteUser("badguy"); + } + + @Test + @Transactional + void testActivateAccount() throws Exception { + final String activationKey = "some activation key"; + User user = new User(); + user.setLogin("activate-account"); + user.setEmail("activate-account@example.com"); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(false); + user.setActivationKey(activationKey); + + userRepository.saveAndFlush(user); + + restAccountMockMvc.perform(get("/api/activate?key={activationKey}", activationKey)).andExpect(status().isOk()); + + user = userRepository.findOneByLogin(user.getLogin()).orElse(null); + assertThat(user.isActivated()).isTrue(); + + userService.deleteUser("activate-account"); + } + + @Test + @Transactional + void testActivateAccountWithWrongKey() throws Exception { + restAccountMockMvc.perform(get("/api/activate?key=wrongActivationKey")).andExpect(status().isBadRequest()); + } + + @Test + @Transactional + @WithMockUser("save-account") + void testSaveAccount() throws Exception { + User user = new User(); + user.setLogin("save-account"); + user.setEmail("save-account@example.com"); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + userRepository.saveAndFlush(user); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setLogin("not-used"); + userDTO.setFirstName("firstname"); + userDTO.setLastName("lastname"); + userDTO.setEmail("save-account@example.com"); + userDTO.setActivated(false); + userDTO.setImageUrl("http://placehold.it/50x50"); + userDTO.setLangKey(Constants.DEFAULT_LANGUAGE); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.ADMIN)); + + restAccountMockMvc + .perform(post("/api/account").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isOk()); + + User updatedUser = userRepository.findOneWithAuthoritiesByLogin(user.getLogin()).orElse(null); + assertThat(updatedUser.getFirstName()).isEqualTo(userDTO.getFirstName()); + assertThat(updatedUser.getLastName()).isEqualTo(userDTO.getLastName()); + assertThat(updatedUser.getEmail()).isEqualTo(userDTO.getEmail()); + assertThat(updatedUser.getLangKey()).isEqualTo(userDTO.getLangKey()); + assertThat(updatedUser.getPassword()).isEqualTo(user.getPassword()); + assertThat(updatedUser.getImageUrl()).isEqualTo(userDTO.getImageUrl()); + assertThat(updatedUser.isActivated()).isTrue(); + assertThat(updatedUser.getAuthorities()).isEmpty(); + + userService.deleteUser("save-account"); + } + + @Test + @Transactional + @WithMockUser("save-invalid-email") + void testSaveInvalidEmail() throws Exception { + User user = new User(); + user.setLogin("save-invalid-email"); + user.setEmail("save-invalid-email@example.com"); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + + userRepository.saveAndFlush(user); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setLogin("not-used"); + userDTO.setFirstName("firstname"); + userDTO.setLastName("lastname"); + userDTO.setEmail("invalid email"); + userDTO.setActivated(false); + userDTO.setImageUrl("http://placehold.it/50x50"); + userDTO.setLangKey(Constants.DEFAULT_LANGUAGE); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.ADMIN)); + + restAccountMockMvc + .perform(post("/api/account").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isBadRequest()); + + assertThat(userRepository.findOneByEmailIgnoreCase("invalid email")).isNotPresent(); + + userService.deleteUser("save-invalid-email"); + } + + @Test + @Transactional + @WithMockUser("save-existing-email") + void testSaveExistingEmail() throws Exception { + User user = new User(); + user.setLogin("save-existing-email"); + user.setEmail("save-existing-email@example.com"); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + userRepository.saveAndFlush(user); + + User anotherUser = new User(); + anotherUser.setLogin("save-existing-email2"); + anotherUser.setEmail("save-existing-email2@example.com"); + anotherUser.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + anotherUser.setActivated(true); + + userRepository.saveAndFlush(anotherUser); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setLogin("not-used"); + userDTO.setFirstName("firstname"); + userDTO.setLastName("lastname"); + userDTO.setEmail("save-existing-email2@example.com"); + userDTO.setActivated(false); + userDTO.setImageUrl("http://placehold.it/50x50"); + userDTO.setLangKey(Constants.DEFAULT_LANGUAGE); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.ADMIN)); + + restAccountMockMvc + .perform(post("/api/account").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isBadRequest()); + + User updatedUser = userRepository.findOneByLogin("save-existing-email").orElseThrow(); + assertThat(updatedUser.getEmail()).isEqualTo("save-existing-email@example.com"); + + userService.deleteUser("save-existing-email"); + userService.deleteUser("save-existing-email2"); + } + + @Test + @Transactional + @WithMockUser("save-existing-email-and-login") + void testSaveExistingEmailAndLogin() throws Exception { + User user = new User(); + user.setLogin("save-existing-email-and-login"); + user.setEmail("save-existing-email-and-login@example.com"); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + userRepository.saveAndFlush(user); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setLogin("not-used"); + userDTO.setFirstName("firstname"); + userDTO.setLastName("lastname"); + userDTO.setEmail("save-existing-email-and-login@example.com"); + userDTO.setActivated(false); + userDTO.setImageUrl("http://placehold.it/50x50"); + userDTO.setLangKey(Constants.DEFAULT_LANGUAGE); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.ADMIN)); + + restAccountMockMvc + .perform(post("/api/account").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isOk()); + + User updatedUser = userRepository.findOneByLogin("save-existing-email-and-login").orElse(null); + assertThat(updatedUser.getEmail()).isEqualTo("save-existing-email-and-login@example.com"); + + userService.deleteUser("save-existing-email-and-login"); + } + + @Test + @Transactional + @WithMockUser("change-password-wrong-existing-password") + void testChangePasswordWrongExistingPassword() throws Exception { + User user = new User(); + String currentPassword = RandomStringUtils.insecure().nextAlphanumeric(60); + user.setPassword(passwordEncoder.encode(currentPassword)); + user.setLogin("change-password-wrong-existing-password"); + user.setEmail("change-password-wrong-existing-password@example.com"); + userRepository.saveAndFlush(user); + + restAccountMockMvc + .perform( + post("/api/account/change-password") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(new PasswordChangeDTO("1" + currentPassword, "new password"))) + ) + .andExpect(status().isBadRequest()); + + User updatedUser = userRepository.findOneByLogin("change-password-wrong-existing-password").orElse(null); + assertThat(passwordEncoder.matches("new password", updatedUser.getPassword())).isFalse(); + assertThat(passwordEncoder.matches(currentPassword, updatedUser.getPassword())).isTrue(); + + userService.deleteUser("change-password-wrong-existing-password"); + } + + @Test + @Transactional + @WithMockUser("change-password") + void testChangePassword() throws Exception { + User user = new User(); + String currentPassword = RandomStringUtils.insecure().nextAlphanumeric(60); + user.setPassword(passwordEncoder.encode(currentPassword)); + user.setLogin("change-password"); + user.setEmail("change-password@example.com"); + userRepository.saveAndFlush(user); + + restAccountMockMvc + .perform( + post("/api/account/change-password") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(new PasswordChangeDTO(currentPassword, "new password"))) + ) + .andExpect(status().isOk()); + + User updatedUser = userRepository.findOneByLogin("change-password").orElse(null); + assertThat(passwordEncoder.matches("new password", updatedUser.getPassword())).isTrue(); + + userService.deleteUser("change-password"); + } + + @Test + @Transactional + @WithMockUser("change-password-too-small") + void testChangePasswordTooSmall() throws Exception { + User user = new User(); + String currentPassword = RandomStringUtils.insecure().nextAlphanumeric(60); + user.setPassword(passwordEncoder.encode(currentPassword)); + user.setLogin("change-password-too-small"); + user.setEmail("change-password-too-small@example.com"); + userRepository.saveAndFlush(user); + + String newPassword = RandomStringUtils.insecure().next(ManagedUserVM.PASSWORD_MIN_LENGTH - 1); + + restAccountMockMvc + .perform( + post("/api/account/change-password") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(new PasswordChangeDTO(currentPassword, newPassword))) + ) + .andExpect(status().isBadRequest()); + + User updatedUser = userRepository.findOneByLogin("change-password-too-small").orElse(null); + assertThat(updatedUser.getPassword()).isEqualTo(user.getPassword()); + + userService.deleteUser("change-password-too-small"); + } + + @Test + @Transactional + @WithMockUser("change-password-too-long") + void testChangePasswordTooLong() throws Exception { + User user = new User(); + String currentPassword = RandomStringUtils.insecure().nextAlphanumeric(60); + user.setPassword(passwordEncoder.encode(currentPassword)); + user.setLogin("change-password-too-long"); + user.setEmail("change-password-too-long@example.com"); + userRepository.saveAndFlush(user); + + String newPassword = RandomStringUtils.insecure().next(ManagedUserVM.PASSWORD_MAX_LENGTH + 1); + + restAccountMockMvc + .perform( + post("/api/account/change-password") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(new PasswordChangeDTO(currentPassword, newPassword))) + ) + .andExpect(status().isBadRequest()); + + User updatedUser = userRepository.findOneByLogin("change-password-too-long").orElse(null); + assertThat(updatedUser.getPassword()).isEqualTo(user.getPassword()); + + userService.deleteUser("change-password-too-long"); + } + + @Test + @Transactional + @WithMockUser("change-password-empty") + void testChangePasswordEmpty() throws Exception { + User user = new User(); + String currentPassword = RandomStringUtils.insecure().nextAlphanumeric(60); + user.setPassword(passwordEncoder.encode(currentPassword)); + user.setLogin("change-password-empty"); + user.setEmail("change-password-empty@example.com"); + userRepository.saveAndFlush(user); + + restAccountMockMvc + .perform( + post("/api/account/change-password") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(new PasswordChangeDTO(currentPassword, ""))) + ) + .andExpect(status().isBadRequest()); + + User updatedUser = userRepository.findOneByLogin("change-password-empty").orElse(null); + assertThat(updatedUser.getPassword()).isEqualTo(user.getPassword()); + + userService.deleteUser("change-password-empty"); + } + + @Test + @Transactional + void testRequestPasswordReset() throws Exception { + User user = new User(); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + user.setLogin("password-reset"); + user.setEmail("password-reset@example.com"); + user.setLangKey("en"); + userRepository.saveAndFlush(user); + + restAccountMockMvc + .perform(post("/api/account/reset-password/init").content("password-reset@example.com")) + .andExpect(status().isOk()); + + userService.deleteUser("password-reset"); + } + + @Test + @Transactional + void testRequestPasswordResetUpperCaseEmail() throws Exception { + User user = new User(); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setActivated(true); + user.setLogin("password-reset-upper-case"); + user.setEmail("password-reset-upper-case@example.com"); + user.setLangKey("en"); + userRepository.saveAndFlush(user); + + restAccountMockMvc + .perform(post("/api/account/reset-password/init").content("password-reset-upper-case@EXAMPLE.COM")) + .andExpect(status().isOk()); + + userService.deleteUser("password-reset-upper-case"); + } + + @Test + void testRequestPasswordResetWrongEmail() throws Exception { + restAccountMockMvc + .perform(post("/api/account/reset-password/init").content("password-reset-wrong-email@example.com")) + .andExpect(status().isOk()); + } + + @Test + void testRequestPasswordResetInvalidEmail() throws Exception { + restAccountMockMvc.perform(post("/api/account/reset-password/init").content("invalid-email")).andExpect(status().isBadRequest()); + } + + @Test + void testRequestPasswordResetTooLongEmail() throws Exception { + restAccountMockMvc + .perform(post("/api/account/reset-password/init").content("a".repeat(250) + "@x.co")) + .andExpect(status().isBadRequest()); + } + + @Test + @Transactional + void testFinishPasswordReset() throws Exception { + User user = new User(); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setLogin("finish-password-reset"); + user.setEmail("finish-password-reset@example.com"); + user.setResetDate(Instant.now().plusSeconds(60)); + user.setResetKey("reset key"); + userRepository.saveAndFlush(user); + + KeyAndPasswordVM keyAndPassword = new KeyAndPasswordVM(); + keyAndPassword.setKey(user.getResetKey()); + keyAndPassword.setNewPassword("new password"); + + restAccountMockMvc + .perform( + post("/api/account/reset-password/finish") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(keyAndPassword)) + ) + .andExpect(status().isOk()); + + User updatedUser = userRepository.findOneByLogin(user.getLogin()).orElse(null); + assertThat(passwordEncoder.matches(keyAndPassword.getNewPassword(), updatedUser.getPassword())).isTrue(); + + userService.deleteUser("finish-password-reset"); + } + + @Test + @Transactional + void testFinishPasswordResetTooSmall() throws Exception { + User user = new User(); + user.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + user.setLogin("finish-password-reset-too-small"); + user.setEmail("finish-password-reset-too-small@example.com"); + user.setResetDate(Instant.now().plusSeconds(60)); + user.setResetKey("reset key too small"); + userRepository.saveAndFlush(user); + + KeyAndPasswordVM keyAndPassword = new KeyAndPasswordVM(); + keyAndPassword.setKey(user.getResetKey()); + keyAndPassword.setNewPassword("foo"); + + restAccountMockMvc + .perform( + post("/api/account/reset-password/finish") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(keyAndPassword)) + ) + .andExpect(status().isBadRequest()); + + User updatedUser = userRepository.findOneByLogin(user.getLogin()).orElse(null); + assertThat(passwordEncoder.matches(keyAndPassword.getNewPassword(), updatedUser.getPassword())).isFalse(); + + userService.deleteUser("finish-password-reset-too-small"); + } + + @Test + @Transactional + void testFinishPasswordResetWrongKey() throws Exception { + KeyAndPasswordVM keyAndPassword = new KeyAndPasswordVM(); + keyAndPassword.setKey("wrong reset key"); + keyAndPassword.setNewPassword("new password"); + + restAccountMockMvc + .perform( + post("/api/account/reset-password/finish") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(keyAndPassword)) + ) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/AuthenticateControllerIT.java b/src/test/java/com/example/questionbank/web/rest/AuthenticateControllerIT.java new file mode 100644 index 0000000..d550bcf --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/AuthenticateControllerIT.java @@ -0,0 +1,102 @@ +package com.example.questionbank.web.rest; + +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.web.rest.vm.LoginVM; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.ObjectMapper; + +/** + * Integration tests for the {@link AuthenticateController} REST controller. + */ +@AutoConfigureMockMvc +@IntegrationTest +class AuthenticateControllerIT { + + @Autowired + private ObjectMapper om; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private MockMvc mockMvc; + + @Test + @Transactional + void testAuthorize() throws Exception { + User user = new User(); + user.setLogin("user-jwt-controller"); + user.setEmail("user-jwt-controller@example.com"); + user.setActivated(true); + user.setPassword(passwordEncoder.encode("test")); + + userRepository.saveAndFlush(user); + + LoginVM login = new LoginVM(); + login.setUsername("user-jwt-controller"); + login.setPassword("test"); + mockMvc + .perform(post("/api/authenticate").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(login))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id_token").isString()) + .andExpect(jsonPath("$.id_token").isNotEmpty()) + .andExpect(header().string("Authorization", not(nullValue()))) + .andExpect(header().string("Authorization", not(is(emptyString())))); + } + + @Test + @Transactional + void testAuthorizeWithRememberMe() throws Exception { + User user = new User(); + user.setLogin("user-jwt-controller-remember-me"); + user.setEmail("user-jwt-controller-remember-me@example.com"); + user.setActivated(true); + user.setPassword(passwordEncoder.encode("test")); + + userRepository.saveAndFlush(user); + + LoginVM login = new LoginVM(); + login.setUsername("user-jwt-controller-remember-me"); + login.setPassword("test"); + login.setRememberMe(true); + mockMvc + .perform(post("/api/authenticate").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(login))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id_token").isString()) + .andExpect(jsonPath("$.id_token").isNotEmpty()) + .andExpect(header().string("Authorization", not(nullValue()))) + .andExpect(header().string("Authorization", not(is(emptyString())))); + } + + @Test + void testAuthorizeFails() throws Exception { + LoginVM login = new LoginVM(); + login.setUsername("wrong-user"); + login.setPassword("wrong password"); + mockMvc + .perform(post("/api/authenticate").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(login))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.id_token").doesNotExist()) + .andExpect(header().doesNotExist("Authorization")); + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/AuthorityResourceIT.java b/src/test/java/com/example/questionbank/web/rest/AuthorityResourceIT.java new file mode 100644 index 0000000..2a8cc30 --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/AuthorityResourceIT.java @@ -0,0 +1,206 @@ +package com.example.questionbank.web.rest; + +import static com.example.questionbank.domain.AuthorityAsserts.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.domain.Authority; +import com.example.questionbank.repository.AuthorityRepository; +import jakarta.persistence.EntityManager; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.ObjectMapper; + +/** + * Integration tests for the {@link AuthorityResource} REST controller. + */ +@IntegrationTest +@AutoConfigureMockMvc +@WithMockUser(authorities = { "ROLE_ADMIN" }) +class AuthorityResourceIT { + + private static final String ENTITY_API_URL = "/api/authorities"; + private static final String ENTITY_API_URL_ID = ENTITY_API_URL + "/{name}"; + + @Autowired + private ObjectMapper om; + + @Autowired + private AuthorityRepository authorityRepository; + + @Autowired + private EntityManager em; + + @Autowired + private MockMvc restAuthorityMockMvc; + + private Authority authority; + + private Authority insertedAuthority; + + /** + * Create an entity for this test. + * + * This is a static method, as tests for other entities might also need it, + * if they test an entity which requires the current entity. + */ + public static Authority createEntity() { + return new Authority().name(UUID.randomUUID().toString()); + } + + /** + * Create an updated entity for this test. + * + * This is a static method, as tests for other entities might also need it, + * if they test an entity which requires the current entity. + */ + public static Authority createUpdatedEntity() { + return new Authority().name(UUID.randomUUID().toString()); + } + + @BeforeEach + void initTest() { + authority = createEntity(); + } + + @AfterEach + void cleanup() { + if (insertedAuthority != null) { + authorityRepository.delete(insertedAuthority); + insertedAuthority = null; + } + } + + @Test + @Transactional + void createAuthority() throws Exception { + long databaseSizeBeforeCreate = getRepositoryCount(); + // Create the Authority + var returnedAuthority = om.readValue( + restAuthorityMockMvc + .perform(post(ENTITY_API_URL).contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(authority))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(), + Authority.class + ); + + // Validate the Authority in the database + assertIncrementedRepositoryCount(databaseSizeBeforeCreate); + assertAuthorityUpdatableFieldsEquals(returnedAuthority, getPersistedAuthority(returnedAuthority)); + + insertedAuthority = returnedAuthority; + } + + @Test + @Transactional + void createAuthorityWithExistingId() throws Exception { + // Create the Authority with an existing ID + insertedAuthority = authorityRepository.saveAndFlush(authority); + + long databaseSizeBeforeCreate = getRepositoryCount(); + + // An entity with an existing ID cannot be created, so this API call must fail + restAuthorityMockMvc + .perform(post(ENTITY_API_URL).contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(authority))) + .andExpect(status().isBadRequest()); + + // Validate the Authority in the database + assertSameRepositoryCount(databaseSizeBeforeCreate); + } + + @Test + @Transactional + void getAllAuthorities() throws Exception { + // Initialize the database + authority.setName(UUID.randomUUID().toString()); + insertedAuthority = authorityRepository.saveAndFlush(authority); + + // Get all the authorityList + restAuthorityMockMvc + .perform(get(ENTITY_API_URL + "?sort=name,desc")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.[*].name").value(hasItem(authority.getName()))); + } + + @Test + @Transactional + void getAuthority() throws Exception { + // Initialize the database + authority.setName(UUID.randomUUID().toString()); + insertedAuthority = authorityRepository.saveAndFlush(authority); + + // Get the authority + restAuthorityMockMvc + .perform(get(ENTITY_API_URL_ID, authority.getName())) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.name").value(authority.getName())); + } + + @Test + @Transactional + void getNonExistingAuthority() throws Exception { + // Get the authority + restAuthorityMockMvc.perform(get(ENTITY_API_URL_ID, Long.MAX_VALUE)).andExpect(status().isNotFound()); + } + + @Test + @Transactional + void deleteAuthority() throws Exception { + // Initialize the database + authority.setName(UUID.randomUUID().toString()); + insertedAuthority = authorityRepository.saveAndFlush(authority); + + long databaseSizeBeforeDelete = getRepositoryCount(); + + // Delete the authority + restAuthorityMockMvc + .perform(delete(ENTITY_API_URL_ID, authority.getName()).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + + // Validate the database contains one less item + assertDecrementedRepositoryCount(databaseSizeBeforeDelete); + } + + protected long getRepositoryCount() { + return authorityRepository.count(); + } + + protected void assertIncrementedRepositoryCount(long countBefore) { + assertThat(countBefore + 1).isEqualTo(getRepositoryCount()); + } + + protected void assertDecrementedRepositoryCount(long countBefore) { + assertThat(countBefore - 1).isEqualTo(getRepositoryCount()); + } + + protected void assertSameRepositoryCount(long countBefore) { + assertThat(countBefore).isEqualTo(getRepositoryCount()); + } + + protected Authority getPersistedAuthority(Authority authority) { + return authorityRepository.findById(authority.getName()).orElseThrow(); + } + + protected void assertPersistedAuthorityToMatchAllProperties(Authority expectedAuthority) { + assertAuthorityAllPropertiesEquals(expectedAuthority, getPersistedAuthority(expectedAuthority)); + } + + protected void assertPersistedAuthorityToMatchUpdatableProperties(Authority expectedAuthority) { + assertAuthorityAllUpdatablePropertiesEquals(expectedAuthority, getPersistedAuthority(expectedAuthority)); + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/PublicUserResourceIT.java b/src/test/java/com/example/questionbank/web/rest/PublicUserResourceIT.java new file mode 100644 index 0000000..b5017bc --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/PublicUserResourceIT.java @@ -0,0 +1,87 @@ +package com.example.questionbank.web.rest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.security.AuthoritiesConstants; +import com.example.questionbank.service.UserService; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration tests for the {@link PublicUserResource} REST controller. + */ +@AutoConfigureMockMvc +@WithMockUser(authorities = AuthoritiesConstants.ADMIN) +@IntegrationTest +class PublicUserResourceIT { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private MockMvc restUserMockMvc; + + private User user; + private Long numberOfUsers; + + @BeforeEach + void initTest() { + numberOfUsers = userRepository.count(); + user = UserResourceIT.initTestUser(); + } + + @AfterEach + void cleanupAndCheck() { + userService.deleteUser(user.getLogin()); + assertThat(userRepository.count()).isEqualTo(numberOfUsers); + numberOfUsers = null; + } + + @Test + @Transactional + void getAllPublicUsers() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + + // Get all the users + restUserMockMvc + .perform(get("/api/users?sort=id,desc").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.[?(@.id == %d)].login".formatted(user.getId())).value(user.getLogin())) + .andExpect(jsonPath("$.[?(@.id == %d)].keys()".formatted(user.getId())).value(Set.of("id", "login"))) + .andExpect(jsonPath("$.[*].email").doesNotHaveJsonPath()) + .andExpect(jsonPath("$.[*].imageUrl").doesNotHaveJsonPath()) + .andExpect(jsonPath("$.[*].langKey").doesNotHaveJsonPath()); + } + + @Test + @Transactional + void getAllUsersSortedByParameters() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + + restUserMockMvc.perform(get("/api/users?sort=resetKey,desc").accept(MediaType.APPLICATION_JSON)).andExpect(status().isBadRequest()); + restUserMockMvc.perform(get("/api/users?sort=password,desc").accept(MediaType.APPLICATION_JSON)).andExpect(status().isBadRequest()); + restUserMockMvc + .perform(get("/api/users?sort=resetKey,desc&sort=id,desc").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + restUserMockMvc.perform(get("/api/users?sort=id,desc").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/TestUtil.java b/src/test/java/com/example/questionbank/web/rest/TestUtil.java new file mode 100644 index 0000000..8d9cc55 --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/TestUtil.java @@ -0,0 +1,190 @@ +package com.example.questionbank.web.rest; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.Arrays; +import java.util.List; +import org.hamcrest.Description; +import org.hamcrest.TypeSafeDiagnosingMatcher; +import org.hamcrest.TypeSafeMatcher; +import org.springframework.cglib.proxy.Enhancer; +import org.springframework.cglib.proxy.MethodInterceptor; +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; + +/** + * Utility class for testing REST controllers. + */ +public final class TestUtil { + + /** + * Create a byte array with a specific size filled with specified data. + * + * @param size the size of the byte array. + * @param data the data to put in the byte array. + * @return the JSON byte array. + */ + public static byte[] createByteArray(int size, String data) { + byte[] byteArray = new byte[size]; + Arrays.fill(byteArray, Byte.parseByte(data, 2)); + return byteArray; + } + + /** + * A matcher that tests that the examined string represents the same instant as the reference datetime. + */ + public static class ZonedDateTimeMatcher extends TypeSafeDiagnosingMatcher { + + private final ZonedDateTime date; + + public ZonedDateTimeMatcher(ZonedDateTime date) { + this.date = date; + } + + @Override + protected boolean matchesSafely(String item, Description mismatchDescription) { + try { + if (!date.isEqual(ZonedDateTime.parse(item))) { + mismatchDescription.appendText("was ").appendValue(item); + return false; + } + return true; + } catch (DateTimeParseException e) { + mismatchDescription.appendText("was ").appendValue(item).appendText(", which could not be parsed as a ZonedDateTime"); + return false; + } + } + + @Override + public void describeTo(Description description) { + description.appendText("a String representing the same Instant as ").appendValue(date); + } + } + + /** + * Creates a matcher that matches when the examined string represents the same instant as the reference datetime. + * + * @param date the reference datetime against which the examined string is checked. + */ + public static ZonedDateTimeMatcher sameInstant(ZonedDateTime date) { + return new ZonedDateTimeMatcher(date); + } + + /** + * A matcher that tests that the examined number represents the same value - it can be Long, Double, etc - as the reference BigDecimal. + */ + public static class NumberMatcher extends TypeSafeMatcher { + + final BigDecimal value; + + public NumberMatcher(BigDecimal value) { + this.value = value; + } + + @Override + public void describeTo(Description description) { + description.appendText("a numeric value is ").appendValue(value); + } + + @Override + protected boolean matchesSafely(Number item) { + BigDecimal bigDecimal = asDecimal(item); + return bigDecimal != null && value.compareTo(bigDecimal) == 0; + } + + private static BigDecimal asDecimal(Number item) { + return switch (item) { + case null -> null; + case BigDecimal bigDecimal -> bigDecimal; + case Long l -> BigDecimal.valueOf(l); + case Integer i -> BigDecimal.valueOf(i); + case Double v -> BigDecimal.valueOf(v); + case Float v -> BigDecimal.valueOf(v); + default -> BigDecimal.valueOf(item.doubleValue()); + }; + } + } + + /** + * Creates a matcher that matches when the examined number represents the same value as the reference BigDecimal. + * + * @param number the reference BigDecimal against which the examined number is checked. + */ + public static NumberMatcher sameNumber(BigDecimal number) { + return new NumberMatcher(number); + } + + /** + * Verifies the equals/hashcode contract on the domain object. + */ + public static void equalsVerifier(Class clazz) throws Exception { + T domainObject1 = clazz.getConstructor().newInstance(); + assertThat(domainObject1.toString()).isNotNull(); + assertThat(domainObject1).isEqualTo(domainObject1); + assertThat(domainObject1).hasSameHashCodeAs(domainObject1); + // Test with an instance of another class + Object testOtherObject = new Object(); + assertThat(domainObject1).isNotEqualTo(testOtherObject); + assertThat(domainObject1).isNotEqualTo(null); + // Test with an instance of the same class + T domainObject2 = clazz.getConstructor().newInstance(); + assertThat(domainObject1).isNotEqualTo(domainObject2); + // HashCodes are equals because the objects are not persisted yet + assertThat(domainObject1).hasSameHashCodeAs(domainObject2); + } + + /** + * Create a {@link FormattingConversionService} which use ISO date format, instead of the localized one. + * @return the {@link FormattingConversionService}. + */ + public static FormattingConversionService createFormattingConversionService() { + DefaultFormattingConversionService dfcs = new DefaultFormattingConversionService(); + var registrar = new DateTimeFormatterRegistrar(); + registrar.setUseIsoFormat(true); + registrar.registerFormatters(dfcs); + return dfcs; + } + + /** + * Executes a query on the EntityManager finding all stored objects. + * @param The type of objects to be searched + * @param em The instance of the EntityManager + * @param clazz The class type to be searched + * @return A list of all found objects + */ + public static List findAll(EntityManager em, Class clazz) { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(clazz); + Root rootEntry = cq.from(clazz); + CriteriaQuery all = cq.select(rootEntry); + TypedQuery allQuery = em.createQuery(all); + return allQuery.getResultList(); + } + + @SuppressWarnings("unchecked") + public static T createUpdateProxyForBean(T update, T original) { + Enhancer e = new Enhancer(); + e.setSuperclass(original.getClass()); + e.setCallback( + (MethodInterceptor) (obj, method, args, proxy) -> { + Object val = update.getClass().getMethod(method.getName(), method.getParameterTypes()).invoke(update, args); + if (val == null) { + return original.getClass().getMethod(method.getName(), method.getParameterTypes()).invoke(original, args); + } + return val; + } + ); + return (T) e.create(); + } + + private TestUtil() {} +} diff --git a/src/test/java/com/example/questionbank/web/rest/UserResourceIT.java b/src/test/java/com/example/questionbank/web/rest/UserResourceIT.java new file mode 100644 index 0000000..584a493 --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/UserResourceIT.java @@ -0,0 +1,484 @@ +package com.example.questionbank.web.rest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.example.questionbank.IntegrationTest; +import com.example.questionbank.domain.User; +import com.example.questionbank.repository.UserRepository; +import com.example.questionbank.security.AuthoritiesConstants; +import com.example.questionbank.service.UserService; +import com.example.questionbank.service.dto.AdminUserDTO; +import com.example.questionbank.service.mapper.UserMapper; +import jakarta.persistence.EntityManager; +import java.util.*; +import java.util.function.Consumer; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.ObjectMapper; + +/** + * Integration tests for the {@link UserResource} REST controller. + */ +@AutoConfigureMockMvc +@WithMockUser(authorities = AuthoritiesConstants.ADMIN) +@IntegrationTest +class UserResourceIT { + + private static final String DEFAULT_LOGIN = "johndoe"; + private static final String UPDATED_LOGIN = "jhipster"; + + private static final Long DEFAULT_ID = 1L; + + private static final String DEFAULT_EMAIL = "johndoe@localhost"; + private static final String UPDATED_EMAIL = "jhipster@localhost"; + + private static final String DEFAULT_FIRSTNAME = "john"; + private static final String UPDATED_FIRSTNAME = "jhipsterFirstName"; + + private static final String DEFAULT_LASTNAME = "doe"; + private static final String UPDATED_LASTNAME = "jhipsterLastName"; + + private static final String DEFAULT_IMAGEURL = "http://placehold.it/50x50"; + private static final String UPDATED_IMAGEURL = "http://placehold.it/40x40"; + + private static final String DEFAULT_LANGKEY = "en"; + private static final String UPDATED_LANGKEY = "fr"; + + @Autowired + private ObjectMapper om; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private UserMapper userMapper; + + @Autowired + private EntityManager em; + + @Autowired + private MockMvc restUserMockMvc; + + private User user; + + private Long numberOfUsers; + + /** + * Create a User. + * + * This is a static method, as tests for other entities might also need it, + * if they test an entity which has a required relationship to the User entity. + */ + public static User createEntity() { + User persistUser = new User(); + persistUser.setLogin(DEFAULT_LOGIN + RandomStringUtils.insecure().nextAlphabetic(5)); + persistUser.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + persistUser.setActivated(true); + persistUser.setEmail(RandomStringUtils.insecure().nextAlphabetic(5) + DEFAULT_EMAIL); + persistUser.setFirstName(DEFAULT_FIRSTNAME); + persistUser.setLastName(DEFAULT_LASTNAME); + persistUser.setImageUrl(DEFAULT_IMAGEURL); + persistUser.setLangKey(DEFAULT_LANGKEY); + return persistUser; + } + + /** + * Setups the database with one user. + */ + public static User initTestUser() { + User persistUser = createEntity(); + persistUser.setLogin(DEFAULT_LOGIN); + persistUser.setEmail(DEFAULT_EMAIL); + return persistUser; + } + + @BeforeEach + void initTest() { + numberOfUsers = userRepository.count(); + user = initTestUser(); + } + + @AfterEach + void cleanupAndCheck() { + userService.deleteUser(DEFAULT_LOGIN); + userService.deleteUser(UPDATED_LOGIN); + userService.deleteUser(user.getLogin()); + userService.deleteUser("anotherlogin"); + assertThat(userRepository.count()).isEqualTo(numberOfUsers); + numberOfUsers = null; + } + + @Test + @Transactional + void createUser() throws Exception { + // Create the User + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setLogin(DEFAULT_LOGIN); + userDTO.setFirstName(DEFAULT_FIRSTNAME); + userDTO.setLastName(DEFAULT_LASTNAME); + userDTO.setEmail(DEFAULT_EMAIL); + userDTO.setActivated(true); + userDTO.setImageUrl(DEFAULT_IMAGEURL); + userDTO.setLangKey(DEFAULT_LANGKEY); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + var returnedUserDTO = om.readValue( + restUserMockMvc + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(), + AdminUserDTO.class + ); + + User convertedUser = userMapper.userDTOToUser(returnedUserDTO); + // Validate the returned User + assertThat(convertedUser.getLogin()).isEqualTo(DEFAULT_LOGIN); + assertThat(convertedUser.getFirstName()).isEqualTo(DEFAULT_FIRSTNAME); + assertThat(convertedUser.getLastName()).isEqualTo(DEFAULT_LASTNAME); + assertThat(convertedUser.getEmail()).isEqualTo(DEFAULT_EMAIL); + assertThat(convertedUser.getImageUrl()).isEqualTo(DEFAULT_IMAGEURL); + assertThat(convertedUser.getLangKey()).isEqualTo(DEFAULT_LANGKEY); + } + + @Test + @Transactional + void createUserWithExistingId() throws Exception { + int databaseSizeBeforeCreate = userRepository.findAll().size(); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setId(DEFAULT_ID); + userDTO.setLogin(DEFAULT_LOGIN); + userDTO.setFirstName(DEFAULT_FIRSTNAME); + userDTO.setLastName(DEFAULT_LASTNAME); + userDTO.setEmail(DEFAULT_EMAIL); + userDTO.setActivated(true); + userDTO.setImageUrl(DEFAULT_IMAGEURL); + userDTO.setLangKey(DEFAULT_LANGKEY); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + // An entity with an existing ID cannot be created, so this API call must fail + restUserMockMvc + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isBadRequest()); + + // Validate the User in the database + assertPersistedUsers(users -> assertThat(users).hasSize(databaseSizeBeforeCreate)); + } + + @Test + @Transactional + void createUserWithExistingLogin() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + int databaseSizeBeforeCreate = userRepository.findAll().size(); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setLogin(DEFAULT_LOGIN); // this login should already be used + userDTO.setFirstName(DEFAULT_FIRSTNAME); + userDTO.setLastName(DEFAULT_LASTNAME); + userDTO.setEmail("anothermail@localhost"); + userDTO.setActivated(true); + userDTO.setImageUrl(DEFAULT_IMAGEURL); + userDTO.setLangKey(DEFAULT_LANGKEY); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + // Create the User + restUserMockMvc + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isBadRequest()); + + // Validate the User in the database + assertPersistedUsers(users -> assertThat(users).hasSize(databaseSizeBeforeCreate)); + } + + @Test + @Transactional + void createUserWithExistingEmail() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + int databaseSizeBeforeCreate = userRepository.findAll().size(); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setLogin("anotherlogin"); + userDTO.setFirstName(DEFAULT_FIRSTNAME); + userDTO.setLastName(DEFAULT_LASTNAME); + userDTO.setEmail(DEFAULT_EMAIL); // this email should already be used + userDTO.setActivated(true); + userDTO.setImageUrl(DEFAULT_IMAGEURL); + userDTO.setLangKey(DEFAULT_LANGKEY); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + // Create the User + restUserMockMvc + .perform(post("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isBadRequest()); + + // Validate the User in the database + assertPersistedUsers(users -> assertThat(users).hasSize(databaseSizeBeforeCreate)); + } + + @Test + @Transactional + void getAllUsers() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + + // Get all the users + restUserMockMvc + .perform(get("/api/admin/users?sort=id,desc").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.[*].login").value(hasItem(DEFAULT_LOGIN))) + .andExpect(jsonPath("$.[*].firstName").value(hasItem(DEFAULT_FIRSTNAME))) + .andExpect(jsonPath("$.[*].lastName").value(hasItem(DEFAULT_LASTNAME))) + .andExpect(jsonPath("$.[*].email").value(hasItem(DEFAULT_EMAIL))) + .andExpect(jsonPath("$.[*].imageUrl").value(hasItem(DEFAULT_IMAGEURL))) + .andExpect(jsonPath("$.[*].langKey").value(hasItem(DEFAULT_LANGKEY))); + } + + @Test + @Transactional + void getUser() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + + // Get the user + restUserMockMvc + .perform(get("/api/admin/users/{login}", user.getLogin())) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.login").value(user.getLogin())) + .andExpect(jsonPath("$.firstName").value(DEFAULT_FIRSTNAME)) + .andExpect(jsonPath("$.lastName").value(DEFAULT_LASTNAME)) + .andExpect(jsonPath("$.email").value(DEFAULT_EMAIL)) + .andExpect(jsonPath("$.imageUrl").value(DEFAULT_IMAGEURL)) + .andExpect(jsonPath("$.langKey").value(DEFAULT_LANGKEY)); + } + + @Test + @Transactional + void getNonExistingUser() throws Exception { + restUserMockMvc.perform(get("/api/admin/users/unknown")).andExpect(status().isNotFound()); + } + + @Test + @Transactional + void updateUser() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + int databaseSizeBeforeUpdate = userRepository.findAll().size(); + + // Update the user + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setId(updatedUser.getId()); + userDTO.setLogin(updatedUser.getLogin()); + userDTO.setFirstName(UPDATED_FIRSTNAME); + userDTO.setLastName(UPDATED_LASTNAME); + userDTO.setEmail(UPDATED_EMAIL); + userDTO.setActivated(updatedUser.isActivated()); + userDTO.setImageUrl(UPDATED_IMAGEURL); + userDTO.setLangKey(UPDATED_LANGKEY); + userDTO.setCreatedBy(updatedUser.getCreatedBy()); + userDTO.setCreatedDate(updatedUser.getCreatedDate()); + userDTO.setLastModifiedBy(updatedUser.getLastModifiedBy()); + userDTO.setLastModifiedDate(updatedUser.getLastModifiedDate()); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + restUserMockMvc + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isOk()); + + // Validate the User in the database + assertPersistedUsers(users -> { + assertThat(users).hasSize(databaseSizeBeforeUpdate); + User testUser = users + .stream() + .filter(usr -> usr.getId().equals(updatedUser.getId())) + .findFirst() + .orElseThrow(); + assertThat(testUser.getFirstName()).isEqualTo(UPDATED_FIRSTNAME); + assertThat(testUser.getLastName()).isEqualTo(UPDATED_LASTNAME); + assertThat(testUser.getEmail()).isEqualTo(UPDATED_EMAIL); + assertThat(testUser.getImageUrl()).isEqualTo(UPDATED_IMAGEURL); + assertThat(testUser.getLangKey()).isEqualTo(UPDATED_LANGKEY); + }); + } + + @Test + @Transactional + void updateUserLogin() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + int databaseSizeBeforeUpdate = userRepository.findAll().size(); + + // Update the user + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setId(updatedUser.getId()); + userDTO.setLogin(UPDATED_LOGIN); + userDTO.setFirstName(UPDATED_FIRSTNAME); + userDTO.setLastName(UPDATED_LASTNAME); + userDTO.setEmail(UPDATED_EMAIL); + userDTO.setActivated(updatedUser.isActivated()); + userDTO.setImageUrl(UPDATED_IMAGEURL); + userDTO.setLangKey(UPDATED_LANGKEY); + userDTO.setCreatedBy(updatedUser.getCreatedBy()); + userDTO.setCreatedDate(updatedUser.getCreatedDate()); + userDTO.setLastModifiedBy(updatedUser.getLastModifiedBy()); + userDTO.setLastModifiedDate(updatedUser.getLastModifiedDate()); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + restUserMockMvc + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isOk()); + + // Validate the User in the database + assertPersistedUsers(users -> { + assertThat(users).hasSize(databaseSizeBeforeUpdate); + User testUser = users + .stream() + .filter(usr -> usr.getId().equals(updatedUser.getId())) + .findFirst() + .orElseThrow(); + assertThat(testUser.getLogin()).isEqualTo(UPDATED_LOGIN); + assertThat(testUser.getFirstName()).isEqualTo(UPDATED_FIRSTNAME); + assertThat(testUser.getLastName()).isEqualTo(UPDATED_LASTNAME); + assertThat(testUser.getEmail()).isEqualTo(UPDATED_EMAIL); + assertThat(testUser.getImageUrl()).isEqualTo(UPDATED_IMAGEURL); + assertThat(testUser.getLangKey()).isEqualTo(UPDATED_LANGKEY); + }); + } + + @Test + @Transactional + void updateUserExistingEmail() throws Exception { + // Initialize the database with 2 users + userRepository.saveAndFlush(user); + + User anotherUser = new User(); + anotherUser.setLogin("jhipster"); + anotherUser.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + anotherUser.setActivated(true); + anotherUser.setEmail("jhipster@localhost"); + anotherUser.setFirstName("java"); + anotherUser.setLastName("hipster"); + anotherUser.setImageUrl(""); + anotherUser.setLangKey("en"); + userRepository.saveAndFlush(anotherUser); + + // Update the user + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setId(updatedUser.getId()); + userDTO.setLogin(updatedUser.getLogin()); + userDTO.setFirstName(updatedUser.getFirstName()); + userDTO.setLastName(updatedUser.getLastName()); + userDTO.setEmail("jhipster@localhost"); // this email should already be used by anotherUser + userDTO.setActivated(updatedUser.isActivated()); + userDTO.setImageUrl(updatedUser.getImageUrl()); + userDTO.setLangKey(updatedUser.getLangKey()); + userDTO.setCreatedBy(updatedUser.getCreatedBy()); + userDTO.setCreatedDate(updatedUser.getCreatedDate()); + userDTO.setLastModifiedBy(updatedUser.getLastModifiedBy()); + userDTO.setLastModifiedDate(updatedUser.getLastModifiedDate()); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + restUserMockMvc + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isBadRequest()); + } + + @Test + @Transactional + void updateUserExistingLogin() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + + User anotherUser = new User(); + anotherUser.setLogin("jhipster"); + anotherUser.setPassword(RandomStringUtils.insecure().nextAlphanumeric(60)); + anotherUser.setActivated(true); + anotherUser.setEmail("jhipster@localhost"); + anotherUser.setFirstName("java"); + anotherUser.setLastName("hipster"); + anotherUser.setImageUrl(""); + anotherUser.setLangKey("en"); + userRepository.saveAndFlush(anotherUser); + + // Update the user + User updatedUser = userRepository.findById(user.getId()).orElseThrow(); + + AdminUserDTO userDTO = new AdminUserDTO(); + userDTO.setId(updatedUser.getId()); + userDTO.setLogin("jhipster"); // this login should already be used by anotherUser + userDTO.setFirstName(updatedUser.getFirstName()); + userDTO.setLastName(updatedUser.getLastName()); + userDTO.setEmail(updatedUser.getEmail()); + userDTO.setActivated(updatedUser.isActivated()); + userDTO.setImageUrl(updatedUser.getImageUrl()); + userDTO.setLangKey(updatedUser.getLangKey()); + userDTO.setCreatedBy(updatedUser.getCreatedBy()); + userDTO.setCreatedDate(updatedUser.getCreatedDate()); + userDTO.setLastModifiedBy(updatedUser.getLastModifiedBy()); + userDTO.setLastModifiedDate(updatedUser.getLastModifiedDate()); + userDTO.setAuthorities(Set.of(AuthoritiesConstants.USER)); + + restUserMockMvc + .perform(put("/api/admin/users").contentType(MediaType.APPLICATION_JSON).content(om.writeValueAsBytes(userDTO))) + .andExpect(status().isBadRequest()); + } + + @Test + @Transactional + void deleteUser() throws Exception { + // Initialize the database + userRepository.saveAndFlush(user); + int databaseSizeBeforeDelete = userRepository.findAll().size(); + + // Delete the user + restUserMockMvc + .perform(delete("/api/admin/users/{login}", user.getLogin()).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()); + + // Validate the database is empty + assertPersistedUsers(users -> assertThat(users).hasSize(databaseSizeBeforeDelete - 1)); + } + + @Test + void testUserEquals() throws Exception { + TestUtil.equalsVerifier(User.class); + User user1 = new User(); + user1.setId(DEFAULT_ID); + User user2 = new User(); + user2.setId(user1.getId()); + assertThat(user1).isEqualTo(user2); + user2.setId(2L); + assertThat(user1).isNotEqualTo(user2); + user1.setId(null); + assertThat(user1).isNotEqualTo(user2); + } + + private void assertPersistedUsers(Consumer> userAssertion) { + userAssertion.accept(userRepository.findAll()); + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/WithUnauthenticatedMockUser.java b/src/test/java/com/example/questionbank/web/rest/WithUnauthenticatedMockUser.java new file mode 100644 index 0000000..1657e38 --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/WithUnauthenticatedMockUser.java @@ -0,0 +1,23 @@ +package com.example.questionbank.web.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContext; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithUnauthenticatedMockUser.Factory.class) +public @interface WithUnauthenticatedMockUser { + class Factory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithUnauthenticatedMockUser annotation) { + return SecurityContextHolder.createEmptyContext(); + } + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorIT.java b/src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorIT.java new file mode 100644 index 0000000..737f6af --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorIT.java @@ -0,0 +1,117 @@ +package com.example.questionbank.web.rest.errors; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.questionbank.IntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration tests {@link ExceptionTranslator} controller advice. + */ +@WithMockUser +@AutoConfigureMockMvc +@IntegrationTest +class ExceptionTranslatorIT { + + @Autowired + private MockMvc mockMvc; + + @Test + void testConcurrencyFailure() throws Exception { + mockMvc + .perform(get("/api/exception-translator-test/concurrency-failure")) + .andExpect(status().isConflict()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value(ErrorConstants.ERR_CONCURRENCY_FAILURE)); + } + + @Test + void testMethodArgumentNotValid() throws Exception { + mockMvc + .perform(post("/api/exception-translator-test/method-argument").content("{}").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value(ErrorConstants.ERR_VALIDATION)) + .andExpect(jsonPath("$.fieldErrors.[0].objectName").value("test")) + .andExpect(jsonPath("$.fieldErrors.[0].field").value("test")) + .andExpect(jsonPath("$.fieldErrors.[0].message").value("must not be null")); + } + + @Test + void testMissingServletRequestPartException() throws Exception { + mockMvc + .perform(get("/api/exception-translator-test/missing-servlet-request-part")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("error.http.400")); + } + + @Test + void testMissingServletRequestParameterException() throws Exception { + mockMvc + .perform(get("/api/exception-translator-test/missing-servlet-request-parameter")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("error.http.400")); + } + + @Test + void testAccessDenied() throws Exception { + mockMvc + .perform(get("/api/exception-translator-test/access-denied")) + .andExpect(status().isForbidden()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("error.http.403")) + .andExpect(jsonPath("$.detail").value("test access denied!")); + } + + @Test + void testUnauthorized() throws Exception { + mockMvc + .perform(get("/api/exception-translator-test/unauthorized")) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("error.http.401")) + .andExpect(jsonPath("$.path").value("/api/exception-translator-test/unauthorized")) + .andExpect(jsonPath("$.detail").value("test authentication failed!")); + } + + @Test + void testMethodNotSupported() throws Exception { + mockMvc + .perform(post("/api/exception-translator-test/access-denied")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("error.http.405")) + .andExpect(jsonPath("$.detail").value("Request method 'POST' is not supported")); + } + + @Test + void testExceptionWithResponseStatus() throws Exception { + mockMvc + .perform(get("/api/exception-translator-test/response-status")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("error.http.400")) + .andExpect(jsonPath("$.title").value("test response status")); + } + + @Test + void testInternalServerError() throws Exception { + mockMvc + .perform(get("/api/exception-translator-test/internal-server-error")) + .andExpect(status().isInternalServerError()) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.message").value("error.http.500")) + .andExpect(jsonPath("$.title").value("Internal Server Error")); + } +} diff --git a/src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorTestController.java b/src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorTestController.java new file mode 100644 index 0000000..44d4240 --- /dev/null +++ b/src/test/java/com/example/questionbank/web/rest/errors/ExceptionTranslatorTestController.java @@ -0,0 +1,72 @@ +package com.example.questionbank.web.rest.errors; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.dao.ConcurrencyFailureException; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/exception-translator-test") +public class ExceptionTranslatorTestController { + + @GetMapping("/concurrency-failure") + public void concurrencyFailure() { + throw new ConcurrencyFailureException("test concurrency failure"); + } + + @PostMapping("/method-argument") + public void methodArgument(@Valid @RequestBody TestDTO testDTO) { + // empty method + } + + @GetMapping("/missing-servlet-request-part") + public void missingServletRequestPartException(@RequestPart("part") String part) { + // empty method + } + + @GetMapping("/missing-servlet-request-parameter") + public void missingServletRequestParameterException(@RequestParam("param") String param) { + // empty method + } + + @GetMapping("/access-denied") + public void accessdenied() { + throw new AccessDeniedException("test access denied!"); + } + + @GetMapping("/unauthorized") + public void unauthorized() { + throw new BadCredentialsException("test authentication failed!"); + } + + @GetMapping("/response-status") + public void exceptionWithResponseStatus() { + throw new TestResponseStatusException(); + } + + @GetMapping("/internal-server-error") + public void internalServerError() { + throw new RuntimeException(); + } + + public static class TestDTO { + + @NotNull + private String test; + + public String getTest() { + return test; + } + + public void setTest(String test) { + this.test = test; + } + } + + @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "test response status") + @SuppressWarnings("serial") + public static class TestResponseStatusException extends RuntimeException {} +} diff --git a/src/test/resources/config/application-testdev.yml b/src/test/resources/config/application-testdev.yml new file mode 100644 index 0000000..e7ddb97 --- /dev/null +++ b/src/test/resources/config/application-testdev.yml @@ -0,0 +1,32 @@ +# =================================================================== +# Spring Boot configuration. +# +# This configuration is used for unit/integration tests with testcontainers database containers. +# +# To activate this configuration launch integration tests with the 'testcontainers' profile +# +# More information on database containers: https://www.testcontainers.org/modules/databases/ +# =================================================================== + +spring: + datasource: + type: com.zaxxer.hikari.HikariDataSource + hikari: + auto-commit: false + poolName: Hikari + maximum-pool-size: 1 + jpa: + open-in-view: false + hibernate: + ddl-auto: none + properties: + hibernate.id.new_generator_mappings: true + hibernate.connection.provider_disables_autocommit: true + hibernate.cache.use_second_level_cache: false + hibernate.cache.use_query_cache: false + hibernate.generate_statistics: false + hibernate.hbm2ddl.auto: none #TODO: temp relief for integration tests, revisit required + hibernate.type.preferred_instant_jdbc_type: TIMESTAMP + hibernate.jdbc.time_zone: UTC + hibernate.timezone.default_storage: NORMALIZE + hibernate.query.fail_on_pagination_over_collection_fetch: true diff --git a/src/test/resources/config/application-testprod.yml b/src/test/resources/config/application-testprod.yml new file mode 100644 index 0000000..b29b452 --- /dev/null +++ b/src/test/resources/config/application-testprod.yml @@ -0,0 +1,32 @@ +# =================================================================== +# Spring Boot configuration. +# +# This configuration is used for unit/integration tests with testcontainers database containers. +# +# To activate this configuration launch integration tests with the 'testcontainers' profile +# +# More information on database containers: https://www.testcontainers.org/modules/databases/ +# =================================================================== + +spring: + datasource: + type: com.zaxxer.hikari.HikariDataSource + hikari: + poolName: Hikari + auto-commit: false + maximum-pool-size: 1 + jpa: + open-in-view: false + hibernate: + ddl-auto: none + properties: + hibernate.id.new_generator_mappings: true + hibernate.connection.provider_disables_autocommit: true + hibernate.cache.use_second_level_cache: false + hibernate.cache.use_query_cache: false + hibernate.generate_statistics: false + hibernate.hbm2ddl.auto: none #TODO: temp relief for integration tests, revisit required + hibernate.type.preferred_instant_jdbc_type: TIMESTAMP + hibernate.jdbc.time_zone: UTC + hibernate.timezone.default_storage: NORMALIZE + hibernate.query.fail_on_pagination_over_collection_fetch: true diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml new file mode 100644 index 0000000..23148ff --- /dev/null +++ b/src/test/resources/config/application.yml @@ -0,0 +1,97 @@ +# =================================================================== +# Spring Boot configuration. +# +# This configuration is used for unit/integration tests. +# +# More information on profiles: https://www.jhipster.tech/profiles/ +# More information on configuration properties: https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# =================================================================== +# Standard Spring Boot properties. +# Full reference is available at: +# https://docs.spring.io/spring-boot/appendix/application-properties/index.html +# =================================================================== + +spring: + application: + name: questionBank + # Replace by 'prod, faker' to add the faker context and have sample data loaded in production + liquibase: + contexts: test + jackson: + datatype: + datetime: + write-durations-as-timestamps: false + mail: + host: localhost + main: + allow-bean-definition-overriding: true + messages: + basename: i18n/messages + security: + oauth2: + resourceserver: + jwt: + authority-prefix: '' + authorities-claim-name: auth + task: + execution: + thread-name-prefix: question-bank-task- + pool: + core-size: 1 + max-size: 50 + queue-capacity: 10000 + scheduling: + thread-name-prefix: question-bank-scheduling- + pool: + size: 20 + thymeleaf: + mode: HTML + +server: + port: 10344 + address: localhost + +# =================================================================== +# JHipster specific properties +# +# Full reference is available at: https://www.jhipster.tech/common-application-properties/ +# =================================================================== +jhipster: + clientApp: + name: 'questionBankApp' + mail: + from: questionBank@localhost.com + base-url: http://127.0.0.1:8080 + logging: + # To test json console appender + use-json-format: false + logstash: + enabled: false + host: localhost + port: 5000 + ring-buffer-size: 512 + security: + authentication: + jwt: + # This token must be encoded using Base64 (you can type `echo 'secret-key'|base64` on your command line) + base64-secret: MzY1ZDU0MDM3M2Y5YWRiMDYxYjBmYzZlOTMxMWFiNmYyMDEyZTUzZjYxOWFjNjhlNjU2NzcxYjkwY2Y0MGIwOGUyNTc5NWQyOGY3OGJkMjExZjhkYzU3ZGU4ZDc0ZmZiMTYwMzY0NGM4ZWZmODRhZTZjNzBlNjk5MWJkYjM5YjI= + # Token is valid 24 hours + token-validity-in-seconds: 86400 + token-validity-in-seconds-for-remember-me: 86400 + +# =================================================================== +# Application specific properties +# Add your own application properties here, see the ApplicationProperties class +# to have type-safe configuration, like in the JHipsterProperties above +# +# More documentation is available at: +# https://www.jhipster.tech/common-application-properties/ +# =================================================================== + +# application: +management: + health: + mail: + enabled: false diff --git a/src/test/resources/i18n/messages_en.properties b/src/test/resources/i18n/messages_en.properties new file mode 100644 index 0000000..d1e6fd7 --- /dev/null +++ b/src/test/resources/i18n/messages_en.properties @@ -0,0 +1,4 @@ +email.test.title=test title +# Value used for English locale unit test in MailServiceIT +# as this file is loaded instead of real file +email.activation.title=questionBank account activation diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..80967e2 --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1,4 @@ +junit.jupiter.execution.timeout.default = 15 s +junit.jupiter.execution.timeout.testable.method.default = 15 s +junit.jupiter.execution.timeout.beforeall.method.default = 60 s +junit.jupiter.testclass.order.default=com.example.questionbank.config.SpringBootTestClassOrderer diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml new file mode 100644 index 0000000..05b86b5 --- /dev/null +++ b/src/test/resources/logback.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/templates/mail/activationEmail.html b/src/test/resources/templates/mail/activationEmail.html new file mode 100644 index 0000000..6db87f6 --- /dev/null +++ b/src/test/resources/templates/mail/activationEmail.html @@ -0,0 +1,19 @@ + + + + JHipster activation + + + +

Dear

+

Your JHipster account has been created, please click on the URL below to activate it:

+

+ Activation link +

+

+ Regards, +
+ JHipster. +

+ + diff --git a/src/test/resources/templates/mail/creationEmail.html b/src/test/resources/templates/mail/creationEmail.html new file mode 100644 index 0000000..07075e0 --- /dev/null +++ b/src/test/resources/templates/mail/creationEmail.html @@ -0,0 +1,19 @@ + + + + JHipster creation + + + +

Dear

+

Your JHipster account has been created, please click on the URL below to access it:

+

+ Login link +

+

+ Regards, +
+ JHipster. +

+ + diff --git a/src/test/resources/templates/mail/passwordResetEmail.html b/src/test/resources/templates/mail/passwordResetEmail.html new file mode 100644 index 0000000..6ddc5d2 --- /dev/null +++ b/src/test/resources/templates/mail/passwordResetEmail.html @@ -0,0 +1,21 @@ + + + + JHipster password reset + + + +

Dear

+

+ For your JHipster account a password reset was requested, please click on the URL below to reset it: +

+

+ Login link +

+

+ Regards, +
+ JHipster. +

+ + diff --git a/src/test/resources/templates/mail/testEmail.html b/src/test/resources/templates/mail/testEmail.html new file mode 100644 index 0000000..a4ca16a --- /dev/null +++ b/src/test/resources/templates/mail/testEmail.html @@ -0,0 +1 @@ + diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..786f59d --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["src/main/webapp/**/*", "src/main/webapp/app/**/*.vue"], + "exclude": ["src/main/webapp/app/**/*.spec.ts"], + "compilerOptions": { + "composite": true, + "outDir": "target/out-tsc/dist", + "types": ["vite/client"], + "paths": { + "@/*": ["./src/main/webapp/app/*"] + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..100cf6a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.vitest.json" + } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8126dfa --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "extends": "@tsconfig/node18/tsconfig.json", + "include": ["vite.config.*", "vitest.config.*"], + "compilerOptions": { + "composite": true, + "lib": ["ES2025"], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json new file mode 100644 index 0000000..d080d61 --- /dev/null +++ b/tsconfig.vitest.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.app.json", + "exclude": [], + "compilerOptions": { + "composite": true, + "lib": [], + "types": ["node", "jsdom"] + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..eb4d2f1 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,76 @@ +import path from 'node:path'; +import { URL, fileURLToPath } from 'node:url'; + +import vue from '@vitejs/plugin-vue'; +import { defineConfig, normalizePath } from 'vite'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; + +const { getAbsoluteFSPath } = await import('swagger-ui-dist'); +const swaggerUiPath = getAbsoluteFSPath(); +const webappDir = fileURLToPath(new URL('./src/main/webapp/', import.meta.url)); + +const config = defineConfig({ + plugins: [ + vue(), + viteStaticCopy({ + targets: [ + { + src: [ + `${normalizePath(swaggerUiPath)}/*.{js,css,html,png}`, + `!${normalizePath(swaggerUiPath)}/**/index.html`, + normalizePath(fileURLToPath(new URL('./dist/axios.min.js', import.meta.resolve('axios/package.json')))), + normalizePath(fileURLToPath(new URL('./src/main/webapp/swagger-ui/index.html', import.meta.url))), + ], + dest: 'swagger-ui', + rename: (name, ext, srcPath) => { + const rel = path.relative(webappDir, path.dirname(srcPath)).replace(/^(\.\.\/)+/, ''); + return `${'../'.repeat(rel === '.' ? 0 : rel.split('/').length)}${name}.${ext}`; + }, + }, + ], + }), + ], + root: fileURLToPath(new URL('./src/main/webapp/', import.meta.url)), + publicDir: fileURLToPath(new URL('./target/classes/static/public', import.meta.url)), + cacheDir: fileURLToPath(new URL('./target/.vite-cache', import.meta.url)), + optimizeDeps: { + // Discover dependencies of lazily loaded modules (translations, ...) at startup, + // a dependency discovered at runtime triggers a re-optimization and a page reload. + entries: [fileURLToPath(new URL('./src/main/webapp/**/*.{ts,vue,js}', import.meta.url))], + }, + build: { + emptyOutDir: true, + outDir: fileURLToPath(new URL('./target/classes/static/', import.meta.url)), + rollupOptions: { + input: { + app: fileURLToPath(new URL('./src/main/webapp/index.html', import.meta.url)), + }, + }, + }, + resolve: { + tsconfigPaths: true, + alias: { + vue: 'vue/dist/vue.esm-bundler.js', + }, + }, + define: { + SERVER_API_URL: '"/"', + APP_VERSION: `"${process.env.APP_VERSION ? process.env.APP_VERSION : 'DEV'}"`, + }, + server: { + host: true, + port: 9000, + proxy: Object.fromEntries( + ['/api', '/management', '/v3/api-docs'].map(res => [ + res, + { + target: 'http://localhost:8080', + }, + ]), + ), + }, +}); + +// jhipster-needle-add-vite-config - JHipster will add custom config + +export default config; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..888dc73 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,32 @@ +import { fileURLToPath } from 'node:url'; + +import { defineConfig, mergeConfig } from 'vitest/config'; + +import viteConfig from './vite.config.ts'; + +export default mergeConfig( + viteConfig, + defineConfig({ + resolve: { + alias: { + vue: 'vue', + }, + }, + test: { + globals: true, + environment: 'happy-dom', // happy-dom provides a better performance but doesn't have a default url. + setupFiles: [fileURLToPath(new URL('./src/main/webapp/app/test-setup.ts', import.meta.url))], + reporters: ['default', 'vitest-sonar-reporter'], + outputFile: { + 'vitest-sonar-reporter': fileURLToPath(new URL('./target/test-results/TESTS-results-vitest.xml', import.meta.url)), + }, + coverage: { + provider: 'v8', + statements: 85, + branches: 75, + lines: 85, + reportsDirectory: fileURLToPath(new URL('./target/vite-coverage', import.meta.url)), + }, + }, + }), +);