diff --git a/.gitignore b/.gitignore index d2767ad2804..3464144b977 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ out/ _site/ *.css !petclinic.css + +# macOS +.DS_Store +**/.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..53cad7473ce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1 +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn -q -DskipTests package + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/docker-compose.app.yml b/docker-compose.app.yml new file mode 100644 index 00000000000..17074df6ab7 --- /dev/null +++ b/docker-compose.app.yml @@ -0,0 +1,14 @@ +services: + app: + build: . + depends_on: + db: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: postgres + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/petclinic + SPRING_DATASOURCE_USERNAME: pet + SPRING_DATASOURCE_PASSWORD: pet + SPRING_SQL_INIT_MODE: "never" + ports: + - "8080:8080" diff --git a/docker-compose.yml b/docker-compose.yml index 50c731a9159..9b31cc4e8a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,21 @@ +version: "3.9" + services: - mysql: - image: mysql:9.2 - ports: - - "3306:3306" + db: + image: postgres:16-alpine + container_name: petclinic-db environment: - - MYSQL_ROOT_PASSWORD= - - MYSQL_ALLOW_EMPTY_PASSWORD=true - - MYSQL_USER=petclinic - - MYSQL_PASSWORD=petclinic - - MYSQL_DATABASE=petclinic - volumes: - - "./conf.d:/etc/mysql/conf.d:ro" - postgres: - image: postgres:18.0 + POSTGRES_USER: pet + POSTGRES_PASSWORD: pet + POSTGRES_DB: petclinic ports: - "5432:5432" - environment: - - POSTGRES_PASSWORD=petclinic - - POSTGRES_USER=petclinic - - POSTGRES_DB=petclinic + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pet -d petclinic"] + interval: 5s + retries: 10 + volumes: + - db_data:/var/lib/postgresql/data + +volumes: + db_data: diff --git a/pom.xml b/pom.xml index 004709a9b04..652fb360c6c 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,7 @@ org.postgresql postgresql + 42.7.4 runtime @@ -114,6 +115,13 @@ font-awesome ${webjars-font-awesome.version} + + + org.testcontainers + postgresql + 1.20.2 + test + org.springframework.boot @@ -133,6 +141,7 @@ org.testcontainers junit-jupiter + 1.20.2 test @@ -140,6 +149,23 @@ mysql test + + org.springframework.boot + spring-boot-starter-security + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + + io.micrometer + micrometer-registry-prometheus + jakarta.xml.bind diff --git a/src/main/java/org/springframework/samples/petclinic/SecurityConfig.java b/src/main/java/org/springframework/samples/petclinic/SecurityConfig.java new file mode 100644 index 00000000000..dc74692589f --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/SecurityConfig.java @@ -0,0 +1,37 @@ +package org.springframework.samples.petclinic; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; + +@Configuration +public class SecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // Security headers + .headers(h -> h + .contentSecurityPolicy(csp -> csp + .policyDirectives("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'")) + .frameOptions(f -> f.sameOrigin()) + .referrerPolicy(r -> r.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.SAME_ORIGIN))) + // Everything is public in this app + .authorizeHttpRequests( + a -> a.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**") + .permitAll() + .anyRequest() + .permitAll()) + // Kill default auth mechanisms to avoid 401 challenges + .httpBasic(h -> h.disable()) + .formLogin(f -> f.disable()) + // Keep CSRF defaults (safe for GETs) + .csrf(Customizer.withDefaults()); + + return http.build(); + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java index 9384b318e1f..1fdf65309d0 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java @@ -22,7 +22,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; /** * Repository class for Owner domain objects. All method names are compliant @@ -39,26 +38,21 @@ public interface OwnerRepository extends JpaRepository { /** - * Retrieve {@link Owner}s from the data store by last name, returning all owners - * whose last name starts with the given name. - * @param lastName Value to search for - * @return a Collection of matching {@link Owner}s (or an empty Collection if none - * found) + * Retrieve {@link Owner}s whose last name starts with the given prefix. Example: + * "Hop" matches "Hopper". */ Page findByLastNameStartingWith(String lastName, Pageable pageable); /** - * Retrieve an {@link Owner} from the data store by id. - *

- * This method returns an {@link Optional} containing the {@link Owner} if found. If - * no {@link Owner} is found with the provided id, it will return an empty - * {@link Optional}. - *

- * @param id the id to search for - * @return an {@link Optional} containing the {@link Owner} if found, or an empty - * {@link Optional} if not found. - * @throws IllegalArgumentException if the id is null (assuming null is not a valid - * input for id) + * Retrieve {@link Owner}s whose last name matches exactly the given value. This is + * used by tests (e.g., OwnerRepositoryIT). + */ + List findByLastName(String lastName); + + /** + * Retrieve an {@link Owner} from the data store by id. Returns an empty Optional if + * not found. + * @throws IllegalArgumentException if the id is null */ Optional findById(@Nonnull Integer id); diff --git a/src/main/resources/application-postgres.properties b/src/main/resources/application-postgres.properties deleted file mode 100644 index b265d7e5b41..00000000000 --- a/src/main/resources/application-postgres.properties +++ /dev/null @@ -1,7 +0,0 @@ -# database init, supports postgres too -database=postgres -spring.datasource.url=${POSTGRES_URL:jdbc:postgresql://localhost/petclinic} -spring.datasource.username=${POSTGRES_USER:petclinic} -spring.datasource.password=${POSTGRES_PASS:petclinic} -# SQL is written to be idempotent so this is safe -spring.sql.init.mode=always diff --git a/src/main/resources/application-postgres.yml b/src/main/resources/application-postgres.yml new file mode 100644 index 00000000000..5180f8d8010 --- /dev/null +++ b/src/main/resources/application-postgres.yml @@ -0,0 +1,27 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/petclinic + username: pet + password: pet + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + sql: + init: + mode: never + +server: + port: 8080 + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + show-details: always diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000000..029384b493b --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,11 @@ +management: + endpoints: + web: + exposure: + include: health,info,prometheus + +springdoc: + api-docs: + enabled: true + swagger-ui: + path: /swagger-ui.html diff --git a/src/test/java/org/springframework/samples/petclinic/owner/OwnerRepositoryIT.java b/src/test/java/org/springframework/samples/petclinic/owner/OwnerRepositoryIT.java new file mode 100644 index 00000000000..ee6262521cc --- /dev/null +++ b/src/test/java/org/springframework/samples/petclinic/owner/OwnerRepositoryIT.java @@ -0,0 +1,92 @@ +package org.springframework.samples.petclinic.owner; + +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration test that uses a real PostgreSQL instance via Testcontainers. + */ +@Testcontainers +@DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) +class OwnerRepositoryIT { + + // PostgreSQL container used as the backing database for tests + @Container + static final PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("petclinic") + .withUsername("pet") + .withPassword("pet"); + + // Configure Spring DataSource properties to point to the container + @DynamicPropertySource + static void dbProps(DynamicPropertyRegistry r) { + r.add("spring.datasource.url", postgres::getJdbcUrl); + r.add("spring.datasource.username", postgres::getUsername); + r.add("spring.datasource.password", postgres::getPassword); + r.add("spring.jpa.hibernate.ddl-auto", () -> "update"); // automatically create + // schema for tests + r.add("spring.sql.init.mode", () -> "never"); // skip default SQL initialization + } + + @Autowired + OwnerRepository owners; + + private Owner seeded; + + // Insert a sample Owner before each test + @BeforeEach + void seed() { + Owner o = new Owner(); + o.setFirstName("Grace"); + o.setLastName("Hopper"); + o.setAddress("123 Main St"); + o.setCity("NYC"); + o.setTelephone("1234567890"); + seeded = owners.save(o); + } + + // Remove all Owners after each test to ensure isolation + @AfterEach + void cleanup() { + owners.deleteAll(); + } + + // Verify that searching by last name returns the expected Owner with all fields + @Test + void findsOwnerByLastName_andChecksAllFields() { + List result = owners.findByLastName("Hopper"); + + Assertions.assertThat(result).hasSize(1); + + Owner found = result.get(0); + Assertions.assertThat(found.getId()).isNotNull(); + Assertions.assertThat(found.getFirstName()).isEqualTo("Grace"); + Assertions.assertThat(found.getLastName()).isEqualTo("Hopper"); + Assertions.assertThat(found.getAddress()).isEqualTo("123 Main St"); + Assertions.assertThat(found.getCity()).isEqualTo("NYC"); + Assertions.assertThat(found.getTelephone()).isEqualTo("1234567890"); + Assertions.assertThat(found.getPets()).isEmpty(); + } + + // Verify that searching for an unknown last name returns no results + @Test + void returnsEmptyListForUnknownLastName() { + List result = owners.findByLastName("DoesNotExist"); + Assertions.assertThat(result).isEmpty(); + } + +}