├── gradle.properties ├── doc ├── notebook │ ├── Dockerfile │ ├── files │ │ ├── test_photo_1.png │ │ └── test_photo_2.png │ └── docker-compose.yml ├── design │ └── application-architecture.png └── training-ideas.md ├── settings.gradle ├── src ├── launcher │ ├── src │ │ ├── main │ │ │ ├── resources │ │ │ │ ├── META-INF │ │ │ │ │ ├── additional-spring-configuration-metadata.json │ │ │ │ │ └── build-info.properties │ │ │ │ ├── application-functionaltests.properties │ │ │ │ └── banner.txt │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ ├── config │ │ │ │ ├── TimeConfig.java │ │ │ │ ├── JacksonConfig.java │ │ │ │ ├── RestTemplateConfig.java │ │ │ │ └── ObjectStoreCredentialsProviderConfig.java │ │ │ │ ├── Launcher.java │ │ │ │ └── tasks │ │ │ │ └── PeriodicFilesMarkedForDeletionRemovalTask.java │ │ └── test │ │ │ ├── resources │ │ │ ├── test_photo_1.png │ │ │ ├── test_photo_2.png │ │ │ └── META-INF │ │ │ │ └── build-info.properties │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── functionaltests │ │ │ ├── helpers │ │ │ ├── UserAttribute.java │ │ │ └── TestEventHandler.java │ │ │ └── scenarios │ │ │ ├── ReplayFunctionalTests.java │ │ │ └── SecurityFunctionalTests.java │ └── db-scripts │ │ └── docker-postgres-ddl.sql ├── photos-persistence │ ├── src │ │ ├── test │ │ │ ├── resources │ │ │ │ └── application.properties │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── photos │ │ │ │ └── persistence │ │ │ │ └── config │ │ │ │ ├── TestClockConfig.java │ │ │ │ └── TestPhotosJpaConfig.java │ │ └── main │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── photos │ │ │ ├── persistence │ │ │ ├── PersistablePhoto.java │ │ │ └── PhotosRepository.java │ │ │ └── services │ │ │ ├── DefaultPhotoWriteService.java │ │ │ └── DefaultPhotosReadService.java │ └── build.gradle ├── competitions-persistence │ ├── src │ │ ├── test │ │ │ ├── resources │ │ │ │ └── application.properties │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── competitions │ │ │ │ └── persistence │ │ │ │ └── config │ │ │ │ ├── TestClockConfig.java │ │ │ │ └── TestCompetitionsJpaConfig.java │ │ └── main │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── competitions │ │ │ ├── persistence │ │ │ ├── CompetitionEntryId.java │ │ │ ├── CompetitionEntriesRepository.java │ │ │ ├── CompetitionsRepository.java │ │ │ ├── PersistableCompetition.java │ │ │ └── PersistableCompetitionEntry.java │ │ │ └── services │ │ │ ├── DefaultCompetitionsReadService.java │ │ │ └── DefaultCompetitionsWriteService.java │ └── build.gradle ├── command-validation-api │ ├── build.gradle │ └── src │ │ └── main │ │ └── java │ │ └── engineering │ │ └── everest │ │ └── lhotse │ │ └── axon │ │ └── command │ │ └── validation │ │ ├── ValidatableCommand.java │ │ ├── EmailAddressValidatableCommand.java │ │ ├── FileStatusValidatableCommand.java │ │ └── Validates.java ├── axon-support │ ├── src │ │ ├── main │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── axon │ │ │ │ ├── replay │ │ │ │ ├── ReplayCompletionAware.java │ │ │ │ ├── ReplayMarkerEvent.java │ │ │ │ ├── ReplayableEventProcessor.java │ │ │ │ └── ReplayMarkerAwareTrackingEventProcessorBuilder.java │ │ │ │ ├── LoggingMessageHandlerInterceptor.java │ │ │ │ └── config │ │ │ │ └── InterceptorConfig.java │ │ └── test │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── axon │ │ │ ├── CreateUserSubclassTestCommand.java │ │ │ └── AxonTestUtils.java │ └── build.gradle ├── i18n-support │ ├── src │ │ ├── main │ │ │ ├── resources │ │ │ │ ├── messages_de.properties │ │ │ │ └── messages.properties │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── i18n │ │ │ │ ├── exceptions │ │ │ │ ├── TranslatableIllegalStateException.java │ │ │ │ ├── TranslatableIllegalArgumentException.java │ │ │ │ └── TranslatableException.java │ │ │ │ ├── RequestParameterAcceptHeaderLocaleResolver.java │ │ │ │ ├── config │ │ │ │ └── InternationalizationConfig.java │ │ │ │ ├── TranslationService.java │ │ │ │ └── MessageKeys.java │ │ └── test │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── i18n │ │ │ └── TranslationServiceTest.java │ └── build.gradle ├── common │ ├── src │ │ ├── main │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── common │ │ │ │ ├── Sleeper.java │ │ │ │ ├── exceptions │ │ │ │ └── RetryTimedOutException.java │ │ │ │ └── RandomFieldsGenerator.java │ │ └── test │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── common │ │ │ └── RandomFieldsGeneratorTest.java │ └── build.gradle ├── database-support │ ├── build.gradle │ └── src │ │ └── main │ │ └── java │ │ └── engineering │ │ └── everest │ │ └── lhotse │ │ └── config │ │ └── DatabaseConfig.java ├── photos-api │ ├── src │ │ └── main │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── photos │ │ │ ├── services │ │ │ ├── PhotosService.java │ │ │ ├── PhotosWriteService.java │ │ │ └── PhotosReadService.java │ │ │ ├── Photo.java │ │ │ └── domain │ │ │ └── commands │ │ │ └── RegisterUploadedPhotoCommand.java │ └── build.gradle ├── forgotten-users-api │ ├── src │ │ └── main │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── users │ │ │ ├── services │ │ │ └── UsersService.java │ │ │ └── domain │ │ │ ├── events │ │ │ └── UserDeletedAndForgottenEvent.java │ │ │ └── commands │ │ │ └── DeleteAndForgetUserCommand.java │ └── build.gradle ├── secretkeys-persistence │ ├── build.gradle │ └── src │ │ └── main │ │ └── java │ │ └── engineering │ │ └── everest │ │ └── lhotse │ │ └── cryptoshredding │ │ └── persistence │ │ ├── PersistableSecretKeyJPARepository.java │ │ └── DefaultSecretKeyRepository.java ├── api │ ├── src │ │ ├── main │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── api │ │ │ │ ├── rest │ │ │ │ ├── responses │ │ │ │ │ ├── ApiErrorResponse.java │ │ │ │ │ ├── PhotoResponse.java │ │ │ │ │ ├── CompetitionEntryFragment.java │ │ │ │ │ ├── CompetitionSummaryResponse.java │ │ │ │ │ └── CompetitionWithEntriesResponse.java │ │ │ │ ├── requests │ │ │ │ │ ├── DeleteAndForgetUserRequest.java │ │ │ │ │ ├── CompetitionSubmissionRequest.java │ │ │ │ │ └── CreateCompetitionRequest.java │ │ │ │ ├── annotations │ │ │ │ │ ├── AdminOnly.java │ │ │ │ │ ├── RegisteredUser.java │ │ │ │ │ └── AdminOrRegisteredUser.java │ │ │ │ ├── controllers │ │ │ │ │ ├── VersionController.java │ │ │ │ │ └── UsersController.java │ │ │ │ └── converters │ │ │ │ │ └── DtoConverter.java │ │ │ │ ├── config │ │ │ │ ├── SecurityConfig.java │ │ │ │ ├── ETagFilterConfig.java │ │ │ │ ├── CorsConfig.java │ │ │ │ ├── ObjectMapperConfig.java │ │ │ │ ├── DocumentationConfig.java │ │ │ │ └── WebSecurityConfig.java │ │ │ │ └── security │ │ │ │ └── KeycloakJwtGrantedAuthoritiesConverter.java │ │ └── test │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── api │ │ │ ├── config │ │ │ ├── TestApiConfig.java │ │ │ └── CorsConfigTest.java │ │ │ ├── ETagHeaderTest.java │ │ │ ├── rest │ │ │ └── controllers │ │ │ │ ├── VersionControllerTest.java │ │ │ │ └── UsersControllerTest.java │ │ │ └── security │ │ │ └── KeycloakJwtGrantedAuthoritiesConverterTest.java │ └── build.gradle ├── competitions-api │ ├── src │ │ └── main │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── competitions │ │ │ ├── domain │ │ │ ├── queries │ │ │ │ └── CompetitionWithEntriesQuery.java │ │ │ ├── CompetitionEntry.java │ │ │ ├── Competition.java │ │ │ ├── CompetitionWithEntries.java │ │ │ └── commands │ │ │ │ └── CreateCompetitionCommand.java │ │ │ └── services │ │ │ ├── CompetitionsReadService.java │ │ │ ├── CompetitionsService.java │ │ │ └── CompetitionsWriteService.java │ └── build.gradle ├── competitions │ ├── src │ │ ├── main │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── competitions │ │ │ │ ├── domain │ │ │ │ ├── events │ │ │ │ │ ├── WinnerAndSubmittedPhotoPair.java │ │ │ │ │ ├── CompetitionEndedEvent.java │ │ │ │ │ ├── PhotoEntryReceivedVoteEvent.java │ │ │ │ │ ├── CompetitionVotingPeriodEndedEvent.java │ │ │ │ │ ├── CompetitionEndedWithNoEntriesSubmittedEvent.java │ │ │ │ │ ├── CompetitionEndedWithNoEntriesReceivingVotesEvent.java │ │ │ │ │ ├── CompetitionCreatedEvent.java │ │ │ │ │ ├── PhotoEnteredInCompetitionEvent.java │ │ │ │ │ └── CompetitionEndedAndWinnersDeclaredEvent.java │ │ │ │ ├── commands │ │ │ │ │ ├── CountVotesAndDeclareOutcomeCommand.java │ │ │ │ │ ├── VoteForPhotoCommand.java │ │ │ │ │ └── EnterPhotoInCompetitionCommand.java │ │ │ │ ├── CompetitionVotingCloseoutSaga.java │ │ │ │ └── CompetitionEntryEntity.java │ │ │ │ ├── config │ │ │ │ └── CompetitionRepositoryConfig.java │ │ │ │ ├── handlers │ │ │ │ └── CompetitionsQueryHandler.java │ │ │ │ └── services │ │ │ │ └── DefaultCompetitionsService.java │ │ └── test │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── competitions │ │ │ ├── handlers │ │ │ └── CompetitionsQueryHandlerTest.java │ │ │ ├── domain │ │ │ └── CompetitionVotingCloseoutSagaTest.java │ │ │ └── services │ │ │ └── DefaultCompetitionsServiceTest.java │ └── build.gradle ├── photos │ ├── src │ │ ├── main │ │ │ └── java │ │ │ │ └── engineering │ │ │ │ └── everest │ │ │ │ └── lhotse │ │ │ │ └── photos │ │ │ │ ├── domain │ │ │ │ ├── events │ │ │ │ │ ├── PhotoDeletedAsPartOfUserDeletionEvent.java │ │ │ │ │ └── PhotoUploadedEvent.java │ │ │ │ ├── commands │ │ │ │ │ └── DeletePhotoForDeletedUserCommand.java │ │ │ │ ├── UserDeletedSaga.java │ │ │ │ └── PhotoAggregate.java │ │ │ │ ├── config │ │ │ │ └── PhotosRepositoryConfig.java │ │ │ │ ├── services │ │ │ │ └── DefaultPhotosService.java │ │ │ │ └── handlers │ │ │ │ └── PhotosEventHandler.java │ │ └── test │ │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── photos │ │ │ ├── services │ │ │ └── DefaultPhotosServiceTest.java │ │ │ ├── handlers │ │ │ └── PhotosEventHandlerTest.java │ │ │ └── domain │ │ │ └── UserDeletedSagaTest.java │ └── build.gradle ├── command-validation-support │ ├── build.gradle │ └── src │ │ ├── main │ │ └── java │ │ │ └── engineering │ │ │ └── everest │ │ │ └── lhotse │ │ │ └── axon │ │ │ └── command │ │ │ └── validators │ │ │ ├── EmailAddressValidator.java │ │ │ └── FileStatusValidator.java │ │ └── test │ │ └── java │ │ └── engineering │ │ └── everest │ │ └── lhotse │ │ └── axon │ │ └── command │ │ └── validators │ │ └── FileStatusValidatorTest.java └── forgotten-users │ ├── build.gradle │ └── src │ ├── main │ └── java │ │ └── engineering │ │ └── everest │ │ └── lhotse │ │ └── users │ │ ├── services │ │ └── DefaultUsersService.java │ │ ├── domain │ │ └── ForgottenUserAggregate.java │ │ └── eventhandlers │ │ └── ForgottenUsersEventHandler.java │ └── test │ └── java │ └── engineering │ └── everest │ └── lhotse │ └── users │ ├── services │ └── DefaultUsersServiceTest.java │ ├── eventhandlers │ └── ForgottenUsersEventHandlerTest.java │ └── domain │ └── ForgottenUserAggregateTest.java ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── lombok.config ├── .env ├── .buildkite └── pipeline.yml ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── PULL_REQUEST_TEMPLATE.md ├── .run └── Launcher.run.xml ├── sonar.gradle ├── docker-compose.yml ├── keycloak └── README.md ├── gradlew.bat └── create-aggregate.sh /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.parallel=true 2 | org.gradle.jvmargs=-Xmx4096m 3 | -------------------------------------------------------------------------------- /doc/notebook/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jupyter/base-notebook 2 | 3 | RUN pip install python-keycloak 4 | 5 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | file("src").eachDir { p -> 2 | include p.name 3 | project(":${p.name}").projectDir = p 4 | } 5 | -------------------------------------------------------------------------------- /src/launcher/src/main/resources/META-INF/additional-spring-configuration-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": [] 3 | } 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everest-engineering/lhotse/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /lombok.config: -------------------------------------------------------------------------------- 1 | config.stopBubbling = true 2 | lombok.log.fieldName = LOGGER 3 | lombok.addLombokGeneratedAnnotation = true 4 | -------------------------------------------------------------------------------- /doc/notebook/files/test_photo_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everest-engineering/lhotse/HEAD/doc/notebook/files/test_photo_1.png -------------------------------------------------------------------------------- /doc/notebook/files/test_photo_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everest-engineering/lhotse/HEAD/doc/notebook/files/test_photo_2.png -------------------------------------------------------------------------------- /doc/design/application-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everest-engineering/lhotse/HEAD/doc/design/application-architecture.png -------------------------------------------------------------------------------- /src/launcher/src/test/resources/test_photo_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everest-engineering/lhotse/HEAD/src/launcher/src/test/resources/test_photo_1.png -------------------------------------------------------------------------------- /src/launcher/src/test/resources/test_photo_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/everest-engineering/lhotse/HEAD/src/launcher/src/test/resources/test_photo_2.png -------------------------------------------------------------------------------- /src/photos-persistence/src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.liquibase.change-log=classpath:database/lhotse.xml 2 | spring.jpa.hibernate.ddl-auto=validate 3 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.liquibase.change-log=classpath:database/lhotse.xml 2 | spring.jpa.hibernate.ddl-auto=validate 3 | -------------------------------------------------------------------------------- /src/command-validation-api/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(':i18n-support') 3 | 4 | implementation "org.axonframework:axon-messaging:${axonVersion}" 5 | } 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | AXON_SERVER_DASHBOARD_PORT=8024 2 | AXON_SERVER_GRPC_PORT=8124 3 | KEYCLOAK_SERVER_PORT=8180 4 | KEYCLOAK_USER=admin@everest.engineering 5 | KEYCLOAK_PASSWORD=ac0n3x72 6 | POSTGRES_PORT=5432 7 | 8 | -------------------------------------------------------------------------------- /src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayCompletionAware.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.replay; 2 | 3 | public interface ReplayCompletionAware { 4 | 5 | default void replayCompleted() {} 6 | } 7 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/resources/messages_de.properties: -------------------------------------------------------------------------------- 1 | COMPETITION_MINIMUM_SUBMISSION_PERIOD=Einreichungen muessen mindestens {0} offen sein 2 | EMAIL_ADDRESS_MALFORMED=Fehlerhafte E-Mail-Adresse 3 | FILE_DOES_NOT_EXIST=Datei {0} existiert nicht 4 | -------------------------------------------------------------------------------- /src/common/src/main/java/engineering/everest/lhotse/common/Sleeper.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.common; 2 | 3 | import java.time.Duration; 4 | 5 | public interface Sleeper { 6 | void sleep(Duration milliseconds) throws InterruptedException; 7 | } 8 | -------------------------------------------------------------------------------- /src/command-validation-api/src/main/java/engineering/everest/lhotse/axon/command/validation/ValidatableCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.command.validation; 2 | 3 | import java.io.Serializable; 4 | 5 | public interface ValidatableCommand extends Serializable {} 6 | -------------------------------------------------------------------------------- /doc/notebook/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | notebook: 4 | build: 5 | context: . 6 | container_name: jupyter-notebook 7 | volumes: 8 | - ./files:/home/jovyan/work 9 | network_mode: host 10 | environment: 11 | - JUPYTER_ENABLE_LAB=yes 12 | 13 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/command-validation-api/src/main/java/engineering/everest/lhotse/axon/command/validation/EmailAddressValidatableCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.command.validation; 2 | 3 | public interface EmailAddressValidatableCommand extends ValidatableCommand { 4 | 5 | String getEmailAddress(); 6 | } 7 | -------------------------------------------------------------------------------- /src/database-support/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 5 | implementation "org.postgresql:postgresql:${postgresDriverVersion}" 6 | implementation "org.liquibase:liquibase-core:${liquibaseVersion}" 7 | } 8 | -------------------------------------------------------------------------------- /src/launcher/db-scripts/docker-postgres-ddl.sql: -------------------------------------------------------------------------------- 1 | CREATE USER lhotse WITH PASSWORD 'lhotse' CREATEDB; 2 | CREATE USER keycloak WITH PASSWORD 'keycloak' CREATEDB; 3 | 4 | CREATE DATABASE lhotse; 5 | CREATE DATABASE keycloak; 6 | 7 | ALTER DATABASE lhotse owner to lhotse; 8 | ALTER DATABASE keycloak owner to keycloak; 9 | -------------------------------------------------------------------------------- /src/launcher/src/main/resources/META-INF/build-info.properties: -------------------------------------------------------------------------------- 1 | # Dummy build info to workaround IntelliJ insisting on using a different output directory than Gradle 2 | build.time=2030-01-01T10\:30\:30.123Z 3 | build.artifact=launcher 4 | build.group=engineering.everest 5 | build.name=launcher 6 | build.version=intellij-dev 7 | -------------------------------------------------------------------------------- /src/photos-api/src/main/java/engineering/everest/lhotse/photos/services/PhotosService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.services; 2 | 3 | import java.util.UUID; 4 | 5 | public interface PhotosService { 6 | 7 | UUID registerUploadedPhoto(UUID requestingUserId, UUID persistedFileId, String filename); 8 | } 9 | -------------------------------------------------------------------------------- /src/forgotten-users-api/src/main/java/engineering/everest/lhotse/users/services/UsersService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.services; 2 | 3 | import java.util.UUID; 4 | 5 | public interface UsersService { 6 | 7 | void deleteAndForgetUser(UUID requestingUserId, UUID userIdToDelete, String requestReason); 8 | } 9 | -------------------------------------------------------------------------------- /src/launcher/src/test/resources/META-INF/build-info.properties: -------------------------------------------------------------------------------- 1 | # Dummy build info to workaround IntelliJ insisting on using a different output directory than Gradle 2 | build.time=2030-01-01T10\:30\:30.123Z 3 | build.artifact=functional-test-launcher 4 | build.group=engineering.everest 5 | build.name=functional-test 6 | build.version=intellij-dev 7 | -------------------------------------------------------------------------------- /src/secretkeys-persistence/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 5 | implementation "org.liquibase:liquibase-core:${liquibaseVersion}" 6 | implementation "engineering.everest.axon:crypto-shredding-extension:${axonCryptoShreddingVersion}" 7 | } 8 | -------------------------------------------------------------------------------- /src/command-validation-api/src/main/java/engineering/everest/lhotse/axon/command/validation/FileStatusValidatableCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.command.validation; 2 | 3 | import java.util.Set; 4 | import java.util.UUID; 5 | 6 | public interface FileStatusValidatableCommand extends ValidatableCommand { 7 | 8 | Set getFileIDs(); 9 | } 10 | -------------------------------------------------------------------------------- /src/i18n-support/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | implementation 'org.springframework.boot:spring-boot-starter-web' 5 | 6 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 7 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 8 | testImplementation "com.github.valfirst:slf4j-test:${slf4jTestVersion}" 9 | } 10 | -------------------------------------------------------------------------------- /src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/helpers/UserAttribute.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.functionaltests.helpers; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | @Data 8 | @NoArgsConstructor 9 | @AllArgsConstructor 10 | public class UserAttribute { 11 | private String firstName; 12 | private String lastName; 13 | } 14 | -------------------------------------------------------------------------------- /src/common/src/main/java/engineering/everest/lhotse/common/exceptions/RetryTimedOutException.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.common.exceptions; 2 | 3 | import java.time.Duration; 4 | 5 | public class RetryTimedOutException extends Exception { 6 | 7 | public RetryTimedOutException(Duration elapsed, String description) { 8 | super(String.format("Timed out while waiting %s for '%s'", elapsed, description)); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/photos-api/src/main/java/engineering/everest/lhotse/photos/services/PhotosWriteService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.services; 2 | 3 | import java.time.Instant; 4 | import java.util.UUID; 5 | 6 | public interface PhotosWriteService { 7 | void createPhoto(UUID id, UUID ownerUserId, UUID persistedFileId, String filename, Instant uploadTimestamp); 8 | 9 | void deleteAll(); 10 | 11 | void deleteById(UUID id); 12 | } 13 | -------------------------------------------------------------------------------- /src/launcher/src/main/resources/application-functionaltests.properties: -------------------------------------------------------------------------------- 1 | keycloak.enabled=true 2 | keycloak.realm=default 3 | keycloak.resource=default-client 4 | keycloak.auth-server-url=http://localhost:8180 5 | keycloak.public-client=true 6 | keycloak.confidential-port=0 7 | 8 | kc.server.admin-email=admin@everest.engineering 9 | kc.server.admin-password=ac0n3x72 10 | kc.server.master-realm.default.client-id=admin-cli 11 | kc.server.connection.pool-size=10 12 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/responses/ApiErrorResponse.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.responses; 2 | 3 | import lombok.Builder; 4 | import lombok.Data; 5 | import org.springframework.http.HttpStatus; 6 | 7 | import java.time.Instant; 8 | 9 | @Data 10 | @Builder 11 | public class ApiErrorResponse { 12 | private final HttpStatus status; 13 | private final String message; 14 | private final Instant timestamp; 15 | } 16 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/domain/queries/CompetitionWithEntriesQuery.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.queries; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.util.UUID; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class CompetitionWithEntriesQuery { 13 | private UUID competitionId; 14 | } 15 | -------------------------------------------------------------------------------- /src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayMarkerEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.replay; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.serialization.Revision; 7 | 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | @Revision("0") 14 | public class ReplayMarkerEvent { 15 | private UUID id; 16 | } 17 | -------------------------------------------------------------------------------- /src/launcher/src/main/java/engineering/everest/lhotse/config/TimeConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | 6 | import java.time.Clock; 7 | 8 | import static java.time.Clock.systemUTC; 9 | 10 | @Configuration 11 | public class TimeConfig { 12 | 13 | @Bean 14 | public Clock clock() { 15 | return systemUTC(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/launcher/src/main/java/engineering/everest/lhotse/config/JacksonConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Configuration 8 | public class JacksonConfig { 9 | 10 | @Bean 11 | public ObjectMapper domainObjectMapper() { 12 | return new ObjectMapper(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/WinnerAndSubmittedPhotoPair.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.util.UUID; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class WinnerAndSubmittedPhotoPair { 13 | private UUID winnerUserId; 14 | private UUID photoId; 15 | } 16 | -------------------------------------------------------------------------------- /src/launcher/src/main/java/engineering/everest/lhotse/config/RestTemplateConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.client.RestTemplate; 6 | 7 | @Configuration 8 | public class RestTemplateConfig { 9 | 10 | @Bean 11 | public RestTemplate restTemplate() { 12 | return new RestTemplate(); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/responses/PhotoResponse.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.responses; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class PhotoResponse { 14 | private UUID id; 15 | private String filename; 16 | private Instant uploadTimestamp; 17 | } 18 | -------------------------------------------------------------------------------- /src/common/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | implementation "org.springframework.boot:spring-boot-starter-logging:${springBootVersion}" 5 | implementation "org.springframework.boot:spring-boot-starter-json:${springBootVersion}" 6 | implementation "org.apache.commons:commons-lang3:${commonsLangVersion}" 7 | 8 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 9 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 10 | } 11 | -------------------------------------------------------------------------------- /src/axon-support/src/test/java/engineering/everest/lhotse/axon/CreateUserSubclassTestCommand.java: -------------------------------------------------------------------------------- 1 | // package engineering.everest.lhotse.axon; 2 | // 3 | // import java.util.Set; 4 | // import java.util.UUID; 5 | // 6 | // import static java.util.Collections.singleton; 7 | // 8 | // public class CreateUserSubclassTestCommand extends CreateOrganizationUserCommand implements UsersStatusValidatableCommand { 9 | // 10 | // @Override 11 | // public Set getUserIds() { 12 | // return singleton(getUserId()); 13 | // } 14 | // } 15 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/CompetitionEndedEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.serialization.Revision; 7 | 8 | import java.util.UUID; 9 | 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | @Getter 13 | @Revision("0") 14 | public class CompetitionEndedEvent { 15 | UUID competitionId; 16 | } 17 | -------------------------------------------------------------------------------- /src/launcher/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | ____ __ ____ _ _ 4 | / __/ _____ _______ ___ / /_ / __/__ ___ _(_)__ ___ ___ ____(_)__ ___ _ 5 | / _/| |/ / -_) __/ -_|_- {} 8 | -------------------------------------------------------------------------------- /src/photos-persistence/src/test/java/engineering/everest/lhotse/photos/persistence/config/TestClockConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.persistence.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | 6 | import java.time.Clock; 7 | import java.time.Instant; 8 | import java.time.ZoneId; 9 | 10 | @Configuration 11 | public class TestClockConfig { 12 | @Bean 13 | public Clock clock() { 14 | return Clock.fixed(Instant.now(), ZoneId.systemDefault()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/commands/CountVotesAndDeclareOutcomeCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.commands; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 7 | 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class CountVotesAndDeclareOutcomeCommand { 14 | @TargetAggregateIdentifier 15 | private UUID competitionId; 16 | } 17 | -------------------------------------------------------------------------------- /.buildkite/pipeline.yml: -------------------------------------------------------------------------------- 1 | if-our-repo: &if-our-repo 2 | if: pipeline.repository =~ /^https:\/\/github.com\/everest-engineering\// 3 | 4 | if-main-branch: &if-main-branch 5 | if: build.branch == 'main' 6 | 7 | steps: 8 | - label: ':hammer: Build & test' 9 | agents: 10 | java: 17 11 | commands: 12 | - ./gradlew clean build --console plain 13 | 14 | - label: ':sonarqube: Quality reporting' 15 | <<: *if-our-repo 16 | <<: *if-main-branch 17 | agents: 18 | java: 17 19 | commands: 20 | - ./gradlew clean codeCoverageReport sonar --console plain 21 | 22 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/test/java/engineering/everest/lhotse/competitions/persistence/config/TestClockConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.persistence.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | 6 | import java.time.Clock; 7 | import java.time.Instant; 8 | import java.time.ZoneId; 9 | 10 | @Configuration 11 | public class TestClockConfig { 12 | @Bean 13 | public Clock clock() { 14 | return Clock.fixed(Instant.now(), ZoneId.systemDefault()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/PhotoEntryReceivedVoteEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.serialization.Revision; 7 | 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | @Revision("0") 14 | public class PhotoEntryReceivedVoteEvent { 15 | private UUID competitionId; 16 | private UUID photoId; 17 | private UUID votingUserId; 18 | } 19 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/responses/CompetitionEntryFragment.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.responses; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class CompetitionEntryFragment { 14 | private UUID photoId; 15 | private UUID submitterUserId; 16 | private Instant entryTimestamp; 17 | private int votesReceived; 18 | private boolean didWin; 19 | } 20 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/services/CompetitionsReadService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.services; 2 | 3 | import engineering.everest.lhotse.competitions.domain.Competition; 4 | import engineering.everest.lhotse.competitions.domain.CompetitionWithEntries; 5 | 6 | import java.util.List; 7 | import java.util.UUID; 8 | 9 | public interface CompetitionsReadService { 10 | 11 | List getAllCompetitionsOrderedByDescVotingEndsTimestamp(); 12 | 13 | CompetitionWithEntries getCompetitionWithEntries(UUID competitionId); 14 | } 15 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/domain/events/PhotoDeletedAsPartOfUserDeletionEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.serialization.Revision; 7 | 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | @Revision("0") 14 | public class PhotoDeletedAsPartOfUserDeletionEvent { 15 | private UUID photoId; 16 | private UUID persistedFileId; 17 | private UUID deletedUserId; 18 | } 19 | -------------------------------------------------------------------------------- /src/forgotten-users-api/src/main/java/engineering/everest/lhotse/users/domain/events/UserDeletedAndForgottenEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.serialization.Revision; 7 | 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | @Revision("0") 14 | public class UserDeletedAndForgottenEvent { 15 | private UUID deletedUserId; 16 | private UUID requestingUserId; 17 | private String requestReason; 18 | } 19 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/requests/CompetitionSubmissionRequest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.requests; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class CompetitionSubmissionRequest { 15 | @NotNull 16 | @Schema 17 | private UUID photoId; 18 | private String submissionNotes; 19 | } 20 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/CompetitionVotingPeriodEndedEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.serialization.Revision; 7 | 8 | import java.time.Instant; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | @Revision("0") 15 | public class CompetitionVotingPeriodEndedEvent { 16 | private UUID competitionId; 17 | private Instant scheduledVotingEndTimestamp; 18 | } 19 | -------------------------------------------------------------------------------- /src/competitions-api/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api project(':common') 3 | api project(':command-validation-api') 4 | 5 | implementation "engineering.everest.starterkit:storage:${storageVersion}" 6 | implementation "engineering.everest.axon:crypto-shredding-extension:${axonCryptoShreddingVersion}" 7 | implementation "org.axonframework:axon-modelling:${axonVersion}" 8 | implementation 'org.springframework.boot:spring-boot-starter-validation' 9 | 10 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 11 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 12 | } 13 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/domain/CompetitionEntry.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class CompetitionEntry { 14 | private UUID competitionId; 15 | private UUID photoId; 16 | private UUID submittedByUserId; 17 | private Instant entryTimestamp; 18 | private int numVotesReceived; 19 | private boolean isWinner; 20 | } 21 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/domain/commands/DeletePhotoForDeletedUserCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.domain.commands; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 7 | 8 | import java.io.Serializable; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class DeletePhotoForDeletedUserCommand implements Serializable { 15 | @TargetAggregateIdentifier 16 | private UUID photoId; 17 | private UUID deletedUserId; 18 | } 19 | -------------------------------------------------------------------------------- /src/command-validation-api/src/main/java/engineering/everest/lhotse/axon/command/validation/Validates.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.command.validation; 2 | 3 | import engineering.everest.lhotse.i18n.exceptions.TranslatableException; 4 | import org.axonframework.commandhandling.CommandExecutionException; 5 | 6 | public interface Validates { 7 | 8 | void validate(T validatable); 9 | 10 | default void throwWrappedInCommandExecutionException(TranslatableException translatableException) { 11 | throw new CommandExecutionException(translatableException.getMessage(), null, translatableException); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/domain/Competition.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class Competition { 14 | private UUID id; 15 | private String description; 16 | private Instant submissionsOpenTimestamp; 17 | private Instant submissionsCloseTimestamp; 18 | private Instant votingEndsTimestamp; 19 | private int maxEntriesPerUser; 20 | } 21 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/responses/CompetitionSummaryResponse.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.responses; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.UUID; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class CompetitionSummaryResponse { 14 | private UUID id; 15 | private String description; 16 | private Instant submissionsOpenTimestamp; 17 | private Instant submissionsCloseTimestamp; 18 | private Instant votingEndsTimestamp; 19 | private int maxEntriesPerUser; 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.jar 15 | *.war 16 | *.nar 17 | *.ear 18 | *.zip 19 | *.tar.gz 20 | *.rar 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | .gradle/ 25 | .idea/ 26 | build/ 27 | 28 | .DS_Store 29 | 30 | .shelf 31 | 32 | /*.iml 33 | /*.ipr 34 | /*.iws 35 | /out 36 | 37 | /*/*.iml 38 | /*/*.ipr 39 | /*/*.iws 40 | /*/out 41 | 42 | .ipynb_checkpoints/ 43 | 44 | .project 45 | .settings 46 | .vscode 47 | /src/launcher/bin 48 | *.classpath 49 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/annotations/AdminOnly.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.annotations; 2 | 3 | import org.springframework.security.access.prepost.PreAuthorize; 4 | 5 | import java.lang.annotation.Documented; 6 | import java.lang.annotation.ElementType; 7 | import java.lang.annotation.Inherited; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | @Target({ ElementType.METHOD, ElementType.TYPE }) 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Inherited 15 | @Documented 16 | @PreAuthorize("hasRole('ADMIN')") 17 | public @interface AdminOnly {} 18 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/commands/VoteForPhotoCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.commands; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 7 | 8 | import java.io.Serializable; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class VoteForPhotoCommand implements Serializable { 15 | @TargetAggregateIdentifier 16 | private UUID competitionId; 17 | private UUID photoId; 18 | private UUID requestingUserId; 19 | } 20 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/annotations/RegisteredUser.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.annotations; 2 | 3 | import org.springframework.security.access.prepost.PreAuthorize; 4 | 5 | import java.lang.annotation.Documented; 6 | import java.lang.annotation.ElementType; 7 | import java.lang.annotation.Inherited; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | @Target({ ElementType.METHOD, ElementType.TYPE }) 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Inherited 15 | @Documented 16 | @PreAuthorize("hasRole('REGISTERED_USER')") 17 | public @interface RegisteredUser {} 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/launcher/src/main/java/engineering/everest/lhotse/Launcher.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.scheduling.annotation.EnableScheduling; 6 | 7 | @SpringBootApplication( 8 | scanBasePackages = { 9 | "engineering.everest.starterkit", 10 | "engineering.everest.lhotse" 11 | }) 12 | @EnableScheduling 13 | @SuppressWarnings("PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal") 14 | public class Launcher { 15 | 16 | public static void main(String[] args) { 17 | SpringApplication.run(Launcher.class, args); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayableEventProcessor.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.replay; 2 | 3 | import org.axonframework.eventhandling.EventProcessor; 4 | import org.axonframework.eventhandling.TrackingToken; 5 | 6 | import java.io.Closeable; 7 | import java.util.function.Consumer; 8 | 9 | public interface ReplayableEventProcessor extends EventProcessor { 10 | void startReplay(TrackingToken startPosition, ReplayMarkerEvent replayMarkerEvent); 11 | 12 | boolean isReplaying(); 13 | 14 | ListenerRegistry registerReplayCompletionListener(Consumer listener); 15 | 16 | interface ListenerRegistry extends Closeable {} 17 | } 18 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/annotations/AdminOrRegisteredUser.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.annotations; 2 | 3 | import org.springframework.security.access.prepost.PreAuthorize; 4 | 5 | import java.lang.annotation.Documented; 6 | import java.lang.annotation.ElementType; 7 | import java.lang.annotation.Inherited; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | @Target({ ElementType.METHOD, ElementType.TYPE }) 13 | @Retention(RetentionPolicy.RUNTIME) 14 | @Inherited 15 | @Documented 16 | @PreAuthorize("hasAnyRole('ADMIN', 'REGISTERED_USER')") 17 | public @interface AdminOrRegisteredUser {} 18 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/CompetitionEndedWithNoEntriesSubmittedEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import org.axonframework.serialization.Revision; 8 | 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @Getter 14 | @EqualsAndHashCode(callSuper = true) 15 | @Revision("0") 16 | public class CompetitionEndedWithNoEntriesSubmittedEvent extends CompetitionEndedEvent { 17 | 18 | public CompetitionEndedWithNoEntriesSubmittedEvent(UUID competitionId) { 19 | super(competitionId); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 6 | import org.springframework.security.crypto.password.PasswordEncoder; 7 | 8 | import java.security.SecureRandom; 9 | 10 | @Configuration 11 | public class SecurityConfig { 12 | private static final int DEFAULT_PASSWORD_ENCODER_STRENGTH = 10; 13 | 14 | @Bean 15 | PasswordEncoder passwordEncoder() { 16 | return new BCryptPasswordEncoder(DEFAULT_PASSWORD_ENCODER_STRENGTH, new SecureRandom()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/photos-api/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api project(':common') 3 | api project(':command-validation-api') 4 | api project(':forgotten-users-api') 5 | 6 | implementation "engineering.everest.starterkit:storage:${storageVersion}" 7 | implementation "engineering.everest.axon:crypto-shredding-extension:${axonCryptoShreddingVersion}" 8 | implementation "org.axonframework:axon-modelling:${axonVersion}" 9 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 10 | implementation 'org.springframework.boot:spring-boot-starter-validation' 11 | 12 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 13 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 14 | } 15 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/config/PhotosRepositoryConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.config; 2 | 3 | import org.axonframework.eventsourcing.EventCountSnapshotTriggerDefinition; 4 | import org.axonframework.eventsourcing.SnapshotTriggerDefinition; 5 | import org.axonframework.eventsourcing.Snapshotter; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | @Configuration 10 | public class PhotosRepositoryConfig { 11 | 12 | @Bean 13 | public SnapshotTriggerDefinition photoAggregateSnapshotTriggerDefinition(Snapshotter snapshotter) { 14 | return new EventCountSnapshotTriggerDefinition(snapshotter, 30); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/helpers/TestEventHandler.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.functionaltests.helpers; 2 | 3 | import lombok.Getter; 4 | import org.axonframework.eventhandling.EventHandler; 5 | import org.axonframework.eventhandling.ReplayStatus; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.concurrent.atomic.AtomicInteger; 9 | 10 | @Service 11 | public class TestEventHandler { 12 | 13 | @Getter 14 | private final AtomicInteger counter = new AtomicInteger(0); 15 | 16 | @EventHandler 17 | void on(Object event, ReplayStatus replayStatus) { 18 | if (!replayStatus.isReplay()) { 19 | counter.incrementAndGet(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/responses/CompetitionWithEntriesResponse.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.responses; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.time.Instant; 8 | import java.util.List; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class CompetitionWithEntriesResponse { 15 | private UUID id; 16 | private String description; 17 | private Instant submissionsOpenTimestamp; 18 | private Instant submissionsCloseTimestamp; 19 | private Instant votingEndsTimestamp; 20 | private int maxEntriesPerUser; 21 | private List entries; 22 | } 23 | -------------------------------------------------------------------------------- /src/photos-api/src/main/java/engineering/everest/lhotse/photos/services/PhotosReadService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.services; 2 | 3 | import engineering.everest.lhotse.photos.Photo; 4 | import org.springframework.data.domain.Pageable; 5 | 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | import java.util.List; 9 | import java.util.UUID; 10 | 11 | public interface PhotosReadService { 12 | List getAllPhotos(UUID requestingUserId, Pageable pageable); 13 | 14 | Photo getPhoto(UUID photoId); 15 | 16 | InputStream streamPhoto(UUID requestingUserId, UUID photoId) throws IOException; 17 | 18 | InputStream streamPhotoThumbnail(UUID requestingUserId, UUID photoId, int width, int height) throws IOException; 19 | } 20 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/config/CompetitionRepositoryConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.config; 2 | 3 | import org.axonframework.eventsourcing.EventCountSnapshotTriggerDefinition; 4 | import org.axonframework.eventsourcing.SnapshotTriggerDefinition; 5 | import org.axonframework.eventsourcing.Snapshotter; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | @Configuration 10 | public class CompetitionRepositoryConfig { 11 | 12 | @Bean 13 | public SnapshotTriggerDefinition competitionAggregateSnapshotTriggerDefinition(Snapshotter snapshotter) { 14 | return new EventCountSnapshotTriggerDefinition(snapshotter, 30); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/commands/EnterPhotoInCompetitionCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.commands; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 7 | 8 | import java.io.Serializable; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class EnterPhotoInCompetitionCommand implements Serializable { 15 | @TargetAggregateIdentifier 16 | private UUID competitionId; 17 | private UUID photoId; 18 | private UUID requestingUserId; 19 | private UUID photoOwnerUserId; 20 | private String submissionNotes; 21 | } 22 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/CompetitionEndedWithNoEntriesReceivingVotesEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import org.axonframework.serialization.Revision; 8 | 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @Getter 14 | @EqualsAndHashCode(callSuper = true) 15 | @Revision("0") 16 | public class CompetitionEndedWithNoEntriesReceivingVotesEvent extends CompetitionEndedEvent { 17 | private int numberOfEntriesReceived; 18 | 19 | public CompetitionEndedWithNoEntriesReceivingVotesEvent(UUID competitionId) { 20 | super(competitionId); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Proposed changes 2 | 3 | Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. 4 | 5 | ## Checklist 6 | 7 | _Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ 8 | 9 | - [ ] My local build passed (You ran `./gradlew clean build` without failures) 10 | - [ ] I have added tests that prove my fix is effective or that my feature works 11 | - [ ] I have added necessary documentation (if appropriate) 12 | - [ ] I have assigned a label to the PR 13 | 14 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/CompetitionCreatedEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.serialization.Revision; 7 | 8 | import java.time.Instant; 9 | import java.util.UUID; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | @Revision("0") 15 | public class CompetitionCreatedEvent { 16 | private UUID requestingUserId; 17 | private UUID competitionId; 18 | private String description; 19 | private Instant submissionsOpenTimestamp; 20 | private Instant submissionsCloseTimestamp; 21 | private Instant votingEndsTimestamp; 22 | private int maxEntriesPerUser; 23 | } 24 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/domain/events/PhotoUploadedEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.domain.events; 2 | 3 | import engineering.everest.axon.cryptoshredding.annotations.EncryptedField; 4 | import engineering.everest.axon.cryptoshredding.annotations.EncryptionKeyIdentifier; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | import org.axonframework.serialization.Revision; 9 | 10 | import java.util.UUID; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | @Revision("0") 16 | public class PhotoUploadedEvent { 17 | private UUID photoId; 18 | @EncryptionKeyIdentifier 19 | private UUID owningUserId; 20 | private UUID persistedFileId; 21 | @EncryptedField 22 | private String filename; 23 | } 24 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/config/ETagFilterConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.config; 2 | 3 | import org.springframework.boot.web.servlet.FilterRegistrationBean; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.filter.ShallowEtagHeaderFilter; 7 | 8 | @Configuration 9 | public class ETagFilterConfig { 10 | 11 | @Bean 12 | public FilterRegistrationBean shallowEtagHeaderFilter() { 13 | var filterRegistrationBean = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter()); 14 | filterRegistrationBean.addUrlPatterns("/*"); 15 | filterRegistrationBean.setName("etagFilter"); 16 | return filterRegistrationBean; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/services/CompetitionsService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.services; 2 | 3 | import java.time.Instant; 4 | import java.util.UUID; 5 | 6 | public interface CompetitionsService { 7 | 8 | UUID createCompetition(UUID requestingUserId, 9 | String description, 10 | Instant submissionsOpenTimestamp, 11 | Instant submissionsCloseTimestamp, 12 | Instant votingEndsTimestamp, 13 | int maxEntriesPerUser); 14 | 15 | void submitPhoto(UUID requestingUserId, UUID competitionId, UUID photoId, String submissionNotes); 16 | 17 | void voteForPhoto(UUID requestingUserId, UUID competitionId, UUID photoId); 18 | } 19 | -------------------------------------------------------------------------------- /src/forgotten-users-api/src/main/java/engineering/everest/lhotse/users/domain/commands/DeleteAndForgetUserCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.domain.commands; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 9 | 10 | import java.io.Serializable; 11 | import java.util.UUID; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class DeleteAndForgetUserCommand implements Serializable { 17 | @TargetAggregateIdentifier 18 | private UUID userIdToDelete; 19 | @NotNull 20 | private UUID requestingUserId; 21 | @NotBlank 22 | private String requestReason; 23 | } 24 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/exceptions/TranslatableIllegalStateException.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n.exceptions; 2 | 3 | public class TranslatableIllegalStateException extends TranslatableException { 4 | 5 | public TranslatableIllegalStateException(String i18nMessageKey) { 6 | super(i18nMessageKey); 7 | } 8 | 9 | public TranslatableIllegalStateException(String i18nMessageKey, Object... args) { 10 | super(i18nMessageKey, args); 11 | } 12 | 13 | public TranslatableIllegalStateException(String i18nMessageKey, Throwable cause) { 14 | super(i18nMessageKey, cause); 15 | } 16 | 17 | public TranslatableIllegalStateException(String i18nMessageKey, Throwable cause, Object... args) { 18 | super(i18nMessageKey, cause, args); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/exceptions/TranslatableIllegalArgumentException.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n.exceptions; 2 | 3 | public class TranslatableIllegalArgumentException extends TranslatableException { 4 | 5 | public TranslatableIllegalArgumentException(String i18nMessageKey) { 6 | super(i18nMessageKey); 7 | } 8 | 9 | public TranslatableIllegalArgumentException(String i18nMessageKey, Object... args) { 10 | super(i18nMessageKey, args); 11 | } 12 | 13 | public TranslatableIllegalArgumentException(String i18nMessageKey, Throwable cause) { 14 | super(i18nMessageKey, cause); 15 | } 16 | 17 | public TranslatableIllegalArgumentException(String i18nMessageKey, Throwable cause, Object... args) { 18 | super(i18nMessageKey, cause, args); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/domain/CompetitionWithEntries.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.NoArgsConstructor; 6 | 7 | import java.util.List; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | @EqualsAndHashCode(callSuper = true) 12 | public class CompetitionWithEntries extends Competition { 13 | private List entries; 14 | 15 | public CompetitionWithEntries(Competition competition, List entries) { 16 | super(competition.getId(), competition.getDescription(), competition.getSubmissionsOpenTimestamp(), 17 | competition.getSubmissionsCloseTimestamp(), competition.getVotingEndsTimestamp(), 18 | competition.getMaxEntriesPerUser()); 19 | this.entries = entries; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/command-validation-support/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | api project(':command-validation-api') 5 | api project(':forgotten-users-api') 6 | 7 | implementation project(':common') 8 | implementation project(':i18n-support') 9 | 10 | implementation "engineering.everest.starterkit:storage:${storageVersion}" 11 | implementation 'org.springframework:spring-context' 12 | implementation "org.axonframework:axon-modelling:${axonVersion}" 13 | implementation "org.apache.commons:commons-lang3:${commonsLangVersion}" 14 | implementation "commons-validator:commons-validator:${commonsValidatorVersion}" 15 | 16 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 17 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 18 | testImplementation "org.hamcrest:hamcrest-library:${hamcrestVersion}" 19 | } 20 | -------------------------------------------------------------------------------- /src/competitions-persistence/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | api project(':competitions-api') 5 | 6 | implementation "engineering.everest.starterkit:storage:${storageVersion}" 7 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 8 | implementation "org.liquibase:liquibase-core:${liquibaseVersion}" 9 | 10 | testImplementation project(':database-support') 11 | testImplementation 'org.springframework.boot:spring-boot-test-autoconfigure' 12 | testImplementation 'org.springframework:spring-test' 13 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 14 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 15 | testImplementation "org.postgresql:postgresql:${postgresDriverVersion}" 16 | testImplementation "io.zonky.test:embedded-database-spring-test:${zonkyEmbeddedDbVersion}" 17 | } 18 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/requests/CreateCompetitionRequest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.requests; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.NotNull; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | import java.time.Instant; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class CreateCompetitionRequest { 16 | @NotBlank 17 | private String description; 18 | @NotNull 19 | @Schema 20 | private Instant submissionsOpenTimestamp; 21 | @NotNull 22 | @Schema 23 | private Instant submissionsCloseTimestamp; 24 | @NotNull 25 | @Schema 26 | private Instant votingEndsTimestamp; 27 | @Schema 28 | private int maxEntriesPerUser; 29 | } 30 | -------------------------------------------------------------------------------- /src/photos-persistence/src/main/java/engineering/everest/lhotse/photos/persistence/PersistablePhoto.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.persistence; 2 | 3 | import engineering.everest.lhotse.photos.Photo; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.Id; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | import java.time.Instant; 11 | import java.util.UUID; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | @Entity(name = "photos") 17 | public class PersistablePhoto { 18 | 19 | @Id 20 | private UUID id; 21 | private UUID ownerUserId; 22 | private UUID persistedFileId; 23 | private String filename; 24 | private Instant uploadTimestamp; 25 | 26 | public Photo toDomain() { 27 | return new Photo(id, ownerUserId, persistedFileId, filename, uploadTimestamp); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/PhotoEnteredInCompetitionEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import engineering.everest.axon.cryptoshredding.annotations.EncryptedField; 4 | import engineering.everest.axon.cryptoshredding.annotations.EncryptionKeyIdentifier; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | import org.axonframework.serialization.Revision; 9 | 10 | import java.util.UUID; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | @Revision("0") 16 | public class PhotoEnteredInCompetitionEvent { 17 | private UUID competitionId; 18 | private UUID photoId; 19 | @EncryptionKeyIdentifier 20 | private UUID submittedByUserId; 21 | private UUID photoOwnerUserId; 22 | @EncryptedField 23 | private String submissionNotes; 24 | } 25 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/services/CompetitionsWriteService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.services; 2 | 3 | import java.time.Instant; 4 | import java.util.UUID; 5 | 6 | public interface CompetitionsWriteService { 7 | void createCompetition(UUID id, 8 | String description, 9 | Instant submissionsOpenTimestamp, 10 | Instant submissionsCloseTimestamp, 11 | Instant votingEndsTimestamp, 12 | int maxEntriesPerUser); 13 | 14 | void createCompetitionEntry(UUID competitionId, UUID photoId, UUID submittedByUserId, Instant entryTimestamp); 15 | 16 | void incrementVotesReceived(UUID competitionId, UUID photoId); 17 | 18 | void setCompetitionWinner(UUID competitionId, UUID photoId); 19 | 20 | void deleteAll(); 21 | } 22 | -------------------------------------------------------------------------------- /src/axon-support/src/main/java/engineering/everest/lhotse/axon/LoggingMessageHandlerInterceptor.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.axonframework.commandhandling.CommandMessage; 5 | import org.axonframework.messaging.InterceptorChain; 6 | import org.axonframework.messaging.MessageHandlerInterceptor; 7 | import org.axonframework.messaging.unitofwork.UnitOfWork; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Slf4j 11 | @Component 12 | public class LoggingMessageHandlerInterceptor implements MessageHandlerInterceptor> { 13 | 14 | @Override 15 | public Object handle(UnitOfWork> unitOfWork, InterceptorChain interceptorChain) throws Exception { 16 | LOGGER.info("Handling command: {}", unitOfWork.getMessage().getPayloadType().getSimpleName()); 17 | return interceptorChain.proceed(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/RequestParameterAcceptHeaderLocaleResolver.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; 5 | 6 | import java.util.Locale; 7 | 8 | import static org.springframework.util.StringUtils.parseLocaleString; 9 | 10 | public class RequestParameterAcceptHeaderLocaleResolver extends AcceptHeaderLocaleResolver { 11 | 12 | public RequestParameterAcceptHeaderLocaleResolver() { 13 | super(); 14 | super.setDefaultLocale(Locale.getDefault()); 15 | } 16 | 17 | @Override 18 | public Locale resolveLocale(HttpServletRequest request) { 19 | String locale = request.getParameter("locale"); 20 | return locale == null 21 | ? super.resolveLocale(request) 22 | : parseLocaleString(locale); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/main/java/engineering/everest/lhotse/competitions/persistence/CompetitionEntriesRepository.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.persistence; 2 | 3 | import org.springframework.data.domain.Sort; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.time.Instant; 8 | import java.util.List; 9 | import java.util.UUID; 10 | 11 | @Repository 12 | public interface CompetitionEntriesRepository extends JpaRepository { 13 | 14 | default void createCompetitionEntry(UUID competitionId, UUID photoId, UUID submittedByUserId, Instant entryTimestamp) { 15 | save(new PersistableCompetitionEntry(competitionId, photoId, submittedByUserId, entryTimestamp)); 16 | } 17 | 18 | List findAllByCompetitionId(UUID competitionId, Sort sort); 19 | } 20 | -------------------------------------------------------------------------------- /src/forgotten-users/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | api project(':forgotten-users-api') 5 | api "org.axonframework:axon-spring:${axonVersion}" 6 | 7 | implementation project(':axon-support') 8 | implementation project(':common') 9 | 10 | implementation "engineering.everest.axon:crypto-shredding-extension:${axonCryptoShreddingVersion}" 11 | implementation 'org.springframework.boot:spring-boot-starter-security' 12 | compileOnly 'org.springframework:spring-tx' 13 | 14 | testImplementation project(':axon-support').sourceSets.test.output 15 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 16 | testImplementation "org.axonframework:axon-test:${axonVersion}" 17 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 18 | testImplementation "org.hamcrest:hamcrest-library:${hamcrestVersion}" 19 | testCompileOnly 'org.springframework:spring-tx' 20 | } 21 | -------------------------------------------------------------------------------- /src/photos-persistence/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | api project(':photos-api') 5 | 6 | implementation "engineering.everest.starterkit:media:${mediaVersion}" 7 | implementation "engineering.everest.starterkit:storage:${storageVersion}" 8 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 9 | implementation "org.liquibase:liquibase-core:${liquibaseVersion}" 10 | 11 | testImplementation project(':database-support') 12 | testImplementation 'org.springframework.boot:spring-boot-test-autoconfigure' 13 | testImplementation 'org.springframework:spring-test' 14 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 15 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 16 | testImplementation "org.postgresql:postgresql:${postgresDriverVersion}" 17 | testImplementation "io.zonky.test:embedded-database-spring-test:${zonkyEmbeddedDbVersion}" 18 | } 19 | -------------------------------------------------------------------------------- /src/forgotten-users/src/main/java/engineering/everest/lhotse/users/services/DefaultUsersService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.services; 2 | 3 | import engineering.everest.lhotse.users.domain.commands.DeleteAndForgetUserCommand; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.axonframework.commandhandling.gateway.CommandGateway; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.UUID; 9 | 10 | @Service 11 | @Slf4j 12 | public class DefaultUsersService implements UsersService { 13 | 14 | private final CommandGateway commandGateway; 15 | 16 | public DefaultUsersService(CommandGateway commandGateway) { 17 | this.commandGateway = commandGateway; 18 | } 19 | 20 | @Override 21 | public void deleteAndForgetUser(UUID requestingUserId, UUID userIdToDelete, String requestReason) { 22 | commandGateway.sendAndWait(new DeleteAndForgetUserCommand(userIdToDelete, requestingUserId, requestReason)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/photos-persistence/src/main/java/engineering/everest/lhotse/photos/persistence/PhotosRepository.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.persistence; 2 | 3 | import org.springframework.data.domain.Pageable; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.time.Instant; 8 | import java.util.List; 9 | import java.util.Optional; 10 | import java.util.UUID; 11 | 12 | @Repository 13 | public interface PhotosRepository extends JpaRepository { 14 | 15 | default void createPhoto(UUID id, UUID ownerUserId, UUID persistedFileId, String filename, Instant uploadTimestamp) { 16 | save(new PersistablePhoto(id, ownerUserId, persistedFileId, filename, uploadTimestamp)); 17 | } 18 | 19 | List findByOwnerUserId(UUID ownerId, Pageable pageable); 20 | 21 | Optional findByIdAndOwnerUserId(UUID photoId, UUID ownerUserId); 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/competitions-api/src/main/java/engineering/everest/lhotse/competitions/domain/commands/CreateCompetitionCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.commands; 2 | 3 | import jakarta.validation.constraints.NotNull; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 8 | 9 | import java.io.Serializable; 10 | import java.time.Instant; 11 | import java.util.UUID; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class CreateCompetitionCommand implements Serializable { 17 | private UUID requestingUserId; 18 | @TargetAggregateIdentifier 19 | private UUID competitionId; 20 | private String description; 21 | @NotNull 22 | private Instant submissionsOpenTimestamp; 23 | @NotNull 24 | private Instant submissionsCloseTimestamp; 25 | @NotNull 26 | private Instant votingEndsTimestamp; 27 | private int maxEntriesPerUser; 28 | } 29 | -------------------------------------------------------------------------------- /src/photos-api/src/main/java/engineering/everest/lhotse/photos/domain/commands/RegisterUploadedPhotoCommand.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.domain.commands; 2 | 3 | import engineering.everest.lhotse.axon.command.validation.FileStatusValidatableCommand; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | import org.axonframework.modelling.command.TargetAggregateIdentifier; 9 | 10 | import java.util.Set; 11 | import java.util.UUID; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class RegisterUploadedPhotoCommand implements FileStatusValidatableCommand { 17 | @TargetAggregateIdentifier 18 | private UUID photoId; 19 | @NotNull 20 | private UUID owningUserId; 21 | @NotNull 22 | private UUID persistedFileId; 23 | private String filename; 24 | 25 | @Override 26 | public Set getFileIDs() { 27 | return Set.of(persistedFileId); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/photos/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | api project(':photos-api') 5 | api "org.axonframework:axon-spring:${axonVersion}" 6 | 7 | implementation project(':axon-support') 8 | implementation project(':common') 9 | implementation project(':command-validation-support') 10 | implementation project(':i18n-support') 11 | 12 | implementation "engineering.everest.axon:crypto-shredding-extension:${axonCryptoShreddingVersion}" 13 | implementation "engineering.everest.starterkit:storage:${storageVersion}" 14 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 15 | 16 | testImplementation project(':axon-support').sourceSets.test.output 17 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 18 | testImplementation "org.axonframework:axon-test:${axonVersion}" 19 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 20 | testImplementation "org.hamcrest:hamcrest-library:${hamcrestVersion}" 21 | } 22 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/main/java/engineering/everest/lhotse/competitions/persistence/CompetitionsRepository.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.persistence; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import java.time.Instant; 7 | import java.util.UUID; 8 | 9 | @Repository 10 | public interface CompetitionsRepository extends JpaRepository { 11 | 12 | default void createCompetition(UUID id, 13 | String description, 14 | Instant submissionsOpenTimestamp, 15 | Instant submissionsCloseTimestamp, 16 | Instant votingEndsTimestamp, 17 | int maxEntriesPerUser) { 18 | save(new PersistableCompetition(id, description, submissionsOpenTimestamp, submissionsCloseTimestamp, votingEndsTimestamp, 19 | maxEntriesPerUser)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.run/Launcher.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/config/InternationalizationConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n.config; 2 | 3 | import engineering.everest.lhotse.i18n.RequestParameterAcceptHeaderLocaleResolver; 4 | import engineering.everest.lhotse.i18n.TranslationService; 5 | import org.springframework.boot.autoconfigure.web.servlet.WebMvcProperties; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 9 | import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; 10 | 11 | @Configuration 12 | public class InternationalizationConfig implements WebMvcConfigurer { 13 | 14 | @Bean 15 | public AcceptHeaderLocaleResolver localeResolver(WebMvcProperties webMvcProperties) { 16 | return new RequestParameterAcceptHeaderLocaleResolver(); 17 | } 18 | 19 | @Bean 20 | public TranslationService translationService() { 21 | return TranslationService.getInstance(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/controllers/VersionController.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.controllers; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.tags.Tag; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.info.BuildProperties; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | @RestController 12 | @RequestMapping("/api") 13 | @Tag(name = "System") 14 | public class VersionController { 15 | 16 | private final BuildProperties buildProperties; 17 | 18 | @Autowired 19 | public VersionController(BuildProperties buildProperties) { 20 | this.buildProperties = buildProperties; 21 | } 22 | 23 | @GetMapping("/version") 24 | @Operation(description = "Version of the service") 25 | public String getVersion() { 26 | return buildProperties.getVersion().replace("'", ""); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/main/java/engineering/everest/lhotse/competitions/persistence/PersistableCompetition.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.persistence; 2 | 3 | import engineering.everest.lhotse.competitions.domain.Competition; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.Id; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | import java.time.Instant; 11 | import java.util.UUID; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | @Entity(name = "competitions") 17 | public class PersistableCompetition { 18 | 19 | @Id 20 | private UUID id; 21 | private String description; 22 | private Instant submissionsOpenTimestamp; 23 | private Instant submissionsCloseTimestamp; 24 | private Instant votingEndsTimestamp; 25 | private int maxEntriesPerUser; 26 | 27 | public Competition toDomain() { 28 | return new Competition(id, description, submissionsOpenTimestamp, submissionsCloseTimestamp, votingEndsTimestamp, 29 | maxEntriesPerUser); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/competitions/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | dependencies { 4 | api project(':competitions-api') 5 | api project(':photos-api') 6 | api "org.axonframework:axon-spring:${axonVersion}" 7 | 8 | implementation project(':axon-support') 9 | implementation project(':command-validation-support') 10 | implementation project(':common') 11 | implementation project(':competitions-persistence') 12 | implementation project(':i18n-support') 13 | 14 | implementation "engineering.everest.axon:crypto-shredding-extension:${axonCryptoShreddingVersion}" 15 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 16 | compileOnly 'com.fasterxml.jackson.core:jackson-annotations' 17 | 18 | testImplementation project(':axon-support').sourceSets.test.output 19 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 20 | testImplementation "org.axonframework:axon-test:${axonVersion}" 21 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 22 | testImplementation "org.hamcrest:hamcrest-library:${hamcrestVersion}" 23 | } 24 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/handlers/CompetitionsQueryHandler.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.handlers; 2 | 3 | import engineering.everest.lhotse.competitions.domain.CompetitionWithEntries; 4 | import engineering.everest.lhotse.competitions.domain.queries.CompetitionWithEntriesQuery; 5 | import engineering.everest.lhotse.competitions.services.CompetitionsReadService; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.axonframework.queryhandling.QueryHandler; 8 | import org.springframework.stereotype.Component; 9 | 10 | @Slf4j 11 | @Component 12 | public class CompetitionsQueryHandler { 13 | 14 | private final CompetitionsReadService competitionsReadService; 15 | 16 | public CompetitionsQueryHandler(CompetitionsReadService competitionsReadService) { 17 | this.competitionsReadService = competitionsReadService; 18 | } 19 | 20 | @QueryHandler 21 | public CompetitionWithEntries handle(CompetitionWithEntriesQuery query) { 22 | return competitionsReadService.getCompetitionWithEntries(query.getCompetitionId()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/photos-persistence/src/main/java/engineering/everest/lhotse/photos/services/DefaultPhotoWriteService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.services; 2 | 3 | import engineering.everest.lhotse.photos.persistence.PhotosRepository; 4 | import org.springframework.stereotype.Service; 5 | 6 | import java.time.Instant; 7 | import java.util.UUID; 8 | 9 | @Service 10 | public class DefaultPhotoWriteService implements PhotosWriteService { 11 | 12 | private final PhotosRepository photosRepository; 13 | 14 | DefaultPhotoWriteService(PhotosRepository photosRepository) { 15 | this.photosRepository = photosRepository; 16 | } 17 | 18 | @Override 19 | public void createPhoto(UUID id, UUID ownerUserId, UUID persistedFileId, String filename, Instant uploadTimestamp) { 20 | photosRepository.createPhoto(id, ownerUserId, persistedFileId, filename, uploadTimestamp); 21 | } 22 | 23 | @Override 24 | public void deleteAll() { 25 | photosRepository.deleteAll(); 26 | } 27 | 28 | @Override 29 | public void deleteById(UUID id) { 30 | photosRepository.deleteById(id); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/common/src/main/java/engineering/everest/lhotse/common/RandomFieldsGenerator.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.common; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.security.SecureRandom; 6 | import java.util.UUID; 7 | 8 | import static java.util.UUID.randomUUID; 9 | import static org.apache.commons.lang3.RandomStringUtils.random; 10 | 11 | @Component 12 | public class RandomFieldsGenerator { 13 | private static final int GENERATED_PASSWORD_LENGTH = 40; 14 | private static final char[] PASSWORD_CHARACTER_SET = 15 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~`!@#$%^&*()-_=+[];:,.".toCharArray(); 16 | 17 | private final SecureRandom secureRandom; 18 | 19 | public RandomFieldsGenerator() { 20 | this.secureRandom = new SecureRandom(); 21 | } 22 | 23 | public UUID genRandomUUID() { 24 | return randomUUID(); 25 | } 26 | 27 | public String generatePassword() { 28 | return random(GENERATED_PASSWORD_LENGTH, 0, PASSWORD_CHARACTER_SET.length - 1, 29 | false, false, PASSWORD_CHARACTER_SET, secureRandom); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/axon-support/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | compileJava { 4 | options.compilerArgs << '-parameters' 5 | } 6 | 7 | dependencies { 8 | api "org.axonframework:axon-spring-boot-starter:${axonVersion}" 9 | 10 | implementation project(':command-validation-api') 11 | implementation project(':common') 12 | implementation project(':i18n-support') 13 | implementation project(':forgotten-users-api') 14 | 15 | implementation "engineering.everest.axon:crypto-shredding-extension:${axonCryptoShreddingVersion}" 16 | implementation 'org.springframework.boot:spring-boot-starter-actuator' 17 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 18 | implementation 'org.springframework.boot:spring-boot-starter-validation' 19 | 20 | implementation "org.liquibase:liquibase-core:${liquibaseVersion}" 21 | implementation 'com.fasterxml.jackson.core:jackson-databind' 22 | 23 | testImplementation project(':command-validation-support') 24 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 25 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 26 | } 27 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/events/CompetitionEndedAndWinnersDeclaredEvent.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain.events; 2 | 3 | import lombok.Data; 4 | import lombok.EqualsAndHashCode; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import org.axonframework.serialization.Revision; 8 | 9 | import java.util.List; 10 | import java.util.UUID; 11 | 12 | @Data 13 | @NoArgsConstructor 14 | @Getter 15 | @EqualsAndHashCode(callSuper = true) 16 | @Revision("0") 17 | public class CompetitionEndedAndWinnersDeclaredEvent extends CompetitionEndedEvent { 18 | private List winnersToPhotoIdList; 19 | private int numVotesReceived; 20 | 21 | public CompetitionEndedAndWinnersDeclaredEvent(UUID competitionId, 22 | List winnersToPhotoIdList, 23 | int numVotesReceived) { 24 | super(competitionId); 25 | this.winnersToPhotoIdList = winnersToPhotoIdList; 26 | this.numVotesReceived = numVotesReceived; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/command-validation-support/src/main/java/engineering/everest/lhotse/axon/command/validators/EmailAddressValidator.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.command.validators; 2 | 3 | import engineering.everest.lhotse.axon.command.validation.EmailAddressValidatableCommand; 4 | import engineering.everest.lhotse.axon.command.validation.Validates; 5 | import engineering.everest.lhotse.i18n.exceptions.TranslatableIllegalArgumentException; 6 | import org.apache.commons.validator.routines.EmailValidator; 7 | import org.springframework.stereotype.Component; 8 | 9 | import static engineering.everest.lhotse.i18n.MessageKeys.EMAIL_ADDRESS_MALFORMED; 10 | 11 | @Component 12 | public class EmailAddressValidator implements Validates { 13 | 14 | @Override 15 | public void validate(EmailAddressValidatableCommand validatable) { 16 | if (validatable.getEmailAddress() == null) { 17 | return; 18 | } 19 | if (!EmailValidator.getInstance(false).isValid(validatable.getEmailAddress())) { 20 | throwWrappedInCommandExecutionException(new TranslatableIllegalArgumentException(EMAIL_ADDRESS_MALFORMED)); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/launcher/src/main/java/engineering/everest/lhotse/config/ObjectStoreCredentialsProviderConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.config; 2 | 3 | import com.amazonaws.auth.AWSCredentialsProvider; 4 | import com.amazonaws.auth.AWSStaticCredentialsProvider; 5 | import com.amazonaws.auth.BasicAWSCredentials; 6 | import com.amazonaws.auth.DefaultAWSCredentialsProviderChain; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | 11 | @Configuration 12 | public class ObjectStoreCredentialsProviderConfig { 13 | 14 | @Bean 15 | public AWSCredentialsProvider awsCredentialsProvider( 16 | @Value("${application.filestore.awsS3.accessKeyId:}") String awsAccessKeyId, 17 | @Value("${application.filestore.awsS3.secretKey:}") String awsSecretKey) { 18 | if (!awsAccessKeyId.isBlank() || !awsSecretKey.isBlank()) { 19 | return new AWSStaticCredentialsProvider(new BasicAWSCredentials(awsAccessKeyId, awsSecretKey)); 20 | } 21 | return DefaultAWSCredentialsProviderChain.getInstance(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/services/DefaultPhotosService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.services; 2 | 3 | import engineering.everest.lhotse.common.RandomFieldsGenerator; 4 | import engineering.everest.lhotse.photos.domain.commands.RegisterUploadedPhotoCommand; 5 | import org.axonframework.commandhandling.gateway.CommandGateway; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.UUID; 9 | 10 | @Service 11 | public class DefaultPhotosService implements PhotosService { 12 | 13 | private final CommandGateway commandGateway; 14 | private final RandomFieldsGenerator randomFieldsGenerator; 15 | 16 | public DefaultPhotosService(CommandGateway commandGateway, RandomFieldsGenerator randomFieldsGenerator) { 17 | this.commandGateway = commandGateway; 18 | this.randomFieldsGenerator = randomFieldsGenerator; 19 | } 20 | 21 | @Override 22 | public UUID registerUploadedPhoto(UUID requestingUserId, UUID persistedFileId, String filename) { 23 | var photoId = randomFieldsGenerator.genRandomUUID(); 24 | commandGateway.sendAndWait(new RegisterUploadedPhotoCommand(photoId, requestingUserId, persistedFileId, filename)); 25 | return photoId; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/photos-persistence/src/test/java/engineering/everest/lhotse/photos/persistence/config/TestPhotosJpaConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.persistence.config; 2 | 3 | import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; 4 | import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; 8 | import org.springframework.transaction.annotation.EnableTransactionManagement; 9 | 10 | import javax.sql.DataSource; 11 | 12 | @Configuration 13 | @EnableTransactionManagement 14 | public class TestPhotosJpaConfig { 15 | 16 | @Bean 17 | public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, 18 | DataSource dataSource, 19 | JpaProperties jpaProperties) { 20 | return builder 21 | .dataSource(dataSource) 22 | .properties(jpaProperties.getProperties()) 23 | .packages("engineering.everest", "org.axonframework") 24 | .build(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/config/CorsConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.config; 2 | 3 | import org.springframework.beans.factory.annotation.Value; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 7 | 8 | @Configuration 9 | public class CorsConfig implements WebMvcConfigurer { 10 | 11 | private final String[] allowedOrigins; 12 | private final String[] allowedMethods; 13 | 14 | @SuppressWarnings({ "PMD.ArrayIsStoredDirectly", "PMD.UseVarargs" }) 15 | public CorsConfig(@Value("${application.cors.global.allowed-origins}") String[] allowedOrigins, 16 | @Value("${application.cors.global.allowed-methods}") String[] allowedMethods) { 17 | super(); 18 | this.allowedOrigins = allowedOrigins; 19 | this.allowedMethods = allowedMethods; 20 | } 21 | 22 | @Override 23 | public void addCorsMappings(CorsRegistry registry) { 24 | WebMvcConfigurer.super.addCorsMappings(registry); 25 | 26 | registry.addMapping("/**") 27 | .allowedOrigins(allowedOrigins) 28 | .allowedMethods(allowedMethods); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sonar.gradle: -------------------------------------------------------------------------------- 1 | 2 | tasks.register("codeCoverageReport", JacocoReport) { 3 | subprojects { subproject -> 4 | subproject.plugins.withType(JacocoPlugin).configureEach { 5 | subproject.tasks.matching({ t -> t.extensions.findByType(JacocoTaskExtension) }).configureEach { testTask -> 6 | sourceSets subproject.sourceSets.main 7 | executionData(testTask) 8 | } 9 | 10 | subproject.tasks.matching({ t -> t.extensions.findByType(JacocoTaskExtension) }).forEach { 11 | rootProject.tasks.codeCoverageReport.dependsOn(it) 12 | it.finalizedBy(jacocoTestReport) 13 | } 14 | } 15 | } 16 | 17 | reports { 18 | xml.required = true 19 | html.required = true 20 | } 21 | } 22 | 23 | sonarqube { 24 | properties { 25 | property 'sonar.projectName', 'Lhotse' 26 | property 'sonar.projectKey', 'everest-engineering_lhotse' 27 | property 'sonar.organization', 'everestengineering' 28 | property 'sonar.host.url', 'https://sonarcloud.io' 29 | property 'sonar.coverage.jacoco.xmlReportPaths', ["${project.rootDir}/build/reports/jacoco/codeCoverageReport/codeCoverageReport.xml"] 30 | property 'sonar.test.exclusions', '**/*Test.java' 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/config/ObjectMapperConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.fasterxml.jackson.databind.json.JsonMapper; 5 | import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; 6 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 7 | import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | import org.springframework.context.annotation.Primary; 11 | 12 | import static com.fasterxml.jackson.databind.DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY; 13 | import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; 14 | 15 | @Configuration 16 | public class ObjectMapperConfig { 17 | 18 | @Bean 19 | @Primary 20 | public ObjectMapper objectMapper() { 21 | return JsonMapper.builder() 22 | .addModule(new ParameterNamesModule()) 23 | .addModule(new Jdk8Module()) 24 | .addModule(new JavaTimeModule()) 25 | .configure(ACCEPT_SINGLE_VALUE_AS_ARRAY, true) 26 | .configure(WRITE_DATES_AS_TIMESTAMPS, false) 27 | .build(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/test/java/engineering/everest/lhotse/competitions/persistence/config/TestCompetitionsJpaConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.persistence.config; 2 | 3 | import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; 4 | import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; 5 | import org.springframework.context.annotation.Bean; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; 8 | import org.springframework.transaction.annotation.EnableTransactionManagement; 9 | 10 | import javax.sql.DataSource; 11 | 12 | @Configuration 13 | @EnableTransactionManagement 14 | public class TestCompetitionsJpaConfig { 15 | 16 | @Bean 17 | public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, 18 | DataSource dataSource, 19 | JpaProperties jpaProperties) { 20 | return builder 21 | .dataSource(dataSource) 22 | .properties(jpaProperties.getProperties()) 23 | .packages("engineering.everest", "org.axonframework") 24 | .build(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/resources/messages.properties: -------------------------------------------------------------------------------- 1 | ALREADY_ENTERED_IN_COMPETITION=Photo already entered into this competition 2 | ALREADY_VOTED_FOR_THIS_ENTRY=Photo already voted for in this competition 3 | COMPETITION_ALREADY_ENDED=Competition has already ended 4 | COMPETITION_MAX_ENTRIES_REACHED=Maximum number of entries per person ({0}) reached for this competition 5 | COMPETITION_MINIMUM_SUBMISSION_PERIOD=Submissions must be open for at least {0} 6 | COMPETITION_MINIMUM_VOTING_PERIOD=Voting must be open for at least {0} 7 | COMPETITION_MIN_1_VOTE_PER_USER=Minimum number of votes per user cannot be less than 1 8 | COMPETITION_SUBMISSIONS_CLOSED=Submissions closed {0} 9 | COMPETITION_SUBMISSIONS_NOT_OPEN=Competition doesn't accept submissions until {0} 10 | DELETED_PHOTO_OWNER_MISMATCH=Cannot delete photo {0} - owner is user {1} but deleted user is {2} 11 | EMAIL_ADDRESS_ALREADY_EXISTS=Email address already exists 12 | EMAIL_ADDRESS_MALFORMED=Malformed email address 13 | FILE_DOES_NOT_EXIST=File {0} does not exist 14 | PHOTO_NOT_ENTERED_IN_COMPETITION=Photo not in this competition 15 | SUBMISSIONS_CLOSE_TIMESTAMP_IN_PAST=Submission close timestamp is in the past 16 | SUBMISSION_BY_NON_PHOTO_OWNER=Cannot enter photo in competition - requesting user is not the owner are different 17 | VOTING_ENDED=Voting ended at {0} 18 | VOTING_PERIOD_NOT_STARTED=Voting opens at {0} 19 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/TranslationService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.context.NoSuchMessageException; 5 | import org.springframework.context.support.ResourceBundleMessageSource; 6 | 7 | import java.util.Locale; 8 | 9 | import static java.lang.Thread.currentThread; 10 | 11 | @Slf4j 12 | public class TranslationService { 13 | private static final TranslationService INSTANCE = new TranslationService(); 14 | 15 | private final ResourceBundleMessageSource resourceBundleMessageSource; 16 | 17 | public TranslationService() { 18 | this.resourceBundleMessageSource = new ResourceBundleMessageSource(); 19 | this.resourceBundleMessageSource.addBasenames("messages"); 20 | this.resourceBundleMessageSource.setBundleClassLoader(currentThread().getContextClassLoader()); 21 | } 22 | 23 | public static TranslationService getInstance() { 24 | return INSTANCE; 25 | } 26 | 27 | public String translate(Locale locale, String key, Object... args) { 28 | try { 29 | return resourceBundleMessageSource.getMessage(key, args, locale); 30 | } catch (NoSuchMessageException e) { 31 | LOGGER.error("Unmapped message key {}", key); 32 | return key; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | postgres: 4 | image: postgres:15-alpine 5 | restart: unless-stopped 6 | ports: 7 | - "${POSTGRES_PORT}:5432" 8 | volumes: 9 | - ./src/launcher/db-scripts:/docker-entrypoint-initdb.d 10 | environment: 11 | POSTGRES_USER: postgres 12 | POSTGRES_DB: default 13 | POSTGRES_PASSWORD: postgres 14 | 15 | axonserver: 16 | image: axoniq/axonserver:2023.2.1-jdk-17 17 | restart: unless-stopped 18 | ports: 19 | - "${AXON_SERVER_DASHBOARD_PORT}:8024" 20 | - "${AXON_SERVER_GRPC_PORT}:8124" 21 | environment: 22 | JAVA_TOOL_OPTIONS: "-Xmx1g" 23 | AXONIQ_AXONSERVER_STANDALONE: "true" 24 | 25 | keycloak: 26 | image: quay.io/keycloak/keycloak:25.0.0 27 | restart: unless-stopped 28 | volumes: 29 | - ./keycloak/imports:/opt/keycloak/data/import 30 | environment: 31 | KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak 32 | KC_DB_DATABASE: keycloak 33 | KC_DB_USER: keycloak 34 | KC_DB_PASSWORD: keycloak 35 | KC_HTTP_ENABLED: "true" 36 | KEYCLOAK_ADMIN: ${KEYCLOAK_USER} 37 | KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_PASSWORD} 38 | entrypoint: [ "/opt/keycloak/bin/kc.sh", "start-dev", "--db=postgres", "--import-realm" ] 39 | ports: 40 | - "${KEYCLOAK_SERVER_PORT}:8080" 41 | depends_on: 42 | - postgres 43 | -------------------------------------------------------------------------------- /src/command-validation-support/src/main/java/engineering/everest/lhotse/axon/command/validators/FileStatusValidator.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.command.validators; 2 | 3 | import engineering.everest.lhotse.axon.command.validation.FileStatusValidatableCommand; 4 | import engineering.everest.lhotse.axon.command.validation.Validates; 5 | import engineering.everest.lhotse.i18n.exceptions.TranslatableIllegalStateException; 6 | import engineering.everest.starterkit.filestorage.FileService; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.NoSuchElementException; 10 | 11 | import static engineering.everest.lhotse.i18n.MessageKeys.FILE_DOES_NOT_EXIST; 12 | 13 | @Component 14 | public class FileStatusValidator implements Validates { 15 | 16 | private final FileService fileService; 17 | 18 | public FileStatusValidator(FileService fileService) { 19 | this.fileService = fileService; 20 | } 21 | 22 | @Override 23 | public void validate(FileStatusValidatableCommand validatable) { 24 | validatable.getFileIDs().forEach(fileId -> { 25 | try { 26 | fileService.fileSizeInBytes(fileId); 27 | } catch (NoSuchElementException e) { 28 | throwWrappedInCommandExecutionException(new TranslatableIllegalStateException(FILE_DOES_NOT_EXIST, fileId)); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/secretkeys-persistence/src/main/java/engineering/everest/lhotse/cryptoshredding/persistence/DefaultSecretKeyRepository.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.cryptoshredding.persistence; 2 | 3 | import engineering.everest.axon.cryptoshredding.TypeDifferentiatedSecretKeyId; 4 | import engineering.everest.axon.cryptoshredding.persistence.PersistableSecretKey; 5 | import engineering.everest.axon.cryptoshredding.persistence.SecretKeyRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import javax.crypto.SecretKey; 9 | import java.util.Optional; 10 | 11 | @Repository 12 | public class DefaultSecretKeyRepository implements SecretKeyRepository { 13 | 14 | final PersistableSecretKeyJPARepository repository; 15 | 16 | public DefaultSecretKeyRepository(PersistableSecretKeyJPARepository repository) { 17 | this.repository = repository; 18 | } 19 | 20 | @Override 21 | public PersistableSecretKey create(TypeDifferentiatedSecretKeyId keyId, SecretKey key) { 22 | return repository.save(new PersistableSecretKey(keyId, key.getEncoded(), key.getAlgorithm())); 23 | } 24 | 25 | @Override 26 | public Optional findById(TypeDifferentiatedSecretKeyId keyId) { 27 | return repository.findById(keyId); 28 | } 29 | 30 | @Override 31 | public PersistableSecretKey save(PersistableSecretKey key) { 32 | return repository.save(key); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/forgotten-users/src/main/java/engineering/everest/lhotse/users/domain/ForgottenUserAggregate.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.domain; 2 | 3 | import engineering.everest.lhotse.users.domain.commands.DeleteAndForgetUserCommand; 4 | import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; 5 | import lombok.NoArgsConstructor; 6 | import org.axonframework.commandhandling.CommandHandler; 7 | import org.axonframework.eventsourcing.EventSourcingHandler; 8 | import org.axonframework.modelling.command.AggregateIdentifier; 9 | import org.axonframework.spring.stereotype.Aggregate; 10 | 11 | import java.io.Serializable; 12 | import java.util.UUID; 13 | 14 | import static org.axonframework.modelling.command.AggregateLifecycle.apply; 15 | import static org.axonframework.modelling.command.AggregateLifecycle.markDeleted; 16 | 17 | @Aggregate 18 | @NoArgsConstructor 19 | public class ForgottenUserAggregate implements Serializable { 20 | 21 | @AggregateIdentifier 22 | private UUID userId; 23 | 24 | @CommandHandler 25 | ForgottenUserAggregate(DeleteAndForgetUserCommand command) { 26 | apply(new UserDeletedAndForgottenEvent(command.getUserIdToDelete(), command.getRequestingUserId(), 27 | command.getRequestReason())); 28 | } 29 | 30 | @EventSourcingHandler 31 | void on(UserDeletedAndForgottenEvent event) { 32 | userId = event.getDeletedUserId(); 33 | markDeleted(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/forgotten-users/src/test/java/engineering/everest/lhotse/users/services/DefaultUsersServiceTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.services; 2 | 3 | import engineering.everest.lhotse.users.domain.commands.DeleteAndForgetUserCommand; 4 | import org.axonframework.commandhandling.gateway.CommandGateway; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.Mock; 9 | import org.mockito.junit.jupiter.MockitoExtension; 10 | 11 | import java.util.UUID; 12 | 13 | import static java.util.UUID.randomUUID; 14 | import static org.mockito.Mockito.verify; 15 | 16 | @ExtendWith(MockitoExtension.class) 17 | class DefaultUsersServiceTest { 18 | 19 | private static final UUID USER_ID = randomUUID(); 20 | private static final UUID ADMIN_ID = randomUUID(); 21 | 22 | @Mock 23 | private CommandGateway commandGateway; 24 | 25 | private DefaultUsersService defaultUsersService; 26 | 27 | @BeforeEach 28 | void setUp() { 29 | defaultUsersService = new DefaultUsersService(commandGateway); 30 | } 31 | 32 | @Test 33 | void deleteAndForget_WillSendCommand() { 34 | defaultUsersService.deleteAndForgetUser(ADMIN_ID, USER_ID, "User requested and we do the right thing"); 35 | verify(commandGateway).sendAndWait(new DeleteAndForgetUserCommand(USER_ID, ADMIN_ID, "User requested and we do the right thing")); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/axon-support/src/main/java/engineering/everest/lhotse/axon/config/InterceptorConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.config; 2 | 3 | import engineering.everest.lhotse.axon.CommandValidatingMessageHandlerInterceptor; 4 | import engineering.everest.lhotse.axon.LoggingMessageHandlerInterceptor; 5 | import org.axonframework.commandhandling.CommandBus; 6 | import org.axonframework.messaging.interceptors.CorrelationDataInterceptor; 7 | import org.axonframework.spring.config.SpringAxonConfiguration; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.context.annotation.Configuration; 10 | 11 | @Configuration 12 | public class InterceptorConfig { 13 | 14 | @Autowired 15 | public void registerInterceptors(@Autowired CommandBus commandBus, 16 | @Autowired SpringAxonConfiguration axonConfiguration, 17 | @Autowired CommandValidatingMessageHandlerInterceptor commandValidatingMessageHandlerInterceptor, 18 | @Autowired LoggingMessageHandlerInterceptor loggingMessageHandlerInterceptor) { 19 | commandBus.registerHandlerInterceptor(new CorrelationDataInterceptor<>(axonConfiguration.getObject().correlationDataProviders())); 20 | commandBus.registerHandlerInterceptor(commandValidatingMessageHandlerInterceptor); 21 | commandBus.registerHandlerInterceptor(loggingMessageHandlerInterceptor); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/main/java/engineering/everest/lhotse/competitions/persistence/PersistableCompetitionEntry.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.persistence; 2 | 3 | import engineering.everest.lhotse.competitions.domain.CompetitionEntry; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.Id; 6 | import jakarta.persistence.IdClass; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | import java.time.Instant; 11 | import java.util.UUID; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @Entity(name = "competition_entries") 16 | @IdClass(CompetitionEntryId.class) 17 | public class PersistableCompetitionEntry { 18 | 19 | @Id 20 | private UUID competitionId; 21 | @Id 22 | private UUID photoId; 23 | private UUID submitterUserId; 24 | private Instant entryTimestamp; 25 | private int votesReceived; 26 | private boolean isWinner; 27 | 28 | public PersistableCompetitionEntry(UUID competitionId, UUID photoId, UUID submitterUserId, Instant entryTimestamp) { 29 | this.competitionId = competitionId; 30 | this.photoId = photoId; 31 | this.submitterUserId = submitterUserId; 32 | this.entryTimestamp = entryTimestamp; 33 | this.votesReceived = 0; 34 | this.isWinner = false; 35 | } 36 | 37 | public CompetitionEntry toDomain() { 38 | return new CompetitionEntry(competitionId, photoId, submitterUserId, entryTimestamp, votesReceived, isWinner); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/forgotten-users/src/main/java/engineering/everest/lhotse/users/eventhandlers/ForgottenUsersEventHandler.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.eventhandlers; 2 | 3 | import engineering.everest.axon.cryptoshredding.CryptoShreddingKeyService; 4 | import engineering.everest.axon.cryptoshredding.TypeDifferentiatedSecretKeyId; 5 | import engineering.everest.lhotse.axon.replay.ReplayCompletionAware; 6 | import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.axonframework.eventhandling.DisallowReplay; 9 | import org.axonframework.eventhandling.EventHandler; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.stereotype.Service; 12 | 13 | @Service 14 | @Slf4j 15 | public class ForgottenUsersEventHandler implements ReplayCompletionAware { 16 | 17 | private final CryptoShreddingKeyService cryptoShreddingKeyService; 18 | 19 | @Autowired 20 | public ForgottenUsersEventHandler(CryptoShreddingKeyService cryptoShreddingKeyService) { 21 | this.cryptoShreddingKeyService = cryptoShreddingKeyService; 22 | } 23 | 24 | @EventHandler 25 | @DisallowReplay 26 | void on(UserDeletedAndForgottenEvent event) { 27 | LOGGER.info("Deleting encryption key for forgotten user {}", event.getDeletedUserId()); 28 | cryptoShreddingKeyService.shredSecretKey( 29 | new TypeDifferentiatedSecretKeyId(event.getDeletedUserId().toString(), "")); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/domain/UserDeletedSaga.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.domain; 2 | 3 | import engineering.everest.lhotse.photos.domain.commands.DeletePhotoForDeletedUserCommand; 4 | import engineering.everest.lhotse.photos.services.PhotosReadService; 5 | import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.axonframework.commandhandling.gateway.CommandGateway; 8 | import org.axonframework.modelling.saga.EndSaga; 9 | import org.axonframework.modelling.saga.SagaEventHandler; 10 | import org.axonframework.modelling.saga.StartSaga; 11 | import org.axonframework.serialization.Revision; 12 | import org.axonframework.spring.stereotype.Saga; 13 | import org.springframework.data.domain.Pageable; 14 | 15 | import java.io.Serializable; 16 | 17 | @Saga 18 | @Revision("0") 19 | @Slf4j 20 | public class UserDeletedSaga implements Serializable { 21 | 22 | @StartSaga 23 | @EndSaga 24 | @SagaEventHandler(associationProperty = "deletedUserId") 25 | public void on(UserDeletedAndForgottenEvent event, PhotosReadService photosReadService, CommandGateway commandGateway) { 26 | LOGGER.debug("Deleting photos for deleted user {}", event.getDeletedUserId()); 27 | photosReadService.getAllPhotos(event.getDeletedUserId(), Pageable.unpaged()) 28 | .forEach(photo -> commandGateway.send(new DeletePhotoForDeletedUserCommand(photo.getId(), event.getDeletedUserId()))); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/api/src/test/java/engineering/everest/lhotse/api/config/CorsConfigTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.config; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.mockito.junit.jupiter.MockitoExtension; 7 | import org.springframework.web.servlet.config.annotation.CorsRegistration; 8 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 9 | 10 | import static org.mockito.Mockito.mock; 11 | import static org.mockito.Mockito.verify; 12 | import static org.mockito.Mockito.when; 13 | 14 | @ExtendWith(MockitoExtension.class) 15 | class CorsConfigTest { 16 | 17 | private static final String[] ALLOWED_ORIGINS = { "home", "office", "the-barn" }; 18 | private static final String[] ALLOWED_METHODS = { "cycling" }; 19 | 20 | private CorsConfig corsConfig; 21 | 22 | @BeforeEach 23 | void setUp() { 24 | corsConfig = new CorsConfig(ALLOWED_ORIGINS, ALLOWED_METHODS); 25 | } 26 | 27 | @Test 28 | void willRegisterCorsConfiguration() { 29 | var registry = mock(CorsRegistry.class); 30 | var corsRegistration = mock(CorsRegistration.class); 31 | when(registry.addMapping("/**")).thenReturn(corsRegistration); 32 | when(corsRegistration.allowedOrigins(ALLOWED_ORIGINS)).thenReturn(corsRegistration); 33 | 34 | corsConfig.addCorsMappings(registry); 35 | 36 | verify(corsRegistration).allowedMethods(ALLOWED_METHODS); 37 | verify(corsRegistration).allowedOrigins(ALLOWED_ORIGINS); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/common/src/test/java/engineering/everest/lhotse/common/RandomFieldsGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.common; 2 | 3 | import org.apache.commons.lang3.StringUtils; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import static org.junit.jupiter.api.Assertions.assertTrue; 8 | 9 | class RandomFieldsGeneratorTest { 10 | private final static char[] ALPHABET_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray(); 11 | private final static char[] INTEGER_CHARACTERS = "0123456789".toCharArray(); 12 | private final static char[] SPECIAL_CHARACTERS = "~`!@#$%^&*()-_=+[];:,.".toCharArray(); 13 | 14 | private RandomFieldsGenerator randomFieldsGenerator; 15 | 16 | @BeforeEach 17 | void setUp() { 18 | randomFieldsGenerator = new RandomFieldsGenerator(); 19 | } 20 | 21 | @Test 22 | void generatePassword_WillCreateAlphanumericAndSpecialLetterPasswords() { 23 | boolean alphaFound = false; 24 | boolean numericFound = false; 25 | boolean specialFound = false; 26 | 27 | for (int i = 0; i < 100; i++) { 28 | var password = randomFieldsGenerator.generatePassword(); 29 | alphaFound |= StringUtils.containsAny(password, ALPHABET_CHARACTERS); 30 | numericFound |= StringUtils.containsAny(password, INTEGER_CHARACTERS); 31 | specialFound |= StringUtils.containsAny(password, SPECIAL_CHARACTERS); 32 | } 33 | 34 | assertTrue(alphaFound); 35 | assertTrue(numericFound); 36 | assertTrue(specialFound); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/config/DocumentationConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.config; 2 | 3 | import io.swagger.v3.oas.models.Components; 4 | import io.swagger.v3.oas.models.OpenAPI; 5 | import io.swagger.v3.oas.models.info.Info; 6 | import io.swagger.v3.oas.models.info.License; 7 | import io.swagger.v3.oas.models.security.SecurityScheme; 8 | import org.springframework.boot.info.BuildProperties; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | 12 | @Configuration 13 | public class DocumentationConfig { 14 | 15 | private final BuildProperties buildProperties; 16 | 17 | public DocumentationConfig(BuildProperties buildProperties) { 18 | this.buildProperties = buildProperties; 19 | } 20 | 21 | @Bean 22 | public OpenAPI openApi() { 23 | var securitySchemeName = "bearerAuth"; 24 | var securityScheme = new SecurityScheme() 25 | .name(securitySchemeName) 26 | .type(SecurityScheme.Type.HTTP) 27 | .scheme("bearer") 28 | .bearerFormat("JWT"); 29 | 30 | return new OpenAPI() 31 | .info(new Info().title("Photo competition API") 32 | .description("A DDD+ES+CQRS implementation of a simple demo domain") 33 | .version(buildProperties.getVersion().replace("'", "")) 34 | .license(new License().name("Apache 2.0 licensed").url("https://www.apache.org/licenses/LICENSE-2.0"))) 35 | .components(new Components().addSecuritySchemes(securitySchemeName, securityScheme)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/forgotten-users/src/test/java/engineering/everest/lhotse/users/eventhandlers/ForgottenUsersEventHandlerTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.eventhandlers; 2 | 3 | import engineering.everest.axon.cryptoshredding.CryptoShreddingKeyService; 4 | import engineering.everest.axon.cryptoshredding.TypeDifferentiatedSecretKeyId; 5 | import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.util.UUID; 13 | 14 | import static java.util.UUID.randomUUID; 15 | import static org.mockito.Mockito.verify; 16 | 17 | @ExtendWith(MockitoExtension.class) 18 | class ForgottenUsersEventHandlerTest { 19 | 20 | private static final UUID USER_ID = randomUUID(); 21 | private static final UUID ADMIN_ID = randomUUID(); 22 | 23 | private ForgottenUsersEventHandler forgottenUsersEventHandler; 24 | 25 | @Mock 26 | private CryptoShreddingKeyService cryptoShreddingKeyService; 27 | 28 | @BeforeEach 29 | void setUp() { 30 | forgottenUsersEventHandler = new ForgottenUsersEventHandler(cryptoShreddingKeyService); 31 | } 32 | 33 | @Test 34 | void onUserDeletedAndForgottenEvent_WillDiscardSecretKey() { 35 | forgottenUsersEventHandler.on(new UserDeletedAndForgottenEvent(USER_ID, ADMIN_ID, "GDPR request")); 36 | 37 | verify(cryptoShreddingKeyService).shredSecretKey(new TypeDifferentiatedSecretKeyId(USER_ID.toString(), "")); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/competitions/src/test/java/engineering/everest/lhotse/competitions/handlers/CompetitionsQueryHandlerTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.handlers; 2 | 3 | import engineering.everest.lhotse.competitions.domain.CompetitionWithEntries; 4 | import engineering.everest.lhotse.competitions.domain.queries.CompetitionWithEntriesQuery; 5 | import engineering.everest.lhotse.competitions.services.CompetitionsReadService; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.util.UUID; 13 | 14 | import static java.util.UUID.randomUUID; 15 | import static org.junit.jupiter.api.Assertions.assertEquals; 16 | import static org.mockito.Mockito.mock; 17 | import static org.mockito.Mockito.when; 18 | 19 | @ExtendWith(MockitoExtension.class) 20 | class CompetitionsQueryHandlerTest { 21 | 22 | private static final UUID COMPETITION_ID = randomUUID(); 23 | 24 | private CompetitionsQueryHandler competitionsQueryHandler; 25 | 26 | @Mock 27 | private CompetitionsReadService competitionsReadService; 28 | 29 | @BeforeEach 30 | void setUp() { 31 | competitionsQueryHandler = new CompetitionsQueryHandler(competitionsReadService); 32 | } 33 | 34 | @Test 35 | void competitionsWithEntriesQuery_WillDelegate() { 36 | var expected = mock(CompetitionWithEntries.class); 37 | when(competitionsReadService.getCompetitionWithEntries(COMPETITION_ID)).thenReturn(expected); 38 | 39 | assertEquals(expected, competitionsQueryHandler.handle(new CompetitionWithEntriesQuery(COMPETITION_ID))); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/forgotten-users/src/test/java/engineering/everest/lhotse/users/domain/ForgottenUserAggregateTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.users.domain; 2 | 3 | import engineering.everest.lhotse.users.domain.commands.DeleteAndForgetUserCommand; 4 | import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; 5 | import org.axonframework.test.aggregate.AggregateTestFixture; 6 | import org.axonframework.test.aggregate.FixtureConfiguration; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.util.UUID; 13 | 14 | import static java.util.UUID.randomUUID; 15 | 16 | @ExtendWith(MockitoExtension.class) 17 | class ForgottenUserAggregateTest { 18 | 19 | private static final UUID USER_ID = randomUUID(); 20 | private static final UUID ADMIN_ID = randomUUID(); 21 | private static final String REQUEST_REASON = "It's the right thing to do"; 22 | 23 | private static final UserDeletedAndForgottenEvent USER_DELETED_AND_FORGOTTEN_EVENT = 24 | new UserDeletedAndForgottenEvent(USER_ID, ADMIN_ID, REQUEST_REASON); 25 | 26 | private FixtureConfiguration testFixture; 27 | 28 | @BeforeEach 29 | void setUp() { 30 | testFixture = new AggregateTestFixture<>(ForgottenUserAggregate.class); 31 | } 32 | 33 | @Test 34 | void emitsUserDeletedAndForgottenEvent_WhenUserIsDeletedAndForgotten() { 35 | testFixture.givenNoPriorActivity() 36 | .when(new DeleteAndForgetUserCommand(USER_ID, ADMIN_ID, REQUEST_REASON)) 37 | .expectEvents(USER_DELETED_AND_FORGOTTEN_EVENT); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /doc/training-ideas.md: -------------------------------------------------------------------------------- 1 | Some potential tasks for training.... 2 | 3 | ## Remove deleted photos from entered competitions (easy) 4 | 5 | Photos are deleted when their owning user is deleted and forgotten. Deleted photos should NOT be removed from the 6 | competitions in which they were entered as this could change perceptions on how competitions panned out. Instead, 7 | make changes to the projections so that the deleted photos are clearly identified instead of relying on 404 responses. 8 | Include the time at which they were removed in the API response body. 9 | 10 | ## Remove votes (easy) 11 | 12 | Create an API that allows a vote to be removed and implement the flow through the domain on to projections. 13 | 14 | Consider renaming events. 15 | 16 | ## Constrain votes within a competition (easy) 17 | 18 | There is no limit on the number of photos a registered user can vote for within a competition. Track the votes received, 19 | rejecting when a user has reached their voting quota. Prevent someone from voting for their own work. 20 | 21 | ## Adjustable competition deadlines (easy) 22 | 23 | There are many cases where an admin might want to change the submission opening and closing times, and the voting ending 24 | deadline. 25 | 26 | ## Introduce (optional) moderator approval of submissions (moderate) 27 | 28 | Not all holiday snaps are equal and deserving of attention. 29 | 30 | Well trusted (verified?) photographers may have their work automatically approved. The rest of us need to wait for a 31 | human. 32 | 33 | ## Major remodel (moderate++) 34 | 35 | We're pivoting the company! 36 | 37 | Assume that the changes are being made to a system already in production. Write event upcasters and perform a replay 38 | (assume that you can disconnect a load balancer so life is easier). 39 | -------------------------------------------------------------------------------- /src/api/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'jacoco' 2 | 3 | repositories { 4 | mavenCentral() 5 | } 6 | 7 | dependencies { 8 | implementation project(':common') 9 | implementation project(':competitions-api') 10 | implementation project(':i18n-support') 11 | implementation project(':photos-api') 12 | implementation project(':forgotten-users-api') 13 | 14 | implementation "engineering.everest.starterkit:media:${mediaVersion}" 15 | implementation "engineering.everest.starterkit:storage:${storageVersion}" 16 | implementation "org.springdoc:springdoc-openapi-starter-common:${springdocVersion}" 17 | implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}" 18 | implementation "org.axonframework:axon-modelling:${axonVersion}" 19 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 20 | implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' 21 | implementation 'org.springframework.boot:spring-boot-starter-security' 22 | implementation 'org.springframework.boot:spring-boot-starter-validation' 23 | implementation 'org.springframework.boot:spring-boot-starter-web' 24 | implementation 'org.springframework.boot:spring-boot-starter-webflux' 25 | implementation 'com.nimbusds:nimbus-jose-jwt' 26 | 27 | testImplementation "org.junit.jupiter:junit-jupiter:${junitVersion}" 28 | testImplementation "org.mockito:mockito-junit-jupiter:${mockitoVersion}" 29 | testImplementation "org.springframework.boot:spring-boot-starter-test" 30 | testImplementation "org.springframework.security:spring-security-test" 31 | testImplementation "org.apache.commons:commons-lang3:${commonsLangVersion}" 32 | testImplementation "com.c4-soft.springaddons:spring-addons-oauth2-test:${springSecurityTestAddonVersion}" 33 | } 34 | -------------------------------------------------------------------------------- /src/api/src/test/java/engineering/everest/lhotse/api/ETagHeaderTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api; 2 | 3 | import engineering.everest.lhotse.api.config.ETagFilterConfig; 4 | import engineering.everest.lhotse.api.rest.controllers.VersionController; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.info.BuildProperties; 8 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 9 | import org.springframework.boot.test.mock.mockito.MockBean; 10 | import org.springframework.security.test.context.support.WithMockUser; 11 | import org.springframework.test.context.ContextConfiguration; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | 14 | import static org.mockito.Mockito.when; 15 | import static org.springframework.http.MediaType.APPLICATION_JSON; 16 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 17 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; 18 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 19 | 20 | @WebMvcTest(controllers = { VersionController.class }) 21 | @ContextConfiguration(classes = { VersionController.class, ETagFilterConfig.class }) 22 | class ETagHeaderTest { 23 | 24 | @Autowired 25 | private MockMvc mockMvc; 26 | 27 | @MockBean 28 | private BuildProperties buildProperties; 29 | 30 | @Test 31 | @WithMockUser 32 | void eTagWillBeIncludedInResponseHeaders() throws Exception { 33 | when(buildProperties.getVersion()).thenReturn("version"); 34 | 35 | mockMvc.perform(get("/api/version").contentType(APPLICATION_JSON)) 36 | .andExpect(status().isOk()) 37 | .andExpect(header().exists("ETag")); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/photos/src/test/java/engineering/everest/lhotse/photos/services/DefaultPhotosServiceTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.services; 2 | 3 | import engineering.everest.lhotse.common.RandomFieldsGenerator; 4 | import engineering.everest.lhotse.photos.domain.commands.RegisterUploadedPhotoCommand; 5 | import org.axonframework.commandhandling.gateway.CommandGateway; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.util.UUID; 13 | 14 | import static java.util.UUID.randomUUID; 15 | import static org.mockito.Mockito.verify; 16 | import static org.mockito.Mockito.when; 17 | 18 | @ExtendWith(MockitoExtension.class) 19 | class DefaultPhotosServiceTest { 20 | 21 | private static final UUID PHOTO_ID = randomUUID(); 22 | private static final UUID USER_ID = randomUUID(); 23 | private static final UUID BACKING_FILE_ID = randomUUID(); 24 | private static final String PHOTO_FILENAME = "my holiday snap.png"; 25 | 26 | private DefaultPhotosService defaultPhotosService; 27 | 28 | @Mock 29 | private CommandGateway commandGateway; 30 | @Mock 31 | private RandomFieldsGenerator randomFieldsGenerator; 32 | 33 | @BeforeEach 34 | void setUp() { 35 | defaultPhotosService = new DefaultPhotosService(commandGateway, randomFieldsGenerator); 36 | } 37 | 38 | @Test 39 | void willDispatch_WhenUploadedPhotoRegistered() { 40 | when(randomFieldsGenerator.genRandomUUID()).thenReturn(PHOTO_ID); 41 | defaultPhotosService.registerUploadedPhoto(USER_ID, BACKING_FILE_ID, PHOTO_FILENAME); 42 | 43 | verify(commandGateway).sendAndWait(new RegisterUploadedPhotoCommand(PHOTO_ID, USER_ID, BACKING_FILE_ID, PHOTO_FILENAME)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/MessageKeys.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n; 2 | 3 | @SuppressWarnings("PMD.ClassNamingConventions") 4 | public class MessageKeys { 5 | public static final String ALREADY_ENTERED_IN_COMPETITION = "ALREADY_ENTERED_IN_COMPETITION"; 6 | public static final String ALREADY_VOTED_FOR_THIS_ENTRY = "ALREADY_VOTED_FOR_THIS_ENTRY"; 7 | public static final String COMPETITION_ALREADY_ENDED = "COMPETITION_ALREADY_ENDED"; 8 | public static final String COMPETITION_MAX_ENTRIES_REACHED = "COMPETITION_MAX_ENTRIES_REACHED"; 9 | public static final String COMPETITION_MINIMUM_SUBMISSION_PERIOD = "COMPETITION_MINIMUM_SUBMISSION_PERIOD"; 10 | public static final String COMPETITION_MINIMUM_VOTING_PERIOD = "COMPETITION_MINIMUM_VOTING_PERIOD"; 11 | public static final String COMPETITION_MIN_1_VOTE_PER_USER = "COMPETITION_MIN_1_VOTE_PER_USER"; 12 | public static final String COMPETITION_SUBMISSIONS_CLOSED = "COMPETITION_SUBMISSIONS_CLOSED"; 13 | public static final String COMPETITION_SUBMISSIONS_NOT_OPEN = "COMPETITION_SUBMISSIONS_NOT_OPEN"; 14 | public static final String DELETED_PHOTO_OWNER_MISMATCH = "DELETED_PHOTO_OWNER_MISMATCH"; 15 | public static final String EMAIL_ADDRESS_MALFORMED = "EMAIL_ADDRESS_MALFORMED"; 16 | public static final String FILE_DOES_NOT_EXIST = "FILE_DOES_NOT_EXIST"; 17 | public static final String PHOTO_NOT_ENTERED_IN_COMPETITION = "PHOTO_NOT_ENTERED_IN_COMPETITION"; 18 | public static final String SUBMISSIONS_CLOSE_TIMESTAMP_IN_PAST = "SUBMISSIONS_CLOSE_TIMESTAMP_IN_PAST"; 19 | public static final String SUBMISSION_BY_NON_PHOTO_OWNER = "SUBMISSION_BY_NON_PHOTO_OWNER"; 20 | public static final String VOTING_ENDED = "VOTING_ENDED"; 21 | public static final String VOTING_PERIOD_NOT_STARTED = "VOTING_PERIOD_NOT_STARTED"; 22 | 23 | private MessageKeys() {} 24 | } 25 | -------------------------------------------------------------------------------- /src/i18n-support/src/main/java/engineering/everest/lhotse/i18n/exceptions/TranslatableException.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n.exceptions; 2 | 3 | import engineering.everest.lhotse.i18n.TranslationService; 4 | import org.springframework.context.NoSuchMessageException; 5 | 6 | import java.io.Serial; 7 | import java.io.Serializable; 8 | 9 | import static org.springframework.context.i18n.LocaleContextHolder.getLocale; 10 | 11 | public class TranslatableException extends RuntimeException implements Serializable { 12 | 13 | @Serial 14 | private static final long serialVersionUID = -7056456777581329666L; 15 | 16 | private final String i18nMessageKey; 17 | private final Object[] args; 18 | 19 | public TranslatableException(String i18nMessageKey) { 20 | super(i18nMessageKey); 21 | this.i18nMessageKey = i18nMessageKey; 22 | this.args = new Object[0]; 23 | } 24 | 25 | public TranslatableException(String i18nMessageKey, Object... args) { 26 | super(i18nMessageKey); 27 | this.i18nMessageKey = i18nMessageKey; 28 | this.args = args.clone(); 29 | } 30 | 31 | public TranslatableException(String i18nMessageKey, Throwable cause) { 32 | super(i18nMessageKey, cause); 33 | this.i18nMessageKey = i18nMessageKey; 34 | this.args = new Object[0]; 35 | } 36 | 37 | public TranslatableException(String i18nMessageKey, Throwable cause, Object... args) { 38 | super(i18nMessageKey, cause); 39 | this.i18nMessageKey = i18nMessageKey; 40 | this.args = args.clone(); 41 | } 42 | 43 | @Override 44 | public String getLocalizedMessage() { 45 | try { 46 | return TranslationService.getInstance().translate(getLocale(), i18nMessageKey, args); 47 | } catch (NoSuchMessageException ignored) { 48 | return getMessage(); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/controllers/UsersController.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.controllers; 2 | 3 | import engineering.everest.lhotse.api.rest.annotations.AdminOnly; 4 | import engineering.everest.lhotse.api.rest.requests.DeleteAndForgetUserRequest; 5 | import engineering.everest.lhotse.users.services.UsersService; 6 | import io.swagger.v3.oas.annotations.Operation; 7 | import io.swagger.v3.oas.annotations.Parameter; 8 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 9 | import io.swagger.v3.oas.annotations.tags.Tag; 10 | import jakarta.validation.Valid; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | import java.security.Principal; 19 | import java.util.UUID; 20 | 21 | @RestController 22 | @RequestMapping("/api/users") 23 | @Tag(name = "Users") 24 | @SecurityRequirement(name = "bearerAuth") 25 | public class UsersController { 26 | 27 | private final UsersService usersService; 28 | 29 | @Autowired 30 | public UsersController(UsersService usersService) { 31 | this.usersService = usersService; 32 | } 33 | 34 | @PostMapping("/{userId}/forget") 35 | @Operation(description = "Handle a GDPR request to delete an account and scrub personal information") 36 | @AdminOnly 37 | public void forgetUser(@Parameter(hidden = true) Principal principal, 38 | @PathVariable UUID userId, 39 | @Valid @RequestBody DeleteAndForgetUserRequest request) { 40 | usersService.deleteAndForgetUser(UUID.fromString(principal.getName()), userId, request.getRequestReason()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/axon-support/src/test/java/engineering/everest/lhotse/axon/AxonTestUtils.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon; 2 | 3 | import engineering.everest.lhotse.axon.command.validation.Validates; 4 | import jakarta.validation.Validation; 5 | 6 | import java.lang.reflect.ParameterizedType; 7 | import java.lang.reflect.Type; 8 | import java.util.Arrays; 9 | import java.util.List; 10 | import java.util.Map; 11 | import java.util.concurrent.ConcurrentHashMap; 12 | 13 | public class AxonTestUtils { 14 | 15 | public static CommandValidatingMessageHandlerInterceptor mockCommandValidatingMessageHandlerInterceptor(Validates< 16 | ?>... mockValidators) { 17 | var validatorClasses = Arrays.stream(mockValidators).map(e -> e.getClass()).toList(); 18 | Map, Validates> validatorLookup = new ConcurrentHashMap<>(); 19 | for (int i = 0; i < validatorClasses.size(); i++) { 20 | Class validator = validatorClasses.get(i); 21 | Type validatableCommandType = Arrays.stream(validator.getGenericInterfaces()) 22 | .map(e -> (ParameterizedType) e) 23 | .filter(e -> Validates.class == e.getRawType()) 24 | .map(e -> e.getActualTypeArguments()[0]) 25 | .findFirst().orElseThrow(); 26 | validatorLookup.put((Class) validatableCommandType, mockValidators[i]); 27 | } 28 | 29 | var commandHandlerInterceptor = 30 | new CommandValidatingMessageHandlerInterceptor(List.of(), Validation.buildDefaultValidatorFactory().getValidator()); 31 | try { 32 | var validatorLookupField = commandHandlerInterceptor.getClass().getDeclaredField("validatorLookup"); 33 | validatorLookupField.setAccessible(true); 34 | validatorLookupField.set(commandHandlerInterceptor, validatorLookup); 35 | } catch (NoSuchFieldException | IllegalAccessException e) { 36 | throw new RuntimeException(e); 37 | } 38 | return commandHandlerInterceptor; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /keycloak/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This directory contains the definition for a `default` realm. 4 | 5 | This sample project takes a minimalistic approach to configuration. Any pre-canned configuration is likely to be unsuitable for your 6 | situation given the myriad of ways that Keycloak can be used. 7 | 8 | Note that we _currently_ run Keycloak in both a container and directly from its distribution archive. The latter is required due to 9 | limitations in our CI/CD pipeline. We hope to be able to standardise on the containerised approach in the future. 10 | 11 | # Command line administration 12 | 13 | Keycloak ships with a `kcadm.sh` script which launchers the administrative CLI jar. The CLI can be used in both a stateless and stateful 14 | way. The latter stores configuration and credential information required to execute tasks in a configuration file. 15 | 16 | See the [official CLI documentation](https://www.keycloak.org/docs/latest/server_admin/#the-admin-cli) for full details. 17 | 18 | ## Running the CLI 19 | 20 | If you have not installed Keycloak locally, you can run `kcadm.sh` directly within the Keycloak container: 21 | 22 | ``` 23 | docker exec keycloak /opt/jboss/keycloak/bin/kcadm.sh 24 | ``` 25 | 26 | Or via a locally cached copy created when running `./gradlew keycloakTestServer`: 27 | 28 | ``` 29 | /tmp/keycloak-$version/bin/kcadm.sh 30 | ``` 31 | 32 | ## Creating CLI configuration 33 | 34 | To create a configuration for the `master` realm (assuming that Keycloak has been configured to listen on port 8180) using the default 35 | account created when the container is bootstrapped: 36 | 37 | ``` 38 | docker exec keycloak /opt/jboss/keycloak/bin/kcadm.sh config credentials \ 39 | --server http://172.21.0.3:8180/auth \ 40 | --realm master \ 41 | --user admin@everest.engineering \ 42 | --password ac0n3x72 \ 43 | --config /tmp/master-admin.config 44 | ``` 45 | 46 | Keycloak only binds to the container's internal IP address and not localhost. You can find this address by inspecting the container: 47 | 48 | ``` 49 | docker container inspect keycloak | grep IPAddress 50 | ``` 51 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/rest/converters/DtoConverter.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.converters; 2 | 3 | import engineering.everest.lhotse.api.rest.responses.CompetitionEntryFragment; 4 | import engineering.everest.lhotse.api.rest.responses.CompetitionSummaryResponse; 5 | import engineering.everest.lhotse.api.rest.responses.CompetitionWithEntriesResponse; 6 | import engineering.everest.lhotse.api.rest.responses.PhotoResponse; 7 | import engineering.everest.lhotse.competitions.domain.Competition; 8 | import engineering.everest.lhotse.competitions.domain.CompetitionWithEntries; 9 | import engineering.everest.lhotse.photos.Photo; 10 | import org.springframework.stereotype.Service; 11 | 12 | @Service 13 | public class DtoConverter { 14 | 15 | public PhotoResponse convert(Photo photo) { 16 | return new PhotoResponse(photo.getId(), photo.getFilename(), photo.getUploadTimestamp()); 17 | } 18 | 19 | public CompetitionSummaryResponse convert(Competition competition) { 20 | return new CompetitionSummaryResponse(competition.getId(), competition.getDescription(), competition.getSubmissionsOpenTimestamp(), 21 | competition.getSubmissionsCloseTimestamp(), competition.getVotingEndsTimestamp(), competition.getMaxEntriesPerUser()); 22 | } 23 | 24 | public CompetitionWithEntriesResponse convert(CompetitionWithEntries competitionWithEntries) { 25 | var entries = competitionWithEntries.getEntries().stream() 26 | .map(x -> new CompetitionEntryFragment(x.getPhotoId(), x.getSubmittedByUserId(), x.getEntryTimestamp(), 27 | x.getNumVotesReceived(), x.isWinner())) 28 | .toList(); 29 | 30 | return new CompetitionWithEntriesResponse( 31 | competitionWithEntries.getId(), 32 | competitionWithEntries.getDescription(), 33 | competitionWithEntries.getSubmissionsOpenTimestamp(), 34 | competitionWithEntries.getSubmissionsCloseTimestamp(), 35 | competitionWithEntries.getVotingEndsTimestamp(), 36 | competitionWithEntries.getMaxEntriesPerUser(), 37 | entries); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/handlers/PhotosEventHandler.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.handlers; 2 | 3 | import engineering.everest.lhotse.photos.domain.events.PhotoDeletedAsPartOfUserDeletionEvent; 4 | import engineering.everest.lhotse.photos.domain.events.PhotoUploadedEvent; 5 | import engineering.everest.lhotse.photos.services.PhotosWriteService; 6 | import engineering.everest.starterkit.filestorage.FileService; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.axonframework.eventhandling.EventHandler; 9 | import org.axonframework.eventhandling.ResetHandler; 10 | import org.axonframework.eventhandling.Timestamp; 11 | import org.springframework.stereotype.Service; 12 | 13 | import java.time.Instant; 14 | 15 | @Service 16 | @Slf4j 17 | public class PhotosEventHandler { 18 | 19 | private final PhotosWriteService photosWriteService; 20 | private final FileService fileService; 21 | 22 | public PhotosEventHandler(PhotosWriteService photosWriteService, FileService fileService) { 23 | this.photosWriteService = photosWriteService; 24 | this.fileService = fileService; 25 | } 26 | 27 | @ResetHandler 28 | public void prepareForReplay() { 29 | LOGGER.info("Deleting photos projections"); 30 | photosWriteService.deleteAll(); 31 | } 32 | 33 | @EventHandler 34 | void on(PhotoUploadedEvent event, @Timestamp Instant uploadTimestamp) { 35 | LOGGER.info("User {} uploaded photo {} (backing file {})", event.getOwningUserId(), event.getPhotoId(), event.getPersistedFileId()); 36 | photosWriteService.createPhoto(event.getPhotoId(), event.getOwningUserId(), event.getPersistedFileId(), event.getFilename(), 37 | uploadTimestamp); 38 | } 39 | 40 | @EventHandler 41 | void on(PhotoDeletedAsPartOfUserDeletionEvent event) { 42 | LOGGER.info("Deleting photo {} (backing file {}) for deleted user {}", event.getPhotoId(), event.getPersistedFileId(), 43 | event.getDeletedUserId()); 44 | fileService.markEphemeralFileForDeletion(event.getPersistedFileId()); 45 | photosWriteService.deleteById(event.getPhotoId()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/command-validation-support/src/test/java/engineering/everest/lhotse/axon/command/validators/FileStatusValidatorTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.command.validators; 2 | 3 | import engineering.everest.lhotse.axon.command.validation.FileStatusValidatableCommand; 4 | import engineering.everest.starterkit.filestorage.FileService; 5 | import org.axonframework.commandhandling.CommandExecutionException; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.util.NoSuchElementException; 13 | import java.util.Set; 14 | import java.util.UUID; 15 | 16 | import static java.util.UUID.randomUUID; 17 | import static org.junit.jupiter.api.Assertions.*; 18 | import static org.mockito.Mockito.lenient; 19 | import static org.mockito.Mockito.when; 20 | 21 | @ExtendWith(MockitoExtension.class) 22 | class FileStatusValidatorTest { 23 | 24 | private static final UUID FILE_ID_1 = randomUUID(); 25 | private static final UUID FILE_ID_2 = randomUUID(); 26 | private static final FileStatusValidatableCommand VALIDATABLE_COMMAND = () -> Set.of(FILE_ID_1, FILE_ID_2); 27 | 28 | private FileStatusValidator fileStatusValidator; 29 | 30 | @Mock 31 | private FileService fileService; 32 | 33 | @BeforeEach 34 | void setUp() { 35 | fileStatusValidator = new FileStatusValidator(fileService); 36 | } 37 | 38 | @Test 39 | void validate_WillPassWhenAllFilesExist() { 40 | when(fileService.fileSizeInBytes(FILE_ID_1)).thenReturn(1234L); 41 | when(fileService.fileSizeInBytes(FILE_ID_2)).thenReturn(5678L); 42 | 43 | fileStatusValidator.validate(VALIDATABLE_COMMAND); 44 | } 45 | 46 | @Test 47 | void validate_WillFailWhenAnySingleFileCannotBeFound() { 48 | lenient().when(fileService.fileSizeInBytes(FILE_ID_1)).thenReturn(1234L); 49 | when(fileService.fileSizeInBytes(FILE_ID_2)).thenThrow(new NoSuchElementException("not here")); 50 | 51 | var exception = assertThrows(CommandExecutionException.class, 52 | () -> fileStatusValidator.validate(VALIDATABLE_COMMAND)); 53 | assertEquals("FILE_DOES_NOT_EXIST", exception.getMessage()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/CompetitionVotingCloseoutSaga.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain; 2 | 3 | import engineering.everest.lhotse.competitions.domain.commands.CountVotesAndDeclareOutcomeCommand; 4 | import engineering.everest.lhotse.competitions.domain.events.CompetitionCreatedEvent; 5 | import engineering.everest.lhotse.competitions.domain.events.CompetitionEndedEvent; 6 | import engineering.everest.lhotse.competitions.domain.events.CompetitionVotingPeriodEndedEvent; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.axonframework.commandhandling.gateway.CommandGateway; 9 | import org.axonframework.eventhandling.scheduling.EventScheduler; 10 | import org.axonframework.eventhandling.scheduling.ScheduleToken; 11 | import org.axonframework.modelling.saga.EndSaga; 12 | import org.axonframework.modelling.saga.SagaEventHandler; 13 | import org.axonframework.modelling.saga.StartSaga; 14 | import org.axonframework.serialization.Revision; 15 | import org.axonframework.spring.stereotype.Saga; 16 | 17 | import java.io.Serializable; 18 | 19 | @Saga 20 | @Revision("0") 21 | @Slf4j 22 | public class CompetitionVotingCloseoutSaga implements Serializable { 23 | 24 | private ScheduleToken votingPeriodEndedEventScheduleToken; 25 | 26 | @StartSaga 27 | @SagaEventHandler(associationProperty = "competitionId") 28 | void on(CompetitionCreatedEvent event, EventScheduler eventScheduler) { 29 | LOGGER.debug("Competition {} created, scheduling competition ending event", event.getCompetitionId()); 30 | 31 | votingPeriodEndedEventScheduleToken = eventScheduler.schedule(event.getVotingEndsTimestamp(), 32 | new CompetitionVotingPeriodEndedEvent(event.getCompetitionId(), event.getVotingEndsTimestamp())); 33 | } 34 | 35 | @SagaEventHandler(associationProperty = "competitionId") 36 | void on(CompetitionVotingPeriodEndedEvent event, CommandGateway commandGateway) { 37 | LOGGER.debug("Competition {} voting period ended", event.getCompetitionId()); 38 | commandGateway.send(new CountVotesAndDeclareOutcomeCommand(event.getCompetitionId())); 39 | } 40 | 41 | @EndSaga 42 | @SagaEventHandler(associationProperty = "competitionId") 43 | void on(CompetitionEndedEvent event) { 44 | LOGGER.debug("Competition {} has ended", event.getCompetitionId()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/main/java/engineering/everest/lhotse/competitions/services/DefaultCompetitionsReadService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.services; 2 | 3 | import engineering.everest.lhotse.competitions.domain.Competition; 4 | import engineering.everest.lhotse.competitions.domain.CompetitionWithEntries; 5 | import engineering.everest.lhotse.competitions.persistence.CompetitionEntriesRepository; 6 | import engineering.everest.lhotse.competitions.persistence.CompetitionsRepository; 7 | import engineering.everest.lhotse.competitions.persistence.PersistableCompetition; 8 | import engineering.everest.lhotse.competitions.persistence.PersistableCompetitionEntry; 9 | import org.springframework.data.domain.Sort; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.util.List; 13 | import java.util.UUID; 14 | 15 | import static org.springframework.data.domain.Sort.Direction.ASC; 16 | import static org.springframework.data.domain.Sort.Direction.DESC; 17 | 18 | @Service 19 | public class DefaultCompetitionsReadService implements CompetitionsReadService { 20 | 21 | private final CompetitionsRepository competitionsRepository; 22 | private final CompetitionEntriesRepository competitionEntriesRepository; 23 | 24 | public DefaultCompetitionsReadService(CompetitionsRepository competitionsRepository, 25 | CompetitionEntriesRepository competitionEntriesRepository) { 26 | this.competitionsRepository = competitionsRepository; 27 | this.competitionEntriesRepository = competitionEntriesRepository; 28 | } 29 | 30 | @Override 31 | public List getAllCompetitionsOrderedByDescVotingEndsTimestamp() { 32 | return competitionsRepository.findAll(Sort.by(DESC, "votingEndsTimestamp")).stream() 33 | .map(PersistableCompetition::toDomain) 34 | .toList(); 35 | } 36 | 37 | @Override 38 | public CompetitionWithEntries getCompetitionWithEntries(UUID competitionId) { 39 | var entries = competitionEntriesRepository 40 | .findAllByCompetitionId(competitionId, Sort.by(ASC, "entryTimestamp")).stream() 41 | .map(PersistableCompetitionEntry::toDomain) 42 | .toList(); 43 | return new CompetitionWithEntries(competitionsRepository.findById(competitionId).orElseThrow().toDomain(), entries); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/security/KeycloakJwtGrantedAuthoritiesConverter.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.security; 2 | 3 | import org.springframework.core.convert.converter.Converter; 4 | import org.springframework.security.core.GrantedAuthority; 5 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 6 | import org.springframework.security.oauth2.jwt.Jwt; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.ArrayList; 10 | import java.util.Collection; 11 | import java.util.List; 12 | import java.util.Locale; 13 | import java.util.Map; 14 | 15 | import static java.util.Arrays.stream; 16 | import static java.util.stream.Collectors.toList; 17 | 18 | @Component 19 | public class KeycloakJwtGrantedAuthoritiesConverter implements Converter> { 20 | 21 | private final Locale defaultLocale; 22 | 23 | public KeycloakJwtGrantedAuthoritiesConverter() { 24 | this.defaultLocale = Locale.getDefault(); 25 | } 26 | 27 | @Override 28 | public Collection convert(Jwt jwt) { 29 | var authorities = extractRealms(jwt); 30 | authorities.addAll(extractScopes(jwt)); 31 | return authorities; 32 | } 33 | 34 | private List extractRealms(Jwt jwt) { 35 | if (jwt.hasClaim("realm_access")) { 36 | var realmAccess = (Map) jwt.getClaims().get("realm_access"); 37 | var roles = (List) realmAccess.get("roles"); 38 | return roles.stream() 39 | .map(name -> "ROLE_" + name.toUpperCase(defaultLocale)) 40 | .map(SimpleGrantedAuthority::new) 41 | .collect(toList()); 42 | } 43 | return new ArrayList<>(); 44 | } 45 | 46 | private List extractScopes(Jwt jwt) { 47 | if (jwt.hasClaim("scope")) { 48 | var scope = (String) jwt.getClaims().get("scope"); 49 | if (!scope.isBlank() && !scope.isEmpty()) { 50 | var scopes = scope.split("\\s"); 51 | return stream(scopes) 52 | .map(name -> "SCOPE_" + name.toUpperCase(defaultLocale)) 53 | .map(SimpleGrantedAuthority::new) 54 | .collect(toList()); 55 | } 56 | } 57 | return new ArrayList<>(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/photos-persistence/src/main/java/engineering/everest/lhotse/photos/services/DefaultPhotosReadService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.services; 2 | 3 | import engineering.everest.lhotse.photos.Photo; 4 | import engineering.everest.lhotse.photos.persistence.PersistablePhoto; 5 | import engineering.everest.lhotse.photos.persistence.PhotosRepository; 6 | import engineering.everest.starterkit.filestorage.FileService; 7 | import engineering.everest.starterkit.media.thumbnails.ThumbnailService; 8 | import org.springframework.data.domain.Pageable; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.io.IOException; 12 | import java.io.InputStream; 13 | import java.util.List; 14 | import java.util.UUID; 15 | 16 | @Service 17 | public class DefaultPhotosReadService implements PhotosReadService { 18 | 19 | private final PhotosRepository photosRepository; 20 | private final FileService fileService; 21 | private final ThumbnailService thumbnailService; 22 | 23 | public DefaultPhotosReadService(PhotosRepository photosRepository, FileService fileService, ThumbnailService thumbnailService) { 24 | this.photosRepository = photosRepository; 25 | this.fileService = fileService; 26 | this.thumbnailService = thumbnailService; 27 | } 28 | 29 | @Override 30 | public List getAllPhotos(UUID requestingUserId, Pageable pageable) { 31 | return photosRepository.findByOwnerUserId(requestingUserId, pageable).stream() 32 | .map(PersistablePhoto::toDomain) 33 | .toList(); 34 | } 35 | 36 | @Override 37 | public Photo getPhoto(UUID photoId) { 38 | return photosRepository.findById(photoId).orElseThrow().toDomain(); 39 | } 40 | 41 | @Override 42 | public InputStream streamPhoto(UUID requestingUserId, UUID photoId) throws IOException { 43 | var persistablePhoto = photosRepository.findByIdAndOwnerUserId(photoId, requestingUserId).orElseThrow(); 44 | return fileService.stream(persistablePhoto.getPersistedFileId()).getInputStream(); 45 | } 46 | 47 | @Override 48 | public InputStream streamPhotoThumbnail(UUID requestingUserId, UUID photoId, int width, int height) throws IOException { 49 | var persistablePhoto = photosRepository.findByIdAndOwnerUserId(photoId, requestingUserId).orElseThrow(); 50 | return thumbnailService.streamThumbnailForOriginalFile(persistablePhoto.getPersistedFileId(), width, height); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/api/src/test/java/engineering/everest/lhotse/api/rest/controllers/VersionControllerTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.controllers; 2 | 3 | import engineering.everest.lhotse.api.config.TestApiConfig; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.info.BuildProperties; 8 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 9 | import org.springframework.boot.test.context.TestConfiguration; 10 | import org.springframework.context.annotation.Bean; 11 | import org.springframework.test.context.ContextConfiguration; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 14 | import org.springframework.web.context.WebApplicationContext; 15 | 16 | import java.util.Properties; 17 | 18 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 20 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 21 | 22 | @WebMvcTest(controllers = { VersionController.class }) 23 | @ContextConfiguration( 24 | classes = { TestApiConfig.class, VersionController.class, VersionControllerTest.VersionControllerTestConfiguration.class }) 25 | class VersionControllerTest { 26 | 27 | private static final String QUOTED_BUILD_TIME_VERSION_STRING = "'build time version string'"; 28 | private static final String UNQUOTED_BUILD_TIME_VERSION_STRING = "build time version string"; 29 | 30 | private MockMvc mockMvc; 31 | 32 | @Autowired 33 | private WebApplicationContext webApplicationContext; 34 | 35 | @BeforeEach 36 | public void setup() { 37 | this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); 38 | } 39 | 40 | @Test 41 | void willExposeBuildTimeApplicationVersion() throws Exception { 42 | mockMvc.perform(get("/api/version")) 43 | .andExpect(status().isOk()) 44 | .andExpect(content().string(UNQUOTED_BUILD_TIME_VERSION_STRING)); 45 | } 46 | 47 | @TestConfiguration 48 | static class VersionControllerTestConfiguration { 49 | 50 | @Bean 51 | public BuildProperties buildProperties() { 52 | Properties entries = new Properties(); 53 | entries.setProperty("version", QUOTED_BUILD_TIME_VERSION_STRING); 54 | return new BuildProperties(entries); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/photos/src/test/java/engineering/everest/lhotse/photos/handlers/PhotosEventHandlerTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.handlers; 2 | 3 | import engineering.everest.lhotse.photos.domain.events.PhotoDeletedAsPartOfUserDeletionEvent; 4 | import engineering.everest.lhotse.photos.domain.events.PhotoUploadedEvent; 5 | import engineering.everest.lhotse.photos.services.PhotosWriteService; 6 | import engineering.everest.starterkit.filestorage.FileService; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | 13 | import java.time.Instant; 14 | import java.util.UUID; 15 | 16 | import static java.util.UUID.randomUUID; 17 | import static org.mockito.Mockito.verify; 18 | 19 | @ExtendWith(MockitoExtension.class) 20 | class PhotosEventHandlerTest { 21 | 22 | private static final UUID PHOTO_ID = randomUUID(); 23 | private static final UUID USER_ID = randomUUID(); 24 | private static final UUID BACKING_FILE_ID = randomUUID(); 25 | private static final String PHOTO_FILENAME = "holiday snap.png"; 26 | private static final Instant UPLOAD_TIMESTAMP = Instant.ofEpochSecond(123); 27 | 28 | private PhotosEventHandler photosEventHandler; 29 | 30 | @Mock 31 | private PhotosWriteService photosWriteService; 32 | @Mock 33 | private FileService fileService; 34 | 35 | @BeforeEach 36 | void setUp() { 37 | photosEventHandler = new PhotosEventHandler(photosWriteService, fileService); 38 | } 39 | 40 | @Test 41 | void prepareForReplay_WillClearProjection() { 42 | photosEventHandler.prepareForReplay(); 43 | verify(photosWriteService).deleteAll(); 44 | } 45 | 46 | @Test 47 | void onPhotoUploadedEvent_WillProject() { 48 | photosEventHandler.on(new PhotoUploadedEvent(PHOTO_ID, USER_ID, BACKING_FILE_ID, PHOTO_FILENAME), UPLOAD_TIMESTAMP); 49 | verify(photosWriteService).createPhoto(PHOTO_ID, USER_ID, BACKING_FILE_ID, PHOTO_FILENAME, UPLOAD_TIMESTAMP); 50 | } 51 | 52 | @Test 53 | void onPhotoDeletedAsPartOfUserDeletionEvent_WillDeletePhoto() { 54 | photosEventHandler.on(new PhotoDeletedAsPartOfUserDeletionEvent(PHOTO_ID, BACKING_FILE_ID, USER_ID)); 55 | verify(photosWriteService).deleteById(PHOTO_ID); 56 | } 57 | 58 | @Test 59 | void onPhotoDeletedAsPartOfUserDeletionEvent_WillMarkBackingFileForDeletion() { 60 | photosEventHandler.on(new PhotoDeletedAsPartOfUserDeletionEvent(PHOTO_ID, BACKING_FILE_ID, USER_ID)); 61 | verify(fileService).markEphemeralFileForDeletion(BACKING_FILE_ID); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/domain/CompetitionEntryEntity.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain; 2 | 3 | import engineering.everest.lhotse.competitions.domain.commands.VoteForPhotoCommand; 4 | import engineering.everest.lhotse.competitions.domain.events.PhotoEntryReceivedVoteEvent; 5 | import engineering.everest.lhotse.i18n.exceptions.TranslatableException; 6 | import engineering.everest.lhotse.i18n.exceptions.TranslatableIllegalStateException; 7 | import lombok.EqualsAndHashCode; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | import org.axonframework.commandhandling.CommandExecutionException; 11 | import org.axonframework.eventsourcing.EventSourcingHandler; 12 | import org.axonframework.modelling.command.EntityId; 13 | 14 | import java.io.Serializable; 15 | import java.util.HashSet; 16 | import java.util.Set; 17 | import java.util.UUID; 18 | 19 | import static engineering.everest.lhotse.i18n.MessageKeys.ALREADY_VOTED_FOR_THIS_ENTRY; 20 | import static org.axonframework.modelling.command.AggregateLifecycle.apply; 21 | 22 | @NoArgsConstructor 23 | @EqualsAndHashCode 24 | @Getter 25 | public class CompetitionEntryEntity implements Serializable { 26 | 27 | @EntityId 28 | private UUID photoId; 29 | private UUID submittedByUserId; 30 | private Set usersVotedFor; 31 | 32 | CompetitionEntryEntity(UUID photoId, UUID submittedByUserId) { 33 | this.photoId = photoId; 34 | this.submittedByUserId = submittedByUserId; 35 | this.usersVotedFor = new HashSet<>(); 36 | } 37 | 38 | // Not annotated with @CommandHandler on purpose. Some validation occurs in aggregate root. 39 | void handle(VoteForPhotoCommand command) { 40 | validateUserHasNotVotedForPhotoBefore(command.getRequestingUserId()); 41 | 42 | apply(new PhotoEntryReceivedVoteEvent(command.getCompetitionId(), photoId, command.getRequestingUserId())); 43 | } 44 | 45 | @EventSourcingHandler 46 | void on(PhotoEntryReceivedVoteEvent event) { 47 | if (event.getPhotoId().equals(photoId)) { 48 | usersVotedFor.add(event.getVotingUserId()); 49 | } 50 | } 51 | 52 | private void validateUserHasNotVotedForPhotoBefore(UUID requestingUserId) { 53 | if (usersVotedFor.contains(requestingUserId)) { 54 | throwWrappedInCommandExecutionException(new TranslatableIllegalStateException(ALREADY_VOTED_FOR_THIS_ENTRY)); 55 | } 56 | } 57 | 58 | private static void throwWrappedInCommandExecutionException(TranslatableException translatableException) { 59 | throw new CommandExecutionException(translatableException.getMessage(), null, translatableException); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/competitions-persistence/src/main/java/engineering/everest/lhotse/competitions/services/DefaultCompetitionsWriteService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.services; 2 | 3 | import engineering.everest.lhotse.competitions.persistence.CompetitionEntriesRepository; 4 | import engineering.everest.lhotse.competitions.persistence.CompetitionEntryId; 5 | import engineering.everest.lhotse.competitions.persistence.CompetitionsRepository; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.time.Instant; 9 | import java.util.UUID; 10 | 11 | @Service 12 | public class DefaultCompetitionsWriteService implements CompetitionsWriteService { 13 | private final CompetitionsRepository competitionsRepository; 14 | private final CompetitionEntriesRepository competitionEntriesRepository; 15 | 16 | public DefaultCompetitionsWriteService(CompetitionsRepository competitionsRepository, 17 | CompetitionEntriesRepository competitionEntriesRepository) { 18 | this.competitionsRepository = competitionsRepository; 19 | this.competitionEntriesRepository = competitionEntriesRepository; 20 | } 21 | 22 | @Override 23 | public void createCompetition(UUID id, 24 | String description, 25 | Instant submissionsOpenTimestamp, 26 | Instant submissionsCloseTimestamp, 27 | Instant votingEndsTimestamp, 28 | int maxEntriesPerUser) { 29 | competitionsRepository.createCompetition(id, description, submissionsOpenTimestamp, 30 | submissionsCloseTimestamp, votingEndsTimestamp, maxEntriesPerUser); 31 | } 32 | 33 | @Override 34 | public void createCompetitionEntry(UUID competitionId, UUID photoId, UUID submittedByUserId, Instant entryTimestamp) { 35 | competitionEntriesRepository.createCompetitionEntry(competitionId, photoId, submittedByUserId, entryTimestamp); 36 | } 37 | 38 | @Override 39 | public void incrementVotesReceived(UUID competitionId, UUID photoId) { 40 | var competitionEntry = competitionEntriesRepository.findById(new CompetitionEntryId(competitionId, photoId)).orElseThrow(); 41 | 42 | competitionEntry.setVotesReceived(competitionEntry.getVotesReceived() + 1); 43 | } 44 | 45 | @Override 46 | public void setCompetitionWinner(UUID competitionId, UUID photoId) { 47 | var competitionEntry = competitionEntriesRepository.findById(new CompetitionEntryId(competitionId, photoId)).orElseThrow(); 48 | 49 | competitionEntry.setWinner(true); 50 | } 51 | 52 | @Override 53 | public void deleteAll() { 54 | competitionsRepository.deleteAll(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/i18n-support/src/test/java/engineering/everest/lhotse/i18n/TranslationServiceTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.i18n; 2 | 3 | import com.github.valfirst.slf4jtest.TestLogger; 4 | import com.github.valfirst.slf4jtest.TestLoggerFactory; 5 | import com.github.valfirst.slf4jtest.TestLoggerFactoryExtension; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | 10 | import java.util.Locale; 11 | 12 | import static java.util.Locale.ENGLISH; 13 | import static java.util.Locale.GERMANY; 14 | import static org.junit.jupiter.api.Assertions.assertEquals; 15 | import static org.slf4j.event.Level.ERROR; 16 | 17 | @ExtendWith(TestLoggerFactoryExtension.class) 18 | class TranslationServiceTest { 19 | 20 | private final TestLogger logger = TestLoggerFactory.getTestLogger(TranslationService.class); 21 | 22 | @BeforeEach 23 | void setUp() { 24 | TestLoggerFactory.clear(); 25 | } 26 | 27 | @Test 28 | void willTranslateMessagesWithNoArguments_WhenLocaleUsesDefaultTranslation() { 29 | assertEquals("Malformed email address", 30 | TranslationService.getInstance().translate(new Locale("smurfs"), "EMAIL_ADDRESS_MALFORMED")); 31 | } 32 | 33 | @Test 34 | void willTranslateMessagesWithNoArguments_WhenLocaleUsesNonDefaultTranslation() { 35 | assertEquals("Fehlerhafte E-Mail-Adresse", 36 | TranslationService.getInstance().translate(GERMANY, "EMAIL_ADDRESS_MALFORMED")); 37 | } 38 | 39 | @Test 40 | void willTranslateMessagesWithArguments_WhenLocaleUsesDefaultTranslation() { 41 | var australianEnglish = new Locale("en", "AU"); 42 | assertEquals("File file-id does not exist", 43 | TranslationService.getInstance().translate(australianEnglish, "FILE_DOES_NOT_EXIST", "file-id")); 44 | } 45 | 46 | @Test 47 | void willTranslateMessagesWithArguments_WhenLocaleUsesNonDefaultTranslation() { 48 | assertEquals("Datei file-id existiert nicht", 49 | TranslationService.getInstance().translate(GERMANY, "FILE_DOES_NOT_EXIST", "file-id")); 50 | } 51 | 52 | @Test 53 | void willLogWhenMessageKeyIsInvalid() { 54 | TranslationService.getInstance().translate(ENGLISH, "unknown-key"); 55 | 56 | var loggingEvent = logger.getLoggingEvents().get(0); 57 | assertEquals(ERROR, loggingEvent.getLevel()); 58 | assertEquals("Unmapped message key {}", loggingEvent.getMessage()); 59 | assertEquals("unknown-key", loggingEvent.getArguments().get(0)); 60 | } 61 | 62 | @Test 63 | void willReturnMessageKeyWhenKeyIsInvalid() { 64 | assertEquals("unknown-key", 65 | TranslationService.getInstance().translate(ENGLISH, "unknown-key")); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/api/src/test/java/engineering/everest/lhotse/api/rest/controllers/UsersControllerTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.rest.controllers; 2 | 3 | import com.c4_soft.springaddons.security.oauth2.test.annotations.WithMockBearerTokenAuthentication; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import engineering.everest.lhotse.api.config.TestApiConfig; 6 | import engineering.everest.lhotse.api.rest.requests.DeleteAndForgetUserRequest; 7 | import engineering.everest.lhotse.users.services.UsersService; 8 | import org.junit.jupiter.api.BeforeEach; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 12 | import org.springframework.boot.test.mock.mockito.MockBean; 13 | import org.springframework.test.context.ContextConfiguration; 14 | import org.springframework.test.web.servlet.MockMvc; 15 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 16 | import org.springframework.web.context.WebApplicationContext; 17 | 18 | import java.util.UUID; 19 | 20 | import static java.util.UUID.randomUUID; 21 | import static org.mockito.Mockito.verify; 22 | import static org.springframework.http.MediaType.APPLICATION_JSON; 23 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 25 | 26 | @WebMvcTest(controllers = { UsersController.class }) 27 | @ContextConfiguration(classes = { TestApiConfig.class, UsersController.class }) 28 | class UsersControllerTest { 29 | 30 | private static final UUID USER_ID = randomUUID(); 31 | private static final UUID ADMIN_ID = randomUUID(); 32 | private static final String ROLE_ADMIN = "ROLE_ADMIN"; 33 | 34 | private MockMvc mockMvc; 35 | 36 | @Autowired 37 | private ObjectMapper objectMapper; 38 | @Autowired 39 | private WebApplicationContext webApplicationContext; 40 | 41 | @MockBean 42 | private UsersService usersService; 43 | 44 | @BeforeEach 45 | public void setup() { 46 | mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); 47 | } 48 | 49 | @Test 50 | @WithMockBearerTokenAuthentication(authorities = ROLE_ADMIN) 51 | void deleteAndForgetUser_WillDelegate() throws Exception { 52 | mockMvc.perform(post("/api/users/{userId}/forget", USER_ID) 53 | .principal(ADMIN_ID::toString) 54 | .contentType(APPLICATION_JSON) 55 | .content(objectMapper.writeValueAsString(new DeleteAndForgetUserRequest("Submitted GDPR request")))) 56 | .andExpect(status().isOk()); 57 | 58 | verify(usersService).deleteAndForgetUser(ADMIN_ID, USER_ID, "Submitted GDPR request"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/photos/src/test/java/engineering/everest/lhotse/photos/domain/UserDeletedSagaTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.domain; 2 | 3 | import engineering.everest.lhotse.photos.Photo; 4 | import engineering.everest.lhotse.photos.domain.commands.DeletePhotoForDeletedUserCommand; 5 | import engineering.everest.lhotse.photos.services.PhotosReadService; 6 | import engineering.everest.lhotse.users.domain.events.UserDeletedAndForgottenEvent; 7 | import org.axonframework.commandhandling.gateway.CommandGateway; 8 | import org.axonframework.test.saga.SagaTestFixture; 9 | import org.junit.jupiter.api.BeforeEach; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | import org.mockito.Mock; 13 | import org.mockito.junit.jupiter.MockitoExtension; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | 16 | import java.time.Instant; 17 | import java.util.List; 18 | import java.util.UUID; 19 | 20 | import static java.util.UUID.randomUUID; 21 | import static org.mockito.Mockito.when; 22 | import static org.springframework.data.domain.Pageable.unpaged; 23 | 24 | @ExtendWith(MockitoExtension.class) 25 | class UserDeletedSagaTest { 26 | 27 | private static final UUID USER_ID = randomUUID(); 28 | private static final UUID ADMIN_ID = randomUUID(); 29 | private static final UUID PHOTO_ID_1 = randomUUID(); 30 | private static final UUID PHOTO_ID_2 = randomUUID(); 31 | private static final UUID BACKING_FILE_ID_1 = randomUUID(); 32 | private static final UUID BACKING_FILE_ID_2 = randomUUID(); 33 | 34 | private SagaTestFixture testFixture; 35 | 36 | @Autowired 37 | private CommandGateway commandGateway; 38 | @Mock 39 | private PhotosReadService photosReadService; 40 | 41 | @BeforeEach 42 | void setUp() { 43 | testFixture = new SagaTestFixture<>(UserDeletedSaga.class); 44 | testFixture.registerResource(photosReadService); 45 | // testFixture.registerResource(commandGateway); 46 | } 47 | 48 | @Test 49 | void userDeletedAndForgotten_WillDispatchPhotoDeletionCommands() { 50 | when(photosReadService.getAllPhotos(USER_ID, unpaged())) 51 | .thenReturn(List.of( 52 | new Photo(PHOTO_ID_1, USER_ID, BACKING_FILE_ID_1, "photo1.png", Instant.now()), 53 | new Photo(PHOTO_ID_2, USER_ID, BACKING_FILE_ID_2, "photo2.png", Instant.now()))); 54 | 55 | testFixture.givenNoPriorActivity() 56 | .whenAggregate(USER_ID.toString()) 57 | .publishes(new UserDeletedAndForgottenEvent(USER_ID, ADMIN_ID, "GDPR request")) 58 | .expectDispatchedCommands( 59 | new DeletePhotoForDeletedUserCommand(PHOTO_ID_1, USER_ID), 60 | new DeletePhotoForDeletedUserCommand(PHOTO_ID_2, USER_ID)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/competitions/src/main/java/engineering/everest/lhotse/competitions/services/DefaultCompetitionsService.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.services; 2 | 3 | import engineering.everest.lhotse.common.RandomFieldsGenerator; 4 | import engineering.everest.lhotse.competitions.domain.commands.CreateCompetitionCommand; 5 | import engineering.everest.lhotse.competitions.domain.commands.EnterPhotoInCompetitionCommand; 6 | import engineering.everest.lhotse.competitions.domain.commands.VoteForPhotoCommand; 7 | import engineering.everest.lhotse.photos.services.PhotosReadService; 8 | import org.axonframework.commandhandling.gateway.CommandGateway; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.time.Instant; 12 | import java.util.UUID; 13 | 14 | @Service 15 | public class DefaultCompetitionsService implements CompetitionsService { 16 | 17 | private final CommandGateway commandGateway; 18 | private final RandomFieldsGenerator randomFieldsGenerator; 19 | private final PhotosReadService photosReadService; 20 | 21 | public DefaultCompetitionsService(CommandGateway commandGateway, 22 | RandomFieldsGenerator randomFieldsGenerator, 23 | PhotosReadService photosReadService) { 24 | this.commandGateway = commandGateway; 25 | this.randomFieldsGenerator = randomFieldsGenerator; 26 | this.photosReadService = photosReadService; 27 | } 28 | 29 | @Override 30 | public UUID createCompetition(UUID requestingUserId, 31 | String description, 32 | Instant submissionsOpenTimestamp, 33 | Instant submissionsCloseTimestamp, 34 | Instant votingEndsTimestamp, 35 | int maxEntriesPerUser) { 36 | var competitionId = randomFieldsGenerator.genRandomUUID(); 37 | commandGateway.sendAndWait(new CreateCompetitionCommand(requestingUserId, competitionId, description, 38 | submissionsOpenTimestamp, submissionsCloseTimestamp, votingEndsTimestamp, maxEntriesPerUser)); 39 | return competitionId; 40 | } 41 | 42 | @Override 43 | public void submitPhoto(UUID requestingUserId, UUID competitionId, UUID photoId, String submissionNotes) { 44 | var owningUserId = photosReadService.getPhoto(photoId).getOwningUserId(); 45 | commandGateway.sendAndWait(new EnterPhotoInCompetitionCommand(competitionId, photoId, requestingUserId, 46 | owningUserId, submissionNotes)); 47 | } 48 | 49 | @Override 50 | public void voteForPhoto(UUID requestingUserId, UUID competitionId, UUID photoId) { 51 | commandGateway.sendAndWait(new VoteForPhotoCommand(competitionId, photoId, requestingUserId)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/ReplayFunctionalTests.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.functionaltests.scenarios; 2 | 3 | import engineering.everest.lhotse.Launcher; 4 | import engineering.everest.lhotse.common.RetryWithExponentialBackoff; 5 | import engineering.everest.lhotse.functionaltests.helpers.ApiRestTestClient; 6 | import engineering.everest.lhotse.functionaltests.helpers.TestEventHandler; 7 | import io.zonky.test.db.AutoConfigureEmbeddedDatabase; 8 | import org.json.JSONException; 9 | import org.junit.jupiter.api.BeforeEach; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.test.annotation.DirtiesContext; 14 | import org.springframework.test.context.ActiveProfiles; 15 | import org.springframework.test.web.reactive.server.WebTestClient; 16 | 17 | import java.time.Duration; 18 | import java.util.Map; 19 | 20 | import static io.zonky.test.db.AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES; 21 | import static java.lang.Boolean.FALSE; 22 | import static org.junit.jupiter.api.Assertions.assertSame; 23 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 24 | import static org.springframework.http.HttpStatus.NO_CONTENT; 25 | import static org.springframework.http.HttpStatus.OK; 26 | 27 | @SpringBootTest(webEnvironment = RANDOM_PORT, classes = Launcher.class) 28 | @AutoConfigureEmbeddedDatabase(type = POSTGRES) 29 | @ActiveProfiles("functionaltests") 30 | @DirtiesContext // Avoids logging noise. Can remove when test containers support shutting down after Spring shut down 31 | class ReplayFunctionalTests { 32 | 33 | @Autowired 34 | private WebTestClient webTestClient; 35 | @Autowired 36 | private TestEventHandler testEventHandler; 37 | @Autowired 38 | private ApiRestTestClient apiRestTestClient; 39 | 40 | @BeforeEach 41 | void setUp() throws JSONException { 42 | apiRestTestClient.setWebTestClient(webTestClient); 43 | apiRestTestClient.createAdminUserAndLogin("alice", "admin", "alice-admin@example.com"); 44 | } 45 | 46 | @Test 47 | void canGetReplayStatus() { 48 | Map replayStatus = apiRestTestClient.getReplayStatus(OK); 49 | assertSame(FALSE, replayStatus.get("isReplaying")); 50 | } 51 | 52 | @Test 53 | void canTriggerReplayEvents() throws Exception { 54 | apiRestTestClient.triggerReplay(NO_CONTENT); 55 | // This is necessary, otherwise the canGetReplayStatus() may sometimes fail if it runs later 56 | // and things happen too fast 57 | RetryWithExponentialBackoff.withMaxDuration(Duration.ofSeconds(20)) 58 | .waitOrThrow(() -> !(boolean) apiRestTestClient.getReplayStatus(OK).get("isReplaying"), "wait for replay"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/launcher/src/main/java/engineering/everest/lhotse/tasks/PeriodicFilesMarkedForDeletionRemovalTask.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.tasks; 2 | 3 | import engineering.everest.lhotse.axon.replay.ReplayCompletionAware; 4 | import engineering.everest.starterkit.filestorage.FileService; 5 | import engineering.everest.starterkit.media.thumbnails.ThumbnailService; 6 | import jakarta.persistence.EntityManager; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.beans.factory.annotation.Qualifier; 10 | import org.springframework.beans.factory.annotation.Value; 11 | import org.springframework.scheduling.annotation.Scheduled; 12 | import org.springframework.stereotype.Component; 13 | import org.springframework.transaction.annotation.Transactional; 14 | 15 | import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; 16 | 17 | @Component 18 | @Slf4j 19 | public class PeriodicFilesMarkedForDeletionRemovalTask implements ReplayCompletionAware { 20 | private static final String POSTGRES_TRY_LOCK = "SELECT pg_try_advisory_lock(42)"; 21 | private static final String POSTGRES_UNLOCK = "SELECT pg_advisory_unlock(42)"; 22 | 23 | private final EntityManager entityManager; 24 | private final FileService fileService; 25 | private final ThumbnailService thumbnailService; 26 | private final int batchSize; 27 | 28 | @Autowired 29 | public PeriodicFilesMarkedForDeletionRemovalTask(@Qualifier("sharedEntityManager") EntityManager entityManager, 30 | FileService fileService, 31 | ThumbnailService thumbnailService, 32 | @Value("${application.filestore.deletion.batch-size:400}") int batchSize) { 33 | this.entityManager = entityManager; 34 | this.fileService = fileService; 35 | this.thumbnailService = thumbnailService; 36 | this.batchSize = batchSize; 37 | } 38 | 39 | @Scheduled(fixedRateString = "PT${storage.files.deletion.fixedRate:5m}") 40 | @Transactional(propagation = REQUIRES_NEW) 41 | public void checkForFilesMarkedForDeletionToCleanUp() { 42 | if (!(boolean) entityManager.createNativeQuery(POSTGRES_TRY_LOCK).getSingleResult()) { 43 | LOGGER.info("Locked by another process"); 44 | return; 45 | } 46 | 47 | LOGGER.info("Lock acquired"); 48 | this.deleteFilesInBatches(); 49 | entityManager.createNativeQuery(POSTGRES_UNLOCK).getSingleResult(); 50 | LOGGER.info("Lock released"); 51 | } 52 | 53 | public void deleteFilesInBatches() { 54 | fileService.deleteEphemeralFileBatch(batchSize); 55 | } 56 | 57 | @Override 58 | public void replayCompleted() { 59 | fileService.markAllEphemeralFilesForDeletion(); 60 | thumbnailService.deleteAllThumbnailMappings(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/database-support/src/main/java/engineering/everest/lhotse/config/DatabaseConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.config; 2 | 3 | import com.zaxxer.hikari.HikariConfig; 4 | import jakarta.persistence.EntityManager; 5 | import jakarta.persistence.EntityManagerFactory; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; 8 | import org.springframework.boot.context.properties.ConfigurationProperties; 9 | import org.springframework.boot.jdbc.DataSourceBuilder; 10 | import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.context.annotation.Configuration; 13 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 14 | import org.springframework.orm.jpa.JpaTransactionManager; 15 | import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; 16 | import org.springframework.orm.jpa.SharedEntityManagerCreator; 17 | import org.springframework.transaction.PlatformTransactionManager; 18 | import org.springframework.transaction.annotation.EnableTransactionManagement; 19 | 20 | import javax.sql.DataSource; 21 | 22 | @Slf4j 23 | @Configuration 24 | @EnableJpaRepositories( 25 | entityManagerFactoryRef = "lhotseEntityManagerFactory", 26 | transactionManagerRef = "lhotsePlatformTransactionManager", 27 | basePackages = { "engineering.everest", "org.axonframework" }) 28 | @EnableTransactionManagement 29 | public class DatabaseConfig { 30 | 31 | @Bean 32 | @ConfigurationProperties(prefix = "spring.datasource.hikari") 33 | public DataSource dataSource() { 34 | return DataSourceBuilder.create().build(); 35 | } 36 | 37 | @Bean 38 | @ConfigurationProperties(prefix = "spring.datasource.hikari") 39 | public HikariConfig hikariConfig() { 40 | return new HikariConfig(); 41 | } 42 | 43 | @Bean 44 | @ConfigurationProperties(prefix = "spring.jpa") 45 | public JpaProperties jpaProperties() { 46 | return new JpaProperties(); 47 | } 48 | 49 | @Bean 50 | public LocalContainerEntityManagerFactoryBean lhotseEntityManagerFactory(EntityManagerFactoryBuilder builder, 51 | DataSource dataSource, 52 | JpaProperties jpaProperties) { 53 | return builder 54 | .dataSource(dataSource) 55 | .properties(jpaProperties.getProperties()) 56 | .packages("engineering.everest", "org.axonframework") 57 | .build(); 58 | } 59 | 60 | @Bean 61 | public PlatformTransactionManager lhotsePlatformTransactionManager(EntityManagerFactory entityManagerFactory) { 62 | return new JpaTransactionManager(entityManagerFactory); 63 | } 64 | 65 | @Bean 66 | public EntityManager sharedEntityManager(EntityManagerFactory entityManagerFactory) { 67 | return SharedEntityManagerCreator.createSharedEntityManager(entityManagerFactory); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/api/src/main/java/engineering/everest/lhotse/api/config/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.config; 2 | 3 | import engineering.everest.lhotse.api.security.KeycloakJwtGrantedAuthoritiesConverter; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 8 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 9 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 10 | import org.springframework.security.core.session.SessionRegistryImpl; 11 | import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; 12 | import org.springframework.security.web.SecurityFilterChain; 13 | import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; 14 | import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; 15 | import org.springframework.security.web.util.matcher.AntPathRequestMatcher; 16 | 17 | import static org.springframework.security.config.Customizer.withDefaults; 18 | 19 | @EnableWebSecurity 20 | @EnableMethodSecurity 21 | @Configuration 22 | public class WebSecurityConfig { 23 | 24 | @Bean 25 | protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { 26 | return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); 27 | } 28 | 29 | @Bean 30 | public SecurityFilterChain configure(HttpSecurity http, KeycloakJwtGrantedAuthoritiesConverter keycloakJwtGrantedAuthoritiesConverter) 31 | throws Exception { 32 | var jwtAuthenticationConverter = new JwtAuthenticationConverter(); 33 | jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(keycloakJwtGrantedAuthoritiesConverter); 34 | 35 | http.cors(withDefaults()).csrf(AbstractHttpConfigurer::disable) 36 | .authorizeHttpRequests(authz -> authz 37 | .requestMatchers( 38 | "/api/version", 39 | "/actuator/health", 40 | "/api/doc/**", 41 | "/swagger-ui/**", 42 | "/swagger-resources/**", 43 | "/sso/login*") 44 | .permitAll() 45 | .requestMatchers("/api/**").authenticated() 46 | .requestMatchers("/actuator/health/**", "/actuator/metrics/**", "/actuator/prometheus").hasAnyRole("ADMIN", "MONITORING") 47 | .requestMatchers("/actuator/**", "/admin/**").hasRole("ADMIN") 48 | .anyRequest().permitAll()) 49 | .logout((logout) -> logout.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")).logoutSuccessUrl("/")) 50 | .oauth2ResourceServer( 51 | oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter))); 52 | return http.build(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/launcher/src/test/java/engineering/everest/lhotse/functionaltests/scenarios/SecurityFunctionalTests.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.functionaltests.scenarios; 2 | 3 | import engineering.everest.lhotse.Launcher; 4 | import io.zonky.test.db.AutoConfigureEmbeddedDatabase; 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.condition.EnabledIfSystemProperty; 7 | import org.junit.jupiter.params.ParameterizedTest; 8 | import org.junit.jupiter.params.provider.Arguments; 9 | import org.junit.jupiter.params.provider.MethodSource; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.boot.test.web.server.LocalServerPort; 13 | import org.springframework.security.test.context.support.WithAnonymousUser; 14 | import org.springframework.test.annotation.DirtiesContext; 15 | import org.springframework.test.context.ActiveProfiles; 16 | import org.springframework.test.web.reactive.server.WebTestClient; 17 | 18 | import java.io.IOException; 19 | import java.nio.file.Files; 20 | import java.nio.file.Paths; 21 | import java.util.stream.Stream; 22 | 23 | import static io.zonky.test.db.AutoConfigureEmbeddedDatabase.DatabaseType.POSTGRES; 24 | import static org.junit.jupiter.api.Assertions.assertNotNull; 25 | import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; 26 | 27 | @SpringBootTest(webEnvironment = RANDOM_PORT, classes = Launcher.class) 28 | @AutoConfigureEmbeddedDatabase(type = POSTGRES) 29 | @ActiveProfiles("functionaltests") 30 | @DirtiesContext // Avoids logging noise. Can remove when test containers support shutting down after Spring shut down 31 | class SecurityFunctionalTests { 32 | 33 | @Autowired 34 | private WebTestClient webTestClient; 35 | 36 | @LocalServerPort 37 | int serverPort; 38 | 39 | @Test 40 | @EnabledIfSystemProperty(named = "org.gradle.project.buildDir", matches = ".+") 41 | @WithAnonymousUser 42 | void swaggerApiDocIsAccessible() throws IOException { 43 | var apiContent = webTestClient.get().uri("/v3/api-docs") 44 | .exchange() 45 | .expectStatus().isOk() 46 | .returnResult(String.class).getResponseBody().blockFirst(); 47 | 48 | assertNotNull(apiContent); 49 | 50 | Files.writeString( 51 | Paths.get(System.getProperty("org.gradle.project.buildDir"), "web-app-api.json"), 52 | apiContent); 53 | } 54 | 55 | @ParameterizedTest 56 | @MethodSource("authenticatedGetEndpoints") 57 | void retrievingAuthenticatedGetEndpointsWillReturnUnauthorised_WhenUserIsNotAuthenticated(String endpoint) { 58 | webTestClient.get().uri(endpoint) 59 | .exchange() 60 | .expectStatus().isUnauthorized(); 61 | } 62 | 63 | private static Stream authenticatedGetEndpoints() { 64 | return Stream.of( 65 | Arguments.of("/api/competitions"), 66 | Arguments.of("/api/competitions/some-id"), 67 | Arguments.of("/api/photos"), 68 | Arguments.of("/api/photos/some-id")); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/photos/src/main/java/engineering/everest/lhotse/photos/domain/PhotoAggregate.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.photos.domain; 2 | 3 | import engineering.everest.lhotse.i18n.exceptions.TranslatableException; 4 | import engineering.everest.lhotse.i18n.exceptions.TranslatableIllegalArgumentException; 5 | import engineering.everest.lhotse.photos.domain.commands.DeletePhotoForDeletedUserCommand; 6 | import engineering.everest.lhotse.photos.domain.commands.RegisterUploadedPhotoCommand; 7 | import engineering.everest.lhotse.photos.domain.events.PhotoDeletedAsPartOfUserDeletionEvent; 8 | import engineering.everest.lhotse.photos.domain.events.PhotoUploadedEvent; 9 | import org.axonframework.commandhandling.CommandExecutionException; 10 | import org.axonframework.commandhandling.CommandHandler; 11 | import org.axonframework.eventsourcing.EventSourcingHandler; 12 | import org.axonframework.modelling.command.AggregateIdentifier; 13 | import org.axonframework.spring.stereotype.Aggregate; 14 | 15 | import java.io.Serializable; 16 | import java.util.UUID; 17 | 18 | import static engineering.everest.lhotse.i18n.MessageKeys.DELETED_PHOTO_OWNER_MISMATCH; 19 | import static org.axonframework.modelling.command.AggregateLifecycle.apply; 20 | import static org.axonframework.modelling.command.AggregateLifecycle.markDeleted; 21 | 22 | @Aggregate(snapshotTriggerDefinition = "photoAggregateSnapshotTriggerDefinition") 23 | public class PhotoAggregate implements Serializable { 24 | 25 | @AggregateIdentifier 26 | private UUID photoId; 27 | private UUID persistedFileId; 28 | private UUID ownerUserId; 29 | 30 | PhotoAggregate() {} 31 | 32 | @CommandHandler 33 | PhotoAggregate(RegisterUploadedPhotoCommand command) { 34 | apply(new PhotoUploadedEvent(command.getPhotoId(), command.getOwningUserId(), command.getPersistedFileId(), command.getFilename())); 35 | } 36 | 37 | @CommandHandler 38 | void handle(DeletePhotoForDeletedUserCommand command) { 39 | validatePhotoBelongsToDeletedUser(command); 40 | 41 | apply(new PhotoDeletedAsPartOfUserDeletionEvent(photoId, persistedFileId, command.getDeletedUserId())); 42 | } 43 | 44 | @EventSourcingHandler 45 | void on(PhotoUploadedEvent event) { 46 | photoId = event.getPhotoId(); 47 | persistedFileId = event.getPersistedFileId(); 48 | ownerUserId = event.getOwningUserId(); 49 | } 50 | 51 | @EventSourcingHandler 52 | void on(PhotoDeletedAsPartOfUserDeletionEvent event) { 53 | markDeleted(); 54 | } 55 | 56 | private void throwWrappedInCommandExecutionException(TranslatableException translatableException) { 57 | throw new CommandExecutionException(translatableException.getMessage(), null, translatableException); 58 | } 59 | 60 | private void validatePhotoBelongsToDeletedUser(DeletePhotoForDeletedUserCommand command) { 61 | if (!command.getDeletedUserId().equals(ownerUserId)) { 62 | throwWrappedInCommandExecutionException( 63 | new TranslatableIllegalArgumentException(DELETED_PHOTO_OWNER_MISMATCH, photoId, command.getDeletedUserId(), ownerUserId)); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /create-aggregate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | if [[ $# -eq 0 ]] ; then 4 | echo Creates the directory structure for a new aggregate. 5 | echo "Usage: $0 " 6 | exit 1 7 | fi 8 | 9 | mkdir -pv src/$1/src/main/java 10 | mkdir -pv src/$1/src/test/java 11 | mkdir -pv src/$1-api/src/main/java 12 | mkdir -pv src/$1-api/src/test/java 13 | mkdir -pv src/$1-persistence/src/main/java 14 | mkdir -pv src/$1-persistence/src/test/java 15 | 16 | cat <> src/$1/build.gradle 17 | apply plugin: 'jacoco' 18 | 19 | dependencies { 20 | api project(':$1-api') 21 | api "org.axonframework:axon-spring:\${axonVersion}" 22 | 23 | implementation project(':axon-support') 24 | implementation project(':common') 25 | implementation project(':command-validation-support') 26 | implementation project(':i18n-support') 27 | implementation project(':$1-persistence') 28 | 29 | implementation "engineering.everest.axon:crypto-shredding-extension:\${axonCryptoShreddingVersion}" 30 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 31 | 32 | testImplementation project(':axon-support').sourceSets.test.output 33 | testImplementation "org.junit.jupiter:junit-jupiter:\${junitVersion}" 34 | testImplementation "org.axonframework:axon-test:\${axonVersion}" 35 | testImplementation "org.mockito:mockito-junit-jupiter:\${mockitoVersion}" 36 | testImplementation "org.hamcrest:hamcrest-library:\${hamcrestVersion}" 37 | } 38 | 39 | EOT 40 | echo "Created src/$1/build.gradle" 41 | 42 | 43 | cat <> src/$1-api/build.gradle 44 | dependencies { 45 | api project(':common') 46 | api project(':command-validation-api') 47 | 48 | implementation "engineering.everest.starterkit:storage:\${storageVersion}" 49 | implementation "engineering.everest.axon:crypto-shredding-extension:\${axonCryptoShreddingVersion}" 50 | implementation "org.axonframework:axon-modelling:\${axonVersion}" 51 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 52 | implementation 'org.springframework.boot:spring-boot-starter-validation' 53 | 54 | testImplementation "org.junit.jupiter:junit-jupiter:\${junitVersion}" 55 | testImplementation "org.mockito:mockito-junit-jupiter:\${mockitoVersion}" 56 | } 57 | 58 | EOT 59 | echo "Created src/$1-api/build.gradle" 60 | 61 | 62 | cat <> src/$1-persistence/build.gradle 63 | apply plugin: 'jacoco' 64 | 65 | dependencies { 66 | api project(':$1-api') 67 | 68 | implementation "engineering.everest.starterkit:storage:\${storageVersion}" 69 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 70 | implementation "org.liquibase:liquibase-core:\${liquibaseVersion}" 71 | 72 | testImplementation 'org.springframework.boot:spring-boot-test-autoconfigure' 73 | testImplementation 'org.springframework:spring-test' 74 | testImplementation "org.junit.jupiter:junit-jupiter:\${junitVersion}" 75 | testImplementation "org.mockito:mockito-junit-jupiter:\${mockitoVersion}" 76 | testImplementation "org.postgresql:postgresql:\${postgresDriverVersion}" 77 | testImplementation "io.zonky.test:embedded-database-spring-test:\${zonkyEmbeddedDbVersion}" 78 | } 79 | EOT 80 | echo "Created src/$1-persistence/build.gradle" 81 | -------------------------------------------------------------------------------- /src/competitions/src/test/java/engineering/everest/lhotse/competitions/domain/CompetitionVotingCloseoutSagaTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.domain; 2 | 3 | import engineering.everest.lhotse.competitions.domain.commands.CountVotesAndDeclareOutcomeCommand; 4 | import engineering.everest.lhotse.competitions.domain.events.CompetitionCreatedEvent; 5 | import engineering.everest.lhotse.competitions.domain.events.CompetitionEndedEvent; 6 | import engineering.everest.lhotse.competitions.domain.events.CompetitionVotingPeriodEndedEvent; 7 | import org.axonframework.commandhandling.gateway.CommandGateway; 8 | import org.axonframework.test.saga.SagaTestFixture; 9 | import org.junit.jupiter.api.BeforeEach; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | 15 | import java.time.Instant; 16 | import java.util.UUID; 17 | 18 | import static java.util.UUID.randomUUID; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | class CompetitionVotingCloseoutSagaTest { 22 | 23 | private static final UUID ADMIN_ID = randomUUID(); 24 | private static final UUID COMPETITION_ID = randomUUID(); 25 | private static final Instant VOTING_ENDS_TIMESTAMP = Instant.ofEpochMilli(789); 26 | 27 | private static final CompetitionVotingPeriodEndedEvent SCHEDULED_VOTING_PERIOD_ENDED_EVENT = 28 | new CompetitionVotingPeriodEndedEvent(COMPETITION_ID, VOTING_ENDS_TIMESTAMP); 29 | private static final CompetitionCreatedEvent COMPETITION_CREATED_EVENT = 30 | new CompetitionCreatedEvent(ADMIN_ID, COMPETITION_ID, "description", Instant.ofEpochMilli(123), 31 | Instant.ofEpochMilli(456), VOTING_ENDS_TIMESTAMP, 1); 32 | private static final CompetitionEndedEvent COMPETITION_ENDED_EVENT = new CompetitionEndedEvent(COMPETITION_ID); 33 | 34 | private SagaTestFixture testFixture; 35 | 36 | @Autowired 37 | private CommandGateway commandGateway; 38 | 39 | @BeforeEach 40 | void setUp() { 41 | testFixture = new SagaTestFixture<>(CompetitionVotingCloseoutSaga.class); 42 | } 43 | 44 | @Test 45 | void onCompetitionCreated_WillScheduleVotingPeriodEndedEvent() { 46 | testFixture.givenNoPriorActivity() 47 | .whenAggregate(COMPETITION_ID.toString()) 48 | .publishes(COMPETITION_CREATED_EVENT) 49 | .expectNoDispatchedCommands() 50 | .expectScheduledEvent(VOTING_ENDS_TIMESTAMP, SCHEDULED_VOTING_PERIOD_ENDED_EVENT); 51 | } 52 | 53 | @Test 54 | void onVotingPeriodEndedEvent_WillDispatchCommandToCountVotes() { 55 | testFixture.givenAPublished(COMPETITION_CREATED_EVENT) 56 | .whenPublishingA(SCHEDULED_VOTING_PERIOD_ENDED_EVENT) 57 | .expectDispatchedCommands(new CountVotesAndDeclareOutcomeCommand(COMPETITION_ID)); 58 | } 59 | 60 | @Test 61 | void onCompetitionEndedEvent_WillEndSagaLifecycle() { 62 | testFixture.givenAPublished(COMPETITION_CREATED_EVENT) 63 | .andThenAPublished(SCHEDULED_VOTING_PERIOD_ENDED_EVENT) 64 | .whenAggregate(COMPETITION_ID.toString()) 65 | .publishes(COMPETITION_ENDED_EVENT) 66 | .expectNoDispatchedCommands() 67 | .expectActiveSagas(0); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/api/src/test/java/engineering/everest/lhotse/api/security/KeycloakJwtGrantedAuthoritiesConverterTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.api.security; 2 | 3 | import com.nimbusds.jose.jwk.JWK; 4 | import com.nimbusds.jose.jwk.JWKSet; 5 | import com.nimbusds.jose.jwk.RSAKey; 6 | import com.nimbusds.jose.jwk.source.ImmutableJWKSet; 7 | import net.minidev.json.JSONArray; 8 | import net.minidev.json.JSONObject; 9 | import org.junit.jupiter.api.BeforeAll; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 13 | import org.springframework.security.oauth2.jwt.Jwt; 14 | import org.springframework.security.oauth2.jwt.JwtClaimsSet; 15 | import org.springframework.security.oauth2.jwt.JwtEncoderParameters; 16 | import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; 17 | 18 | import java.security.KeyPairGenerator; 19 | import java.security.NoSuchAlgorithmException; 20 | import java.security.interfaces.RSAPrivateKey; 21 | import java.security.interfaces.RSAPublicKey; 22 | import java.util.List; 23 | 24 | import static com.nimbusds.jose.jwk.KeyUse.SIGNATURE; 25 | import static java.util.UUID.randomUUID; 26 | import static org.junit.jupiter.api.Assertions.assertIterableEquals; 27 | 28 | class KeycloakJwtGrantedAuthoritiesConverterTest { 29 | 30 | private static JWK jwk; 31 | 32 | private KeycloakJwtGrantedAuthoritiesConverter keycloakJwtGrantedAuthoritiesConverter; 33 | 34 | @BeforeAll 35 | static void beforeAll() throws NoSuchAlgorithmException { 36 | var gen = KeyPairGenerator.getInstance("RSA"); 37 | gen.initialize(2048); 38 | var keyPair = gen.generateKeyPair(); 39 | 40 | jwk = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) 41 | .privateKey((RSAPrivateKey) keyPair.getPrivate()) 42 | .keyUse(SIGNATURE) 43 | .keyID(randomUUID().toString()) 44 | .build(); 45 | } 46 | 47 | @BeforeEach 48 | void setUp() { 49 | keycloakJwtGrantedAuthoritiesConverter = new KeycloakJwtGrantedAuthoritiesConverter(); 50 | } 51 | 52 | @Test 53 | void convert_WillExtractRealmRoles() { 54 | var roles = new JSONArray(); 55 | roles.add("first"); 56 | roles.add("second"); 57 | 58 | var realm_access = new JSONObject(); 59 | realm_access.appendField("roles", roles); 60 | 61 | var claims = JwtClaimsSet.builder() 62 | .claim("realm_access", realm_access) 63 | .build(); 64 | 65 | var expected = List.of(new SimpleGrantedAuthority("ROLE_FIRST"), new SimpleGrantedAuthority("ROLE_SECOND")); 66 | assertIterableEquals(expected, keycloakJwtGrantedAuthoritiesConverter.convert(createJwtFromClaims(claims))); 67 | } 68 | 69 | @Test 70 | void convert_WillExtractScopes() { 71 | var claims = JwtClaimsSet.builder() 72 | .claim("scope", "first second") 73 | .build(); 74 | 75 | var expected = List.of(new SimpleGrantedAuthority("SCOPE_FIRST"), new SimpleGrantedAuthority("SCOPE_SECOND")); 76 | assertIterableEquals(expected, keycloakJwtGrantedAuthoritiesConverter.convert(createJwtFromClaims(claims))); 77 | } 78 | 79 | private static Jwt createJwtFromClaims(JwtClaimsSet claims) { 80 | return new NimbusJwtEncoder(new ImmutableJWKSet(new JWKSet(jwk))).encode(JwtEncoderParameters.from(claims)); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/axon-support/src/main/java/engineering/everest/lhotse/axon/replay/ReplayMarkerAwareTrackingEventProcessorBuilder.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.axon.replay; 2 | 3 | import org.axonframework.config.Configuration; 4 | import org.axonframework.config.EventProcessingConfigurer.EventProcessorBuilder; 5 | import org.axonframework.config.EventProcessingModule; 6 | import org.axonframework.eventhandling.EventHandlerInvoker; 7 | import org.axonframework.eventhandling.EventProcessor; 8 | import org.axonframework.eventhandling.TrackedEventMessage; 9 | import org.axonframework.eventhandling.TrackingEventProcessor; 10 | import org.axonframework.eventhandling.TrackingEventProcessorConfiguration; 11 | import org.axonframework.messaging.StreamableMessageSource; 12 | import org.springframework.core.task.TaskExecutor; 13 | 14 | public class ReplayMarkerAwareTrackingEventProcessorBuilder implements EventProcessorBuilder { 15 | 16 | private final TaskExecutor taskExecutor; 17 | private final EventProcessingModule eventProcessingModule; 18 | 19 | public ReplayMarkerAwareTrackingEventProcessorBuilder(TaskExecutor taskExecutor, EventProcessingModule eventProcessingModule) { 20 | this.taskExecutor = taskExecutor; 21 | this.eventProcessingModule = eventProcessingModule; 22 | } 23 | 24 | @Override 25 | @SuppressWarnings("unchecked") 26 | public EventProcessor build(String name, Configuration configuration, EventHandlerInvoker eventHandlerInvoker) { 27 | var trackingEventProcessorConfiguration = configuration.getComponent( 28 | TrackingEventProcessorConfiguration.class, 29 | () -> TrackingEventProcessorConfiguration.forParallelProcessing(1)); 30 | 31 | if (eventHandlerInvoker.supportsReset()) { 32 | return ReplayMarkerAwareTrackingEventProcessor.builder() 33 | .name(name) 34 | .eventHandlerInvoker(eventHandlerInvoker) 35 | .rollbackConfiguration(eventProcessingModule.rollbackConfiguration(name)) 36 | .errorHandler(eventProcessingModule.errorHandler(name)) 37 | .messageMonitor(eventProcessingModule.messageMonitor(TrackingEventProcessor.class, name)) 38 | .messageSource((StreamableMessageSource>) configuration.eventBus()) 39 | .tokenStore(eventProcessingModule.tokenStore(name)) 40 | .transactionManager(eventProcessingModule.transactionManager(name)) 41 | .trackingEventProcessorConfiguration(trackingEventProcessorConfiguration) 42 | .taskExecutor(taskExecutor) 43 | .build(); 44 | } else { 45 | return TrackingEventProcessor.builder() 46 | .name(name) 47 | .eventHandlerInvoker(eventHandlerInvoker) 48 | .rollbackConfiguration(eventProcessingModule.rollbackConfiguration(name)) 49 | .errorHandler(eventProcessingModule.errorHandler(name)) 50 | .messageMonitor(eventProcessingModule.messageMonitor(TrackingEventProcessor.class, name)) 51 | .messageSource((StreamableMessageSource>) configuration.eventBus()) 52 | .tokenStore(eventProcessingModule.tokenStore(name)) 53 | .transactionManager(eventProcessingModule.transactionManager(name)) 54 | .trackingEventProcessorConfiguration(trackingEventProcessorConfiguration) 55 | .build(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/competitions/src/test/java/engineering/everest/lhotse/competitions/services/DefaultCompetitionsServiceTest.java: -------------------------------------------------------------------------------- 1 | package engineering.everest.lhotse.competitions.services; 2 | 3 | import engineering.everest.lhotse.common.RandomFieldsGenerator; 4 | import engineering.everest.lhotse.competitions.domain.commands.CreateCompetitionCommand; 5 | import engineering.everest.lhotse.competitions.domain.commands.EnterPhotoInCompetitionCommand; 6 | import engineering.everest.lhotse.competitions.domain.commands.VoteForPhotoCommand; 7 | import engineering.everest.lhotse.photos.Photo; 8 | import engineering.everest.lhotse.photos.services.PhotosReadService; 9 | import org.axonframework.commandhandling.gateway.CommandGateway; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | import org.junit.jupiter.api.extension.ExtendWith; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | 16 | import java.time.Instant; 17 | import java.util.UUID; 18 | 19 | import static java.util.UUID.randomUUID; 20 | import static org.mockito.Mockito.verify; 21 | import static org.mockito.Mockito.when; 22 | 23 | @ExtendWith(MockitoExtension.class) 24 | class DefaultCompetitionsServiceTest { 25 | 26 | private static final UUID USER_ID = randomUUID(); 27 | private static final UUID COMPETITION_ID = randomUUID(); 28 | private static final UUID PHOTO_ID = randomUUID(); 29 | private static final Instant SUBMISSIONS_OPEN_TIMESTAMP = Instant.ofEpochMilli(123); 30 | private static final Instant SUBMISSIONS_CLOSE_TIMESTAMP = Instant.ofEpochMilli(456); 31 | private static final Instant VOTING_ENDS_TIMESTAMP = Instant.ofEpochMilli(789); 32 | 33 | private DefaultCompetitionsService defaultCompetitionsService; 34 | 35 | @Mock 36 | private CommandGateway commandGateway; 37 | @Mock 38 | private RandomFieldsGenerator randomFieldsGenerator; 39 | @Mock 40 | private PhotosReadService photosReadService; 41 | 42 | @BeforeEach 43 | void setUp() { 44 | defaultCompetitionsService = new DefaultCompetitionsService(commandGateway, randomFieldsGenerator, photosReadService); 45 | } 46 | 47 | @Test 48 | void createCompetition_WillDispatch() { 49 | when(randomFieldsGenerator.genRandomUUID()).thenReturn(COMPETITION_ID); 50 | 51 | defaultCompetitionsService.createCompetition(USER_ID, "description", SUBMISSIONS_OPEN_TIMESTAMP, 52 | SUBMISSIONS_CLOSE_TIMESTAMP, VOTING_ENDS_TIMESTAMP, 2); 53 | 54 | verify(commandGateway).sendAndWait(new CreateCompetitionCommand(USER_ID, COMPETITION_ID, "description", 55 | SUBMISSIONS_OPEN_TIMESTAMP, SUBMISSIONS_CLOSE_TIMESTAMP, VOTING_ENDS_TIMESTAMP, 2)); 56 | } 57 | 58 | @Test 59 | void submitPhoto_WillDispatch() { 60 | var ownerUserId = randomUUID(); 61 | when(photosReadService.getPhoto(PHOTO_ID)) 62 | .thenReturn(new Photo(PHOTO_ID, ownerUserId, randomUUID(), "file name", Instant.ofEpochMilli(10))); 63 | 64 | defaultCompetitionsService.submitPhoto(USER_ID, COMPETITION_ID, PHOTO_ID, "submission notes"); 65 | 66 | verify(commandGateway).sendAndWait(new EnterPhotoInCompetitionCommand(COMPETITION_ID, PHOTO_ID, USER_ID, 67 | ownerUserId, "submission notes")); 68 | } 69 | 70 | @Test 71 | void voteForPhoto_WillDispatch() { 72 | defaultCompetitionsService.voteForPhoto(USER_ID, COMPETITION_ID, PHOTO_ID); 73 | 74 | verify(commandGateway).sendAndWait(new VoteForPhotoCommand(COMPETITION_ID, PHOTO_ID, USER_ID)); 75 | } 76 | } 77 | --------------------------------------------------------------------------------