Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 3c2f40036d feat(library): let auditors correct game data
Build, Test & Push Core Service / docker (push) Successful in 1m25s
Build, Test & Push Core Service / quality-check (push) Successful in 1m0s
An AUDITOR corrects name, header image, speedrun link, moods, endless and genres of a game for every user (V20 game_correction, PUT/DELETE /api/library/games/{appid}/corrections/{field}). The value is also written into the cached row, so reads stay unchanged; the Steam sync, the image repair, the IGDB add and the mood and speedrun upserts skip corrected fields. SteamGame gets @DynamicUpdate so a sync no longer writes back columns it did not change.
2026-09-28 09:31:39 +02:00
lobo 2596df609e feat(security): check role markers on services and read the role claim
Every service method a controller calls carries @RequiresRole, @Public or @Internal; a Spring Security method interceptor checks it while a request is served. Core now grants the active role from the token and every lower one. ArchUnit fails the build for an unmarked method, two markers, a controller calling an @Async method, or a service reaching a method that needs more. The background refreshes moved from LibraryController into the services, because an @Async method is never checked.
2026-09-28 08:50:10 +02:00
72 changed files with 2180 additions and 41 deletions

No files matched your search

+23 -1
View File
@@ -70,7 +70,9 @@ To Be Played is split into three repositories:
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId` and `steamId` from the claims.
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId`, `steamId` and the active
`role` (missing or unknown = USER) from the claims and grants that role and every lower one
(`USER < AUDITOR < PLATFORM_ADMIN`).
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
@@ -79,12 +81,21 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its
only guard.
Every service method a controller calls carries one role marker, on the method or on its class (the method wins):
`@RequiresRole(Role.X)`, `@Public` (no login, e.g. landing stats; the public profile checks its ticket) or
`@Internal` (only Auth's service token). A Spring Security method interceptor checks it while a request is served;
scheduled jobs and `@Async` work run without a user and are not checked. A denied call answers `401` without a
login, else `403`. The URL rules in `SecurityConfig` stay as the outer guard. ArchUnit fails the build for an
unmarked method, two markers, a controller calling an `@Async` method (the check would never run), and a service
method calling another service's method that needs more.
## API
| Method | Path | Auth |
|---|---|---|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
@@ -196,6 +207,17 @@ The frontend polls `GET /api/library/sync-status` until the run ends.
At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table
`broken_image`, one row per game and kind). Reports only count for Steam games the user owns.
### Corrections by an auditor
An AUDITOR (active role) corrects a game's name, header image, speedrun link, moods, endless flag and genres for
every user (`game_correction`, V20). The value is also written into the cached row (`steam_game`, `game_speedrun`,
`game_mood`), so no read joins the corrections. The refresh writers skip a corrected field: the Steam sync (name),
the image repair (image), the IGDB add (name, image), the mood and speedrun upserts (SQL). **A new writer of these
fields must do the same.** "Back to source" (`DELETE`) writes back the source value from the first correction and
lets the next refresh ask IGDB or speedrun.com again. An image must be on a host of the frontend's CSP.
Known limit: a mood or speedrun refresh that waits on the row lock while an auditor saves can still write the
source value once (its guard sees the state from before the save); the auditor saves again.
### Deleted accounts
Auth drives it (see Auth's readme). `POST /internal/accounts/deleted` adds a tombstone in `deleted_user`: the user
@@ -1,9 +1,12 @@
package core_service.configuration.security;
import core_service.shared.security.Role;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
@@ -19,6 +22,14 @@ final class AccessTokens {
private AccessTokens() {
}
/**
* The active role and every role below it, so a check for USER also lets an admin in.
*/
static List<SimpleGrantedAuthority> authorities(Role role) {
return Arrays.stream(Role.values()).filter(role::atLeast)
.map(each -> new SimpleGrantedAuthority("ROLE_" + each.name())).toList();
}
static Stream<String> candidates(HttpServletRequest request) {
return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream);
}
@@ -16,7 +16,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
/**
@@ -49,7 +48,7 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal,
null,
Collections.emptyList()
AccessTokens.authorities(principal.role())
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
@@ -15,7 +15,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
@Component("jwtAuthenticationFilter")
@@ -40,7 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal.get(),
null,
Collections.emptyList()
AccessTokens.authorities(principal.get().role())
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
@@ -2,6 +2,7 @@ package core_service.configuration.security;
import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
@@ -23,6 +24,7 @@ public class JwtService implements ProfileTicketVerifier {
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final String SERVICE_TOKEN_TYPE = "service";
private static final String ROLE_CLAIM = "role";
private final JwtParser parser;
@@ -45,7 +47,8 @@ public class JwtService implements ProfileTicketVerifier {
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
return Optional.empty();
}
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class)));
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class),
Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null)));
} catch (Exception e) {
return Optional.empty();
}
@@ -0,0 +1,34 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.RequiresRole;
import org.springframework.aop.Advisor;
import org.springframework.aop.Pointcut;
import org.springframework.aop.support.Pointcuts;
import org.springframework.aop.support.annotation.AnnotationMatchingPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Runs {@link RoleAuthorizationManager} before every method with a role marker, or of a class with one. A method
* that is only {@code @Public} needs no check.
*/
@Configuration
@EnableMethodSecurity(prePostEnabled = false)
public class MethodSecurityConfig {
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
static Advisor roleAuthorization() {
Pointcut marked = Pointcuts.union(
Pointcuts.union(new AnnotationMatchingPointcut(RequiresRole.class, true),
new AnnotationMatchingPointcut(null, RequiresRole.class, true)),
Pointcuts.union(new AnnotationMatchingPointcut(Internal.class, true),
new AnnotationMatchingPointcut(null, Internal.class, true)));
return new AuthorizationManagerBeforeMethodInterceptor(marked, new RoleAuthorizationManager());
}
}
@@ -0,0 +1,57 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.aop.support.AopUtils;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.context.request.RequestContextHolder;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.util.function.Supplier;
class RoleAuthorizationManager implements AuthorizationManager<MethodInvocation> {
private static final AuthorizationDecision GRANTED = new AuthorizationDecision(true);
private static final AuthorizationDecision DENIED = new AuthorizationDecision(false);
@Override
public AuthorizationResult authorize(Supplier<? extends Authentication> authentication, MethodInvocation invocation) {
if (RequestContextHolder.getRequestAttributes() == null) {
return GRANTED;
}
Class<?> targetClass = invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis())
: invocation.getMethod().getDeclaringClass();
Method method = AopUtils.getMostSpecificMethod(invocation.getMethod(), targetClass);
AnnotatedElement marked = isMarked(method) ? method : targetClass;
if (AnnotatedElementUtils.hasAnnotation(marked, Public.class)) {
return GRANTED;
}
if (AnnotatedElementUtils.hasAnnotation(marked, Internal.class)) {
return hasAuthority(authentication.get(), ServiceTokenFilter.SERVICE_AUTHORITY) ? GRANTED : DENIED;
}
RequiresRole required = AnnotatedElementUtils.findMergedAnnotation(marked, RequiresRole.class);
if (required == null) {
return DENIED;
}
return hasAuthority(authentication.get(), "ROLE_" + required.value().name()) ? GRANTED : DENIED;
}
private static boolean isMarked(Method method) {
return AnnotatedElementUtils.hasAnnotation(method, RequiresRole.class)
|| AnnotatedElementUtils.hasAnnotation(method, Public.class)
|| AnnotatedElementUtils.hasAnnotation(method, Internal.class);
}
private static boolean hasAuthority(Authentication authentication, String authority) {
return authentication != null && authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).anyMatch(authority::equals);
}
}
@@ -2,6 +2,7 @@ package core_service.modules.account.service;
import core_service.modules.account.domain.CleanupResult;
import core_service.modules.account.repo.AccountCleanupRepository;
import core_service.shared.security.Internal;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
@@ -16,6 +17,7 @@ import java.util.Map;
* Auth decides who is deleted and when; Core only checks that a purged user is tombstoned.
*/
@Service
@Internal
@Slf4j
public class AccountCleanupService {
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CorrectionDto;
import core_service.modules.library.api.dto.FinishedHintDismissal;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.HiddenUpdateDto;
@@ -19,12 +20,12 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameCorrectionService;
import core_service.modules.library.service.GameDetailService;
import core_service.modules.library.service.ImageRepairService;
import core_service.modules.library.service.InvalidCorrectionException;
import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.modules.library.service.SteamProfileService;
import core_service.modules.library.service.SyncCooldownException;
import core_service.modules.library.service.UserStatsService;
@@ -37,6 +38,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -70,10 +72,9 @@ public class LibraryController {
private final GameDetailService gameDetailService;
private final BacklogService backlogService;
private final SteamProfileService steamProfileService;
private final MoodService moodService;
private final SpeedrunService speedrunService;
private final ImageRepairService imageRepairService;
private final UserStatsService userStatsService;
private final GameCorrectionService gameCorrectionService;
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
@@ -160,6 +161,41 @@ public class LibraryController {
return ResponseEntity.noContent().build();
}
/**
* An auditor's correction, for every user. 204; the game details show the new value.
*/
@PutMapping("/games/{appid}/corrections/{field}")
public ResponseEntity<?> correctGame(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid, @PathVariable("field") String field,
@RequestBody CorrectionDto correction) {
try {
return correctionAnswer(gameCorrectionService.correct(appid, field, correction.value(), principal.getIdAsLong()));
} catch (InvalidCorrectionException e) {
return invalidCorrection(e.getMessage());
}
}
/**
* Back to the source's value. 204 also when the field had no correction.
*/
@DeleteMapping("/games/{appid}/corrections/{field}")
public ResponseEntity<?> resetCorrection(@PathVariable("appid") Long appid, @PathVariable("field") String field) {
try {
return correctionAnswer(gameCorrectionService.reset(appid, field));
} catch (InvalidCorrectionException e) {
return invalidCorrection(e.getMessage());
}
}
private static ResponseEntity<?> correctionAnswer(GameCorrectionService.Result result) {
return result == GameCorrectionService.Result.DONE ? ResponseEntity.noContent().build()
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game"));
}
private static ResponseEntity<?> invalidCorrection(String message) {
return ResponseEntity.badRequest().body(Map.of("code", "INVALID_CORRECTION", "message", message));
}
@PutMapping("/games/{appid}/backlog")
public ResponseEntity<?> updateBacklog(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@@ -241,11 +277,6 @@ public class LibraryController {
if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
}
if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) {
moodService.refreshBacklogInBackground(userId);
}
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
}
@@ -257,10 +288,7 @@ public class LibraryController {
@RequestParam(required = false) Set<StatGroup> groups) {
Long userId = principal.getIdAsLong();
Set<StatGroup> asked = groups == null || groups.isEmpty() ? EnumSet.allOf(StatGroup.class) : groups;
if (asked.contains(StatGroup.TASTE)) {
moodService.refreshListedInBackground(userId);
}
return ResponseEntity.ok(userStatsService.getStats(userId, asked));
return ResponseEntity.ok(userStatsService.getOwnStats(userId, asked));
}
@GetMapping("/sync-status")
@@ -0,0 +1,6 @@
package core_service.modules.library.api.dto;
/**
* @param value a string (name, image, speedrun), a list of names (moods, genres), a boolean (endless), or null
*/
public record CorrectionDto(Object value) {}
@@ -0,0 +1,7 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.CorrectionField;
import java.time.Instant;
public record CorrectionMarkDto(CorrectionField field, Instant correctedAt) {}
@@ -35,7 +35,15 @@ public record GameDetailDto(
// see GameDto#hidden
boolean hidden,
// null when there is nothing to ask
FinishedHintDto finishedHint
FinishedHintDto finishedHint,
// Mood names without SPEEDRUN (that is speedrunUrl); empty until IGDB was asked
List<String> moods,
// null when not known
Boolean endless,
// IGDB genre names
List<String> genres,
// the fields an auditor corrected
List<CorrectionMarkDto> corrections
) {
/**
@@ -0,0 +1,15 @@
package core_service.modules.library.domain;
import java.util.Arrays;
import java.util.Optional;
public enum CorrectionField {
NAME, IMAGE, SPEEDRUN, MOODS, ENDLESS, GENRES;
/**
* @param name the lower-case name in the URL, e.g. "speedrun"
*/
public static Optional<CorrectionField> fromPath(String name) {
return Arrays.stream(values()).filter(field -> field.name().equalsIgnoreCase(name)).findFirst();
}
}
@@ -0,0 +1,55 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
/**
* An auditor's correction of one field of a game, for every user. See V20 for how it is applied.
*/
@Entity
@Table(name = "game_correction")
@IdClass(GameCorrectionId.class)
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameCorrection {
@Id
@Column(name = "appid")
private Long appid;
@Id
@Enumerated(EnumType.STRING)
@Column(name = "field")
private CorrectionField field;
/** JSON; null (the JSON text, not SQL NULL) is a valid value, e.g. "no speedrun". */
@Column(name = "value")
private String value;
@Column(name = "source_value")
private String sourceValue;
@Column(name = "corrected_by", nullable = false)
private Long correctedBy;
@Column(name = "corrected_at", nullable = false)
private Instant correctedAt;
public GameCorrection(Long appid, CorrectionField field, String sourceValue) {
this.appid = appid;
this.field = field;
this.sourceValue = sourceValue;
}
}
@@ -0,0 +1,19 @@
package core_service.modules.library.domain;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class GameCorrectionId implements Serializable {
private Long appid;
private CorrectionField field;
}
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import org.hibernate.annotations.DynamicUpdate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -12,6 +13,8 @@ import lombok.Setter;
import java.time.Instant;
@Entity
// Only changed columns: a sync touching the icon must not write back a name or image an auditor changed meanwhile.
@DynamicUpdate
@Table(name = "steam_game")
@Getter
@Setter
@@ -0,0 +1,35 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameCorrection;
import core_service.modules.library.domain.GameCorrectionId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Set;
public interface GameCorrectionRepository extends JpaRepository<GameCorrection, GameCorrectionId> {
List<GameCorrection> findByAppidOrderByField(Long appid);
@Query("select c.appid from GameCorrection c where c.field = :field and c.appid in :appids")
Set<Long> findCorrectedAppids(@Param("field") CorrectionField field, @Param("appids") Collection<Long> appids);
/**
* source_value only on the first correction: it stays what the source had before any auditor.
*/
@Modifying
@Query(value = "insert into game_correction (appid, field, value, source_value, corrected_by, corrected_at) "
+ "values (:appid, :field, :value, :sourceValue, :correctedBy, :correctedAt) "
+ "on conflict (appid, field) do update set value = excluded.value, "
+ "corrected_by = excluded.corrected_by, corrected_at = excluded.corrected_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("field") String field, @Param("value") String value,
@Param("sourceValue") String sourceValue, @Param("correctedBy") Long correctedBy,
@Param("correctedAt") Instant correctedAt);
}
@@ -20,14 +20,50 @@ public interface GameMoodsRepository extends JpaRepository<GameMoods, Long> {
* @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits
* @param genres IGDB genre names; empty when IGDB has none
*/
// An upsert, not save(): two users' syncs can store the same new game at the same time.
// An upsert, not save(): two users' syncs can store the same new game at the same time. A corrected column
// keeps the auditor's value.
@Modifying
@Transactional
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, cast(:moods as varchar(16)[]), cast(:genres as varchar(64)[]), :endless, :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods, genres = excluded.genres, " +
"endless = excluded.endless, checked_at = excluded.checked_at",
"on conflict (appid) do update set " +
"moods = case when " + CORRECTED + "'MOODS') then game_mood.moods else excluded.moods end, " +
"genres = case when " + CORRECTED + "'GENRES') then game_mood.genres else excluded.genres end, " +
"endless = case when " + CORRECTED + "'ENDLESS') then game_mood.endless else excluded.endless end, " +
"checked_at = excluded.checked_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("genres") String[] genres,
@Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt);
String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_mood.appid and c.field = ";
// A game IGDB never answered for has no row yet; it gets one with nothing known but the corrected column, and
// checkedAt only for that new row (stale, so the next refresh fills the other columns).
@Modifying
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, cast(:moods as varchar(16)[]), '{}', null, :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods",
nativeQuery = true)
void writeMoods(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("checkedAt") Instant checkedAt);
@Modifying
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, '{}', cast(:genres as varchar(64)[]), null, :checkedAt) " +
"on conflict (appid) do update set genres = excluded.genres",
nativeQuery = true)
void writeGenres(@Param("appid") Long appid, @Param("genres") String[] genres, @Param("checkedAt") Instant checkedAt);
@Modifying
@Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " +
"values (:appid, '{}', '{}', :endless, :checkedAt) " +
"on conflict (appid) do update set endless = excluded.endless",
nativeQuery = true)
void writeEndless(@Param("appid") Long appid, @Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt);
/**
* The next mood refresh asks IGDB again.
*/
@Modifying
@Query("update GameMoods m set m.checkedAt = :longAgo where m.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
}
@@ -16,13 +16,36 @@ public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long
@Query("select s.appid from GameSpeedrun s where s.appid in :appids and s.checkedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
// A corrected link keeps the auditor's value.
@Modifying
@Transactional
@Query(value = "insert into game_speedrun (appid, speedrun_game_id, weblink, checked_at) " +
"values (:appid, :speedrunGameId, :weblink, :checkedAt) " +
"on conflict (appid) do update set speedrun_game_id = excluded.speedrun_game_id, " +
"weblink = excluded.weblink, checked_at = excluded.checked_at",
"on conflict (appid) do update set " +
"speedrun_game_id = case when " + CORRECTED + " then game_speedrun.speedrun_game_id " +
"else excluded.speedrun_game_id end, " +
"weblink = case when " + CORRECTED + " then game_speedrun.weblink else excluded.weblink end, " +
"checked_at = excluded.checked_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("speedrunGameId") String speedrunGameId,
@Param("weblink") String weblink, @Param("checkedAt") Instant checkedAt);
String CORRECTED = "exists (select 1 from game_correction c where c.appid = game_speedrun.appid and c.field = 'SPEEDRUN')";
/**
* An auditor's link has no speedrun.com game id.
*/
@Modifying
@Query(value = "insert into game_speedrun (appid, speedrun_game_id, weblink, checked_at) " +
"values (:appid, null, :weblink, :checkedAt) " +
"on conflict (appid) do update set speedrun_game_id = null, weblink = excluded.weblink",
nativeQuery = true)
void writeWeblink(@Param("appid") Long appid, @Param("weblink") String weblink, @Param("checkedAt") Instant checkedAt);
/**
* The next speedrun check asks speedrun.com again.
*/
@Modifying
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
}
@@ -9,8 +9,17 @@ import org.springframework.transaction.annotation.Transactional;
public interface SteamGameRepository extends JpaRepository<SteamGame, Long> {
// Only for a name from Steam or IGDB: an auditor's name stays.
@Modifying
@Transactional
@Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid")
@Query("update SteamGame g set g.name = :name where g.appid = :appid and not exists "
+ "(select 1 from GameCorrection c where c.appid = g.appid and c.field = core_service.modules.library.domain.CorrectionField.NAME)")
void setSourceName(@Param("appid") Long appid, @Param("name") String name);
@Modifying
@Transactional
// An auditor's image stays.
@Query("update SteamGame g set g.imageUrl = :imageUrl where g.appid = :appid and not exists "
+ "(select 1 from GameCorrection c where c.appid = g.appid and c.field = core_service.modules.library.domain.CorrectionField.IMAGE)")
void setImageUrl(@Param("appid") Long appid, @Param("imageUrl") String imageUrl);
}
@@ -7,6 +7,8 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,6 +17,7 @@ import java.time.Instant;
import java.util.Optional;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class BacklogService {
@@ -0,0 +1,204 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameCorrection;
import core_service.modules.library.domain.GameCorrectionId;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.databind.json.JsonMapper;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* An auditor's corrections of shared game data; V20 says how they are applied.
*/
@Service
@RequiresRole(Role.AUDITOR)
@RequiredArgsConstructor
public class GameCorrectionService {
public enum Result { DONE, NOT_FOUND }
// The image hosts the frontend's Content Security Policy allows (nginx.conf img-src).
private static final Set<String> IMAGE_HOSTS = Set.of("steamcdn-a.akamaihd.net", "media.steampowered.com",
"images.igdb.com");
private static final String IMAGE_HOST_SUFFIX = ".steamstatic.com";
private static final int MAX_TEXT = 512;
private static final int MAX_GENRES = 10;
private static final int MAX_GENRE_LENGTH = 64;
// Far enough back that the next refresh asks the source again.
private static final Instant LONG_AGO = Instant.EPOCH;
private final SteamGameRepository steamGameRepository;
private final GameCorrectionRepository correctionRepository;
private final GameMoodsRepository gameMoodsRepository;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final JsonMapper jsonMapper;
/**
* @param fieldName see {@link CorrectionField#fromPath}
* @param value a string, a string list, a boolean or null, as the field needs
* @throws InvalidCorrectionException for an unknown field or a value that does not fit it
*/
@Transactional
public Result correct(long appid, String fieldName, Object value, long auditorId) {
CorrectionField field = field(fieldName);
Optional<SteamGame> game = steamGameRepository.findById(appid);
if (game.isEmpty()) {
return Result.NOT_FOUND;
}
Object clean = validate(field, value);
// One statement, not find-then-save: two auditors' first correction of a field must not collide on the key.
correctionRepository.upsert(appid, field.name(), jsonMapper.writeValueAsString(clean),
jsonMapper.writeValueAsString(cachedValue(field, game.get())), auditorId, Instant.now());
write(field, game.get(), clean);
return Result.DONE;
}
/**
* Back to the source: the value the source had at the first correction. Moods and the speedrun link are also
* asked again at the next refresh.
*/
@Transactional
public Result reset(long appid, String fieldName) {
CorrectionField field = field(fieldName);
Optional<SteamGame> game = steamGameRepository.findById(appid);
if (game.isEmpty()) {
return Result.NOT_FOUND;
}
Optional<GameCorrection> correction = correctionRepository.findById(new GameCorrectionId(appid, field));
if (correction.isEmpty()) {
return Result.DONE;
}
correctionRepository.delete(correction.get());
write(field, game.get(), jsonMapper.readValue(correction.get().getSourceValue(), Object.class));
switch (field) {
case MOODS, GENRES, ENDLESS -> gameMoodsRepository.markStale(appid, LONG_AGO);
case SPEEDRUN -> gameSpeedrunRepository.markStale(appid, LONG_AGO);
case NAME, IMAGE -> { }
}
return Result.DONE;
}
// Here, not in the controller: a USER gets 403 before learning which fields exist.
private static CorrectionField field(String name) {
return CorrectionField.fromPath(name).orElseThrow(() -> new InvalidCorrectionException("unknown field " + name));
}
private Object cachedValue(CorrectionField field, SteamGame game) {
return switch (field) {
case NAME -> game.getName();
case IMAGE -> game.getImageUrl();
case SPEEDRUN -> gameSpeedrunRepository.findById(game.getAppid()).map(GameSpeedrun::getWeblink).orElse(null);
case MOODS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getMoods).orElse(List.of());
case GENRES -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getGenres).orElse(List.of());
case ENDLESS -> gameMoodsRepository.findById(game.getAppid()).map(GameMoods::getEndless).orElse(null);
};
}
private void write(CorrectionField field, SteamGame game, Object value) {
switch (field) {
case NAME -> game.setName((String) value);
case IMAGE -> game.setImageUrl((String) value);
// A new row has only the corrected link, so the next check would ask speedrun.com for nothing.
case SPEEDRUN -> gameSpeedrunRepository.writeWeblink(game.getAppid(), (String) value, Instant.now());
case MOODS -> gameMoodsRepository.writeMoods(game.getAppid(), strings(value), LONG_AGO);
case GENRES -> gameMoodsRepository.writeGenres(game.getAppid(), strings(value), LONG_AGO);
case ENDLESS -> gameMoodsRepository.writeEndless(game.getAppid(), (Boolean) value, LONG_AGO);
}
}
private static String[] strings(Object list) {
return ((List<?>) list).stream().map(String.class::cast).toArray(String[]::new);
}
static Object validate(CorrectionField field, Object value) {
return switch (field) {
case NAME -> {
if (!(value instanceof String name) || name.isBlank() || name.strip().length() > MAX_TEXT) {
throw new InvalidCorrectionException("name must be 1 to " + MAX_TEXT + " characters");
}
yield name.strip();
}
case IMAGE -> {
if (value == null) {
yield null;
}
if (!(value instanceof String url) || url.length() > MAX_TEXT || !isAllowedImage(url)) {
throw new InvalidCorrectionException("image must be an https URL on " + IMAGE_HOSTS
+ " or *" + IMAGE_HOST_SUFFIX);
}
yield url;
}
case SPEEDRUN -> {
if (value == null) {
yield null;
}
if (!(value instanceof String url) || !SpeedrunMatch.isWeblink(url)) {
throw new InvalidCorrectionException("speedrun must be a page on " + SpeedrunMatch.WEBLINK_PREFIX);
}
yield url;
}
case MOODS -> textList(value, "moods").stream()
.map(GameCorrectionService::storedMood).distinct().sorted().toList();
case GENRES -> {
List<String> genres = textList(value, "genres").stream().map(String::strip).distinct().sorted().toList();
if (genres.size() > MAX_GENRES || genres.stream().anyMatch(g -> g.isEmpty() || g.length() > MAX_GENRE_LENGTH)) {
throw new InvalidCorrectionException("genres: at most " + MAX_GENRES + " names of 1 to "
+ MAX_GENRE_LENGTH + " characters");
}
yield genres;
}
case ENDLESS -> {
if (value != null && !(value instanceof Boolean)) {
throw new InvalidCorrectionException("endless must be true, false or null");
}
yield value;
}
};
}
private static List<String> textList(Object value, String name) {
if (!(value instanceof List<?> list) || list.stream().anyMatch(item -> !(item instanceof String))) {
throw new InvalidCorrectionException(name + " must be a list of names");
}
return list.stream().map(String.class::cast).toList();
}
private static boolean isAllowedImage(String url) {
try {
URI uri = new URI(url);
String host = uri.getHost();
return "https".equals(uri.getScheme()) && host != null
&& (IMAGE_HOSTS.contains(host) || host.endsWith(IMAGE_HOST_SUFFIX));
} catch (URISyntaxException e) {
return false;
}
}
// SPEEDRUN comes from the speedrun link, never from the stored moods.
private static String storedMood(String name) {
boolean known = Arrays.stream(Mood.values()).anyMatch(mood -> mood != Mood.SPEEDRUN && mood.name().equals(name));
if (!known) {
throw new InvalidCorrectionException("moods: unknown mood " + name + " (SPEEDRUN is the speedrun link)");
}
return name;
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementProgress;
@@ -17,6 +18,7 @@ import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
@@ -25,6 +27,8 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -39,6 +43,7 @@ import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
@Slf4j
public class GameDetailService {
@@ -56,6 +61,7 @@ public class GameDetailService {
private final GameSpeedrunRepository gameSpeedrunRepository;
private final SpeedrunService speedrunService;
private final GameMoodsRepository gameMoodsRepository;
private final GameCorrectionRepository gameCorrectionRepository;
@Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours;
@@ -153,10 +159,11 @@ public class GameDetailService {
Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid());
Set<String> unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName)
.collect(Collectors.toSet());
Optional<GameMoods> moods = gameMoodsRepository.findById(userGame.getAppid());
FinishedHintDto finishedHint = FinishedHint.of(new FinishedHint.Facts(userGame.getStatus(), userGame.getSource(),
schema, unlockedNames, statsVisible, userGame.getPlaytimeMinutes(),
timeToBeat.map(GameTimeToBeat::getNormallyMinutes).orElse(null),
gameMoodsRepository.findById(userGame.getAppid()).map(GameMoods::getEndless).orElse(null),
moods.map(GameMoods::getEndless).orElse(null),
userGame.getFinishedHintDismissed()))
.map(hint -> new FinishedHintDto(hint.type(), hint.achievement()))
.orElse(null);
@@ -190,7 +197,12 @@ public class GameDetailService {
meta != null ? meta.getImageUrl() : null,
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null),
userGame.getHiddenAt() != null,
finishedHint
finishedHint,
moods.map(GameMoods::getMoods).orElse(List.of()),
moods.map(GameMoods::getEndless).orElse(null),
moods.map(GameMoods::getGenres).orElse(List.of()),
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList()
);
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameKeys;
@@ -9,10 +10,13 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.modules.steam_adapter.service.SteamCdnService;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
@@ -35,12 +39,14 @@ import java.util.stream.Collectors;
* the job checks the picture itself first, so a false report (ad blocker, bad network) changes nothing.
*/
@Service
@RequiresRole(Role.USER)
@Slf4j
public class ImageRepairService {
private final BrokenImageRepository brokenImageRepository;
private final UserGameRepository userGameRepository;
private final SteamGameRepository steamGameRepository;
private final GameCorrectionRepository gameCorrectionRepository;
private final GameAchievementSchemaRepository schemaRepository;
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final AchievementSyncService achievementSyncService;
@@ -56,6 +62,7 @@ public class ImageRepairService {
public ImageRepairService(BrokenImageRepository brokenImageRepository,
UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository,
GameCorrectionRepository gameCorrectionRepository,
GameAchievementSchemaRepository schemaRepository,
GameAchievementSchemaMetaRepository schemaMetaRepository,
AchievementSyncService achievementSyncService,
@@ -68,6 +75,7 @@ public class ImageRepairService {
this.brokenImageRepository = brokenImageRepository;
this.userGameRepository = userGameRepository;
this.steamGameRepository = steamGameRepository;
this.gameCorrectionRepository = gameCorrectionRepository;
this.schemaRepository = schemaRepository;
this.schemaMetaRepository = schemaMetaRepository;
this.achievementSyncService = achievementSyncService;
@@ -118,8 +126,10 @@ public class ImageRepairService {
private void repairCovers(List<BrokenImage> rows, Instant startedAt) {
if (rows.isEmpty()) return;
Map<Long, SteamGame> games = steamGameRepository.findAllById(rows.stream().map(BrokenImage::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, Function.identity()));
List<Long> appids = rows.stream().map(BrokenImage::getAppid).toList();
Map<Long, SteamGame> games = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, Function.identity()));
Set<Long> corrected = gameCorrectionRepository.findCorrectedAppids(CorrectionField.IMAGE, appids);
List<BrokenImage> broken = new ArrayList<>();
for (BrokenImage row : rows) {
SteamGame game = games.get(row.getAppid());
@@ -127,6 +137,10 @@ public class ImageRepairService {
? game.getImageUrl() : steamImageBaseUrl + row.getAppid() + "/header.jpg";
if (steamCdnService.isAvailable(current)) {
delete(row);
} else if (corrected.contains(row.getAppid())) {
// An auditor chose this image; setImageUrl keeps it, so only an auditor can fix it.
log.warn("Corrected cover of app {} does not load: {}", row.getAppid(), current);
markChecked(row, startedAt);
} else {
broken.add(row);
}
@@ -0,0 +1,8 @@
package core_service.modules.library.service;
public class InvalidCorrectionException extends RuntimeException {
public InvalidCorrectionException(String message) {
super(message);
}
}
@@ -28,6 +28,9 @@ import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
@@ -50,6 +53,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LibraryQueryService {
@@ -68,6 +72,8 @@ public class LibraryQueryService {
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final UserStatsService userStatsService;
private final MoodService moodService;
private final SpeedrunService speedrunService;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@@ -86,7 +92,15 @@ public class LibraryQueryService {
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
}
/**
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*/
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) {
if (mood == Mood.SPEEDRUN) {
speedrunService.refreshBacklogInBackground(userId);
} else if (mood != null) {
moodService.refreshBacklogInBackground(userId);
}
return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count)));
}
@@ -197,6 +211,7 @@ public class LibraryQueryService {
/**
* @param profileStats the stat group names of the profile ticket
*/
@Public
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) {
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
@@ -3,6 +3,8 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -16,6 +18,7 @@ import java.time.ZoneId;
import java.util.List;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
@Slf4j
public class LibrarySyncService {
@@ -1,11 +1,13 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -29,6 +31,7 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@@ -47,6 +50,7 @@ public class LibrarySyncWorker {
private final SteamApiService steamApiService;
private final SteamGameRepository steamGameRepository;
private final GameCorrectionRepository gameCorrectionRepository;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
private final PlaytimeDayRepository playtimeDayRepository;
@@ -109,10 +113,13 @@ public class LibrarySyncWorker {
return;
}
// setSourceName keeps them anyway; skipping saves an update and a flush per corrected game and sync.
Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME,
games.stream().map(SteamOwnedGamesResponse.Game::appid).filter(Objects::nonNull).map(Integer::longValue).toList());
List<Long> played = new ArrayList<>();
for (SteamOwnedGamesResponse.Game g : games) {
if (g.appid() == null) continue;
upsertGame(g, syncStartedAt);
upsertGame(g, syncStartedAt, correctedNames);
if (upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null)) {
played.add(g.appid().longValue());
}
@@ -175,7 +182,7 @@ public class LibrarySyncWorker {
}).toList());
}
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now) {
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now, Set<Long> correctedNames) {
Long appid = g.appid().longValue();
SteamGame existing = steamGameRepository.findById(appid).orElse(null);
// An unvetted app can come without a name, and the name column is NOT NULL.
@@ -183,7 +190,9 @@ public class LibrarySyncWorker {
if (existing == null) {
steamGameRepository.save(new SteamGame(appid, hasName ? g.name() : SteamGame.placeholderName(appid), g.imgIconUrl(), now));
} else {
if (hasName) existing.setName(g.name());
if (hasName && !g.name().equals(existing.getName()) && !correctedNames.contains(appid)) {
steamGameRepository.setSourceName(appid, g.name());
}
existing.setImgIconUrl(g.imgIconUrl());
existing.setLastSeenAt(now);
}
@@ -11,6 +11,8 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
@@ -32,6 +34,7 @@ import java.util.stream.Stream;
* to the Steam sync.
*/
@Service
@RequiresRole(Role.USER)
@Slf4j
public class ManualGameService {
@@ -219,8 +222,8 @@ public class ManualGameService {
}
steamGameRepository.save(created);
} else if (!GameKeys.isSteam(appid)) {
existing.get().setName(game.name());
existing.get().setImageUrl(game.imageUrl());
steamGameRepository.setSourceName(appid, game.name());
steamGameRepository.setImageUrl(appid, game.imageUrl());
existing.get().setLastSeenAt(now);
}
}
@@ -30,6 +30,14 @@ final class SpeedrunMatch {
private SpeedrunMatch() {
}
/**
* The weblink goes to the browser as a link; only speedrun.com's own pages may get there.
*/
static boolean isWeblink(String url) {
return url != null && url.startsWith(WEBLINK_PREFIX) && url.length() > WEBLINK_PREFIX.length()
&& url.length() <= MAX_WEBLINK_LENGTH;
}
/**
* @param years our release years of the game; empty when unknown
* @param hits in speedrun.com's order
@@ -41,9 +49,7 @@ final class SpeedrunMatch {
if (ours.year() != null) ourYears.add(ours.year());
return hits.stream()
.filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH)
// The weblink goes to the browser as a link; only speedrun.com's own pages may get there.
.filter(hit -> hit.weblink() != null && hit.weblink().startsWith(WEBLINK_PREFIX)
&& hit.weblink().length() <= MAX_WEBLINK_LENGTH)
.filter(hit -> isWeblink(hit.weblink()))
.filter(hit -> {
NameAndYear theirs = split(hit.name());
Integer year = theirs.year() != null ? theirs.year() : hit.year();
@@ -2,10 +2,13 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class SteamProfileService {
@@ -8,6 +8,8 @@ import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -33,10 +35,23 @@ public class UserStatsService {
private final UserGameRepository userGameRepository;
private final UserStatsRepository userStatsRepository;
private final BacklogSnapshotRepository backlogSnapshotRepository;
private final MoodService moodService;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* The stats page of the user: {@link #getStats}, and for TASTE also a refresh of the listed games' moods in the
* background, not done for a profile visitor.
*/
@RequiresRole(Role.USER)
public UserStatsDto getOwnStats(long userId, Set<StatGroup> groups) {
if (groups.contains(StatGroup.TASTE)) {
moodService.refreshListedInBackground(userId);
}
return getStats(userId, groups);
}
/**
* Runs only the queries of the asked groups; the others stay null.
*/
@@ -2,6 +2,7 @@ package core_service.modules.stats.service;
import core_service.modules.stats.api.dto.PublicStatsDto;
import core_service.modules.stats.repo.StatsRepository;
import core_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@@ -10,6 +11,7 @@ import java.util.List;
import java.util.Map;
@Service
@Public
@RequiredArgsConstructor
public class PublicStatsService {
@@ -8,6 +8,8 @@ import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesR
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.web.client.HttpClientErrorException;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import io.github.resilience4j.retry.annotation.Retry;
@@ -27,6 +29,7 @@ import java.util.Optional;
import java.util.stream.Collectors;
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
@Slf4j
public class SteamApiService {
@@ -0,0 +1,16 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Callable only with Auth's service token (the /internal calls). See {@link RequiresRole}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Internal {
}
@@ -0,0 +1,16 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Callable without a login, e.g. the landing stats. See {@link RequiresRole}.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Public {
}
@@ -0,0 +1,20 @@
package core_service.shared.security;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* The lowest active role that may call this service method, or every method of this class. A method marker
* replaces the class marker. Every service method a controller calls carries one of {@link RequiresRole},
* {@link Public} or {@link Internal} (ArchitectureTest). Checked only while an HTTP request is served: scheduled
* jobs and {@code @Async} work run without a user.
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresRole {
Role value();
}
@@ -0,0 +1,26 @@
package core_service.shared.security;
/**
* A ladder: each role has every right of the roles before it. Auth owns the roles; the same enum lives there.
*/
public enum Role {
USER, AUDITOR, PLATFORM_ADMIN;
public boolean atLeast(Role other) {
return compareTo(other) >= 0;
}
/**
* USER for null or an unknown name, e.g. a token from before roles.
*/
public static Role parse(String name) {
if (name == null) {
return USER;
}
try {
return valueOf(name);
} catch (IllegalArgumentException _) {
return USER;
}
}
}
@@ -2,12 +2,20 @@ package core_service.shared.security;
import java.security.Principal;
/**
* @param role the active role of this login ("view as"), set by Auth
*/
public record UserPrincipal(
String id,
String email,
Long steamId
Long steamId,
Role role
) implements Principal {
public UserPrincipal(String id, String email, Long steamId) {
this(id, email, steamId, Role.USER);
}
@Override
public String getName() {
return id;
@@ -0,0 +1,13 @@
-- An auditor's correction of shared game data. The value is also written into the cached row (steam_game,
-- game_speedrun, game_mood), so reads need no join; the refresh writers skip a corrected field. value and
-- source_value are JSON (a string, a string list, a boolean or null); source_value is what the source had at the
-- first correction, written back by "back to source".
CREATE TABLE game_correction (
appid BIGINT NOT NULL,
field VARCHAR(16) NOT NULL,
value TEXT,
source_value TEXT,
corrected_by BIGINT NOT NULL,
corrected_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (appid, field)
);
@@ -100,4 +100,17 @@ class ArchitectureTest extends AbstractArchitectureTest {
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class)
.orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class)
.because("Classes in 'api' package named *Controller should be Spring REST controllers.");
// ===================================================================================
// 5. ROLE MARKERS (see RoleAnnotationRules)
// ===================================================================================
@ArchTest
static final ArchRule controllers_call_only_marked_service_methods = RoleAnnotationRules.controllers_call_only_marked_service_methods;
@ArchTest
static final ArchRule one_role_marker_per_element = RoleAnnotationRules.one_role_marker_per_element;
@ArchTest
static final ArchRule services_never_call_a_method_that_needs_more = RoleAnnotationRules.services_never_call_a_method_that_needs_more;
}
@@ -0,0 +1,198 @@
package core_service.architecture;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaCodeUnit;
import com.tngtech.archunit.core.domain.JavaCodeUnitAccess;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.domain.properties.HasAnnotations;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.scheduling.annotation.Async;
import java.lang.annotation.Annotation;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Stream;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
/**
* The rules for {@link RequiresRole}, {@link Public} and {@link Internal}. Auth has the same rules without
* {@code Internal}.
*/
final class RoleAnnotationRules {
private static final List<Class<? extends Annotation>> MARKERS = List.of(RequiresRole.class, Public.class,
Internal.class);
private static final String APP_PACKAGE = "core_service.";
private RoleAnnotationRules() {
}
/**
* What a marked method needs from its caller. {@code role} is set only for {@link RequiresRole}.
*/
private record Need(Class<? extends Annotation> marker, Role role) {
boolean isMetBy(Need caller) {
if (marker == Public.class) {
return true;
}
if (marker == Internal.class) {
return caller.marker == Internal.class;
}
return caller.marker == RequiresRole.class && caller.role.atLeast(role);
}
@Override
public String toString() {
return marker == RequiresRole.class ? "@RequiresRole(" + role + ")" : "@" + marker.getSimpleName();
}
}
private static long markerCount(HasAnnotations<?> element) {
return MARKERS.stream().filter(element::isAnnotatedWith).count();
}
private static Optional<Need> need(HasAnnotations<?> element) {
for (Class<? extends Annotation> marker : MARKERS) {
if (element.isAnnotatedWith(marker)) {
Role role = marker == RequiresRole.class
? element.getAnnotationOfType(RequiresRole.class).value() : null;
return Optional.of(new Need(marker, role));
}
}
return Optional.empty();
}
private static Optional<Need> effectiveNeed(JavaMethod method) {
Optional<Need> own = need(method);
return own.isPresent() ? own : need(method.getOwner());
}
private record MethodAccess(JavaCodeUnitAccess<?> access, JavaMethod target) {
@Override
public String toString() {
return target.getOwner().getSimpleName() + "." + target.getName() + " called in "
+ access.getSourceCodeLocation();
}
}
/**
* Calls and method references ({@code service::method}) to non-static methods: a proxy never sees a static call,
* so a marker there would promise a check that does not run.
*/
private static List<MethodAccess> methodAccesses(JavaCodeUnit from) {
return Stream.<JavaCodeUnitAccess<?>>concat(from.getMethodCallsFromSelf().stream(),
from.getMethodReferencesFromSelf().stream())
.flatMap(access -> access.getTarget().resolveMember().stream()
.filter(JavaMethod.class::isInstance).map(JavaMethod.class::cast)
.filter(method -> !method.getModifiers().contains(JavaModifier.STATIC))
.map(method -> new MethodAccess(access, method)))
.toList();
}
private static boolean isService(JavaClass javaClass) {
return javaClass.getSimpleName().endsWith("Service") && javaClass.getPackageName().contains(".service");
}
private static boolean isAsync(JavaMethod method) {
return method.isAnnotatedWith(Async.class) || method.getOwner().isAnnotatedWith(Async.class);
}
private static final DescribedPredicate<JavaClass> SERVICE_CLASSES =
DescribedPredicate.describe("are services", RoleAnnotationRules::isService);
/**
* Every service method a controller calls is marked, and is not {@code @Async}: there the check would never run.
*/
static final ArchRule controllers_call_only_marked_service_methods = classes()
.that().resideInAPackage("..api..")
.should(new ArchCondition<>("call only service methods that carry a role marker and are not @Async") {
@Override
public void check(JavaClass controller, ConditionEvents events) {
for (JavaCodeUnit codeUnit : controller.getCodeUnits()) {
for (MethodAccess access : methodAccesses(codeUnit)) {
if (!isService(access.target().getOwner())) {
continue;
}
if (effectiveNeed(access.target()).isEmpty()) {
events.add(SimpleConditionEvent.violated(access.access(), "no role marker on " + access));
}
if (isAsync(access.target())) {
events.add(SimpleConditionEvent.violated(access.access(), "@Async method " + access));
}
}
}
}
})
.because("a new endpoint must not forget the role check");
static final ArchRule one_role_marker_per_element = classes()
.should(new ArchCondition<>("carry at most one role marker on the class and on each method") {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
if (markerCount(javaClass) > 1) {
events.add(SimpleConditionEvent.violated(javaClass, javaClass.getName() + " has two role markers"));
}
for (JavaMethod method : javaClass.getMethods()) {
if (markerCount(method) > 1) {
events.add(SimpleConditionEvent.violated(method, method.getFullName() + " has two role markers"));
}
}
}
})
.because("the marker must say one thing");
/**
* Starts at every method the proxy checks (not private, not static) and follows every call that is not checked
* again: self calls and calls of unmarked methods. Calls through an interface (a port) end the walk: the
* implementation is not known here.
*/
static final ArchRule services_never_call_a_method_that_needs_more = classes()
.that(SERVICE_CLASSES)
.should(new ArchCondition<>("never call a service method that needs more than the caller") {
@Override
public void check(JavaClass service, ConditionEvents events) {
for (JavaMethod entry : service.getMethods()) {
Optional<Need> caller = effectiveNeed(entry);
if (caller.isEmpty() || entry.getModifiers().contains(JavaModifier.PRIVATE)
|| entry.getModifiers().contains(JavaModifier.STATIC)) {
continue;
}
Set<JavaCodeUnit> seen = new HashSet<>(List.of(entry));
Deque<JavaCodeUnit> toVisit = new ArrayDeque<>(seen);
while (!toVisit.isEmpty()) {
for (MethodAccess access : methodAccesses(toVisit.pop())) {
JavaMethod target = access.target();
Optional<Need> callee = target.getOwner().equals(service) ? Optional.empty()
: effectiveNeed(target);
if (callee.isEmpty()) {
if (target.getOwner().getPackageName().startsWith(APP_PACKAGE) && seen.add(target)) {
toVisit.push(target);
}
} else if (!callee.get().isMetBy(caller.get())) {
events.add(SimpleConditionEvent.violated(access.access(), entry.getName() + " ("
+ caller.get() + ") calls " + access + ", which needs " + callee.get()));
}
}
}
}
}
})
.because("the inner check would refuse a caller the outer check let in");
}
@@ -0,0 +1,113 @@
package core_service.architecture;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.EvaluationResult;
import core_service.architecture.fixture.api.BadController;
import core_service.architecture.fixture.api.GoodController;
import core_service.architecture.fixture.api.MethodReferenceController;
import core_service.architecture.fixture.service.ForwardingService;
import core_service.architecture.fixture.service.InternalCallerService;
import core_service.architecture.fixture.service.InternalMarkedService;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.PublicCallerService;
import core_service.architecture.fixture.service.PublicViaUnmarkedCallerService;
import core_service.architecture.fixture.service.PublicHelperCallerService;
import core_service.architecture.fixture.service.PublicMethodReferenceCallerService;
import core_service.architecture.fixture.service.TwoMarkersService;
import core_service.architecture.fixture.service.UnmarkedService;
import core_service.architecture.fixture.service.UserCallerService;
import core_service.shared.test.AbstractArchitectureTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RoleAnnotationRulesTest extends AbstractArchitectureTest {
private static List<String> violations(ArchRule rule, Class<?>... classes) {
JavaClasses imported = new ClassFileImporter().importClasses(classes);
EvaluationResult result = rule.evaluate(imported);
return result.getFailureReport().getDetails();
}
@Test
void controllerRule_PassesMarkedMethodsTheMethodMarkerAndStaticHelpers() {
assertThat(violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
GoodController.class, MarkedService.class, UnmarkedService.class)).isEmpty();
}
@Test
void controllerRule_FindsAnUnmarkedAndAnAsyncMethod() {
List<String> found = violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
BadController.class, MarkedService.class, UnmarkedService.class);
assertThat(found).hasSize(2);
assertThat(found).anyMatch(line -> line.contains("no role marker on UnmarkedService.bare"));
assertThat(found).anyMatch(line -> line.contains("@Async method MarkedService.later"));
}
@Test
void markerRule_FindsTwoMarkersOnOneMethod() {
assertThat(violations(RoleAnnotationRules.one_role_marker_per_element, TwoMarkersService.class))
.singleElement().asString().contains("both");
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethod() {
List<String> found = violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicCallerService.class, MarkedService.class);
assertThat(found).singleElement().asString()
.contains("callsUser (@Public)", "MarkedService.plain", "needs @RequiresRole(USER)");
}
@Test
void escalationRule_FindsAUserMethodCallingAnAuditorMethod() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
UserCallerService.class, MarkedService.class))
.singleElement().asString().contains("needs @RequiresRole(AUDITOR)");
}
@Test
void controllerRule_FindsAnUnmarkedMethodCalledByMethodReference() {
assertThat(violations(RoleAnnotationRules.controllers_call_only_marked_service_methods,
MethodReferenceController.class, UnmarkedService.class))
.anyMatch(line -> line.contains("no role marker on UnmarkedService.bare"));
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethodThroughAPrivateHelper() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicHelperCallerService.class, MarkedService.class))
.anyMatch(line -> line.contains("MarkedService.plain") && line.contains("needs @RequiresRole(USER)"));
}
@Test
void escalationRule_FindsAPublicMethodCallingAUserMethodByMethodReference() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicMethodReferenceCallerService.class, MarkedService.class))
.anyMatch(line -> line.contains("MarkedService.plain") && line.contains("needs @RequiresRole(USER)"));
}
@Test
void escalationRule_FindsAPublicMethodReachingAUserMethodThroughAnUnmarkedService() {
assertThat(violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
PublicViaUnmarkedCallerService.class, ForwardingService.class, MarkedService.class))
.singleElement().asString()
.contains("callsThroughAnUnmarkedService (@Public)", "MarkedService.plain", "needs @RequiresRole(USER)");
}
@Test
void escalationRule_KeepsInternalAndRolesApart() {
List<String> found = violations(RoleAnnotationRules.services_never_call_a_method_that_needs_more,
InternalCallerService.class, InternalMarkedService.class, MarkedService.class);
assertThat(found).hasSize(2);
assertThat(found).anyMatch(line -> line.contains("callsUser (@Internal)") && line.contains("needs @RequiresRole(USER)"));
assertThat(found).anyMatch(line -> line.contains("adminCallsInternal (@RequiresRole(PLATFORM_ADMIN))")
&& line.contains("needs @Internal"));
}
}
@@ -0,0 +1,15 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.UnmarkedService;
public class BadController {
private final UnmarkedService unmarkedService = new UnmarkedService();
private final MarkedService markedService = new MarkedService();
public void handle() {
unmarkedService.bare();
markedService.later();
}
}
@@ -0,0 +1,15 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.MarkedService;
import core_service.architecture.fixture.service.UnmarkedService;
public class GoodController {
private final MarkedService markedService = new MarkedService();
public void handle() {
markedService.plain();
markedService.open();
UnmarkedService.helper();
}
}
@@ -0,0 +1,13 @@
package core_service.architecture.fixture.api;
import core_service.architecture.fixture.service.UnmarkedService;
public class MethodReferenceController {
private final UnmarkedService unmarkedService = new UnmarkedService();
public void handle() {
Runnable call = unmarkedService::bare;
call.run();
}
}
@@ -0,0 +1,10 @@
package core_service.architecture.fixture.service;
public class ForwardingService {
private final MarkedService markedService = new MarkedService();
public void forward() {
markedService.plain();
}
}
@@ -0,0 +1,25 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@Internal
public class InternalCallerService {
private final MarkedService markedService = new MarkedService();
private final InternalMarkedService internalMarkedService = new InternalMarkedService();
public void callsInternal() {
internalMarkedService.call();
}
public void callsUser() {
markedService.plain();
}
@RequiresRole(Role.PLATFORM_ADMIN)
public void adminCallsInternal() {
internalMarkedService.call();
}
}
@@ -0,0 +1,10 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
@Internal
public class InternalMarkedService {
public void call() {
}
}
@@ -0,0 +1,25 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import org.springframework.scheduling.annotation.Async;
@RequiresRole(Role.USER)
public class MarkedService {
public void plain() {
}
@Public
public void open() {
}
@RequiresRole(Role.AUDITOR)
public void audit() {
}
@Async
public void later() {
}
}
@@ -0,0 +1,17 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
@Public
public class PublicCallerService {
private final MarkedService markedService = new MarkedService();
public void callsOpen() {
markedService.open();
}
public void callsUser() {
markedService.plain();
}
}
@@ -0,0 +1,20 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class PublicHelperCallerService {
private final MarkedService markedService = new MarkedService();
@Public
public void open() {
helper();
}
private void helper() {
markedService.plain();
}
}
@@ -0,0 +1,17 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class PublicMethodReferenceCallerService {
private final MarkedService markedService = new MarkedService();
@Public
public void open() {
Runnable call = markedService::plain;
call.run();
}
}
@@ -0,0 +1,13 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Public;
@Public
public class PublicViaUnmarkedCallerService {
private final ForwardingService forwardingService = new ForwardingService();
public void callsThroughAnUnmarkedService() {
forwardingService.forward();
}
}
@@ -0,0 +1,12 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
public class TwoMarkersService {
@Public
@Internal
public void both() {
}
}
@@ -0,0 +1,11 @@
package core_service.architecture.fixture.service;
public class UnmarkedService {
public void bare() {
}
public static boolean helper() {
return true;
}
}
@@ -0,0 +1,14 @@
package core_service.architecture.fixture.service;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@RequiresRole(Role.USER)
public class UserCallerService {
private final MarkedService markedService = new MarkedService();
public void callsAuditor() {
markedService.audit();
}
}
@@ -1,6 +1,7 @@
package core_service.configuration.security;
import core_service.shared.security.ProfileTicket;
import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts;
@@ -166,4 +167,33 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ServiceTokenIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
}
private String signTokenWithRole(String role) {
return Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.claim("role", role)
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
.compact();
}
@Test
void parseLoginToken_ReadsTheActiveRole() {
assertEquals(Role.AUDITOR, jwtService.parseLoginToken(signTokenWithRole("AUDITOR")).orElseThrow().role());
}
@Test
void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() {
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", "test@example.com", null)).orElseThrow().role());
assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role());
}
@Test
void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() {
String token = Jwts.builder().subject("test@example.com").claim("userId", "0815").claim("role", List.of("AUDITOR"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
}
}
@@ -20,6 +20,7 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -91,6 +92,23 @@ class PublicEndpointsTest {
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
}
// Without a login: a class role marker on the way would answer 401.
@Test
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
.claim("profileStats", List.of("TOP_GAMES", "OVERVIEW")).expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket))
.andExpect(status().isOk())
.andExpect(jsonPath("$.stats.topGames").exists());
}
@Test
void landingStatsArePublic() throws Exception {
mockMvc.perform(get("/api/stats/public")).andExpect(status().isOk());
}
@Test
void versionIsPublic() throws Exception {
mockMvc.perform(get("/api/version"))
@@ -0,0 +1,119 @@
package core_service.configuration.security;
import core_service.shared.security.Internal;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractUnitTest;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
class RoleAuthorizationManagerTest extends AbstractUnitTest {
private final RoleAuthorizationManager manager = new RoleAuthorizationManager();
@RequiresRole(Role.USER)
static class UserMarked {
public void plain() {
}
@RequiresRole(Role.AUDITOR)
public void audit() {
}
@Public
public void open() {
}
}
@Internal
static class InternalMarked {
public void call() {
}
}
@AfterEach
void clearRequest() {
RequestContextHolder.resetRequestAttributes();
}
private static void inRequest() {
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(new MockHttpServletRequest()));
}
private static Authentication login(Role role) {
return new UsernamePasswordAuthenticationToken("42", null, AccessTokens.authorities(role));
}
private static Authentication anonymous() {
return new AnonymousAuthenticationToken("key", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
}
private static Authentication service() {
return new UsernamePasswordAuthenticationToken("tbp-auth", null,
List.of(new SimpleGrantedAuthority(ServiceTokenFilter.SERVICE_AUTHORITY)));
}
private boolean granted(Authentication authentication, Object target, String method) throws Exception {
MethodInvocation invocation = mock(MethodInvocation.class);
// Lenient: without a request the manager does not look at the invocation.
lenient().when(invocation.getThis()).thenReturn(target);
lenient().when(invocation.getMethod()).thenReturn(target.getClass().getMethod(method));
return manager.authorize(() -> authentication, invocation).isGranted();
}
@Test
void classMarker_CoversTheMethodsWithoutOwnMarker() throws Exception {
inRequest();
assertTrue(granted(login(Role.USER), new UserMarked(), "plain"));
assertFalse(granted(anonymous(), new UserMarked(), "plain"));
}
@Test
void methodMarker_ReplacesTheClassMarker() throws Exception {
inRequest();
assertFalse(granted(login(Role.USER), new UserMarked(), "audit"));
assertTrue(granted(anonymous(), new UserMarked(), "open"));
}
@Test
void higherRoles_PassLowerChecks() throws Exception {
inRequest();
assertTrue(granted(login(Role.AUDITOR), new UserMarked(), "audit"));
assertTrue(granted(login(Role.PLATFORM_ADMIN), new UserMarked(), "audit"));
assertTrue(granted(login(Role.PLATFORM_ADMIN), new UserMarked(), "plain"));
}
@Test
void internal_NeedsTheServiceToken() throws Exception {
inRequest();
assertTrue(granted(service(), new InternalMarked(), "call"));
assertFalse(granted(login(Role.PLATFORM_ADMIN), new InternalMarked(), "call"));
assertFalse(granted(service(), new UserMarked(), "plain"));
}
@Test
void withoutARequest_EverythingRuns() throws Exception {
assertTrue(granted(null, new UserMarked(), "audit"));
assertTrue(granted(null, new InternalMarked(), "call"));
}
}
@@ -0,0 +1,137 @@
package core_service.configuration.security;
import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.http.Cookie;
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.stereotype.Service;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The role markers through the real (non-dev) filter chain, on a probe that only this test sees.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
@Import({RoleCheckFlowTest.ProbeService.class, RoleCheckFlowTest.ProbeController.class})
class RoleCheckFlowTest extends AbstractIntegrationTest {
@Service
@RequiresRole(Role.USER)
static class ProbeService {
public String user() {
return "user";
}
@RequiresRole(Role.AUDITOR)
public String auditor() {
return "auditor";
}
@Public
public String open() {
return "open";
}
}
@RestController
static class ProbeController {
private final ProbeService probeService;
ProbeController(ProbeService probeService) {
this.probeService = probeService;
}
@GetMapping("/api/role-probe/user")
String user() {
return probeService.user();
}
@GetMapping("/api/role-probe/auditor")
String auditor() {
return probeService.auditor();
}
// Authenticated URL: the marker opens nothing the URL rules close.
@GetMapping("/api/role-probe/open")
String open() {
return probeService.open();
}
// Public URL: only the marker stops an anonymous caller.
@GetMapping("/api/public/role-probe/user")
String publicUser() {
return probeService.user();
}
@GetMapping("/api/public/role-probe/open")
String publicOpen() {
return probeService.open();
}
}
@Autowired
private MockMvc mockMvc;
@Autowired
private ProbeService probeService;
private static String token(Role role) {
return Jwts.builder()
.subject("probe@example.com")
.claim("userId", "4711")
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private ResultActions call(String path, Role role) throws Exception {
MockHttpServletRequestBuilder request = get("/api/role-probe/" + path);
if (role != null) {
request.cookie(new Cookie(AccessTokens.ACCESS_COOKIE, token(role)));
}
return mockMvc.perform(request);
}
@Test
void user_PassesUserAndIsRefusedAuditor() throws Exception {
call("user", Role.USER).andExpect(status().isOk());
call("open", Role.USER).andExpect(status().isOk());
call("auditor", Role.USER).andExpect(status().isForbidden());
}
@Test
void higherRoles_PassTheLowerChecks() throws Exception {
call("auditor", Role.AUDITOR).andExpect(status().isOk());
call("auditor", Role.PLATFORM_ADMIN).andExpect(status().isOk());
call("user", Role.PLATFORM_ADMIN).andExpect(status().isOk());
}
@Test
void noLogin_OnAPublicUrl_IsStoppedByTheMarker() throws Exception {
mockMvc.perform(get("/api/public/role-probe/user")).andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/public/role-probe/open")).andExpect(status().isOk());
}
@Test
void outsideARequest_TheServiceRuns() {
assertEquals("auditor", probeService.auditor());
}
}
@@ -0,0 +1,191 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.empty;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Corrections through the real (non-dev) filter chain, so the role in the token decides.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class GameCorrectionFlowTest extends AbstractIntegrationTest {
private static final long AUDITOR_ID = 4711L;
private static final long GAME = 620L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@Autowired
private GameCorrectionRepository gameCorrectionRepository;
@BeforeEach
void seed() {
gameCorrectionRepository.deleteAll();
userGameRepository.deleteAll();
gameMoodsRepository.deleteAll();
gameSpeedrunRepository.deleteAll();
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
UserGame owned = new UserGame(AUDITOR_ID, GAME, 60, Instant.now(), Instant.now());
// Hidden: the details make no calls to Steam, IGDB or speedrun.com.
owned.setHiddenAt(Instant.now());
userGameRepository.save(owned);
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, false, Instant.now());
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
}
private static String token(Role role) {
return Jwts.builder()
.subject("auditor@example.com")
.claim("userId", String.valueOf(AUDITOR_ID))
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception {
return mockMvc.perform(put("/api/library/games/" + appid + "/corrections/" + field)
.header("Authorization", "Bearer " + token(role)).header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"value\":" + valueJson + "}"));
}
private ResultActions reset(String field) throws Exception {
return mockMvc.perform(delete("/api/library/games/" + GAME + "/corrections/" + field)
.header("Authorization", "Bearer " + token(Role.AUDITOR)).header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
private ResultActions details() throws Exception {
return mockMvc.perform(get("/api/library/games/" + GAME).header("Authorization", "Bearer " + token(Role.USER)));
}
@Test
void name_IsCorrectedForEveryoneAndGoesBackToTheSource() throws Exception {
correct(Role.AUDITOR, GAME, "name", "\" Portal Two \"").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.name").value("Portal Two"))
.andExpect(jsonPath("$.corrections[0].field").value("NAME"));
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME).getFirst().getCorrectedBy()).isEqualTo(AUDITOR_ID);
reset("name").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.name").value("Portal 2"))
.andExpect(jsonPath("$.corrections", empty()));
}
@Test
void moodsAndSpeedrun_SurviveTheNextRefresh() throws Exception {
correct(Role.AUDITOR, GAME, "moods", "[\"COOP\", \"CHILL\"]").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "speedrun", "null").andExpect(status().isNoContent());
gameMoodsRepository.upsert(GAME, new String[]{"ACTION"}, new String[]{"Shooter"}, true, Instant.now());
gameSpeedrunRepository.upsert(GAME, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
details().andExpect(jsonPath("$.moods", contains("CHILL", "COOP")))
.andExpect(jsonPath("$.genres", contains("Shooter")))
.andExpect(jsonPath("$.speedrunUrl").doesNotExist());
}
@Test
void reset_WritesTheSourceBackAndAsksTheSourceAgain() throws Exception {
correct(Role.AUDITOR, GAME, "moods", "[\"COOP\"]").andExpect(status().isNoContent());
reset("moods").andExpect(status().isNoContent());
GameMoods stored = gameMoodsRepository.findById(GAME).orElseThrow();
assertThat(stored.getMoods()).containsExactly("STORY");
assertThat(stored.getCheckedAt()).isEqualTo(Instant.EPOCH);
}
@Test
void genresAndEndless_AreStored() throws Exception {
correct(Role.AUDITOR, GAME, "genres", "[\"Puzzle\", \"Platform\", \"Puzzle\"]").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "endless", "true").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.genres", contains("Platform", "Puzzle")))
.andExpect(jsonPath("$.endless").value(true));
}
@Test
void roles_OnlyAuditorsAndAdminsCorrect() throws Exception {
correct(Role.USER, GAME, "name", "\"Hacked\"").andExpect(status().isForbidden());
correct(Role.USER, GAME, "rating", "5").andExpect(status().isForbidden());
mockMvc.perform(delete("/api/library/games/" + GAME + "/corrections/name")
.header("Authorization", "Bearer " + token(Role.USER)).header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isForbidden());
mockMvc.perform(put("/api/library/games/" + GAME + "/corrections/name").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"value\":\"x\"}"))
.andExpect(status().isUnauthorized());
correct(Role.PLATFORM_ADMIN, GAME, "name", "\"Portal Two\"").andExpect(status().isNoContent());
}
@Test
void reset_RestoresNullSourcesAndAsksSpeedrunComAgain() throws Exception {
gameMoodsRepository.upsert(GAME, new String[]{"STORY"}, new String[]{"Puzzle"}, null, Instant.now());
correct(Role.AUDITOR, GAME, "image", "\"https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg\"")
.andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "endless", "true").andExpect(status().isNoContent());
correct(Role.AUDITOR, GAME, "speedrun", "\"https://www.speedrun.com/portal\"").andExpect(status().isNoContent());
reset("image").andExpect(status().isNoContent());
reset("endless").andExpect(status().isNoContent());
reset("speedrun").andExpect(status().isNoContent());
details().andExpect(jsonPath("$.imageUrl").doesNotExist())
.andExpect(jsonPath("$.endless").doesNotExist())
.andExpect(jsonPath("$.speedrunUrl").value("https://www.speedrun.com/portal_2"))
.andExpect(jsonPath("$.corrections", empty()));
assertThat(gameSpeedrunRepository.findById(GAME).orElseThrow().getCheckedAt()).isEqualTo(Instant.EPOCH);
}
@Test
void badInput_IsRefused() throws Exception {
correct(Role.AUDITOR, 999_999L, "name", "\"x\"").andExpect(status().isNotFound());
correct(Role.AUDITOR, GAME, "rating", "5").andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_CORRECTION"));
correct(Role.AUDITOR, GAME, "image", "\"https://evil.example.com/a.jpg\"").andExpect(status().isBadRequest());
correct(Role.AUDITOR, GAME, "moods", "[\"SPEEDRUN\"]").andExpect(status().isBadRequest());
correct(Role.AUDITOR, GAME, "name", "\" \"").andExpect(status().isBadRequest());
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameMoods;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
@@ -67,4 +68,20 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest {
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
}
@Test
void upsert_KeepsACorrectedColumnAndRefreshesTheOthers() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL"}, new String[]{"Puzzle"}, false, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'MOODS', '[\"COOP\"]', '[\"CHILL\"]', 7, now())").executeUpdate();
gameMoodsRepository.upsert(620L, new String[]{"ACTION"}, new String[]{"Shooter"}, true, Instant.now());
entityManager.clear();
GameMoods stored = gameMoodsRepository.findById(620L).orElseThrow();
assertThat(stored.getMoods()).containsExactly("CHILL");
assertThat(stored.getGenres()).containsExactly("Shooter");
assertThat(stored.getEndless()).isTrue();
assertThat(stored.getCheckedAt()).isAfter(Instant.EPOCH);
}
}
@@ -48,4 +48,20 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
assertThat(gameSpeedrunRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
}
@Test
void upsert_KeepsACorrectedLink() {
gameSpeedrunRepository.writeWeblink(620L, null, Instant.EPOCH);
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'SPEEDRUN', 'null', '\"https://www.speedrun.com/portal\"', 7, now())")
.executeUpdate();
gameSpeedrunRepository.upsert(620L, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
entityManager.clear();
GameSpeedrun row = gameSpeedrunRepository.findById(620L).orElseThrow();
assertThat(row.getWeblink()).isNull();
assertThat(row.getSpeedrunGameId()).isNull();
assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH);
}
}
@@ -0,0 +1,58 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.SteamGame;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class SteamGameRepositoryTest extends AbstractPersistenceTest {
private static final String REPAIRED = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/abc/header.jpg";
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private EntityManager entityManager;
private String storedImage(long appid) {
entityManager.clear();
return steamGameRepository.findById(appid).orElseThrow().getImageUrl();
}
@Test
void setImageUrl_KeepsAnAuditorsImage() {
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(400L, "Portal", null, Instant.now()));
entityManager.flush();
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'IMAGE', 'null', 'null', 7, now())").executeUpdate();
steamGameRepository.setImageUrl(620L, REPAIRED);
steamGameRepository.setImageUrl(400L, REPAIRED);
assertThat(storedImage(620L)).isNull();
assertThat(storedImage(400L)).isEqualTo(REPAIRED);
}
@Test
void setSourceName_KeepsAnAuditorsName() {
steamGameRepository.save(new SteamGame(620L, "Portal Two", null, Instant.now()));
steamGameRepository.save(new SteamGame(400L, "Portal", null, Instant.now()));
entityManager.flush();
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
+ "corrected_at) values (620, 'NAME', '\"Portal Two\"', '\"Portal 2\"', 7, now())").executeUpdate();
steamGameRepository.setSourceName(620L, "Portal 2");
steamGameRepository.setSourceName(400L, "Portal: Still Alive");
entityManager.clear();
assertThat(steamGameRepository.findById(620L).orElseThrow().getName()).isEqualTo("Portal Two");
assertThat(steamGameRepository.findById(400L).orElseThrow().getName()).isEqualTo("Portal: Still Alive");
}
}
@@ -0,0 +1,105 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class GameCorrectionServiceConcurrencyTest extends AbstractIntegrationTest {
private static final long GAME = 620L;
private static final String IMAGE = "https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg";
@Autowired
private GameCorrectionService gameCorrectionService;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameCorrectionRepository gameCorrectionRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
@AfterEach
void clean() {
jdbcTemplate.update("delete from game_correction where appid = ?", GAME);
if (steamGameRepository.findById(GAME).isEmpty()) {
steamGameRepository.save(new SteamGame(GAME, "Portal 2", null, Instant.now()));
}
jdbcTemplate.update("update steam_game set name = 'Portal 2', image_url = null where appid = ?", GAME);
}
@Test
void twoAuditorsCorrectTheSameFieldForTheFirstTime_BothSucceed() {
CountDownLatch firstCorrected = new CountDownLatch(1);
// The first commits a moment after the second has started, so both see no row before either commits.
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> {
gameCorrectionService.correct(GAME, "name", "Portal Two", 1L);
firstCorrected.countDown();
pause();
}));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
await(firstCorrected);
gameCorrectionService.correct(GAME, "name", "Portal II", 2L);
});
assertDoesNotThrow(() -> CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS));
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME)).hasSize(1);
}
@Test
void aCorrectionCommittedDuringASync_SurvivesTheSync() {
new TransactionTemplate(transactionManager).executeWithoutResult(tx -> {
// What LibrarySyncWorker.upsertGame does for every owned game.
SteamGame existing = steamGameRepository.findById(GAME).orElseThrow();
existing.setImgIconUrl("icon");
existing.setLastSeenAt(Instant.now());
assertDoesNotThrow(() -> CompletableFuture.runAsync(() ->
gameCorrectionService.correct(GAME, "image", IMAGE, 1L)).get(10, TimeUnit.SECONDS));
});
assertThat(gameCorrectionRepository.findByAppidOrderByField(GAME)).extracting("field").containsExactly(CorrectionField.IMAGE);
assertThat(steamGameRepository.findById(GAME).orElseThrow().getImageUrl()).isEqualTo(IMAGE);
}
private static void pause() {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,63 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class GameCorrectionServiceTest extends AbstractUnitTest {
private static void refused(CorrectionField field, Object value) {
assertThatThrownBy(() -> GameCorrectionService.validate(field, value))
.as(field + " " + value).isInstanceOf(InvalidCorrectionException.class);
}
@Test
void image_OnlyHttpsOnTheAllowedHosts() {
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/header.jpg")).isNotNull();
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE,
"https://images.igdb.com/igdb/image/upload/t_cover_big/co1rs4.jpg")).isNotNull();
assertThat(GameCorrectionService.validate(CorrectionField.IMAGE, null)).isNull();
refused(CorrectionField.IMAGE, "http://images.igdb.com/a.jpg");
refused(CorrectionField.IMAGE, "https://steamstatic.com.evil.example/a.jpg");
refused(CorrectionField.IMAGE, "not a url");
}
@Test
void speedrun_OnlyAPageOnSpeedrunCom() {
assertThat(GameCorrectionService.validate(CorrectionField.SPEEDRUN, "https://www.speedrun.com/portal_2"))
.isEqualTo("https://www.speedrun.com/portal_2");
assertThat(GameCorrectionService.validate(CorrectionField.SPEEDRUN, null)).isNull();
refused(CorrectionField.SPEEDRUN, "https://www.speedrun.com/");
refused(CorrectionField.SPEEDRUN, "https://speedrun.com.evil.example/portal");
refused(CorrectionField.SPEEDRUN, 42);
}
@Test
void lists_AreSortedWithoutDuplicatesAndLimited() {
assertThat(GameCorrectionService.validate(CorrectionField.MOODS, List.of("COOP", "CHILL", "COOP")))
.isEqualTo(List.of("CHILL", "COOP"));
assertThat(GameCorrectionService.validate(CorrectionField.MOODS, List.of())).isEqualTo(List.of());
refused(CorrectionField.MOODS, List.of("SPEEDRUN"));
refused(CorrectionField.MOODS, List.of("chill"));
refused(CorrectionField.MOODS, "CHILL");
refused(CorrectionField.GENRES, Collections.nCopies(11, "x").stream().map(g -> g + Math.random()).toList());
refused(CorrectionField.GENRES, List.of(" "));
refused(CorrectionField.GENRES, List.of(1));
}
@Test
void nameAndEndless() {
assertThat(GameCorrectionService.validate(CorrectionField.NAME, " Portal 2 ")).isEqualTo("Portal 2");
refused(CorrectionField.NAME, null);
refused(CorrectionField.NAME, "x".repeat(513));
assertThat(GameCorrectionService.validate(CorrectionField.ENDLESS, null)).isNull();
refused(CorrectionField.ENDLESS, "true");
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.BrokenImage;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.ImageKind;
@@ -8,6 +9,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.service.SteamApiService;
@@ -22,6 +24,7 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -48,6 +51,8 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private GameCorrectionRepository gameCorrectionRepository;
@Mock
private GameAchievementSchemaRepository schemaRepository;
@Mock
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@@ -62,7 +67,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ImageRepairService(brokenImageRepository, userGameRepository, steamGameRepository, schemaRepository,
service = new ImageRepairService(brokenImageRepository, userGameRepository, steamGameRepository, gameCorrectionRepository, schemaRepository,
schemaMetaRepository, achievementSyncService, steamApiService, steamCdnService, BASE, 200, 3, 30);
}
@@ -281,4 +286,17 @@ class ImageRepairServiceTest extends AbstractUnitTest {
// Clear the flag for the next test on this thread.
org.assertj.core.api.Assertions.assertThat(Thread.interrupted()).isTrue();
}
@Test
void repairNightly_ABrokenCorrectedCoverIsKeptAndCountsAnAttempt() {
due(row(620L, ImageKind.COVER, Instant.now()));
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.IMAGE), any())).thenReturn(Set.of(620L));
when(steamCdnService.isAvailable(FIXED_620)).thenReturn(false);
service.repairNightly();
verify(brokenImageRepository, never()).deleteReportedUpTo(anyLong(), anyString(), any());
verifyNoInteractions(steamApiService);
verify(steamGameRepository, never()).setImageUrl(anyLong(), anyString());
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
@@ -7,6 +8,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -32,6 +34,7 @@ import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -40,6 +43,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
@@ -91,6 +95,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private GameCorrectionRepository gameCorrectionRepository;
@Mock
private UserGameRepository userGameRepository;
@@ -541,4 +548,29 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertThat(stored.getName()).isEqualTo("El Mucho");
}
@Test
void runSync_NewName_GoesThroughTheGuardedUpdate() {
SteamGame stored = new SteamGame(220L, "Half-Life 2: Corrected", null, Instant.EPOCH);
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(stored));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
// The update keeps an auditor's name (SteamGameRepositoryTest); the entity itself is not renamed.
verify(steamGameRepository).setSourceName(220L, "Half-Life 2");
assertThat(stored.getName()).isEqualTo("Half-Life 2: Corrected");
assertThat(stored.getImgIconUrl()).isEqualTo("icon");
}
@Test
void runSync_CorrectedName_SendsNoUpdate() {
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(new SteamGame(220L, "HL2 (corrected)", null, Instant.EPOCH)));
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.NAME), any())).thenReturn(Set.of(220L));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
verify(steamGameRepository, never()).setSourceName(anyLong(), anyString());
}
}
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -12,6 +13,7 @@ import org.mockito.Mock;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -84,6 +86,22 @@ class ManualGameServiceTest extends AbstractUnitTest {
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
}
@Test
void add_KnownGameOffSteam_UpdatesNameAndImageThroughTheGuardedUpdates() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty());
SteamGame stored = new SteamGame(-119388L, "Zelda (corrected)", null, Instant.EPOCH);
when(steamGameRepository.findById(-119388L)).thenReturn(Optional.of(stored));
service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0);
// Both updates keep an auditor's value (SteamGameRepositoryTest); the entity is not changed.
verify(steamGameRepository).setSourceName(-119388L, ZELDA.name());
verify(steamGameRepository).setImageUrl(-119388L, ZELDA.imageUrl());
assertThat(stored.getName()).isEqualTo("Zelda (corrected)");
}
private static IgdbApiService.Game game(long igdbId, String name) {
return new IgdbApiService.Game(igdbId, name, 2000, null, List.of("Switch"), null);
}
@@ -14,6 +14,7 @@ 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 org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.Instant;
import java.time.LocalDate;
@@ -29,6 +30,9 @@ import static org.assertj.core.api.Assertions.assertThat;
@Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class})
class UserStatsServiceTrendTest extends AbstractPersistenceTest {
@MockitoBean
private MoodService moodService;
private static final long USER_ID = 1L;
@Autowired
@@ -19,8 +19,10 @@ import java.util.Base64;
*/
@Tag("IntegrationTest")
// The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off.
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false"})
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.speedrun.enabled=false",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest {