Public Access
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3336181cdb | ||
|
|
1f94c87a20 | ||
|
|
509280da14 | ||
|
|
cf21f4a654 | ||
|
|
fb51f836a8 | ||
|
|
d082f3befd | ||
|
|
b6be1879f0 |
No files matched your search
@@ -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
|
||||
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
|
||||
(`users.enabled`; blocking ends every login, 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
|
||||
(`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth; Core accepts the token
|
||||
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`.
|
||||
- **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.
|
||||
"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`.
|
||||
- **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
|
||||
link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a
|
||||
new Steam link. A nightly job (04:15, Europe/Berlin) removes
|
||||
`users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public
|
||||
profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an
|
||||
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
|
||||
**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. |
|
||||
| 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.
|
||||
`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
|
||||
@@ -144,7 +153,9 @@ use each other.
|
||||
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
|
||||
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
|
||||
classes (`AbstractUnitTest`, `AbstractWebMvcTest`, `AbstractPersistenceTest`, `AbstractIntegrationTest`) live in
|
||||
`auth_service.shared.test`; all integration tests share one PostgreSQL container.
|
||||
`auth_service.shared.test`. Integration and persistence tests share one PostgreSQL container (`TestPostgres`), and
|
||||
the base classes carry every annotation, so each kind starts one Spring context. A test with its own annotations
|
||||
starts another one.
|
||||
|
||||
### Login flow
|
||||
|
||||
@@ -207,6 +218,7 @@ sequenceDiagram
|
||||
A->>A: users.deleted_at = now, revoke refresh tokens
|
||||
A->>C: POST /internal/accounts/deleted (service token)
|
||||
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
|
||||
A->>C: PUT /internal/accounts/deleted (all flagged users)
|
||||
A->>C: POST /internal/accounts/purge (flagged 30+ days)
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
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;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Global configuration to enable Spring Boot caching (backed by Caffeine).
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
@Profile("!dev")
|
||||
@Slf4j
|
||||
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
|
||||
public CacheManager cacheManager() {
|
||||
log.info("########### Productive cache active ###########");
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.security.ActiveUserCheck;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
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
|
||||
* 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
|
||||
@RequiredArgsConstructor
|
||||
@@ -33,6 +35,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private static final String BEARER = "Bearer ";
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final ActiveUserCheck activeUserCheck;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@@ -45,6 +48,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
.flatMap(Optional::stream)
|
||||
.map(jwtService::parseLoginToken)
|
||||
.flatMap(Optional::stream)
|
||||
.filter(each -> activeUserCheck.isActive(each.getIdAsLong()))
|
||||
.findFirst();
|
||||
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||
|
||||
@@ -9,6 +9,7 @@ import auth_service.shared.security.RequiresRole;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Caching;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
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
|
||||
* 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) {
|
||||
String key = String.valueOf(userId);
|
||||
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
|
||||
@@ -62,7 +64,8 @@ public class AccountService implements AccountDeleter {
|
||||
|
||||
@Override
|
||||
@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) {
|
||||
return flagAndTellCore(userId);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import auth_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Caching;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -39,7 +40,8 @@ public class AdminUserService {
|
||||
}
|
||||
|
||||
@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) {
|
||||
TargetState target = checkedTarget(actorUserId, targetUserId);
|
||||
if (target.deleted()) {
|
||||
|
||||
+24
-29
@@ -1,13 +1,11 @@
|
||||
package auth_service.modules.login.persistence;
|
||||
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import auth_service.modules.login.model.User;
|
||||
import auth_service.modules.login.persistence.entities.MinimalUserEntity;
|
||||
import auth_service.modules.login.persistence.mapper.EntityMapper;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import jakarta.transaction.Transactional;
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import auth_service.shared.security.Role;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Clock;
|
||||
@@ -15,39 +13,36 @@ import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
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
|
||||
public Optional<User> findByEmail(String email) {
|
||||
var criteriaBuilder = entityManager.getCriteriaBuilder();
|
||||
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);
|
||||
return jdbc.query(SELECT_USER + "WHERE email = ?", USER_ROW, EmailAddress.normalize(email)).stream().findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
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
|
||||
public void updateLoginDate(long userId) {
|
||||
var userEntity = entityManager.find(MinimalUserEntity.class, userId);
|
||||
|
||||
if (userEntity != null) {
|
||||
userEntity.setLastLoginDate(LocalDateTime.now(Clock.systemUTC()));
|
||||
entityManager.flush();
|
||||
}
|
||||
jdbc.update("UPDATE users SET last_login_date = ? WHERE id = ?", LocalDateTime.now(Clock.systemUTC()), userId);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
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.shared.security.Role;
|
||||
|
||||
@@ -11,22 +9,6 @@ public abstract class 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) {
|
||||
return new RefreshToken(entity.getId(), entity.getUserId(), entity.getFamilyId(), entity.getExpiresAt(),
|
||||
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;
|
||||
|
||||
import auth_service.modules.profile.model.PublicProfile;
|
||||
import auth_service.shared.account.ActiveUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
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,
|
||||
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
|
||||
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.getTimestamp("registration_date") != null
|
||||
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
|
||||
|
||||
+24
-23
@@ -1,44 +1,45 @@
|
||||
package auth_service.modules.registration.persistence;
|
||||
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.modules.registration.persistence.mapper.EntityMapper;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.transaction.Transactional;
|
||||
import auth_service.shared.account.EmailAddress;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
@Override
|
||||
public boolean existsByEmail(String email) {
|
||||
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE u.email = :email";
|
||||
var count = entityManager.createQuery(jpql, Long.class)
|
||||
.setParameter("email", EmailAddress.normalize(email))
|
||||
.getSingleResult();
|
||||
|
||||
return count != null && count > 0;
|
||||
return Boolean.TRUE.equals(jdbc.queryForObject("SELECT EXISTS (SELECT 1 FROM users WHERE email = ?)",
|
||||
Boolean.class, EmailAddress.normalize(email)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByDisplayName(String displayName) {
|
||||
var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)";
|
||||
var count = entityManager.createQuery(jpql, Long.class)
|
||||
.setParameter("displayName", displayName)
|
||||
.getSingleResult();
|
||||
|
||||
return count != null && count > 0;
|
||||
return Boolean.TRUE.equals(jdbc.queryForObject(
|
||||
"SELECT EXISTS (SELECT 1 FROM users WHERE LOWER(display_name) = LOWER(?))", Boolean.class, displayName));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public long save(Registration registration) {
|
||||
var userToRegisterEntity = EntityMapper.mapToPersistence(registration);
|
||||
entityManager.persist(userToRegisterEntity);
|
||||
return userToRegisterEntity.getId();
|
||||
Long id = jdbc.queryForObject("""
|
||||
INSERT INTO users (email, password, enabled, display_name, registration_date)
|
||||
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;
|
||||
}
|
||||
}
|
||||
-38
@@ -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.shared.security.Public;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -20,6 +21,7 @@ public class RegistrationService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RegistrationAttemptService registrationAttemptService;
|
||||
|
||||
@CacheEvict(value = "publicStats", allEntries = true)
|
||||
public long register(Registration registration, String clientIp) {
|
||||
registrationAttemptService.acquire(clientIp);
|
||||
validate(registration);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.stats.repo;
|
||||
|
||||
import auth_service.shared.account.ActiveUser;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -15,13 +16,13 @@ public class StatsRepository {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public long countUsersWithSteamLinked() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import java.time.LocalDateTime;
|
||||
public class User {
|
||||
private long id;
|
||||
private String email;
|
||||
private String encryptedPassword;
|
||||
private LocalDateTime lastLogin;
|
||||
private LocalDateTime createdDate;
|
||||
private Long steamId;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
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.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Repository for database operations regarding the User entity.
|
||||
@@ -22,22 +16,11 @@ public interface UserRepository {
|
||||
*/
|
||||
Optional<User> findById(long userId);
|
||||
|
||||
@NotNull
|
||||
UserSettings findSettingsByUserId(String userId);
|
||||
|
||||
/**
|
||||
* Stores only the display name and the settings of the changed user.
|
||||
*/
|
||||
@Transactional
|
||||
void updateSettings(long userId, String displayName, UserSettings settings);
|
||||
|
||||
@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);
|
||||
void update(long userId, Consumer<User> change);
|
||||
|
||||
Optional<SteamIdHolder> findSteamIdHolder(long steamId);
|
||||
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
package auth_service.modules.user.persistence;
|
||||
|
||||
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.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
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 jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@Repository
|
||||
public class UserRepositoryImpl implements UserRepository {
|
||||
@@ -33,7 +26,6 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
var userEntity = userEntityCriteriaQuery.from(UserEntity.class);
|
||||
|
||||
// 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);
|
||||
|
||||
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("id"), userId));
|
||||
@@ -45,103 +37,16 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
.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
|
||||
@Override
|
||||
public void updateSettings(long userId, String displayName, UserSettings settings) {
|
||||
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) {
|
||||
public void update(long userId, Consumer<User> change) {
|
||||
UserEntity userEntity = entityManager.find(UserEntity.class, userId);
|
||||
if (userEntity == null) {
|
||||
throw new UserNotFoundException("Could not find user with id " + userId);
|
||||
}
|
||||
UserSettingsEntity settingsEntity = userEntity.getSettings();
|
||||
if (settingsEntity == null) {
|
||||
settingsEntity = new UserSettingsEntity();
|
||||
settingsEntity.setUserId(userId);
|
||||
settingsEntity.setLanguage("");
|
||||
settingsEntity.setTheme("light");
|
||||
userEntity.setSettings(settingsEntity);
|
||||
}
|
||||
return settingsEntity;
|
||||
User user = EntityMapper.mapToModel(userEntity);
|
||||
change.accept(user);
|
||||
EntityMapper.apply(user, userEntity);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+6
-11
@@ -1,9 +1,5 @@
|
||||
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.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
@@ -35,12 +31,11 @@ public class UserSettingsEntity {
|
||||
@Column(name = "public_profile", nullable = false)
|
||||
private boolean publicProfile;
|
||||
|
||||
// Java defaults as well: a row created here, not by the migration, must not insert null.
|
||||
@Column(nullable = false)
|
||||
private String accent = Appearance.DEFAULT.accent();
|
||||
private String accent;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String corners = Appearance.DEFAULT.corners();
|
||||
private String corners;
|
||||
|
||||
@Column(name = "wide_layout", nullable = false)
|
||||
private boolean wideLayout;
|
||||
@@ -52,13 +47,13 @@ public class UserSettingsEntity {
|
||||
private Long bannerAppid;
|
||||
|
||||
@Column(name = "show_banner", nullable = false)
|
||||
private boolean showBanner = Appearance.DEFAULT.showBanner();
|
||||
private boolean showBanner;
|
||||
|
||||
@Column(name = "profile_accent", nullable = false)
|
||||
private String profileAccent = ProfileStyle.DEFAULT.accent();
|
||||
private String profileAccent;
|
||||
|
||||
@Column(name = "profile_corners", nullable = false)
|
||||
private String profileCorners = ProfileStyle.DEFAULT.corners();
|
||||
private String profileCorners;
|
||||
|
||||
@Column(name = "profile_banner_appid")
|
||||
private Long profileBannerAppid;
|
||||
@@ -76,5 +71,5 @@ public class UserSettingsEntity {
|
||||
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@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.shared.security.Role;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class EntityMapper {
|
||||
|
||||
private EntityMapper() {
|
||||
@@ -21,38 +19,27 @@ public abstract class EntityMapper {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Map the settings if they exist, otherwise use defaults
|
||||
UserSettings settingsModel;
|
||||
if (entity.getSettings() != null) {
|
||||
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();
|
||||
UserSettingsEntity settings = entity.getSettings();
|
||||
if (settings == null) {
|
||||
throw new IllegalStateException("User " + entity.getId() + " has no settings row");
|
||||
}
|
||||
|
||||
// 2. Map the main user fields and attach the settings
|
||||
UserSettings settingsModel = UserSettings.builder()
|
||||
.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()
|
||||
.id(entity.getId())
|
||||
.email(entity.getEmail())
|
||||
.encryptedPassword(entity.getPassword())
|
||||
.lastLogin(entity.getLastLoginDate())
|
||||
.createdDate(entity.getRegistrationDate())
|
||||
.steamId(entity.getSteamId())
|
||||
@@ -63,12 +50,28 @@ public abstract class EntityMapper {
|
||||
.build();
|
||||
}
|
||||
|
||||
public static UserSettingsEntity toPersistence(long userId, UserSettings settings) {
|
||||
UserSettingsEntity entity = new UserSettingsEntity();
|
||||
entity.setUserId(userId);
|
||||
entity.setLanguage(settings.getLanguage());
|
||||
entity.setTheme(settings.getTheme());
|
||||
entity.setPublicProfile(settings.isPublicProfile());
|
||||
return entity;
|
||||
public static void apply(User user, UserEntity entity) {
|
||||
entity.setDisplayName(user.getDisplayName());
|
||||
UserSettings settings = user.getSettings();
|
||||
UserSettingsEntity target = entity.getSettings();
|
||||
target.setLanguage(settings.getLanguage());
|
||||
target.setTheme(settings.getTheme());
|
||||
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 org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.cache.annotation.Caching;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -47,7 +48,12 @@ public class UserService {
|
||||
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -58,7 +64,7 @@ public class UserService {
|
||||
*/
|
||||
@Transactional
|
||||
// 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) {
|
||||
Optional<SteamIdHolder> holder = userRepository.findSteamIdHolder(steamId);
|
||||
if (holder.isPresent() && holder.get().userId() != userId) {
|
||||
@@ -79,7 +85,8 @@ public class UserService {
|
||||
*/
|
||||
@RequiresRole(Role.USER)
|
||||
@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) {
|
||||
userRepository.clearSteamId(userId);
|
||||
log.info("Steam ID unlinked for user {}", userId);
|
||||
@@ -90,34 +97,36 @@ public class UserService {
|
||||
@Transactional
|
||||
@CacheEvict(value = "users", key = "#userId")
|
||||
public void updateAppearance(long userId, Appearance appearance) {
|
||||
userRepository.updateAppearance(userId, appearance);
|
||||
userRepository.update(userId, user -> user.getSettings().setAppearance(appearance));
|
||||
}
|
||||
|
||||
@RequiresRole(Role.USER)
|
||||
@Transactional
|
||||
@CacheEvict(value = "users", key = "#userId")
|
||||
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)
|
||||
@Transactional
|
||||
@CacheEvict(value = "users", key = "#userId")
|
||||
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)
|
||||
@Transactional
|
||||
@CacheEvict(value = "users", key = "#userId")
|
||||
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)
|
||||
@Transactional
|
||||
@CacheEvict(value = "users", key = "#userId")
|
||||
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
|
||||
static final ArchRule apis_should_only_call_services = noClasses()
|
||||
.that().resideInAPackage("..api..")
|
||||
.should().dependOnClassesThat().resideInAPackage("..persistence..")
|
||||
.should().dependOnClassesThat().resideInAnyPackage("..persistence..", "..repo..")
|
||||
.because("APIs must delegate to Services and should not access Persistence directly.");
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule persistence_should_not_depend_on_apis = noClasses()
|
||||
.that().resideInAPackage("..persistence..")
|
||||
.that().resideInAnyPackage("..persistence..", "..repo..")
|
||||
.should().dependOnClassesThat().resideInAPackage("..api..")
|
||||
.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 org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Set;
|
||||
@@ -14,7 +13,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@AutoConfigureMockMvc
|
||||
class PublicEndpointsTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
@@ -7,10 +7,11 @@ import auth_service.shared.security.TokenGenerator;
|
||||
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.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
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.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
@Import({RoleCheckFlowTest.ProbeService.class, RoleCheckFlowTest.ProbeController.class})
|
||||
class RoleCheckFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
@@ -92,8 +92,21 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
|
||||
@Autowired
|
||||
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) {
|
||||
return tokenGenerator.generateToken("4711", null, role);
|
||||
return tokenGenerator.generateToken(String.valueOf(userId), null, role);
|
||||
}
|
||||
|
||||
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.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
@@ -38,7 +37,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
* The whole cookie session against a real database: login, refresh rotation, reuse detection, logout, the CSRF
|
||||
* header and CORS.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class SessionFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String EMAIL = "session-flow@example.com";
|
||||
@@ -63,6 +61,7 @@ class SessionFlowTest extends AbstractIntegrationTest {
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
|
||||
jdbc.update("INSERT INTO users (email, password, enabled, steam_id) VALUES (?, ?, true, NULL)",
|
||||
EMAIL, passwordEncoder.encode(PASSWORD));
|
||||
addSettingsRows();
|
||||
}
|
||||
|
||||
private MockHttpServletResponse login() throws Exception {
|
||||
@@ -97,6 +96,14 @@ class SessionFlowTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$.email").value(EMAIL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception {
|
||||
String expired = expiredCopy(cookie(login(), AuthCookies.ACCESS_COOKIE));
|
||||
|
||||
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, expired)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noLogin_Is401() throws Exception {
|
||||
mockMvc.perform(get("/api/users/me"))
|
||||
|
||||
@@ -7,7 +7,6 @@ import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
@@ -35,7 +34,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
/**
|
||||
* "Sign in through Steam" through the whole filter chain and a real database; only Steam itself is mocked.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class SteamLinkFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String EMAIL = "steam-link@example.com";
|
||||
@@ -61,6 +59,7 @@ class SteamLinkFlowTest extends AbstractIntegrationTest {
|
||||
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.
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
@@ -30,7 +29,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
* Delete my account against a real database. Core is not reachable in tests, so this also proves that a failed
|
||||
* mark in Core does not fail the delete.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String EMAIL = "delete-flow@example.com";
|
||||
@@ -59,6 +57,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
deleteAccount(access, PASSWORD).andExpect(status().isNoContent());
|
||||
Long newUser = jdbc.queryForObject("INSERT INTO users (email, password, enabled) VALUES (?, 'x', true) RETURNING id",
|
||||
Long.class, newEmail);
|
||||
addSettingsRows();
|
||||
|
||||
try {
|
||||
assertThat(userService.linkVerifiedSteamId(newUser, steamId)).isEqualTo(SteamLinkOutcome.LINKED);
|
||||
@@ -92,8 +91,26 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
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();
|
||||
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
|
||||
@@ -137,6 +154,8 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
MockHttpServletResponse session = login();
|
||||
String access = session.getCookie(AuthCookies.ACCESS_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();
|
||||
|
||||
MockHttpServletResponse deleted = deleteAccount(access, PASSWORD).andExpect(status().isNoContent())
|
||||
@@ -149,10 +168,32 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, refresh)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
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());
|
||||
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
|
||||
void registration_WithTheEmailOfAFlaggedUser_IsStillRefused() throws Exception {
|
||||
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.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
@@ -15,7 +14,6 @@ import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Import(AccountRepository.class)
|
||||
class AccountRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS);
|
||||
@@ -45,6 +43,8 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
// Integration tests share the database and commit their rows.
|
||||
jdbc.update("DELETE FROM users");
|
||||
alice = insertUser("alice@example.com");
|
||||
bob = insertUser("bob@example.com");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
@@ -26,7 +25,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
/**
|
||||
* Roles, "view as" and the admin users API against a real database.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class AdminFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
@@ -48,8 +46,10 @@ class AdminFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private long insertUser(String email, String role) {
|
||||
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));
|
||||
addSettingsRows();
|
||||
return id;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@@ -167,9 +167,12 @@ class AdminFlowTest extends AbstractIntegrationTest {
|
||||
void block_EndsTheSessionsAndTheLoginTellsWhy() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
Session player = Session.of(login(PLAYER));
|
||||
assertThat(me(player)).isEqualTo(200);
|
||||
|
||||
block(admin, playerId, true).andExpect(status().isNoContent());
|
||||
|
||||
assertThat(me(player)).isEqualTo(401);
|
||||
|
||||
assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_BLOCKED");
|
||||
assertThat(login(PLAYER).getStatus()).isEqualTo(403);
|
||||
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());
|
||||
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
|
||||
void delete_FlagsTheUser() throws Exception {
|
||||
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")
|
||||
.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(login(PLAYER).getStatus()).isEqualTo(401);
|
||||
assertThat(me(player)).isEqualTo(401);
|
||||
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.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Import(AdminUserRepository.class)
|
||||
class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
@@ -7,7 +7,6 @@ import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
@@ -24,7 +23,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@AutoConfigureMockMvc
|
||||
class HintFlowTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
@@ -45,6 +43,7 @@ class HintFlowTest extends AbstractIntegrationTest {
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", email);
|
||||
jdbc.update("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?)",
|
||||
email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16));
|
||||
addSettingsRows();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
|
||||
@@ -9,7 +9,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -19,7 +18,6 @@ import java.time.temporal.ChronoUnit;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.tuple;
|
||||
|
||||
@Import(HintRepository.class)
|
||||
class HintRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
|
||||
+39
-41
@@ -1,68 +1,66 @@
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import 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 {
|
||||
|
||||
@Autowired
|
||||
private MinimalUserRepository minimalUserRepository;
|
||||
|
||||
@Autowired
|
||||
private TestEntityManager entityManager;
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Test
|
||||
void findByEmail_Success() {
|
||||
// Arrange
|
||||
var email = "minimal@example.com";
|
||||
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());
|
||||
private long insertUser(String email) {
|
||||
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, registration_date) "
|
||||
+ "VALUES (?, 'password', TRUE, ?) RETURNING id", Long.class, email, LocalDateTime.now());
|
||||
return id == null ? 0 : id;
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateLoginDate_Success() {
|
||||
// Arrange
|
||||
var entity = MinimalUserEntity.builder()
|
||||
.email("login@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.build();
|
||||
entity = entityManager.persist(entity);
|
||||
entityManager.flush();
|
||||
void findByEmail_MapsTheUser() {
|
||||
long id = insertUser("minimal@example.com");
|
||||
jdbc.update("UPDATE users SET steam_id = 76561198000000001, deleted_at = now() WHERE id = ?", id);
|
||||
|
||||
// Act
|
||||
minimalUserRepository.updateLoginDate(entity.getId());
|
||||
entityManager.clear(); // Clear cache to force reload from DB
|
||||
User user = minimalUserRepository.findByEmail("minimal@example.com").orElseThrow();
|
||||
|
||||
// Assert
|
||||
var updated = entityManager.find(MinimalUserEntity.class, entity.getId());
|
||||
assertNotNull(updated.getLastLoginDate());
|
||||
assertEquals(id, user.getId());
|
||||
assertEquals("password", user.getPassword());
|
||||
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.
|
||||
@Test
|
||||
void findByEmail_IgnoresCase() {
|
||||
entityManager.persist(MinimalUserEntity.builder().email("case@example.com").password("password").enabled(true).build());
|
||||
entityManager.flush();
|
||||
insertUser("case@example.com");
|
||||
|
||||
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.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@@ -25,7 +24,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@AutoConfigureMockMvc
|
||||
class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
|
||||
+46
-43
@@ -1,75 +1,78 @@
|
||||
package auth_service.modules.registration.persistence;
|
||||
|
||||
import auth_service.modules.registration.model.Registration;
|
||||
import auth_service.modules.registration.persistence.entities.RegisteredUserEntity;
|
||||
import auth_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
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 {
|
||||
|
||||
@Autowired
|
||||
private RegisteredUserRepository registeredUserRepository;
|
||||
|
||||
@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
|
||||
void existsByEmail_Success() {
|
||||
// Arrange
|
||||
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();
|
||||
void existsByEmail_OnlyForAStoredEmail() {
|
||||
registeredUserRepository.save(registration("exists@example.com", null));
|
||||
|
||||
// Act & Assert
|
||||
assertTrue(registeredUserRepository.existsByEmail(email));
|
||||
assertTrue(registeredUserRepository.existsByEmail("exists@example.com"));
|
||||
assertFalse(registeredUserRepository.existsByEmail("other@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_Success() {
|
||||
// Arrange
|
||||
var registration = Registration.builder()
|
||||
.email("save@example.com")
|
||||
.encryptedPassword("hashed")
|
||||
.build();
|
||||
void existsByDisplayName_IgnoresCase() {
|
||||
registeredUserRepository.save(registration("named@example.com", "RegisteredName"));
|
||||
|
||||
// Act
|
||||
long id = registeredUserRepository.save(registration);
|
||||
assertTrue(registeredUserRepository.existsByDisplayName("registeredname"));
|
||||
assertFalse(registeredUserRepository.existsByDisplayName("Wolf"));
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertTrue(id > 0);
|
||||
var saved = entityManager.find(RegisteredUserEntity.class, id);
|
||||
assertNotNull(saved);
|
||||
assertEquals("save@example.com", saved.getEmail());
|
||||
@Test
|
||||
void save_StoresAnEnabledUser() {
|
||||
long id = registeredUserRepository.save(registration("save@example.com", "Saver"));
|
||||
|
||||
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.
|
||||
@Test
|
||||
void emailsThatDifferOnlyInCase_AreOneAccount() {
|
||||
entityManager.persist(RegisteredUserEntity.builder().email("dup@example.com").password("password")
|
||||
.enabled(true).registrationDate(java.time.LocalDateTime.now()).build());
|
||||
entityManager.flush();
|
||||
registeredUserRepository.save(registration("dup@example.com", null));
|
||||
|
||||
assertTrue(registeredUserRepository.existsByEmail("Dup@Example.com"));
|
||||
var refused = assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> {
|
||||
entityManager.persist(RegisteredUserEntity.builder().email("Dup@example.com").password("password")
|
||||
.enabled(true).registrationDate(java.time.LocalDateTime.now()).build());
|
||||
entityManager.flush();
|
||||
});
|
||||
// GlobalExceptionController decides on the state; on an English server Hibernate also gives the name.
|
||||
assertEquals("23505", refused.getSQLState());
|
||||
assertEquals("users_email_lower", refused.getConstraintName());
|
||||
DuplicateKeyException refused = assertThrows(DuplicateKeyException.class,
|
||||
() -> registeredUserRepository.save(registration("Dup@example.com", null)));
|
||||
// GlobalExceptionController finds the name in the message, in every server language.
|
||||
assertTrue(refused.getMostSpecificCause().getMessage().contains("users_email_lower"));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import auth_service.shared.test.AbstractPersistenceTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -21,7 +20,6 @@ import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Import(UserRepositoryImpl.class)
|
||||
class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
@@ -43,14 +41,31 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.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
|
||||
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());
|
||||
// The delete request commits after the settings request loaded the user.
|
||||
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();
|
||||
|
||||
assertNotNull(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, user.getId()));
|
||||
@@ -67,8 +82,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.steamId(123456L)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build();
|
||||
entityManager.persist(userEntity);
|
||||
entityManager.flush();
|
||||
persisted(userEntity);
|
||||
|
||||
// Act
|
||||
var result = findByEmail(email);
|
||||
@@ -89,8 +103,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.steamId(123456L)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build();
|
||||
userEntity = entityManager.persist(userEntity);
|
||||
entityManager.flush();
|
||||
userEntity = persisted(userEntity);
|
||||
|
||||
var settings = UserSettings.builder()
|
||||
.language("de")
|
||||
@@ -98,7 +111,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.build();
|
||||
|
||||
// Act
|
||||
userRepository.updateSettings(userEntity.getId(), null, settings);
|
||||
updateSettings(userEntity.getId(), null, settings);
|
||||
|
||||
// Assert: the Steam ID changes only through the Steam link
|
||||
var updatedUser = entityManager.find(UserEntity.class, userEntity.getId());
|
||||
@@ -112,13 +125,12 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
void findByEmail_NoSteamId() {
|
||||
// Arrange
|
||||
var email = "nosteam@example.com";
|
||||
entityManager.persist(UserEntity.builder()
|
||||
persisted(UserEntity.builder()
|
||||
.email(email)
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
// Act
|
||||
var result = findByEmail(email);
|
||||
@@ -129,7 +141,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
private UserEntity user(String email, Long steamId, boolean verified) {
|
||||
UserEntity user = entityManager.persist(UserEntity.builder()
|
||||
UserEntity user = persisted(UserEntity.builder()
|
||||
.email(email)
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
@@ -137,7 +149,6 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.steamIdVerified(verified)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -173,7 +184,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
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());
|
||||
jdbc.update("UPDATE users SET deleted_at = NOW() WHERE id = ?", deleted.getId());
|
||||
|
||||
@@ -184,7 +195,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
void updateSettings_UnknownUser() {
|
||||
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
|
||||
@@ -196,43 +207,41 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
@Test
|
||||
void displayName_StoredAndCheckedCaseInsensitive() {
|
||||
// Arrange
|
||||
var owner = entityManager.persist(UserEntity.builder()
|
||||
var owner = persisted(UserEntity.builder()
|
||||
.email("owner2@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
var other = entityManager.persist(UserEntity.builder()
|
||||
var other = persisted(UserEntity.builder()
|
||||
.email("other2@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
var settings = UserSettings.builder().language("en").theme("light").build();
|
||||
|
||||
// Act
|
||||
userRepository.updateSettings(owner.getId(), "Lobo", settings);
|
||||
updateSettings(owner.getId(), "RepoName", settings);
|
||||
entityManager.flush();
|
||||
|
||||
// Assert
|
||||
assertEquals("Lobo", findByEmail("owner2@example.com").orElseThrow().getDisplayName());
|
||||
assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "lobo"));
|
||||
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "LOBO"));
|
||||
assertEquals("RepoName", findByEmail("owner2@example.com").orElseThrow().getDisplayName());
|
||||
assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "reponame"));
|
||||
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "REPONAME"));
|
||||
assertFalse(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "Wolf"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateSettings_StoresPublicProfile_AndFindByEmailReadsIt() {
|
||||
var userEntity = entityManager.persist(UserEntity.builder()
|
||||
var userEntity = persisted(UserEntity.builder()
|
||||
.email("public@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
userRepository.updateSettings(userEntity.getId(), "Lobo",
|
||||
updateSettings(userEntity.getId(), "RepoName",
|
||||
UserSettings.builder().language("de").theme("dark").publicProfile(true).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
@@ -240,37 +249,19 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
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
|
||||
void updateSettings_KeepsTheAppearance() {
|
||||
var userEntity = entityManager.persist(UserEntity.builder()
|
||||
var userEntity = persisted(UserEntity.builder()
|
||||
.email("keep@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L, null, true));
|
||||
userRepository.update(userEntity.getId(),
|
||||
user -> user.getSettings().setAppearance(new Appearance("pink", "sharp", false, 220L, null, true)));
|
||||
entityManager.flush();
|
||||
|
||||
userRepository.updateSettings(userEntity.getId(), null,
|
||||
updateSettings(userEntity.getId(), null,
|
||||
UserSettings.builder().language("de").theme("dark").build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
@@ -279,34 +270,19 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
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
|
||||
void updateProfileStyle_IsReadBackAndStartsWithTheDefaults() {
|
||||
UserEntity userEntity = entityManager.persist(UserEntity.builder()
|
||||
UserEntity userEntity = persisted(UserEntity.builder()
|
||||
.email("style@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
assertEquals(ProfileStyle.DEFAULT, findByEmail("style@example.com").orElseThrow()
|
||||
.getSettings().getProfileStyle());
|
||||
|
||||
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.clear();
|
||||
|
||||
@@ -315,23 +291,22 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void updateTiles_AreReadBackAndKeptByUpdateSettings() {
|
||||
UserEntity userEntity = entityManager.persist(UserEntity.builder()
|
||||
UserEntity userEntity = persisted(UserEntity.builder()
|
||||
.email("tiles@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
UserSettings fresh = findByEmail("tiles@example.com").orElseThrow().getSettings();
|
||||
assertNull(fresh.getDashboardTiles());
|
||||
assertEquals(UserSettings.DEFAULT_PROFILE_TILES, fresh.getProfileTiles());
|
||||
|
||||
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"));
|
||||
userRepository.updateDashboardTiles(userEntity.getId(), dashboard);
|
||||
userRepository.updateProfileTiles(userEntity.getId(), profile);
|
||||
userRepository.update(userEntity.getId(), user -> user.getSettings().setDashboardTiles(dashboard));
|
||||
userRepository.update(userEntity.getId(), user -> user.getSettings().setProfileTiles(profile));
|
||||
entityManager.flush();
|
||||
userRepository.updateSettings(userEntity.getId(), null,
|
||||
updateSettings(userEntity.getId(), null,
|
||||
UserSettings.builder().language("de").theme("dark").build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
@@ -343,7 +318,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void updateFavoritePlatform_StoresAndClears() {
|
||||
UserEntity userEntity = entityManager.persist(UserEntity.builder()
|
||||
UserEntity userEntity = persisted(UserEntity.builder()
|
||||
.email("favorite@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
@@ -351,13 +326,13 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.build());
|
||||
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.clear();
|
||||
assertEquals("Switch", findByEmail("favorite@example.com").orElseThrow().getSettings()
|
||||
.getFavoritePlatform());
|
||||
|
||||
userRepository.updateFavoritePlatform(userEntity.getId(), null);
|
||||
userRepository.update(userEntity.getId(), user -> user.getSettings().setFavoritePlatform(null));
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
assertNull(findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform());
|
||||
@@ -365,24 +340,29 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void updateDashboardTiles_NullResetsToTheDefault() {
|
||||
UserEntity userEntity = entityManager.persist(UserEntity.builder()
|
||||
UserEntity userEntity = persisted(UserEntity.builder()
|
||||
.email("reset@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.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();
|
||||
|
||||
userRepository.updateDashboardTiles(userEntity.getId(), null);
|
||||
userRepository.update(userEntity.getId(), user -> user.getSettings().setDashboardTiles(null));
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertNull(findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAppearance_UnknownUser() {
|
||||
assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT));
|
||||
private void updateSettings(long userId, String displayName, UserSettings settings) {
|
||||
userRepository.update(userId, user -> {
|
||||
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.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
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.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"), new Tile("SUMMARY", "S")));
|
||||
|
||||
verify(userRepository).updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S")));
|
||||
verify(userRepository).updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M")));
|
||||
List<User> changed = appliedChanges(2);
|
||||
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
|
||||
void updateDashboardTiles_NullStaysNull() {
|
||||
userService.updateDashboardTiles(1L, null);
|
||||
|
||||
verify(userRepository).updateDashboardTiles(1L, null);
|
||||
assertNull(appliedChanges(1).getFirst().getSettings().getDashboardTiles());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -77,7 +82,7 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
|
||||
userService.updateUserSettings(1L, null, userSettings);
|
||||
|
||||
verify(userRepository).updateSettings(1L, null, userSettings);
|
||||
appliedChanges(1);
|
||||
verify(userRepository, never()).clearSteamId(anyLong());
|
||||
verify(userRepository, never()).setVerifiedSteamId(anyLong(), anyLong());
|
||||
verifyNoInteractions(tokenGenerator);
|
||||
@@ -92,7 +97,7 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
// Act & Assert
|
||||
assertThrows(DisplayNameAlreadyInUseException.class,
|
||||
() -> userService.updateUserSettings(1L, "Lobo", userSettings));
|
||||
verify(userRepository, never()).updateSettings(anyLong(), any(), any());
|
||||
verify(userRepository, never()).update(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -105,7 +110,9 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
userService.updateUserSettings(1L, "Lobo", userSettings);
|
||||
|
||||
// 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;
|
||||
@@ -165,7 +172,28 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
|
||||
userService.updateAppearance(1L, appearance);
|
||||
|
||||
verify(userRepository).updateAppearance(1L, appearance);
|
||||
assertEquals(appearance, appliedChanges(1).getFirst().getSettings().getAppearance());
|
||||
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;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jws;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.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.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
@@ -12,25 +17,31 @@ import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Base class for integration tests.
|
||||
* Full Spring context with Testcontainers.
|
||||
*/
|
||||
@Tag("IntegrationTest")
|
||||
@AutoConfigureMockMvc
|
||||
// The account cleanup must not run by itself, and Core is never reachable in tests.
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"})
|
||||
@ActiveProfiles("dev")
|
||||
public abstract class AbstractIntegrationTest {
|
||||
|
||||
// One container per JVM, not per class: Spring caches the context across test classes,
|
||||
// and a per-class container would be stopped under the cached context.
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||
static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;
|
||||
|
||||
static {
|
||||
postgres.start();
|
||||
@Autowired
|
||||
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.
|
||||
@@ -43,6 +54,19 @@ public abstract class AbstractIntegrationTest {
|
||||
registry.add("auth.registration.invitation-code", () -> "test-invite-code");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same token, signed again with an expiry in the past.
|
||||
*/
|
||||
protected static String expiredCopy(String token) {
|
||||
Jws<Claims> jws = Jwts.parser().verifyWith(KEYS.getPublic()).build().parseSignedClaims(token);
|
||||
return Jwts.builder()
|
||||
.header().keyId(jws.getHeader().getKeyId()).and()
|
||||
.claims(jws.getPayload())
|
||||
.expiration(new Date(System.currentTimeMillis() - 1000))
|
||||
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA)
|
||||
.compact();
|
||||
}
|
||||
|
||||
private static KeyPair generateKeys() {
|
||||
try {
|
||||
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
|
||||
@@ -4,6 +4,9 @@ import org.junit.jupiter.api.Tag;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.FilterType;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
|
||||
/**
|
||||
@@ -11,16 +14,11 @@ import org.testcontainers.containers.PostgreSQLContainer;
|
||||
* Slices only the persistence layer with Testcontainers.
|
||||
*/
|
||||
@Tag("PersistenceTest")
|
||||
@DataJpaTest
|
||||
// Every @Repository, so all persistence tests share one Spring context.
|
||||
@DataJpaTest(includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Repository.class))
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
public abstract class AbstractPersistenceTest {
|
||||
|
||||
// One container per JVM, not per class: Spring caches the context across test classes,
|
||||
// and a per-class container would be stopped under the cached context.
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||
|
||||
static {
|
||||
postgres.start();
|
||||
}
|
||||
static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth_service.shared.test;
|
||||
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
|
||||
/**
|
||||
* One database for all integration and persistence tests of a JVM. Integration tests commit their rows, so every
|
||||
* test uses emails and display names no other test uses.
|
||||
*/
|
||||
final class TestPostgres {
|
||||
|
||||
static final PostgreSQLContainer<?> CONTAINER = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||
|
||||
static {
|
||||
CONTAINER.start();
|
||||
}
|
||||
|
||||
private TestPostgres() {
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user