Public Access
feat(profile): public profile data for a ticket from Auth
GET /api/public/profiles/library with X-Profile-Ticket returns totals, status counts, playing and finished games with rating; never notes, priority or the Steam ID. Login filters reject the ticket, and Core keeps no copy of the privacy flag.
This commit is contained in:
1 parent
ea1e1e9aec
commit
2f2f8a2918
11 files changed
+348
-15
No files matched your search
@@ -164,6 +164,6 @@ Today `user_game` mirrors Steam ownership and playtime, nothing else. Closing th
|
|||||||
- Series / franchise grouping: needs IGDB integration (separate adapter, requires a free IGDB OAuth token).
|
- Series / franchise grouping: needs IGDB integration (separate adapter, requires a free IGDB OAuth token).
|
||||||
- **Multi-platform support**: bigger schema change. Decouple `Game` (platform-agnostic) from `SteamGame` (platform-specific). New `source` enum (`STEAM`, `GOG`, `EPIC`, `MANUAL`, ...). Defer until at least one non-Steam adapter is on the immediate horizon.
|
- **Multi-platform support**: bigger schema change. Decouple `Game` (platform-agnostic) from `SteamGame` (platform-specific). New `source` enum (`STEAM`, `GOG`, `EPIC`, `MANUAL`, ...). Defer until at least one non-Steam adapter is on the immediate horizon.
|
||||||
|
|
||||||
### 8. Public profile data
|
### 8. ~~Public profile data~~ Done
|
||||||
|
|
||||||
- Endpoint: `GET /api/public/users/{username}/profile` returning currently-playing + recently-completed + total hours, gated by a `profile_public` flag (owned by Auth, mirrored or fetched on demand).
|
- `GET /api/public/profiles/library` with header `X-Profile-Ticket` (issued by Auth for a public profile, valid 10 minutes, see the Auth readme) returns totals, status counts, up to 8 playing and 12 finished games with rating. Never notes, priority or the Steam ID. Core keeps no copy of the privacy flag; the ticket is the proof.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.configuration.security;
|
package core_service.configuration.security;
|
||||||
|
|
||||||
|
import core_service.shared.security.ProfileTicketVerifier;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -8,14 +9,18 @@ import java.security.KeyFactory;
|
|||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key.
|
* Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class JwtService {
|
public class JwtService implements ProfileTicketVerifier {
|
||||||
|
|
||||||
private static final String ED25519 = "Ed25519";
|
private static final String ED25519 = "Ed25519";
|
||||||
|
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
||||||
|
private static final String TYPE_CLAIM = "typ";
|
||||||
|
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
||||||
|
|
||||||
private final JwtConfig jwtConfig;
|
private final JwtConfig jwtConfig;
|
||||||
private PublicKey publicKey;
|
private PublicKey publicKey;
|
||||||
@@ -56,18 +61,39 @@ public class JwtService {
|
|||||||
.get("steamId", Long.class);
|
.get("steamId", Long.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login.
|
||||||
|
*/
|
||||||
public boolean isTokenValid(String token) {
|
public boolean isTokenValid(String token) {
|
||||||
try {
|
try {
|
||||||
Jwts.parser()
|
var claims = Jwts.parser()
|
||||||
.verifyWith(publicKey)
|
.verifyWith(publicKey)
|
||||||
.build()
|
.build()
|
||||||
.parseSignedClaims(token);
|
.parseSignedClaims(token)
|
||||||
return true;
|
.getPayload();
|
||||||
|
return claims.get(TYPE_CLAIM) == null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Long> verifyProfileTicket(String ticket) {
|
||||||
|
try {
|
||||||
|
var claims = Jwts.parser()
|
||||||
|
.verifyWith(publicKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(ticket)
|
||||||
|
.getPayload();
|
||||||
|
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(Long.parseLong(claims.get("profileUserId", String.class)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static PublicKey decodePublicKey(String base64X509Der) {
|
private static PublicKey decodePublicKey(String base64X509Der) {
|
||||||
try {
|
try {
|
||||||
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
|
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ public class SecurityConfig {
|
|||||||
private static final String[] WHITE_LIST_URLS = {
|
private static final String[] WHITE_LIST_URLS = {
|
||||||
"/actuator/health", // Docker health check; details are off
|
"/actuator/health", // Docker health check; details are off
|
||||||
"/error",
|
"/error",
|
||||||
"/api/stats/public"
|
"/api/stats/public",
|
||||||
|
"/api/public/**" // Public profiles; PublicProfileController checks the ticket
|
||||||
};
|
};
|
||||||
|
|
||||||
private final OncePerRequestFilter jwtAuthFilter;
|
private final OncePerRequestFilter jwtAuthFilter;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.service.LibraryQueryService;
|
||||||
|
import core_service.shared.security.ProfileTicketVerifier;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, without login. Auth hands out the ticket only for a user whose profile is public, so this controller
|
||||||
|
* never needs to know the name or the privacy switch.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/public/profiles")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicProfileController {
|
||||||
|
|
||||||
|
private final LibraryQueryService queryService;
|
||||||
|
private final ProfileTicketVerifier profileTicketVerifier;
|
||||||
|
|
||||||
|
@GetMapping("/library")
|
||||||
|
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
|
||||||
|
return (ticket == null ? Optional.<Long>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
|
||||||
|
.<ResponseEntity<?>>map(userId -> ResponseEntity.ok(queryService.getPublicLibrary(userId)))
|
||||||
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
|
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a public profile shows. Never notes, priority or anything that identifies the Steam account.
|
||||||
|
*
|
||||||
|
* @param byStatus has every status, also those with 0 games
|
||||||
|
* @param playing up to 8 games, most played first
|
||||||
|
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
|
||||||
|
*/
|
||||||
|
public record PublicLibraryDto(
|
||||||
|
long totalGames,
|
||||||
|
long totalPlaytimeMinutes,
|
||||||
|
Map<GameStatus, Long> byStatus,
|
||||||
|
List<Game> playing,
|
||||||
|
List<Game> finished
|
||||||
|
) {
|
||||||
|
|
||||||
|
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -24,6 +25,11 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
|
|
||||||
long countByUserIdAndRemovedAtIsNull(Long userId);
|
long countByUserIdAndRemovedAtIsNull(Long userId);
|
||||||
|
|
||||||
|
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null")
|
||||||
|
long sumPlaytime(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
List<UserGame> findByUserIdAndRemovedAtIsNullAndStatusIn(Long userId, Collection<GameStatus> statuses, Pageable pageable);
|
||||||
|
|
||||||
List<UserGame> findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(
|
List<UserGame> findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(
|
||||||
Long userId, int playtime2Weeks);
|
Long userId, int playtime2Weeks);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
|
|||||||
import core_service.modules.library.api.dto.ActivityDto;
|
import core_service.modules.library.api.dto.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
|
import core_service.modules.library.api.dto.PublicLibraryDto;
|
||||||
import core_service.modules.library.api.dto.RecentGameDto;
|
import core_service.modules.library.api.dto.RecentGameDto;
|
||||||
import core_service.modules.library.api.dto.WishlistDto;
|
import core_service.modules.library.api.dto.WishlistDto;
|
||||||
import core_service.modules.library.domain.DayMinutes;
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
@@ -19,6 +20,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -30,12 +32,16 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class LibraryQueryService {
|
public class LibraryQueryService {
|
||||||
|
|
||||||
|
private static final int PUBLIC_PLAYING = 8;
|
||||||
|
private static final int PUBLIC_FINISHED = 12;
|
||||||
|
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||||
private final SteamGameRepository steamGameRepository;
|
private final SteamGameRepository steamGameRepository;
|
||||||
@@ -64,11 +70,7 @@ public class LibraryQueryService {
|
|||||||
* @param excludeAppid the current suggestion, so a new pick differs from it; null for none
|
* @param excludeAppid the current suggestion, so a new pick differs from it; null for none
|
||||||
*/
|
*/
|
||||||
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
|
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
|
||||||
Map<GameStatus, Long> byStatus = new EnumMap<>(GameStatus.class);
|
Map<GameStatus, Long> byStatus = countByStatus(userId);
|
||||||
for (GameStatus status : GameStatus.values()) {
|
|
||||||
byStatus.put(status, 0L);
|
|
||||||
}
|
|
||||||
userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count()));
|
|
||||||
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
||||||
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
|
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
|
||||||
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
|
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
|
||||||
@@ -105,6 +107,40 @@ public class LibraryQueryService {
|
|||||||
return new ActivityDto(perDay, total, recentGames);
|
return new ActivityDto(perDay, total, recentGames);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PublicLibraryDto getPublicLibrary(Long userId) {
|
||||||
|
Map<GameStatus, Long> byStatus = countByStatus(userId);
|
||||||
|
List<UserGame> playing = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
|
||||||
|
List.of(GameStatus.PLAYING),
|
||||||
|
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
|
||||||
|
List<UserGame> finished = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
|
||||||
|
List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
||||||
|
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))));
|
||||||
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(
|
||||||
|
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList())
|
||||||
|
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
|
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
||||||
|
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
|
||||||
|
toPublicGames(playing, meta), toPublicGames(finished, meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every status, also those with 0 games.
|
||||||
|
private Map<GameStatus, Long> countByStatus(Long userId) {
|
||||||
|
Map<GameStatus, Long> byStatus = new EnumMap<>(GameStatus.class);
|
||||||
|
for (GameStatus status : GameStatus.values()) {
|
||||||
|
byStatus.put(status, 0L);
|
||||||
|
}
|
||||||
|
userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count()));
|
||||||
|
return byStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
|
||||||
|
return games.stream()
|
||||||
|
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
|
||||||
|
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
||||||
|
ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public WishlistDto getWishlist(Long userId) {
|
public WishlistDto getWishlist(Long userId) {
|
||||||
return new WishlistDto(wishlistGameRepository.findForUser(userId).stream()
|
return new WishlistDto(wishlistGameRepository.findForUser(userId).stream()
|
||||||
.map(w -> new WishlistDto.Game(w.getAppid(), w.getName(), w.getAddedAt(),
|
.map(w -> new WishlistDto.Game(w.getAppid(), w.getName(), w.getAddedAt(),
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package core_service.shared.security;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port for the public-profile ticket that Auth issues for a user with a public profile.
|
||||||
|
*/
|
||||||
|
public interface ProfileTicketVerifier {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the user of a valid, unexpired ticket; empty for anything else, also for a login token
|
||||||
|
*/
|
||||||
|
Optional<Long> verifyProfileTicket(String ticket);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import java.security.KeyPairGenerator;
|
|||||||
import java.security.PrivateKey;
|
import java.security.PrivateKey;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
@@ -99,4 +100,37 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
assertFalse(jwtService.isTokenValid(token));
|
assertFalse(jwtService.isTokenValid(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String signTicket(String type, String profileUserId, long expiresInMs) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject("public-profile")
|
||||||
|
.claim("typ", type)
|
||||||
|
.claim("profileUserId", profileUserId)
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
|
||||||
|
.signWith(signingKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyProfileTicket_ReturnsTheUser() {
|
||||||
|
assertEquals(Optional.of(42L), jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception {
|
||||||
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", "a@b.de", null)));
|
||||||
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000)));
|
||||||
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "42", -1_000)));
|
||||||
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "not-a-number", 60_000)));
|
||||||
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage"));
|
||||||
|
|
||||||
|
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
|
||||||
|
String foreign = Jwts.builder().claim("typ", "public-profile").claim("profileUserId", "42")
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact();
|
||||||
|
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isTokenValid_ProfileTicketIsNoLogin() {
|
||||||
|
assertFalse(jwtService.isTokenValid(signTicket("public-profile", "42", 60_000)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.configuration.security;
|
package core_service.configuration.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
@@ -15,8 +16,10 @@ import org.testcontainers.containers.PostgreSQLContainer;
|
|||||||
import org.testcontainers.junit.jupiter.Container;
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
@@ -37,10 +40,19 @@ class PublicEndpointsTest {
|
|||||||
@ServiceConnection
|
@ServiceConnection
|
||||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||||
|
|
||||||
|
private static final KeyPair KEYS = generateKeys();
|
||||||
|
|
||||||
|
private static KeyPair generateKeys() {
|
||||||
|
try {
|
||||||
|
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@DynamicPropertySource
|
@DynamicPropertySource
|
||||||
static void properties(DynamicPropertyRegistry registry) throws Exception {
|
static void properties(DynamicPropertyRegistry registry) {
|
||||||
byte[] publicKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPublic().getEncoded();
|
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
|
||||||
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(publicKey));
|
|
||||||
registry.add("app.steam.api-key", () -> "test-key");
|
registry.add("app.steam.api-key", () -> "test-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,4 +73,21 @@ class PublicEndpointsTest {
|
|||||||
int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus();
|
int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus();
|
||||||
assertTrue(Set.of(401, 403).contains(status), path + " answered " + status);
|
assertTrue(Set.of(401, 403).contains(status), path + " answered " + status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void profileTicketIsNoLogin() throws Exception {
|
||||||
|
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1")
|
||||||
|
.claim("userId", "1").expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||||
|
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||||
|
|
||||||
|
int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket))
|
||||||
|
.andReturn().getResponse().getStatus();
|
||||||
|
assertTrue(Set.of(401, 403).contains(status), "ticket as login answered " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicProfileEndpointIsPublic() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GamePriority;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
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.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs its own tickets: the test key pair replaces the dev public key.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 7L;
|
||||||
|
private static final KeyPair KEYS = generateKeys();
|
||||||
|
|
||||||
|
private static KeyPair generateKeys() {
|
||||||
|
try {
|
||||||
|
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void properties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
private static String ticket(long userId) {
|
||||||
|
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
|
||||||
|
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
|
||||||
|
game.setStatus(status);
|
||||||
|
game.setRating(rating);
|
||||||
|
game.setNotes("private note");
|
||||||
|
game.setPriority(GamePriority.HIGH);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
steamGameRepository.saveAll(List.of(
|
||||||
|
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||||
|
new SteamGame(620L, "Portal 2", null, Instant.now()),
|
||||||
|
new SteamGame(700L, "Dota 2", null, Instant.now()),
|
||||||
|
new SteamGame(800L, "Hades", null, Instant.now())));
|
||||||
|
UserGame removed = game(800L, 999, GameStatus.FINISHED, 5);
|
||||||
|
removed.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.saveAll(List.of(
|
||||||
|
game(220L, 300, GameStatus.PLAYING, null),
|
||||||
|
game(620L, 900, GameStatus.COMPLETED, 5),
|
||||||
|
game(700L, 60, GameStatus.FINISHED, 3),
|
||||||
|
removed,
|
||||||
|
new UserGame(99L, 220L, 5000, Instant.now(), Instant.now())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicLibrary_ShowsStatsPlayingAndFinishedGamesButNoNotesOrPriority() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalGames").value(3))
|
||||||
|
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260))
|
||||||
|
.andExpect(jsonPath("$.byStatus.FINISHED").value(1))
|
||||||
|
.andExpect(jsonPath("$.byStatus.COMPLETED").value(1))
|
||||||
|
.andExpect(jsonPath("$.playing", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.playing[0].name").value("Half-Life 2"))
|
||||||
|
.andExpect(jsonPath("$.finished", hasSize(2)))
|
||||||
|
.andExpect(jsonPath("$.finished[0].name").value("Portal 2"))
|
||||||
|
.andExpect(jsonPath("$.finished[0].rating").value(5))
|
||||||
|
.andExpect(jsonPath("$.finished[1].status").value("FINISHED"))
|
||||||
|
.andExpect(jsonPath("$..notes").isEmpty())
|
||||||
|
.andExpect(jsonPath("$..priority").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", "garbage"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_TICKET"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dev filter falls back to the dummy user 1 for a token it rejects; user 7's game would answer 200.
|
||||||
|
@Test
|
||||||
|
void profileTicket_IsNoLoginForTheLibraryApi() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/library/games/220/backlog").header("Authorization", "Bearer " + ticket(USER_ID))
|
||||||
|
.contentType("application/json").content("{\"status\":\"DROPPED\"}"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user