test: one test container and one context per test kind
Build, Test & Push Auth Service / quality-check (push) Successful in 43s
Build, Test & Push Auth Service / docker (push) Successful in 1m29s

This commit is contained in:
lobo committed 2026-09-30 13:41:57 +02:00
1 parent 1f94c87a20
commit 3336181cdb
20 files changed
+65 -130

No files matched your search

+3 -1
View File
@@ -153,7 +153,9 @@ use each other.
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in
`auth_service.shared.test`; all integration tests share one PostgreSQL container.
`auth_service.shared.test`. Integration and persistence tests share one PostgreSQL container (`TestPostgres`), and
the base classes carry every annotation, so each kind starts one Spring context. A test with its own annotations
starts another one.
### Login flow
@@ -1,13 +0,0 @@
package auth_service;
import auth_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.Test;
class AuthServiceApplicationTests extends AbstractIntegrationTest {
@Test
void contextLoads() {
// If the context fails to load (e.g. Flyway migration fails or beans are missing),
// this test will fail automatically. No explicit assertions needed.
}
}
@@ -1,66 +0,0 @@
package auth_service.configuration.security;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Every access token from this context is already expired when it is issued.
*/
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.auth.access-token-ttl=-1s")
class ExpiredAccessTokenTest extends AbstractIntegrationTest {
private static final String EMAIL = "review-findings@example.com";
private static final String PASSWORD = "correct horse battery";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
void seedUser() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)",
EMAIL, passwordEncoder.encode(PASSWORD));
addSettingsRows();
}
private String expiredAccessCookie() throws Exception {
Cookie cookie = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
assertNotNull(cookie);
return cookie.getValue();
}
@Test
void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception {
String expired = expiredAccessCookie();
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, expired)))
.andExpect(status().isUnauthorized());
}
}
@@ -3,7 +3,6 @@ package auth_service.configuration.security;
import auth_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Set;
@@ -14,7 +13,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class PublicEndpointsTest extends AbstractIntegrationTest {
@Autowired
@@ -10,7 +10,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -27,7 +26,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/**
* The role markers through the real filter chain, on a probe that only this test sees.
*/
@AutoConfigureMockMvc
@Import({RoleCheckFlowTest.ProbeService.class, RoleCheckFlowTest.ProbeController.class})
class RoleCheckFlowTest extends AbstractIntegrationTest {
@@ -8,7 +8,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -38,7 +37,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The whole cookie session against a real database: login, refresh rotation, reuse detection, logout, the CSRF
* header and CORS.
*/
@AutoConfigureMockMvc
class SessionFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "session-flow@example.com";
@@ -98,6 +96,14 @@ class SessionFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.email").value(EMAIL));
}
@Test
void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception {
String expired = expiredCopy(cookie(login(), AuthCookies.ACCESS_COOKIE));
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, expired)))
.andExpect(status().isUnauthorized());
}
@Test
void noLogin_Is401() throws Exception {
mockMvc.perform(get("/api/users/me"))
@@ -7,7 +7,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -35,7 +34,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/**
* "Sign in through Steam" through the whole filter chain and a real database; only Steam itself is mocked.
*/
@AutoConfigureMockMvc
class SteamLinkFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "steam-link@example.com";
@@ -10,7 +10,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -30,7 +29,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* Delete my account against a real database. Core is not reachable in tests, so this also proves that a failed
* mark in Core does not fail the delete.
*/
@AutoConfigureMockMvc
class DeletedAccountFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "delete-flow@example.com";
@@ -4,7 +4,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
@@ -15,7 +14,6 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountRepository.class)
class AccountRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS);
@@ -45,6 +43,8 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
@BeforeEach
void seed() {
// Integration tests share the database and commit their rows.
jdbc.update("DELETE FROM users");
alice = insertUser("alice@example.com");
bob = insertUser("bob@example.com");
}
@@ -7,7 +7,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -26,7 +25,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/**
* Roles, "view as" and the admin users API against a real database.
*/
@AutoConfigureMockMvc
class AdminFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
@@ -6,14 +6,12 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AdminUserRepository.class)
class AdminUserRepositoryTest extends AbstractPersistenceTest {
@Autowired
@@ -7,7 +7,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -24,7 +23,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class HintFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
@@ -9,7 +9,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Duration;
@@ -19,7 +18,6 @@ import java.time.temporal.ChronoUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@Import(HintRepository.class)
class HintRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
@@ -5,7 +5,6 @@ import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
@@ -15,7 +14,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Import(MinimalUserRepositoryImpl.class)
class MinimalUserRepositoryTest extends AbstractPersistenceTest {
@Autowired
@@ -9,7 +9,6 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
@@ -25,7 +24,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired
@@ -4,7 +4,6 @@ import auth_service.modules.registration.model.Registration;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -15,7 +14,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Import(RegisteredUserRepositoryImpl.class)
class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
@Autowired
@@ -38,9 +36,9 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
@Test
void existsByDisplayName_IgnoresCase() {
registeredUserRepository.save(registration("named@example.com", "Lobo"));
registeredUserRepository.save(registration("named@example.com", "RegisteredName"));
assertTrue(registeredUserRepository.existsByDisplayName("lobo"));
assertTrue(registeredUserRepository.existsByDisplayName("registeredname"));
assertFalse(registeredUserRepository.existsByDisplayName("Wolf"));
}
@@ -12,7 +12,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
@@ -21,7 +20,6 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@Import(UserRepositoryImpl.class)
class UserRepositoryTest extends AbstractPersistenceTest {
@Autowired
@@ -224,13 +222,13 @@ class UserRepositoryTest extends AbstractPersistenceTest {
var settings = UserSettings.builder().language("en").theme("light").build();
// Act
updateSettings(owner.getId(), "Lobo", settings);
updateSettings(owner.getId(), "RepoName", settings);
entityManager.flush();
// Assert
assertEquals("Lobo", findByEmail("owner2@example.com").orElseThrow().getDisplayName());
assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "lobo"));
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "LOBO"));
assertEquals("RepoName", findByEmail("owner2@example.com").orElseThrow().getDisplayName());
assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "reponame"));
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "REPONAME"));
assertFalse(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "Wolf"));
}
@@ -243,7 +241,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.registrationDate(LocalDateTime.now())
.build());
updateSettings(userEntity.getId(), "Lobo",
updateSettings(userEntity.getId(), "RepoName",
UserSettings.builder().language("de").theme("dark").publicProfile(true).build());
entityManager.flush();
entityManager.clear();
@@ -1,8 +1,12 @@
package auth_service.shared.test;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.DynamicPropertyRegistry;
@@ -13,25 +17,21 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Date;
/**
* Base class for integration tests.
* Full Spring context with Testcontainers.
*/
@Tag("IntegrationTest")
@AutoConfigureMockMvc
// The account cleanup must not run by itself, and Core is never reachable in tests.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"})
public abstract class AbstractIntegrationTest {
// One container per JVM, not per class: Spring caches the context across test classes,
// and a per-class container would be stopped under the cached context.
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
static {
postgres.start();
}
static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;
@Autowired
private JdbcTemplate baseJdbc;
@@ -54,6 +54,19 @@ public abstract class AbstractIntegrationTest {
registry.add("auth.registration.invitation-code", () -> "test-invite-code");
}
/**
* The same token, signed again with an expiry in the past.
*/
protected static String expiredCopy(String token) {
Jws<Claims> jws = Jwts.parser().verifyWith(KEYS.getPublic()).build().parseSignedClaims(token);
return Jwts.builder()
.header().keyId(jws.getHeader().getKeyId()).and()
.claims(jws.getPayload())
.expiration(new Date(System.currentTimeMillis() - 1000))
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private static KeyPair generateKeys() {
try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
@@ -4,6 +4,9 @@ import org.junit.jupiter.api.Tag;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.stereotype.Repository;
import org.testcontainers.containers.PostgreSQLContainer;
/**
@@ -11,16 +14,11 @@ import org.testcontainers.containers.PostgreSQLContainer;
* Slices only the persistence layer with Testcontainers.
*/
@Tag("PersistenceTest")
@DataJpaTest
// Every @Repository, so all persistence tests share one Spring context.
@DataJpaTest(includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Repository.class))
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class AbstractPersistenceTest {
// One container per JVM, not per class: Spring caches the context across test classes,
// and a per-class container would be stopped under the cached context.
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
static {
postgres.start();
}
static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;
}
@@ -0,0 +1,19 @@
package auth_service.shared.test;
import org.testcontainers.containers.PostgreSQLContainer;
/**
* One database for all integration and persistence tests of a JVM. Integration tests commit their rows, so every
* test uses emails and display names no other test uses.
*/
final class TestPostgres {
static final PostgreSQLContainer<?> CONTAINER = new PostgreSQLContainer<>("postgres:17-alpine");
static {
CONTAINER.start();
}
private TestPostgres() {
}
}