Compare commits

..
7 Commits
Author SHA1 Message Date
lobo 3336181cdb test: one test container and one context per test kind
Build, Test & Push Auth Service / docker (push) Successful in 1m29s
Build, Test & Push Auth Service / quality-check (push) Successful in 43s
2026-09-30 13:41:57 +02:00
lobo 1f94c87a20 fix(security): refuse the access token of a blocked or deleted user
activeUsers cache, 30 s. Core still accepts the token until it expires; X4 (batch 4) tells Core.
2026-09-30 13:41:57 +02:00
lobo 509280da14 fix(cache): one cache config that expires after the write
publicStats is evicted on registration and Steam link/unlink. The cached user no longer holds the password hash. Tests no longer run with a 1 s TTL.
2026-09-30 13:41:56 +02:00
lobo cf21f4a654 refactor(settings): one load-change-save update for all settings 2026-09-30 13:41:56 +02:00
lobo fb51f836a8 fix(settings): give every user a settings row
V20 adds the row for old users, registration inserts one. The mapper fallback (en, system) disagreed with the column defaults.
2026-09-30 13:41:56 +02:00
lobo d082f3befd refactor(users): login and registration use JdbcTemplate for users 2026-09-30 13:41:56 +02:00
lobo b6be1879f0 test(architecture): apply the layer rules to repo packages 2026-09-30 13:41:56 +02:00
47 changed files with 566 additions and 732 deletions

No files matched your search

+19 -7
View File
@@ -63,17 +63,17 @@ To Be Played is split into three repositories:
guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a guard. ArchUnit fails the build for an unmarked method, two markers, a controller calling an `@Async` method, and a
service method calling another service's method that needs more. service method calling another service's method that needs more.
- **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock - **Admin users page** (`/api/admin/**`, active role PLATFORM_ADMIN): search and page all users, block and unblock
(`users.enabled`; blocking ends every login, the login then answers `403 ACCOUNT_BLOCKED`, but only after the (`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth; Core accepts the token
right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account until it expires, max 15 min; the login then answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account
or on another admin. Every action and every "view as" is a row in `admin_audit`. or on another admin. Every action and every "view as" is a row in `admin_audit`.
- **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start - **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start
and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first.
"Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never "Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never
comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`. comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`.
- **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets - **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no public profile, no Steam `users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
new Steam link. A nightly job (04:15, Europe/Berlin) removes undo needs a new Steam link. A nightly job (04:15, Europe/Berlin) removes
users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a
**service token** (EdDSA JWT, 60 s, `aud=tbp-internal`). `app.account-cleanup.dry-run` (default `true`) only logs. **service token** (EdDSA JWT, 60 s, `aud=tbp-internal`). `app.account-cleanup.dry-run` (default `true`) only logs.
@@ -129,8 +129,17 @@ layers:
|---|---|---| |---|---|---|
| API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. | | API | `api` | REST controllers, request/response records, `ApiMapper`. Calls services, never repositories. |
| Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. | | Domain | `model`, `service`, `exception` | Plain Java models and business logic. Uses repositories, never API classes, entities or `EntityMapper`. |
| Persistence | `persistence` | JPA entities, Spring Data repositories, `EntityMapper`. Entities never reach the API layer. | | Persistence | `persistence`, `repo` | JPA entities, Spring Data repositories, `EntityMapper`; `repo` holds `JdbcTemplate` queries. Entities never reach the API layer. |
`user` owns the tables `users` and `user_settings` through the one JPA entity `UserEntity`. `login` and
`registration` read and write `users` with `JdbcTemplate` (refresh tokens stay JPA). Every query for "a user who
may log in and is shown to others" uses `shared/account/ActiveUser`.
A settings change is one `UserRepository.update(userId, change)`: load the user, change the model, write back the
display name and the settings. A new setting needs no new repository method.
Three Caffeine caches in `CacheConfig`: `users` (by userId) and `publicStats` expire 10 minutes after the write,
`activeUsers` (the filter's "blocked or deleted?" check per request) after 30 seconds. Every write evicts what it
changes. A request that read the database just before a block or delete can put the old answer back, so the short
TTL is the real limit: a blocked or deleted user keeps a valid access token for up to 30 s.
`profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package. `profile` and `stats` only read data. They have no entities and use `JdbcTemplate` queries in a `repo` package.
`version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image. `version` has only an `api` package: `GET /api/version` answers the commit and build time of the running image.
`account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core `account` (delete and nightly cleanup) sets the flag with `JdbcTemplate` in a `repo` package too, and calls Core
@@ -144,7 +153,9 @@ use each other.
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer 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 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 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 ### Login flow
@@ -207,6 +218,7 @@ sequenceDiagram
A->>A: users.deleted_at = now, revoke refresh tokens A->>A: users.deleted_at = now, revoke refresh tokens
A->>C: POST /internal/accounts/deleted (service token) A->>C: POST /internal/accounts/deleted (service token)
A-->>B: 204, both cookies cleared A-->>B: 204, both cookies cleared
Note over A: an old access token gets 401 within 30 s (activeUsers cache)
Note over A,C: nightly 04:15 Note over A,C: nightly 04:15
A->>C: PUT /internal/accounts/deleted (all flagged users) A->>C: PUT /internal/accounts/deleted (all flagged users)
A->>C: POST /internal/accounts/purge (flagged 30+ days) A->>C: POST /internal/accounts/purge (flagged 30+ days)
@@ -1,37 +1,35 @@
package auth_service.configuration; package auth_service.configuration;
import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.util.concurrent.TimeUnit; import java.time.Duration;
/**
* Global configuration to enable Spring Boot caching (backed by Caffeine).
*/
@Configuration @Configuration
@EnableCaching @EnableCaching
@Profile("!dev")
@Slf4j
public class CacheConfig { public class CacheConfig {
// After write, not after access: an entry another path changed without an evict still ages out.
static final Duration DEFAULT_TTL = Duration.ofMinutes(10);
// The real limit after a block or delete: a concurrent read can put the old answer back after the evict.
static final Duration ACTIVE_USERS_TTL = Duration.ofSeconds(30);
@Bean @Bean
public CacheManager cacheManager() { public CacheManager cacheManager() {
log.info("########### Productive cache active ###########");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "publicStats"); CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "publicStats");
cacheManager.setCaffeine(caffeineCacheBuilder()); cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(DEFAULT_TTL)
.recordStats());
cacheManager.registerCustomCache("activeUsers", Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(ACTIVE_USERS_TTL)
.recordStats()
.build());
return cacheManager; return cacheManager;
} }
Caffeine<Object, Object> caffeineCacheBuilder() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(500)
.expireAfterAccess(10, TimeUnit.MINUTES)
.recordStats();
}
} }
@@ -1,37 +0,0 @@
package auth_service.configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import java.util.concurrent.TimeUnit;
/**
* Global configuration to enable Spring Boot caching (backed by Caffeine).
*/
@Configuration
@EnableCaching
@Profile("dev")
@Slf4j
public class DevCacheConfig {
@Bean
public CacheManager cacheManager() {
log.error("########### DEVELOPMENT CACHE ACTIVE ###########");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "publicStats");
cacheManager.setCaffeine(caffeineCacheBuilder());
return cacheManager;
}
Caffeine<Object, Object> caffeineCacheBuilder() {
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(500)
.expireAfterAccess(1, TimeUnit.SECONDS)
.recordStats();
}
}
@@ -1,6 +1,7 @@
package auth_service.configuration.security; package auth_service.configuration.security;
import auth_service.shared.api.AuthCookies; import auth_service.shared.api.AuthCookies;
import auth_service.shared.security.ActiveUserCheck;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
@@ -24,7 +25,8 @@ import java.util.stream.Stream;
/** /**
* Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that * Takes the login token from the access cookie or from a Bearer header (API clients and tests), the first that
* verifies. A stale cookie next to a valid header must not block the request. * verifies. A stale cookie next to a valid header must not block the request. A blocked or deleted user counts as
* not logged in.
*/ */
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -33,6 +35,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER = "Bearer "; private static final String BEARER = "Bearer ";
private final JwtService jwtService; private final JwtService jwtService;
private final ActiveUserCheck activeUserCheck;
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
@@ -45,6 +48,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
.flatMap(Optional::stream) .flatMap(Optional::stream)
.map(jwtService::parseLoginToken) .map(jwtService::parseLoginToken)
.flatMap(Optional::stream) .flatMap(Optional::stream)
.filter(each -> activeUserCheck.isActive(each.getIdAsLong()))
.findFirst(); .findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) { if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
@@ -9,6 +9,7 @@ import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -41,7 +42,8 @@ public class AccountService implements AccountDeleter {
* Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core * Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core
* hides the user at once; when Core is down, the nightly cleanup tells it. * hides the user at once; when Core is down, the nightly cleanup tells it.
*/ */
@CacheEvict(value = {"users", "publicStats"}, allEntries = true) @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public void deleteAccount(long userId, String password) { public void deleteAccount(long userId, String password) {
String key = String.valueOf(userId); String key = String.valueOf(userId);
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) { if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
@@ -62,7 +64,8 @@ public class AccountService implements AccountDeleter {
@Override @Override
@RequiresRole(Role.PLATFORM_ADMIN) @RequiresRole(Role.PLATFORM_ADMIN)
@CacheEvict(value = {"users", "publicStats"}, allEntries = true) @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#userId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public boolean deleteByAdmin(long userId) { public boolean deleteByAdmin(long userId) {
return flagAndTellCore(userId); return flagAndTellCore(userId);
} }
@@ -12,6 +12,7 @@ import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -39,7 +40,8 @@ public class AdminUserService {
} }
@Transactional @Transactional
@CacheEvict(value = {"users", "publicStats"}, allEntries = true) @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) { public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
TargetState target = checkedTarget(actorUserId, targetUserId); TargetState target = checkedTarget(actorUserId, targetUserId);
if (target.deleted()) { if (target.deleted()) {
@@ -1,13 +1,11 @@
package auth_service.modules.login.persistence; package auth_service.modules.login.persistence;
import auth_service.shared.account.EmailAddress;
import auth_service.modules.login.model.User; import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.entities.MinimalUserEntity; import auth_service.shared.account.EmailAddress;
import auth_service.modules.login.persistence.mapper.EntityMapper; import auth_service.shared.security.Role;
import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor;
import jakarta.persistence.PersistenceContext; import org.springframework.jdbc.core.JdbcTemplate;
import jakarta.persistence.TypedQuery; import org.springframework.jdbc.core.RowMapper;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.Clock; import java.time.Clock;
@@ -15,39 +13,36 @@ import java.time.LocalDateTime;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@Transactional @RequiredArgsConstructor
public class MinimalUserRepositoryImpl implements MinimalUserRepository { public class MinimalUserRepositoryImpl implements MinimalUserRepository {
@PersistenceContext
private EntityManager entityManager; private static final String SELECT_USER =
"SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL AS deleted, role FROM users ";
private static final RowMapper<User> USER_ROW = (rs, row) -> User.builder()
.id(rs.getLong("id"))
.email(rs.getString("email"))
.password(rs.getString("password"))
.steamId(rs.getObject("steam_id", Long.class))
.enabled(rs.getBoolean("enabled"))
.deleted(rs.getBoolean("deleted"))
.role(Role.parse(rs.getString("role")))
.build();
private final JdbcTemplate jdbc;
@Override @Override
public Optional<User> findByEmail(String email) { public Optional<User> findByEmail(String email) {
var criteriaBuilder = entityManager.getCriteriaBuilder(); return jdbc.query(SELECT_USER + "WHERE email = ?", USER_ROW, EmailAddress.normalize(email)).stream().findFirst();
var userEntityCriteriaQuery = criteriaBuilder.createQuery(MinimalUserEntity.class);
var userEntity = userEntityCriteriaQuery.from(MinimalUserEntity.class);
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(
userEntity.get("email"), EmailAddress.normalize(email)));
TypedQuery<MinimalUserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
return typedQuery.getResultStream()
.findFirst()
.map(EntityMapper::mapToModel);
} }
@Override @Override
public Optional<User> findById(long userId) { public Optional<User> findById(long userId) {
return Optional.ofNullable(entityManager.find(MinimalUserEntity.class, userId)).map(EntityMapper::mapToModel); return jdbc.query(SELECT_USER + "WHERE id = ?", USER_ROW, userId).stream().findFirst();
} }
@Override @Override
public void updateLoginDate(long userId) { public void updateLoginDate(long userId) {
var userEntity = entityManager.find(MinimalUserEntity.class, userId); jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId);
if (userEntity != null) {
userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC()));
entityManager.flush();
}
} }
} }
@@ -1,46 +0,0 @@
package auth_service.modules.login.persistence.entities;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.time.LocalDateTime;
/**
* Represents a user entity mapped to the 'users' table in PostgreSQL.
*/
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MinimalUserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled;
@Column(name = "last_login_date")
private LocalDateTime lastLoginDate;
@Column(name = "steam_id")
private Long steamId;
@Column(name = "deleted_at", insertable = false, updatable = false)
private Instant deletedAt;
// Set by SQL only (the first admin).
@Column(name = "role", insertable = false, updatable = false)
private String role;
}
@@ -1,8 +1,6 @@
package auth_service.modules.login.persistence.mapper; package auth_service.modules.login.persistence.mapper;
import auth_service.modules.login.model.RefreshToken; import auth_service.modules.login.model.RefreshToken;
import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
import auth_service.modules.login.persistence.entities.RefreshTokenEntity; import auth_service.modules.login.persistence.entities.RefreshTokenEntity;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
@@ -11,22 +9,6 @@ public abstract class EntityMapper {
private EntityMapper() { private EntityMapper() {
} }
public static User mapToModel(MinimalUserEntity entity) {
User model = User.builder().build();
if (entity != null) {
model.setId(entity.getId());
model.setEmail(entity.getEmail());
model.setPassword(entity.getPassword());
model.setSteamId(entity.getSteamId());
model.setEnabled(entity.isEnabled());
model.setDeleted(entity.getDeletedAt() != null);
model.setRole(Role.parse(entity.getRole()));
}
return model;
}
public static RefreshToken mapToModel(RefreshTokenEntity entity) { public static RefreshToken mapToModel(RefreshTokenEntity entity) {
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(), return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
entity.getRotatedAt(), entity.getRevokedAt(), entity.getRotatedAt(), entity.getRevokedAt(),
@@ -0,0 +1,26 @@
package auth_service.modules.login.service;
import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository;
import auth_service.shared.security.ActiveUserCheck;
import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* Runs on every request with a token, so the answer is cached.
*/
@Service
@Public
@RequiredArgsConstructor
public class ActiveUserService implements ActiveUserCheck {
private final MinimalUserRepository minimalUserRepository;
@Override
@Cacheable(value = "activeUsers", key = "#userId")
public boolean isActive(long userId) {
return minimalUserRepository.findById(userId).filter(User::canLogin).isPresent();
}
}
@@ -1,6 +1,7 @@
package auth_service.modules.profile.repo; package auth_service.modules.profile.repo;
import auth_service.modules.profile.model.PublicProfile; import auth_service.modules.profile.model.PublicProfile;
import auth_service.shared.account.ActiveUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -32,8 +33,8 @@ public class PublicProfileRepository {
SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners, SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
FROM users u JOIN user_settings s ON s.user_id = u.id FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
""", """.formatted(ActiveUser.of("u")),
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"), (rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null rs.getTimestamp("registration_date") != null
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null, ? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
@@ -1,44 +1,45 @@
package auth_service.modules.registration.persistence; package auth_service.modules.registration.persistence;
import auth_service.shared.account.EmailAddress;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.mapper.EntityMapper; import auth_service.shared.account.EmailAddress;
import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor;
import jakarta.persistence.PersistenceContext; import org.springframework.jdbc.core.JdbcTemplate;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
@Repository @Repository
@Transactional @RequiredArgsConstructor
public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
@PersistenceContext private final JdbcTemplate jdbc;
private EntityManager entityManager;
@Override @Override
public boolean existsByEmail(String email) { public boolean existsByEmail(String email) {
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.email = :email"; return Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS (SELECT 1 FROM users WHERE email = ?)",
var count = entityManager.createQuery(jpql, Long.class) Boolean.class, EmailAddress.normalize(email)));
.setParameter("email", EmailAddress.normalize(email))
.getSingleResult();
return count != null && count > 0;
} }
@Override @Override
public boolean existsByDisplayName(String displayName) { public boolean existsByDisplayName(String displayName) {
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)"; return Boolean.TRUE.equals(jdbc.queryForObject(
var count = entityManager.createQuery(jpql, Long.class) "SELECT EXISTS (SELECT 1 FROM users WHERE LOWER(display_name) = LOWER(?))", Boolean.class, displayName));
.setParameter("displayName", displayName)
.getSingleResult();
return count != null && count > 0;
} }
@Override @Override
@Transactional
public long save(Registration registration) { public long save(Registration registration) {
var userToRegisterEntity = EntityMapper.mapToPersistence(registration); Long id = jdbc.queryForObject("""
entityManager.persist(userToRegisterEntity); INSERT INTO users (email, password, enabled, display_name, registration_date)
return userToRegisterEntity.getId(); VALUES (?, ?, TRUE, ?, ?) RETURNING id""", Long.class,
registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(),
LocalDateTime.now(Clock.systemUTC()));
if (id == null) {
throw new IllegalStateException("No id for the new user");
}
jdbc.update("INSERT INTO user_settings (user_id) VALUES (?)", id);
return id;
} }
} }
@@ -1,38 +0,0 @@
package auth_service.modules.registration.persistence.entities;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public class RegisteredUserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private boolean enabled;
@Column(name = "display_name", length = 24)
private String displayName;
@Column(name = "registration_date", nullable = false)
private LocalDateTime registrationDate;
@Column(name = "last_login_date")
private LocalDateTime lastLoginDate;
}
@@ -1,24 +0,0 @@
package auth_service.modules.registration.persistence.mapper;
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.entities.RegisteredUserEntity;
import java.time.Clock;
import java.time.LocalDateTime;
public abstract class EntityMapper {
private EntityMapper() {
}
public static RegisteredUserEntity mapToPersistence(Registration userModel) {
return RegisteredUserEntity.builder().
email(userModel.getEmail()).
registrationDate(LocalDateTime.now(Clock.systemUTC())).
password(userModel.getEncryptedPassword()).
enabled(true).
displayName(userModel.getDisplayName()).
lastLoginDate(null).
build();
}
}
@@ -7,6 +7,7 @@ import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,6 +21,7 @@ public class RegistrationService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RegistrationAttemptService registrationAttemptService; private final RegistrationAttemptService registrationAttemptService;
@CacheEvict(value = "publicStats", allEntries = true)
public long register(Registration registration, String clientIp) { public long register(Registration registration, String clientIp) {
registrationAttemptService.acquire(clientIp); registrationAttemptService.acquire(clientIp);
validate(registration); validate(registration);
@@ -1,5 +1,6 @@
package auth_service.modules.stats.repo; package auth_service.modules.stats.repo;
import auth_service.shared.account.ActiveUser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -15,13 +16,13 @@ public class StatsRepository {
} }
public long countTotalUsers() { public long countTotalUsers() {
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL", Long.class); Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countUsersWithSteamLinked() { public long countUsersWithSteamLinked() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM users WHERE enabled = TRUE AND deleted_at IS NULL AND steam_id IS NOT NULL", Long.class); "SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
} }
@@ -11,7 +11,6 @@ import java.time.LocalDateTime;
public class User { public class User {
private long id; private long id;
private String email; private String email;
private String encryptedPassword;
private LocalDateTime lastLogin; private LocalDateTime lastLogin;
private LocalDateTime createdDate; private LocalDateTime createdDate;
private Long steamId; private Long steamId;
@@ -1,17 +1,11 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer;
/** /**
* Repository for database operations regarding the User entity. * Repository for database operations regarding the User entity.
@@ -22,22 +16,11 @@ public interface UserRepository {
*/ */
Optional<User> findById(long userId); Optional<User> findById(long userId);
@NotNull /**
UserSettings findSettingsByUserId(String userId); * Stores only the display name and the settings of the changed user.
*/
@Transactional @Transactional
void updateSettings(long userId, String displayName, UserSettings settings); void update(long userId, Consumer<User> change);
@Transactional
void updateAppearance(long userId, Appearance appearance);
void updateDashboardTiles(long userId, List<Tile> tiles);
void updateProfileTiles(long userId, List<Tile> tiles);
void updateFavoritePlatform(long userId, String platform);
void updateProfileStyle(long userId, ProfileStyle style);
Optional<SteamIdHolder> findSteamIdHolder(long steamId); Optional<SteamIdHolder> findSteamIdHolder(long steamId);
@@ -1,25 +1,18 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
import auth_service.modules.user.persistence.entities.UserSettingsEntity;
import auth_service.modules.user.persistence.mapper.EntityMapper; import auth_service.modules.user.persistence.mapper.EntityMapper;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery; import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.function.Consumer;
@Repository @Repository
public class UserRepositoryImpl implements UserRepository { public class UserRepositoryImpl implements UserRepository {
@@ -33,7 +26,6 @@ public class UserRepositoryImpl implements UserRepository {
var userEntity = userEntityCriteriaQuery.from(UserEntity.class); var userEntity = userEntityCriteriaQuery.from(UserEntity.class);
// Fetch the settings eagerly using a LEFT JOIN to prevent N+1 queries. // Fetch the settings eagerly using a LEFT JOIN to prevent N+1 queries.
// A LEFT JOIN ensures the user is returned even if no settings exist yet.
userEntity.fetch("settings", jakarta.persistence.criteria.JoinType.LEFT); userEntity.fetch("settings", jakarta.persistence.criteria.JoinType.LEFT);
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("id"), userId)); userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("id"), userId));
@@ -45,103 +37,16 @@ public class UserRepositoryImpl implements UserRepository {
.map(EntityMapper::mapToModel); .map(EntityMapper::mapToModel);
} }
@Override
public @NotNull UserSettings findSettingsByUserId(String userId) {
UserSettingsEntity entity = entityManager.find(UserSettingsEntity.class, UUID.fromString(userId));
if (entity == null) {
// Return defaults if none exist yet
return UserSettings.builder()
.language("")
.theme("light")
.build();
}
return UserSettings.builder()
.language(entity.getLanguage())
.theme(entity.getTheme())
.build();
}
@Transactional @Transactional
@Override @Override
public void updateSettings(long userId, String displayName, UserSettings settings) { public void update(long userId, Consumer<User> change) {
var userEntity = entityManager.find(UserEntity.class, userId);
if (userEntity == null) {
throw new UserNotFoundException("Could not find user with id " + userId);
}
var settingsEntity = userEntity.getSettings();
if (settingsEntity == null) {
settingsEntity = new UserSettingsEntity();
settingsEntity.setUserId(userId);
userEntity.setSettings(settingsEntity);
}
settingsEntity.setLanguage(settings.getLanguage());
settingsEntity.setTheme(settings.getTheme());
settingsEntity.setPublicProfile(settings.isPublicProfile());
userEntity.setDisplayName(displayName);
}
@Transactional
@Override
public void updateAppearance(long userId, Appearance appearance) {
UserSettingsEntity settingsEntity = settingsOf(userId);
settingsEntity.setAccent(appearance.accent());
settingsEntity.setCorners(appearance.corners());
settingsEntity.setWideLayout(appearance.wideLayout());
settingsEntity.setBackgroundAppid(appearance.backgroundAppid());
settingsEntity.setBannerAppid(appearance.bannerAppid());
settingsEntity.setShowBanner(appearance.showBanner());
}
@Transactional
@Override
public void updateDashboardTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setDashboardTiles(tiles == null ? null : Tile.storedAll(tiles));
}
@Transactional
@Override
public void updateProfileTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setProfileTiles(Tile.storedAll(tiles));
}
@Transactional
@Override
public void updateFavoritePlatform(long userId, String platform) {
settingsOf(userId).setFavoritePlatform(platform);
}
@Transactional
@Override
public void updateProfileStyle(long userId, ProfileStyle style) {
UserSettingsEntity settingsEntity = settingsOf(userId);
settingsEntity.setProfileAccent(style.accent());
settingsEntity.setProfileCorners(style.corners());
settingsEntity.setProfileBannerAppid(style.bannerAppid());
settingsEntity.setProfileBackgroundAppid(style.backgroundAppid());
}
/**
* The settings row of the user, created with the defaults of the user_settings columns when there is none.
*/
private UserSettingsEntity settingsOf(long userId) {
UserEntity userEntity = entityManager.find(UserEntity.class, userId); UserEntity userEntity = entityManager.find(UserEntity.class, userId);
if (userEntity == null) { if (userEntity == null) {
throw new UserNotFoundException("Could not find user with id " + userId); throw new UserNotFoundException("Could not find user with id " + userId);
} }
UserSettingsEntity settingsEntity = userEntity.getSettings(); User user = EntityMapper.mapToModel(userEntity);
if (settingsEntity == null) { change.accept(user);
settingsEntity = new UserSettingsEntity(); EntityMapper.apply(user, userEntity);
settingsEntity.setUserId(userId);
settingsEntity.setLanguage("");
settingsEntity.setTheme("light");
userEntity.setSettings(settingsEntity);
}
return settingsEntity;
} }
@Override @Override
@@ -1,9 +1,5 @@
package auth_service.modules.user.persistence.entities; package auth_service.modules.user.persistence.entities;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.UserSettings;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
@@ -35,12 +31,11 @@ public class UserSettingsEntity {
@Column(name = "public_profile", nullable = false) @Column(name = "public_profile", nullable = false)
private boolean publicProfile; private boolean publicProfile;
// Java defaults as well: a row created here, not by the migration, must not insert null.
@Column(nullable = false) @Column(nullable = false)
private String accent = Appearance.DEFAULT.accent(); private String accent;
@Column(nullable = false) @Column(nullable = false)
private String corners = Appearance.DEFAULT.corners(); private String corners;
@Column(name = "wide_layout", nullable = false) @Column(name = "wide_layout", nullable = false)
private boolean wideLayout; private boolean wideLayout;
@@ -52,13 +47,13 @@ public class UserSettingsEntity {
private Long bannerAppid; private Long bannerAppid;
@Column(name = "show_banner", nullable = false) @Column(name = "show_banner", nullable = false)
private boolean showBanner = Appearance.DEFAULT.showBanner(); private boolean showBanner;
@Column(name = "profile_accent", nullable = false) @Column(name = "profile_accent", nullable = false)
private String profileAccent = ProfileStyle.DEFAULT.accent(); private String profileAccent;
@Column(name = "profile_corners", nullable = false) @Column(name = "profile_corners", nullable = false)
private String profileCorners = ProfileStyle.DEFAULT.corners(); private String profileCorners;
@Column(name = "profile_banner_appid") @Column(name = "profile_banner_appid")
private Long profileBannerAppid; private Long profileBannerAppid;
@@ -76,5 +71,5 @@ public class UserSettingsEntity {
@JdbcTypeCode(SqlTypes.ARRAY) @JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_tiles", nullable = false) @Column(name = "profile_tiles", nullable = false)
private List<String> profileTiles = Tile.storedAll(UserSettings.DEFAULT_PROFILE_TILES); private List<String> profileTiles;
} }
@@ -9,8 +9,6 @@ import auth_service.modules.user.persistence.entities.UserEntity;
import auth_service.modules.user.persistence.entities.UserSettingsEntity; import auth_service.modules.user.persistence.entities.UserSettingsEntity;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import java.util.List;
public abstract class EntityMapper { public abstract class EntityMapper {
private EntityMapper() { private EntityMapper() {
@@ -21,38 +19,27 @@ public abstract class EntityMapper {
return null; return null;
} }
// 1. Map the settings if they exist, otherwise use defaults UserSettingsEntity settings = entity.getSettings();
UserSettings settingsModel; if (settings == null) {
if (entity.getSettings() != null) { throw new IllegalStateException("User " + entity.getId() + " has no settings row");
settingsModel = UserSettings.builder()
.language(entity.getSettings().getLanguage())
.theme(entity.getSettings().getTheme())
.publicProfile(entity.getSettings().isPublicProfile())
.appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(),
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner()))
.profileStyle(new ProfileStyle(entity.getSettings().getProfileAccent(),
entity.getSettings().getProfileCorners(), entity.getSettings().getProfileBannerAppid(),
entity.getSettings().getProfileBackgroundAppid()))
.dashboardTiles(entity.getSettings().getDashboardTiles() == null ? null
: entity.getSettings().getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(entity.getSettings().getProfileTiles().stream().map(Tile::parse).toList())
.favoritePlatform(entity.getSettings().getFavoritePlatform())
.build();
} else {
// No settings row. "" means no language chosen, as in a new row; the frontend keeps its own.
settingsModel = UserSettings.builder()
.language("")
.theme("system")
.build();
} }
UserSettings settingsModel = UserSettings.builder()
// 2. Map the main user fields and attach the settings .language(settings.getLanguage())
.theme(settings.getTheme())
.publicProfile(settings.isPublicProfile())
.appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(),
settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner()))
.profileStyle(new ProfileStyle(settings.getProfileAccent(), settings.getProfileCorners(),
settings.getProfileBannerAppid(), settings.getProfileBackgroundAppid()))
.dashboardTiles(settings.getDashboardTiles() == null ? null
: settings.getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
.favoritePlatform(settings.getFavoritePlatform())
.build();
return User.builder() return User.builder()
.id(entity.getId()) .id(entity.getId())
.email(entity.getEmail()) .email(entity.getEmail())
.encryptedPassword(entity.getPassword())
.lastLogin(entity.getLastLoginDate()) .lastLogin(entity.getLastLoginDate())
.createdDate(entity.getRegistrationDate()) .createdDate(entity.getRegistrationDate())
.steamId(entity.getSteamId()) .steamId(entity.getSteamId())
@@ -63,12 +50,28 @@ public abstract class EntityMapper {
.build(); .build();
} }
public static UserSettingsEntity toPersistence(long userId, UserSettings settings) { public static void apply(User user, UserEntity entity) {
UserSettingsEntity entity = new UserSettingsEntity(); entity.setDisplayName(user.getDisplayName());
entity.setUserId(userId); UserSettings settings = user.getSettings();
entity.setLanguage(settings.getLanguage()); UserSettingsEntity target = entity.getSettings();
entity.setTheme(settings.getTheme()); target.setLanguage(settings.getLanguage());
entity.setPublicProfile(settings.isPublicProfile()); target.setTheme(settings.getTheme());
return entity; target.setPublicProfile(settings.isPublicProfile());
Appearance appearance = settings.getAppearance();
target.setAccent(appearance.accent());
target.setCorners(appearance.corners());
target.setWideLayout(appearance.wideLayout());
target.setBackgroundAppid(appearance.backgroundAppid());
target.setBannerAppid(appearance.bannerAppid());
target.setShowBanner(appearance.showBanner());
ProfileStyle style = settings.getProfileStyle();
target.setProfileAccent(style.accent());
target.setProfileCorners(style.corners());
target.setProfileBannerAppid(style.bannerAppid());
target.setProfileBackgroundAppid(style.backgroundAppid());
target.setDashboardTiles(settings.getDashboardTiles() == null ? null
: Tile.storedAll(settings.getDashboardTiles()));
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
target.setFavoritePlatform(settings.getFavoritePlatform());
} }
} }
@@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -47,7 +48,12 @@ public class UserService {
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) { if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
throw new DisplayNameAlreadyInUseException("The display name is already in use."); throw new DisplayNameAlreadyInUseException("The display name is already in use.");
} }
userRepository.updateSettings(userId, displayName, userSettings); userRepository.update(userId, user -> {
user.setDisplayName(displayName);
user.getSettings().setLanguage(userSettings.getLanguage());
user.getSettings().setTheme(userSettings.getTheme());
user.getSettings().setPublicProfile(userSettings.isPublicProfile());
});
log.info("User settings updated for user {}", userId); log.info("User settings updated for user {}", userId);
} }
@@ -58,7 +64,7 @@ public class UserService {
*/ */
@Transactional @Transactional
// All: the user who had typed the same Steam ID in loses it. // All: the user who had typed the same Steam ID in loses it.
@CacheEvict(value = "users", allEntries = true) @CacheEvict(value = {"users", "publicStats"}, allEntries = true)
public SteamLinkOutcome linkVerifiedSteamId(long userId, long steamId) { public SteamLinkOutcome linkVerifiedSteamId(long userId, long steamId) {
Optional<SteamIdHolder> holder = userRepository.findSteamIdHolder(steamId); Optional<SteamIdHolder> holder = userRepository.findSteamIdHolder(steamId);
if (holder.isPresent() && holder.get().userId() != userId) { if (holder.isPresent() && holder.get().userId() != userId) {
@@ -79,7 +85,8 @@ public class UserService {
*/ */
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @Caching(evict = {@CacheEvict(value = "users", key = "#userId"),
@CacheEvict(value = "publicStats", allEntries = true)})
public String unlinkSteamId(long userId, Role role) { public String unlinkSteamId(long userId, Role role) {
userRepository.clearSteamId(userId); userRepository.clearSteamId(userId);
log.info("Steam ID unlinked for user {}", userId); log.info("Steam ID unlinked for user {}", userId);
@@ -90,34 +97,36 @@ public class UserService {
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
public void updateAppearance(long userId, Appearance appearance) { public void updateAppearance(long userId, Appearance appearance) {
userRepository.updateAppearance(userId, appearance); userRepository.update(userId, user -> user.getSettings().setAppearance(appearance));
} }
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
public void updateDashboardTiles(long userId, List<Tile> tiles) { public void updateDashboardTiles(long userId, List<Tile> tiles) {
userRepository.updateDashboardTiles(userId, tiles == null ? null : Tile.distinct(tiles)); userRepository.update(userId,
user -> user.getSettings().setDashboardTiles(tiles == null ? null : Tile.distinct(tiles)));
} }
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
public void updateProfileTiles(long userId, List<Tile> tiles) { public void updateProfileTiles(long userId, List<Tile> tiles) {
userRepository.updateProfileTiles(userId, Tile.distinct(tiles)); userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
} }
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
public void updateFavoritePlatform(long userId, String platform) { public void updateFavoritePlatform(long userId, String platform) {
userRepository.updateFavoritePlatform(userId, platform == null ? null : platform.strip()); userRepository.update(userId,
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
} }
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
public void updateProfileStyle(long userId, ProfileStyle style) { public void updateProfileStyle(long userId, ProfileStyle style) {
userRepository.updateProfileStyle(userId, style); userRepository.update(userId, user -> user.getSettings().setProfileStyle(style));
} }
} }
@@ -0,0 +1,14 @@
package auth_service.shared.account;
/**
* The SQL rule for a user who may log in and is shown to others: not blocked and not deleted.
*/
public final class ActiveUser {
private ActiveUser() {
}
public static String of(String alias) {
return alias + ".enabled AND " + alias + ".deleted_at IS NULL";
}
}
@@ -0,0 +1,8 @@
package auth_service.shared.security;
/**
* Port for the filter: the access token of a blocked or deleted user stops working before it expires.
*/
public interface ActiveUserCheck {
boolean isActive(long userId);
}
@@ -0,0 +1,4 @@
-- Registration made no settings row, so the code had fallback defaults that disagreed with the column defaults.
-- Every user gets the row now, with the column defaults ('' = no language chosen, 'light' as decided in item 25).
INSERT INTO user_settings (user_id)
SELECT u.id FROM users u WHERE NOT EXISTS (SELECT 1 FROM user_settings s WHERE s.user_id = u.id);
@@ -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.
}
}
@@ -73,12 +73,12 @@ class ArchitectureTest extends AbstractArchitectureTest {
@ArchTest @ArchTest
static final ArchRule apis_should_only_call_services = noClasses() static final ArchRule apis_should_only_call_services = noClasses()
.that().resideInAPackage("..api..") .that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..persistence..") .should().dependOnClassesThat().resideInAnyPackage("..persistence..", "..repo..")
.because("APIs must delegate to Services and should not access Persistence directly."); .because("APIs must delegate to Services and should not access Persistence directly.");
@ArchTest @ArchTest
static final ArchRule persistence_should_not_depend_on_apis = noClasses() static final ArchRule persistence_should_not_depend_on_apis = noClasses()
.that().resideInAPackage("..persistence..") .that().resideInAnyPackage("..persistence..", "..repo..")
.should().dependOnClassesThat().resideInAPackage("..api..") .should().dependOnClassesThat().resideInAPackage("..api..")
.because("Persistence layer must be independent of the API layer."); .because("Persistence layer must be independent of the API layer.");
@@ -1,65 +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));
}
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 auth_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Set; 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.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class PublicEndpointsTest extends AbstractIntegrationTest { class PublicEndpointsTest extends AbstractIntegrationTest {
@Autowired @Autowired
@@ -7,10 +7,11 @@ import auth_service.shared.security.TokenGenerator;
import auth_service.shared.api.AuthCookies; import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest; import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
@@ -25,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. * The role markers through the real filter chain, on a probe that only this test sees.
*/ */
@AutoConfigureMockMvc
@Import({RoleCheckFlowTest.ProbeService.class, RoleCheckFlowTest.ProbeController.class}) @Import({RoleCheckFlowTest.ProbeService.class, RoleCheckFlowTest.ProbeController.class})
class RoleCheckFlowTest extends AbstractIntegrationTest { class RoleCheckFlowTest extends AbstractIntegrationTest {
@@ -92,8 +92,21 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
@Autowired @Autowired
private TokenGenerator tokenGenerator; private TokenGenerator tokenGenerator;
@Autowired
private JdbcTemplate jdbc;
private long userId;
// The filter accepts only the token of an existing, active user.
@BeforeEach
void seedUser() {
jdbc.update("DELETE FROM users WHERE email = 'role-check@example.com'");
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES ('role-check@example.com', 'x', true) "
+ "RETURNING id", Long.class);
}
private String token(Role role) { private String token(Role role) {
return tokenGenerator.generateToken("4711", null, role); return tokenGenerator.generateToken(String.valueOf(userId), null, role);
} }
private ResultActions call(String path, Role role) throws Exception { private ResultActions call(String path, Role role) throws Exception {
@@ -8,7 +8,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse; 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 * The whole cookie session against a real database: login, refresh rotation, reuse detection, logout, the CSRF
* header and CORS. * header and CORS.
*/ */
@AutoConfigureMockMvc
class SessionFlowTest extends AbstractIntegrationTest { class SessionFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "session-flow@example.com"; private static final String EMAIL = "session-flow@example.com";
@@ -63,6 +61,7 @@ class SessionFlowTest extends AbstractIntegrationTest {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL); jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)", jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)",
EMAIL, passwordEncoder.encode(PASSWORD)); EMAIL, passwordEncoder.encode(PASSWORD));
addSettingsRows();
} }
private MockHttpServletResponse login() throws Exception { private MockHttpServletResponse login() throws Exception {
@@ -97,6 +96,14 @@ class SessionFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.email").value(EMAIL)); .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 @Test
void noLogin_Is401() throws Exception { void noLogin_Is401() throws Exception {
mockMvc.perform(get("/api/users/me")) 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse; 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. * "Sign in through Steam" through the whole filter chain and a real database; only Steam itself is mocked.
*/ */
@AutoConfigureMockMvc
class SteamLinkFlowTest extends AbstractIntegrationTest { class SteamLinkFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "steam-link@example.com"; private static final String EMAIL = "steam-link@example.com";
@@ -61,6 +59,7 @@ class SteamLinkFlowTest extends AbstractIntegrationTest {
jdbc.update("INSERT INTO users (email, password, enabled) VALUES (?, ?, true)", EMAIL, passwordEncoder.encode(PASSWORD)); jdbc.update("INSERT INTO users (email, password, enabled) VALUES (?, ?, true)", EMAIL, passwordEncoder.encode(PASSWORD));
// Someone who typed the same ID in before Steam linking existed. // Someone who typed the same ID in before Steam linking existed.
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, 'x', true, ?)", TYPIST, STEAM_ID); jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, 'x', true, ?)", TYPIST, STEAM_ID);
addSettingsRows();
when(steamOpenIdService.loginUrl(anyString(), anyString())).thenReturn("https://steamcommunity.com/openid/login?test"); when(steamOpenIdService.loginUrl(anyString(), anyString())).thenReturn("https://steamcommunity.com/openid/login?test");
} }
@@ -10,7 +10,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse; 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 * 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. * mark in Core does not fail the delete.
*/ */
@AutoConfigureMockMvc
class DeletedAccountFlowTest extends AbstractIntegrationTest { class DeletedAccountFlowTest extends AbstractIntegrationTest {
private static final String EMAIL = "delete-flow@example.com"; private static final String EMAIL = "delete-flow@example.com";
@@ -59,6 +57,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
deleteAccount(access, PASSWORD).andExpect(status().isNoContent()); deleteAccount(access, PASSWORD).andExpect(status().isNoContent());
Long newUser = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id", Long newUser = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id",
Long.class, newEmail); Long.class, newEmail);
addSettingsRows();
try { try {
assertThat(userService.linkVerifiedSteamId(newUser, steamId)).isEqualTo(SteamLinkOutcome.LINKED); assertThat(userService.linkVerifiedSteamId(newUser, steamId)).isEqualTo(SteamLinkOutcome.LINKED);
@@ -92,8 +91,26 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
} }
private long totalUsers() throws Exception { private long totalUsers() throws Exception {
return publicStat("totalUsers");
}
private long publicStat(String name) throws Exception {
String body = mockMvc.perform(get("/api/stats/public")).andReturn().getResponse().getContentAsString(); String body = mockMvc.perform(get("/api/stats/public")).andReturn().getResponse().getContentAsString();
return JsonPath.<Number>read(body, "$.totalUsers").longValue(); return JsonPath.<Number>read(body, "$." + name).longValue();
}
@Test
void steamLinkAndUnlink_CountInThePublicStatsAtOnce() throws Exception {
long userId = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, EMAIL);
long before = publicStat("usersWithSteamLinked");
userService.linkVerifiedSteamId(userId, 76561198099990002L);
assertThat(publicStat("usersWithSteamLinked")).isEqualTo(before + 1);
mockMvc.perform(delete("/api/users/steam-link").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(login().getCookie(AuthCookies.ACCESS_COOKIE)))
.andExpect(status().is2xxSuccessful());
assertThat(publicStat("usersWithSteamLinked")).isEqualTo(before);
} }
@Test @Test
@@ -137,6 +154,8 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
MockHttpServletResponse session = login(); MockHttpServletResponse session = login();
String access = session.getCookie(AuthCookies.ACCESS_COOKIE).getValue(); String access = session.getCookie(AuthCookies.ACCESS_COOKIE).getValue();
String refresh = session.getCookie(AuthCookies.REFRESH_COOKIE).getValue(); String refresh = session.getCookie(AuthCookies.REFRESH_COOKIE).getValue();
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access)))
.andExpect(status().isOk());
long usersBefore = totalUsers(); long usersBefore = totalUsers();
MockHttpServletResponse deleted = deleteAccount(access, PASSWORD).andExpect(status().isNoContent()) MockHttpServletResponse deleted = deleteAccount(access, PASSWORD).andExpect(status().isNoContent())
@@ -149,10 +168,32 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refresh))) .cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refresh)))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
assertThat(login().getStatus()).isEqualTo(401); assertThat(login().getStatus()).isEqualTo(401);
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, access)))
.andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound()); mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound());
assertThat(totalUsers()).isEqualTo(usersBefore - 1); assertThat(totalUsers()).isEqualTo(usersBefore - 1);
} }
@Test
void registration_CountsInThePublicStatsAtOnce() throws Exception {
String newEmail = "delete-flow-registered@example.com";
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
long before = totalUsers();
try {
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
+ "\",\"inviteCode\":\"test-invite-code\"}"))
.andExpect(status().is2xxSuccessful());
assertThat(totalUsers()).isEqualTo(before + 1);
} finally {
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
}
}
@Test @Test
void registration_WithTheEmailOfAFlaggedUser_IsStillRefused() throws Exception { void registration_WithTheEmailOfAFlaggedUser_IsStillRefused() throws Exception {
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL); jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL);
@@ -4,7 +4,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp; import java.sql.Timestamp;
@@ -15,7 +14,6 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@Import(AccountRepository.class)
class AccountRepositoryTest extends AbstractPersistenceTest { class AccountRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS); private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS);
@@ -45,6 +43,8 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
// Integration tests share the database and commit their rows.
jdbc.update("DELETE FROM users");
alice = insertUser("alice@example.com"); alice = insertUser("alice@example.com");
bob = insertUser("bob@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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse; 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. * Roles, "view as" and the admin users API against a real database.
*/ */
@AutoConfigureMockMvc
class AdminFlowTest extends AbstractIntegrationTest { class AdminFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery"; private static final String PASSWORD = "correct horse battery";
@@ -48,8 +46,10 @@ class AdminFlowTest extends AbstractIntegrationTest {
private long insertUser(String email, String role) { private long insertUser(String email, String role) {
jdbc.update("DELETE FROM users WHERE email = ?", email); jdbc.update("DELETE FROM users WHERE email = ?", email);
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?) " Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?) "
+ "RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16)); + "RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16));
addSettingsRows();
return id;
} }
@BeforeEach @BeforeEach
@@ -167,9 +167,12 @@ class AdminFlowTest extends AbstractIntegrationTest {
void block_EndsTheSessionsAndTheLoginTellsWhy() throws Exception { void block_EndsTheSessionsAndTheLoginTellsWhy() throws Exception {
Session admin = Session.of(login(ADMIN)); Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER)); Session player = Session.of(login(PLAYER));
assertThat(me(player)).isEqualTo(200);
block(admin, playerId, true).andExpect(status().isNoContent()); block(admin, playerId, true).andExpect(status().isNoContent());
assertThat(me(player)).isEqualTo(401);
assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_BLOCKED"); assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_BLOCKED");
assertThat(login(PLAYER).getStatus()).isEqualTo(403); assertThat(login(PLAYER).getStatus()).isEqualTo(403);
assertThat(mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web") assertThat(mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web")
@@ -179,11 +182,32 @@ class AdminFlowTest extends AbstractIntegrationTest {
block(admin, playerId, false).andExpect(status().isNoContent()); block(admin, playerId, false).andExpect(status().isNoContent());
assertThat(login(PLAYER).getStatus()).isEqualTo(200); assertThat(login(PLAYER).getStatus()).isEqualTo(200);
assertThat(me(player)).isEqualTo(200);
}
@Test
void blockedCookieNextToAValidBearer_UsesTheBearer() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
block(admin, playerId, true).andExpect(status().isNoContent());
mockMvc.perform(get("/api/users/me")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, player.access()))
.header("Authorization", "Bearer " + admin.access()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value(ADMIN));
}
private int me(Session session) throws Exception {
return mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())))
.andReturn().getResponse().getStatus();
} }
@Test @Test
void delete_FlagsTheUser() throws Exception { void delete_FlagsTheUser() throws Exception {
Session admin = Session.of(login(ADMIN)); Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
assertThat(me(player)).isEqualTo(200);
mockMvc.perform(delete("/api/admin/users/" + playerId).header(ClientHeaderFilter.CLIENT_HEADER, "web") mockMvc.perform(delete("/api/admin/users/" + playerId).header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access()))) .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
@@ -191,6 +215,7 @@ class AdminFlowTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, playerId)).isNotNull(); assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, playerId)).isNotNull();
assertThat(login(PLAYER).getStatus()).isEqualTo(401); assertThat(login(PLAYER).getStatus()).isEqualTo(401);
assertThat(me(player)).isEqualTo(401);
assertThat(audits("DELETE", playerId)).isEqualTo(1); assertThat(audits("DELETE", playerId)).isEqualTo(1);
} }
@@ -6,14 +6,12 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant; import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@Import(AdminUserRepository.class)
class AdminUserRepositoryTest extends AbstractPersistenceTest { class AdminUserRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
@@ -7,7 +7,6 @@ import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class HintFlowTest extends AbstractIntegrationTest { class HintFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery"; private static final String PASSWORD = "correct horse battery";
@@ -45,6 +43,7 @@ class HintFlowTest extends AbstractIntegrationTest {
jdbc.update("DELETE FROM users WHERE email = ?", email); jdbc.update("DELETE FROM users WHERE email = ?", email);
jdbc.update("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?)", jdbc.update("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?)",
email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16)); email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16));
addSettingsRows();
} }
@BeforeEach @BeforeEach
@@ -9,7 +9,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Duration; 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.assertThat;
import static org.assertj.core.api.Assertions.tuple; import static org.assertj.core.api.Assertions.tuple;
@Import(HintRepository.class)
class HintRepositoryTest extends AbstractPersistenceTest { class HintRepositoryTest extends AbstractPersistenceTest {
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS); private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
@@ -1,68 +1,66 @@
package auth_service.modules.login.persistence; package auth_service.modules.login.persistence;
import auth_service.modules.login.persistence.entities.MinimalUserEntity; import auth_service.modules.login.model.User;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractPersistenceTest; import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.context.annotation.Import;
import static org.junit.jupiter.api.Assertions.*; import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 { class MinimalUserRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
private MinimalUserRepository minimalUserRepository; private MinimalUserRepository minimalUserRepository;
@Autowired @Autowired
private TestEntityManager entityManager; private JdbcTemplate jdbc;
@Test private long insertUser(String email) {
void findByEmail_Success() { Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, registration_date) "
// Arrange + "VALUES (?, 'password', TRUE, ?) RETURNING id", Long.class, email, LocalDateTime.now());
var email = "minimal@example.com"; return id == null ? 0 : id;
var entity = MinimalUserEntity.builder()
.email(email)
.password("password")
.enabled(true)
.build();
entityManager.persist(entity);
entityManager.flush();
// Act
var result = minimalUserRepository.findByEmail(email);
// Assert
assertTrue(result.isPresent());
assertEquals(email, result.get().getEmail());
} }
@Test @Test
void updateLoginDate_Success() { void findByEmail_MapsTheUser() {
// Arrange long id = insertUser("minimal@example.com");
var entity = MinimalUserEntity.builder() jdbc.update("UPDATE users SET steam_id = 76561198000000001, deleted_at = now() WHERE id = ?", id);
.email("login@example.com")
.password("password")
.enabled(true)
.build();
entity = entityManager.persist(entity);
entityManager.flush();
// Act User user = minimalUserRepository.findByEmail("minimal@example.com").orElseThrow();
minimalUserRepository.updateLoginDate(entity.getId());
entityManager.clear(); // Clear cache to force reload from DB
// Assert assertEquals(id, user.getId());
var updated = entityManager.find(MinimalUserEntity.class, entity.getId()); assertEquals("password", user.getPassword());
assertNotNull(updated.getLastLoginDate()); assertEquals(76561198000000001L, user.getSteamId());
assertTrue(user.isEnabled());
assertTrue(user.isDeleted());
assertEquals(Role.USER, user.getRole());
}
@Test
void findById_UnknownUser_IsEmpty() {
assertFalse(minimalUserRepository.findById(-1).isPresent());
}
@Test
void updateLoginDate_StoresTheDate() {
long id = insertUser("login@example.com");
minimalUserRepository.updateLoginDate(id);
assertNotNull(jdbc.queryForObject("SELECT last_login_date FROM users WHERE id = ?", LocalDateTime.class, id));
} }
// An access token from before V19 still carries the email as it was typed. // An access token from before V19 still carries the email as it was typed.
@Test @Test
void findByEmail_IgnoresCase() { void findByEmail_IgnoresCase() {
entityManager.persist(MinimalUserEntity.builder().email("case@example.com").password("password").enabled(true).build()); insertUser("case@example.com");
entityManager.flush();
assertTrue(minimalUserRepository.findByEmail("Case@Example.com").isPresent()); assertTrue(minimalUserRepository.findByEmail("Case@Example.com").isPresent());
} }
@@ -9,7 +9,6 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
class PublicProfileControllerTest extends AbstractIntegrationTest { class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired @Autowired
@@ -1,75 +1,78 @@
package auth_service.modules.registration.persistence; package auth_service.modules.registration.persistence;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.entities.RegisteredUserEntity;
import auth_service.shared.test.AbstractPersistenceTest; import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.dao.DuplicateKeyException;
import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.*; import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 { class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
private RegisteredUserRepository registeredUserRepository; private RegisteredUserRepository registeredUserRepository;
@Autowired @Autowired
private TestEntityManager entityManager; private JdbcTemplate jdbc;
private static Registration registration(String email, String displayName) {
return Registration.builder().email(email).encryptedPassword("hashed").displayName(displayName).build();
}
@Test @Test
void existsByEmail_Success() { void existsByEmail_OnlyForAStoredEmail() {
// Arrange registeredUserRepository.save(registration("exists@example.com", null));
var email = "exists@example.com";
var entity = RegisteredUserEntity.builder()
.email(email)
.password("password")
.enabled(true)
.registrationDate(java.time.LocalDateTime.now())
.build();
entityManager.persist(entity);
entityManager.flush();
// Act & Assert assertTrue(registeredUserRepository.existsByEmail("exists@example.com"));
assertTrue(registeredUserRepository.existsByEmail(email));
assertFalse(registeredUserRepository.existsByEmail("other@example.com")); assertFalse(registeredUserRepository.existsByEmail("other@example.com"));
} }
@Test @Test
void save_Success() { void existsByDisplayName_IgnoresCase() {
// Arrange registeredUserRepository.save(registration("named@example.com", "RegisteredName"));
var registration = Registration.builder()
.email("save@example.com")
.encryptedPassword("hashed")
.build();
// Act assertTrue(registeredUserRepository.existsByDisplayName("registeredname"));
long id = registeredUserRepository.save(registration); assertFalse(registeredUserRepository.existsByDisplayName("Wolf"));
}
// Assert @Test
assertTrue(id > 0); void save_StoresAnEnabledUser() {
var saved = entityManager.find(RegisteredUserEntity.class, id); long id = registeredUserRepository.save(registration("save@example.com", "Saver"));
assertNotNull(saved);
assertEquals("save@example.com", saved.getEmail()); Map<String, Object> saved = jdbc.queryForMap("SELECT * FROM users WHERE id = ?", id);
assertEquals("save@example.com", saved.get("email"));
assertEquals("hashed", saved.get("password"));
assertEquals(true, saved.get("enabled"));
assertEquals("Saver", saved.get("display_name"));
assertEquals("USER", saved.get("role"));
}
@Test
void save_CreatesTheSettingsRowWithTheColumnDefaults() {
long id = registeredUserRepository.save(registration("row@example.com", null));
Map<String, Object> settings = jdbc.queryForMap("SELECT * FROM user_settings WHERE user_id = ?", id);
assertEquals("", settings.get("language"));
assertEquals("light", settings.get("theme"));
assertEquals(false, settings.get("public_profile"));
} }
// V19: a second account under the same email in other case is refused by the database too. // V19: a second account under the same email in other case is refused by the database too.
@Test @Test
void emailsThatDifferOnlyInCase_AreOneAccount() { void emailsThatDifferOnlyInCase_AreOneAccount() {
entityManager.persist(RegisteredUserEntity.builder().email("dup@example.com").password("password") registeredUserRepository.save(registration("dup@example.com", null));
.enabled(true).registrationDate(java.time.LocalDateTime.now()).build());
entityManager.flush();
assertTrue(registeredUserRepository.existsByEmail("Dup@Example.com")); assertTrue(registeredUserRepository.existsByEmail("Dup@Example.com"));
var refused = assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> { DuplicateKeyException refused = assertThrows(DuplicateKeyException.class,
entityManager.persist(RegisteredUserEntity.builder().email("Dup@example.com").password("password") () -> registeredUserRepository.save(registration("Dup@example.com", null)));
.enabled(true).registrationDate(java.time.LocalDateTime.now()).build()); // GlobalExceptionController finds the name in the message, in every server language.
entityManager.flush(); assertTrue(refused.getMostSpecificCause().getMessage().contains("users_email_lower"));
});
// GlobalExceptionController decides on the state; on an English server Hibernate also gives the name.
assertEquals("23505", refused.getSQLState());
assertEquals("users_email_lower", refused.getConstraintName());
} }
} }
@@ -12,7 +12,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -21,7 +20,6 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@Import(UserRepositoryImpl.class)
class UserRepositoryTest extends AbstractPersistenceTest { class UserRepositoryTest extends AbstractPersistenceTest {
@Autowired @Autowired
@@ -43,14 +41,31 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.flatMap(userRepository::findById); .flatMap(userRepository::findById);
} }
// Registration inserts the settings row in production; the entity alone does not.
private UserEntity persisted(UserEntity user) {
entityManager.persist(user);
jdbc.update("INSERT INTO user_settings (user_id) VALUES (?)", user.getId());
entityManager.clear();
return user;
}
@Test
void findById_SettingsRowMissing_Throws() {
UserEntity user = entityManager.persistFlushFind(UserEntity.builder().email("norow@example.com")
.password("password").enabled(true).registrationDate(LocalDateTime.now()).build());
entityManager.clear();
assertThrows(IllegalStateException.class, () -> userRepository.findById(user.getId()));
}
@Test @Test
void updateSettings_KeepsADeleteThatCommittedAfterTheLoad() { void updateSettings_KeepsADeleteThatCommittedAfterTheLoad() {
var user = entityManager.persistFlushFind(UserEntity.builder().email("race@example.com").password("password") var user = persisted(UserEntity.builder().email("race@example.com").password("password")
.enabled(true).registrationDate(LocalDateTime.now()).build()); .enabled(true).registrationDate(LocalDateTime.now()).build());
// The delete request commits after the settings request loaded the user. // The delete request commits after the settings request loaded the user.
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", user.getId()); jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", user.getId());
userRepository.updateSettings(user.getId(), "RaceName", UserSettings.builder().language("de").theme("dark").build()); updateSettings(user.getId(), "RaceName", UserSettings.builder().language("de").theme("dark").build());
entityManager.flush(); entityManager.flush();
assertNotNull(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, user.getId())); assertNotNull(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, user.getId()));
@@ -67,8 +82,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.steamId(123456L) .steamId(123456L)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build(); .build();
entityManager.persist(userEntity); persisted(userEntity);
entityManager.flush();
// Act // Act
var result = findByEmail(email); var result = findByEmail(email);
@@ -89,8 +103,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.steamId(123456L) .steamId(123456L)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build(); .build();
userEntity = entityManager.persist(userEntity); userEntity = persisted(userEntity);
entityManager.flush();
var settings = UserSettings.builder() var settings = UserSettings.builder()
.language("de") .language("de")
@@ -98,7 +111,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.build(); .build();
// Act // Act
userRepository.updateSettings(userEntity.getId(), null, settings); updateSettings(userEntity.getId(), null, settings);
// Assert: the Steam ID changes only through the Steam link // Assert: the Steam ID changes only through the Steam link
var updatedUser = entityManager.find(UserEntity.class, userEntity.getId()); var updatedUser = entityManager.find(UserEntity.class, userEntity.getId());
@@ -112,13 +125,12 @@ class UserRepositoryTest extends AbstractPersistenceTest {
void findByEmail_NoSteamId() { void findByEmail_NoSteamId() {
// Arrange // Arrange
var email = "nosteam@example.com"; var email = "nosteam@example.com";
entityManager.persist(UserEntity.builder() persisted(UserEntity.builder()
.email(email) .email(email)
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush();
// Act // Act
var result = findByEmail(email); var result = findByEmail(email);
@@ -129,7 +141,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
} }
private UserEntity user(String email, Long steamId, boolean verified) { private UserEntity user(String email, Long steamId, boolean verified) {
UserEntity user = entityManager.persist(UserEntity.builder() UserEntity user = persisted(UserEntity.builder()
.email(email) .email(email)
.password("password") .password("password")
.enabled(true) .enabled(true)
@@ -137,7 +149,6 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.steamIdVerified(verified) .steamIdVerified(verified)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush();
return user; return user;
} }
@@ -173,7 +184,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void setVerifiedSteamId_DeletedUser_IsRefused() { void setVerifiedSteamId_DeletedUser_IsRefused() {
var deleted = entityManager.persistFlushFind(UserEntity.builder().email("deleted@example.com").password("password") var deleted = persisted(UserEntity.builder().email("deleted@example.com").password("password")
.enabled(true).registrationDate(LocalDateTime.now()).build()); .enabled(true).registrationDate(LocalDateTime.now()).build());
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", deleted.getId()); jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", deleted.getId());
@@ -184,7 +195,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
void updateSettings_UnknownUser() { void updateSettings_UnknownUser() {
var settings = UserSettings.builder().language("en").theme("light").build(); var settings = UserSettings.builder().language("en").theme("light").build();
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, settings)); assertThrows(UserNotFoundException.class, () -> updateSettings(-1L, null, settings));
} }
@Test @Test
@@ -196,43 +207,41 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void displayName_StoredAndCheckedCaseInsensitive() { void displayName_StoredAndCheckedCaseInsensitive() {
// Arrange // Arrange
var owner = entityManager.persist(UserEntity.builder() var owner = persisted(UserEntity.builder()
.email("owner2@example.com") .email("owner2@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
var other = entityManager.persist(UserEntity.builder() var other = persisted(UserEntity.builder()
.email("other2@example.com") .email("other2@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush();
var settings = UserSettings.builder().language("en").theme("light").build(); var settings = UserSettings.builder().language("en").theme("light").build();
// Act // Act
userRepository.updateSettings(owner.getId(), "Lobo", settings); updateSettings(owner.getId(), "RepoName", settings);
entityManager.flush(); entityManager.flush();
// Assert // Assert
assertEquals("Lobo", findByEmail("owner2@example.com").orElseThrow().getDisplayName()); assertEquals("RepoName", findByEmail("owner2@example.com").orElseThrow().getDisplayName());
assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "lobo")); assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "reponame"));
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "LOBO")); assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "REPONAME"));
assertFalse(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "Wolf")); assertFalse(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "Wolf"));
} }
@Test @Test
void updateSettings_StoresPublicProfile_AndFindByEmailReadsIt() { void updateSettings_StoresPublicProfile_AndFindByEmailReadsIt() {
var userEntity = entityManager.persist(UserEntity.builder() var userEntity = persisted(UserEntity.builder()
.email("public@example.com") .email("public@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush();
userRepository.updateSettings(userEntity.getId(), "Lobo", updateSettings(userEntity.getId(), "RepoName",
UserSettings.builder().language("de").theme("dark").publicProfile(true).build()); UserSettings.builder().language("de").theme("dark").publicProfile(true).build());
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
@@ -240,37 +249,19 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile()); assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
} }
@Test
void updateAppearance_WithoutSettingsRow_CreatesItAndFindByEmailReadsIt() {
var userEntity = entityManager.persist(UserEntity.builder()
.email("appearance@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
userRepository.updateAppearance(userEntity.getId(), new Appearance("orange", "round", true, 620L, 1086940L, false));
entityManager.flush();
entityManager.clear();
var settings = findByEmail("appearance@example.com").orElseThrow().getSettings();
assertEquals(new Appearance("orange", "round", true, 620L, 1086940L, false), settings.getAppearance());
}
@Test @Test
void updateSettings_KeepsTheAppearance() { void updateSettings_KeepsTheAppearance() {
var userEntity = entityManager.persist(UserEntity.builder() var userEntity = persisted(UserEntity.builder()
.email("keep@example.com") .email("keep@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush(); userRepository.update(userEntity.getId(),
userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L, null, true)); user -> user.getSettings().setAppearance(new Appearance("pink", "sharp", false, 220L, null, true)));
entityManager.flush(); entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null, updateSettings(userEntity.getId(), null,
UserSettings.builder().language("de").theme("dark").build()); UserSettings.builder().language("de").theme("dark").build());
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
@@ -279,34 +270,19 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertEquals(new Appearance("pink", "sharp", false, 220L, null, true), settings.getAppearance()); assertEquals(new Appearance("pink", "sharp", false, 220L, null, true), settings.getAppearance());
} }
@Test
void findByEmail_WithoutSettingsRow_HasTheDefaultAppearance() {
entityManager.persist(UserEntity.builder()
.email("defaults@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(Appearance.DEFAULT, findByEmail("defaults@example.com").orElseThrow()
.getSettings().getAppearance());
}
@Test @Test
void updateProfileStyle_IsReadBackAndStartsWithTheDefaults() { void updateProfileStyle_IsReadBackAndStartsWithTheDefaults() {
UserEntity userEntity = entityManager.persist(UserEntity.builder() UserEntity userEntity = persisted(UserEntity.builder()
.email("style@example.com") .email("style@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush();
assertEquals(ProfileStyle.DEFAULT, findByEmail("style@example.com").orElseThrow() assertEquals(ProfileStyle.DEFAULT, findByEmail("style@example.com").orElseThrow()
.getSettings().getProfileStyle()); .getSettings().getProfileStyle());
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L); ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L);
userRepository.updateProfileStyle(userEntity.getId(), style); userRepository.update(userEntity.getId(), user -> user.getSettings().setProfileStyle(style));
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
@@ -315,23 +291,22 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void updateTiles_AreReadBackAndKeptByUpdateSettings() { void updateTiles_AreReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = entityManager.persist(UserEntity.builder() UserEntity userEntity = persisted(UserEntity.builder()
.email("tiles@example.com") .email("tiles@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush();
UserSettings fresh = findByEmail("tiles@example.com").orElseThrow().getSettings(); UserSettings fresh = findByEmail("tiles@example.com").orElseThrow().getSettings();
assertNull(fresh.getDashboardTiles()); assertNull(fresh.getDashboardTiles());
assertEquals(UserSettings.DEFAULT_PROFILE_TILES, fresh.getProfileTiles()); assertEquals(UserSettings.DEFAULT_PROFILE_TILES, fresh.getProfileTiles());
List<Tile> dashboard = List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S")); List<Tile> dashboard = List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S"));
List<Tile> profile = List.of(new Tile("STATS_PILE", "M"), new Tile("SUMMARY", "S")); List<Tile> profile = List.of(new Tile("STATS_PILE", "M"), new Tile("SUMMARY", "S"));
userRepository.updateDashboardTiles(userEntity.getId(), dashboard); userRepository.update(userEntity.getId(), user -> user.getSettings().setDashboardTiles(dashboard));
userRepository.updateProfileTiles(userEntity.getId(), profile); userRepository.update(userEntity.getId(), user -> user.getSettings().setProfileTiles(profile));
entityManager.flush(); entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null, updateSettings(userEntity.getId(), null,
UserSettings.builder().language("de").theme("dark").build()); UserSettings.builder().language("de").theme("dark").build());
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
@@ -343,7 +318,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void updateFavoritePlatform_StoresAndClears() { void updateFavoritePlatform_StoresAndClears() {
UserEntity userEntity = entityManager.persist(UserEntity.builder() UserEntity userEntity = persisted(UserEntity.builder()
.email("favorite@example.com") .email("favorite@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
@@ -351,13 +326,13 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.build()); .build());
assertNull(findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform()); assertNull(findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform());
userRepository.updateFavoritePlatform(userEntity.getId(), "Switch"); userRepository.update(userEntity.getId(), user -> user.getSettings().setFavoritePlatform("Switch"));
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
assertEquals("Switch", findByEmail("favorite@example.com").orElseThrow().getSettings() assertEquals("Switch", findByEmail("favorite@example.com").orElseThrow().getSettings()
.getFavoritePlatform()); .getFavoritePlatform());
userRepository.updateFavoritePlatform(userEntity.getId(), null); userRepository.update(userEntity.getId(), user -> user.getSettings().setFavoritePlatform(null));
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
assertNull(findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform()); assertNull(findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform());
@@ -365,24 +340,29 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void updateDashboardTiles_NullResetsToTheDefault() { void updateDashboardTiles_NullResetsToTheDefault() {
UserEntity userEntity = entityManager.persist(UserEntity.builder() UserEntity userEntity = persisted(UserEntity.builder()
.email("reset@example.com") .email("reset@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
userRepository.updateDashboardTiles(userEntity.getId(), List.of(new Tile("PLAYING", "S"))); userRepository.update(userEntity.getId(),
user -> user.getSettings().setDashboardTiles(List.of(new Tile("PLAYING", "S"))));
entityManager.flush(); entityManager.flush();
userRepository.updateDashboardTiles(userEntity.getId(), null); userRepository.update(userEntity.getId(), user -> user.getSettings().setDashboardTiles(null));
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
assertNull(findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles()); assertNull(findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
} }
@Test private void updateSettings(long userId, String displayName, UserSettings settings) {
void updateAppearance_UnknownUser() { userRepository.update(userId, user -> {
assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT)); user.setDisplayName(displayName);
user.getSettings().setLanguage(settings.getLanguage());
user.getSettings().setTheme(settings.getTheme());
user.getSettings().setPublicProfile(settings.isPublicProfile());
});
} }
} }
@@ -1,16 +0,0 @@
package auth_service.modules.user.persistence.mapper;
import auth_service.modules.user.persistence.entities.UserEntity;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class EntityMapperTest {
@Test
void mapToModel_UserWithoutSettingsRow_HasNoLanguage() {
UserEntity entity = UserEntity.builder().id(1L).email("a@b.c").role("USER").build();
assertThat(EntityMapper.mapToModel(entity).getSettings().getLanguage()).isEmpty();
}
}
@@ -13,17 +13,21 @@ import auth_service.modules.user.persistence.UserRepository;
import auth_service.shared.security.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder; import org.mockito.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
@@ -60,15 +64,16 @@ class UserServiceTest extends AbstractUnitTest {
userService.updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S"), new Tile("PLAYING", "L"))); userService.updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S"), new Tile("PLAYING", "L")));
userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"), new Tile("SUMMARY", "S"))); userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"), new Tile("SUMMARY", "S")));
verify(userRepository).updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S"))); List<User> changed = appliedChanges(2);
verify(userRepository).updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"))); assertEquals(List.of(new Tile("PLAYING", "S")), changed.get(0).getSettings().getDashboardTiles());
assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles());
} }
@Test @Test
void updateDashboardTiles_NullStaysNull() { void updateDashboardTiles_NullStaysNull() {
userService.updateDashboardTiles(1L, null); userService.updateDashboardTiles(1L, null);
verify(userRepository).updateDashboardTiles(1L, null); assertNull(appliedChanges(1).getFirst().getSettings().getDashboardTiles());
} }
@Test @Test
@@ -77,7 +82,7 @@ class UserServiceTest extends AbstractUnitTest {
userService.updateUserSettings(1L, null, userSettings); userService.updateUserSettings(1L, null, userSettings);
verify(userRepository).updateSettings(1L, null, userSettings); appliedChanges(1);
verify(userRepository, never()).clearSteamId(anyLong()); verify(userRepository, never()).clearSteamId(anyLong());
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong()); verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
verifyNoInteractions(tokenGenerator); verifyNoInteractions(tokenGenerator);
@@ -92,7 +97,7 @@ class UserServiceTest extends AbstractUnitTest {
// Act & Assert // Act & Assert
assertThrows(DisplayNameAlreadyInUseException.class, assertThrows(DisplayNameAlreadyInUseException.class,
() -> userService.updateUserSettings(1L, "Lobo", userSettings)); () -> userService.updateUserSettings(1L, "Lobo", userSettings));
verify(userRepository, never()).updateSettings(anyLong(), any(), any()); verify(userRepository, never()).update(anyLong(), any());
} }
@Test @Test
@@ -105,7 +110,9 @@ class UserServiceTest extends AbstractUnitTest {
userService.updateUserSettings(1L, "Lobo", userSettings); userService.updateUserSettings(1L, "Lobo", userSettings);
// Assert // Assert
verify(userRepository).updateSettings(1L, "Lobo", userSettings); User changed = appliedChanges(1).getFirst();
assertEquals("Lobo", changed.getDisplayName());
assertEquals("en", changed.getSettings().getLanguage());
} }
private static final long STEAM_ID = 76561198000000001L; private static final long STEAM_ID = 76561198000000001L;
@@ -165,7 +172,28 @@ class UserServiceTest extends AbstractUnitTest {
userService.updateAppearance(1L, appearance); userService.updateAppearance(1L, appearance);
verify(userRepository).updateAppearance(1L, appearance); assertEquals(appearance, appliedChanges(1).getFirst().getSettings().getAppearance());
verifyNoInteractions(tokenGenerator); verifyNoInteractions(tokenGenerator);
} }
@Test
void updateFavoritePlatform_StoresItStripped() {
userService.updateFavoritePlatform(1L, " Switch ");
assertEquals("Switch", appliedChanges(1).getFirst().getSettings().getFavoritePlatform());
}
/**
* Runs each change user 1 got on a fresh user with default settings.
*/
@SuppressWarnings("unchecked")
private List<User> appliedChanges(int count) {
ArgumentCaptor<Consumer<User>> change = ArgumentCaptor.forClass(Consumer.class);
verify(userRepository, times(count)).update(eq(1L), change.capture());
return change.getAllValues().stream().map(each -> {
User user = User.builder().id(1L).settings(UserSettings.builder().build()).build();
each.accept(user);
return user;
}).toList();
}
} }
@@ -1,8 +1,13 @@
package auth_service.shared.test; 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.junit.jupiter.api.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; 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.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.DynamicPropertySource;
@@ -12,25 +17,31 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Base64; import java.util.Base64;
import java.util.Date;
/** /**
* Base class for integration tests. * Base class for integration tests.
* Full Spring context with Testcontainers. * Full Spring context with Testcontainers.
*/ */
@Tag("IntegrationTest") @Tag("IntegrationTest")
@AutoConfigureMockMvc
// The account cleanup must not run by itself, and Core is never reachable in tests. // The account cleanup must not run by itself, and Core is never reachable in tests.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"}) properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"})
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest { 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 @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine"); static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;
static { @Autowired
postgres.start(); private JdbcTemplate baseJdbc;
/**
* For users a test inserts by SQL. Registration inserts the row itself.
*/
protected void addSettingsRows() {
baseJdbc.update("INSERT INTO user_settings (user_id) SELECT u.id FROM users u "
+ "WHERE NOT EXISTS (SELECT 1 FROM user_settings s WHERE s.user_id = u.id)");
} }
// No key is committed: a fresh one per JVM. // No key is committed: a fresh one per JVM.
@@ -43,6 +54,19 @@ public abstract class AbstractIntegrationTest {
registry.add("auth.registration.invitation-code", () -> "test-invite-code"); 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() { private static KeyPair generateKeys() {
try { try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); 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.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; 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; import org.testcontainers.containers.PostgreSQLContainer;
/** /**
@@ -11,16 +14,11 @@ import org.testcontainers.containers.PostgreSQLContainer;
* Slices only the persistence layer with Testcontainers. * Slices only the persistence layer with Testcontainers.
*/ */
@Tag("PersistenceTest") @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) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class AbstractPersistenceTest { 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 @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine"); static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;
static {
postgres.start();
}
} }
@@ -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() {
}
}