diff --git a/CHANGELOG.md b/CHANGELOG.md index dc84cfcea..d8b817045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,85 @@ Toutes les versions publiées de Catapult, la plus récente en premier. Ce fichier est régénéré par le bot à chaque release à partir des notes des [GitHub Releases](https://github.com/enimaloc/catapult/releases) : pour corriger une entrée, modifie les notes de la release (ou le changelog de la PR de release avant sa publication), pas ce fichier. +## [v1.2.0](https://github.com/enimaloc/catapult/releases/tag/v1.2.0) - 2026-11-12 + +### Features +- **api:** tell the settings whether the title's conditions and drops are offered (b1f56be) +- **api:** let an admin game mapping ignore its game by default (939704b) +- **web:** ignore a game by default from the admin game mappings (aac9d6e) +- **api:** expose each DTDD topic's severity in the public game detail (5f54d94) +- **api:** track each DTDD key's remaining quota and let bulk work keep a monthly reserve (0b11316) +- **api:** list the DTDD caches for the admins and refetch them under the rate limit (8ed75a3) +- **web:** add an admin tab for the DTDD caches with a refetch per entry and a full one (6f7012c) +- **api:** give the admins everything the DTDD caches keep about one entry (458d771) +- **web:** open a DTDD cache entry in full from the admin tab (525ecd0) +- **api:** keep each cached DTDD topic's severity and show it to the admins (67508d9) +- **web:** show each DTDD topic's severity in a wider entry dialog (0bb16dc) +- **api:** let a DTDD topic's severity keep its TW off below the mapping's least severe level (2092d4c) +- **api:** let the admins read and set each DTDD topic's least severe level on a TW (a3656a7) +- **web:** edit a TW's DTDD topics and their least severe level from the admin tab (9c099d8) +- **api:** tell a streamer why each of a binding's TWs is on (6e8c455) +- **web:** show a streamer why each of a binding's TWs is on, in a details dialog (2c1e5d9) +- **api:** run one TW recalculation at a time, follow and cancel it, waiting out Steam's rate limit (1ae844a) +- **web:** show the TW recalculation's progress in the admin tab, and let it be stopped (9f3102f) +- let an admin game mapping ignore its game by default (4dab0bc) +- an admin tab for the DTDD caches, with each topic's severity (4f681f3) +- let a DTDD topic's severity decide its TW, and tell streamers why each TW is on (45ffb48) +- follow the TW recalculation and respect Steam's rate limit (3a9d91d) +- **ratelimit:** add route keys and caller priority (cc3231d) +- **ratelimit:** learn limits and resume delays from 429s (cd059a6) +- **ratelimit:** persist learned rate limits (a62732a) +- **ratelimit:** throttle routes by learned limits with live priority (4465212) +- **ratelimit:** apply learned limits to outbound HTTP calls (6694be6) +- **ratelimit:** bound the routes per host and learn only from windows counted whole (e4227c5) +- **tw:** run the TW recalculation as bulk traffic under the learned rate limits (ca73b99) +- **admin:** expose the learned rate limits in the admin API (1f3f8db) +- **admin:** a tab to see, correct and reset the learned rate limits (e55fe64) +- merge learned rate limits into dev (47477c9) +- **keys:** count each shared key's calls against its window and daily or monthly limit (d37dd81) +- **admin:** merge the rate limits into the Keys tab, with each key's usage, updated live (d511999) +- merge the keys and rate limits tab into dev (831cad3) + +### Fixes +- **api:** explain each title condition and the drops in the help, only when offered (d85167e) +- **web:** name the title's conditions and drop variables under its fields when offered (b5ad1b1) +- **api:** read DTDD's new topic severity instead of failing every item call (8478f18) +- **api:** leave the Steam store alone after a 429, for its Retry-After or a doubling wait (4bbbc96) +- **api:** resolve a Steam game's TWs again once the store's rate limit ends (d7170cb) +- **api:** decide the title help's parts in a service, not in the controller (859daae) +- show the title's conditions and drop variables in its help when offered (11e0f6e) +- **api:** read DTDD's new topic severity instead of failing every item call (63a3ae7) +- **web:** write the DTDD caches' API paths in their calls (025d804) +- **web:** stop completing an SSE stream the container already failed (434b489) +- **api:** leave the query string out of the http.client.requests uri tag (9029240) +- **api:** never take a Twitch website URL for a binding's category id (3a1bc1c) +- merge fix/1.1.1 into dev (142d52d) +- **xbox:** use a URI template for the store's product calls (8f4041a) +- **ratelimit:** fall back to the system clock in profiles without a Clock bean (4a7cd42) +- **ratelimit:** keep admin changes across instances and learn no tiny first limit (3add1c0) +- **steam:** let only live appdetails refusals stop the store, back off when nothing learned (1f6de35) +- **admin:** give a route's rate limit scope back to the learning (e83f87b) + +### Refactoring +- **api:** inject the binding service's event publisher through its constructor (e13f736) +- **steam:** rely on learned rate limits for the store (da95d94) +- **ratelimit:** name the host row check hostScope so JSON keeps the host field (48cac62) + +### Other +- merge dev into feat/dtdd-cache-admin (375f54a) +- merge dev into feat/tw-dtdd-severity (9ee1eef) +- **web:** cover the DTDD caches calls of the real and mock API services (bc3ce92) +- merge feat/dtdd-cache-admin into feat/tw-dtdd-severity (62bf3a7) +- **web:** cover the TW details and DTDD topics calls of the real and mock API services (6cf1834) +- merge dev into feat/dtdd-cache-admin (e6a9391) +- merge feat/dtdd-cache-admin into feat/tw-dtdd-severity (939f5f2) +- merge feat/tw-dtdd-severity into feat/tw-rebuild-progress (bc730cc) +- **common:** cover the admin DTOs' shorter constructors and older payloads (e9b65e8) +- **release:** promote dev straight to main, skipping beta (8ff3dc2) +- **release:** auto-merge the backports that bring a release back to dev (353eaaf) +- run the checks on pull requests only and cancel superseded runs (89bb860) +- merge ci-skip-beta into dev (0e7496d) + ## [v1.1.0](https://github.com/enimaloc/catapult/releases/tag/v1.1.0) - 2026-11-10 ### Features diff --git a/build.gradle.kts b/build.gradle.kts index 335a8629c..908633328 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { allprojects { group = "fr.enimaloc" - version = "1.2.0" + version = "1.3.0" } subprojects { diff --git a/catapult-api/Dockerfile b/catapult-api/Dockerfile index 5815b8104..cc480e5df 100644 --- a/catapult-api/Dockerfile +++ b/catapult-api/Dockerfile @@ -7,27 +7,39 @@ RUN apk add --no-cache git COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./ COPY gradle ./gradle COPY catapult-common/build.gradle.kts ./catapult-common/ +COPY catapult-bgs/build.gradle.kts ./catapult-bgs/ COPY catapult-api/build.gradle.kts ./catapult-api/ COPY catapult-web/build.gradle.kts ./catapult-web/ RUN ./gradlew :catapult-api:dependencies --no-daemon -q COPY catapult-common/src ./catapult-common/src +COPY catapult-bgs/src ./catapult-bgs/src COPY catapult-api/src ./catapult-api/src COPY CHANGELOG.md ./ COPY .git ./.git # bootJar alone: `build` would also run the tests and leave a second (-plain) jar for the COPY below RUN ./gradlew :catapult-api:bootJar --no-daemon -FROM eclipse-temurin:25-jre-alpine +# glibc base: Chromium (Battle.net login, Playwright) does not run on musl +FROM eclipse-temurin:25-jre-noble WORKDIR /app -RUN apk add --no-cache curl jq -RUN addgroup -S catapult && adduser -S catapult -G catapult +RUN apt-get update && apt-get install -y --no-install-recommends curl jq && rm -rf /var/lib/apt/lists/* +RUN groupadd --system catapult && useradd --system --create-home --home-dir /home/catapult --gid catapult catapult COPY --from=builder /app/catapult-api/build/libs/*.jar app.jar -RUN chown catapult:catapult app.jar +# Chromium and its OS libraries are installed at build time (never at startup) with Playwright's CLI, run from +# the boot jar's classpath through PropertiesLauncher; the browsers live in a folder the catapult user can read +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN java -Dloader.main=com.microsoft.playwright.CLI -cp app.jar \ + org.springframework.boot.loader.launch.PropertiesLauncher install --with-deps chromium \ + && rm -rf /var/lib/apt/lists/* \ + && chown -R catapult:catapult /ms-playwright app.jar + +# Only Chromium is baked in: Playwright.create() must not try to download the other browsers at runtime +ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 USER catapult diff --git a/catapult-api/build.gradle.kts b/catapult-api/build.gradle.kts index ad4fd6ebc..1bd034d39 100644 --- a/catapult-api/build.gradle.kts +++ b/catapult-api/build.gradle.kts @@ -32,6 +32,7 @@ val springCloudContextVersion = "5.0.4" dependencies { implementation(project(":catapult-common")) + implementation(project(":catapult-bgs")) implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-data-jpa") @@ -61,6 +62,7 @@ dependencies { implementation("io.sentry:sentry-spring-boot-4-starter") implementation("io.sentry:sentry-logback") implementation("org.jsoup:jsoup:$jsoupVersion") + implementation("com.microsoft.playwright:playwright:1.64.0") implementation("org.commonmark:commonmark:$commonmarkVersion") implementation("org.springframework.cloud:spring-cloud-context:$springCloudContextVersion") @@ -133,7 +135,7 @@ val englishConventions = tasks.register("englishConventions") { testClassesDirs = testSourceSet.output.classesDirs classpath = testSourceSet.runtimeClasspath filter.includeTestsMatching("*EnglishConventionsTest") - listOf("catapult-common", "catapult-web").forEach { module -> + listOf("catapult-common", "catapult-bgs", "catapult-web").forEach { module -> inputs.dir(rootProject.file("$module/src")) .withPropertyName("${module}Sources") .withPathSensitivity(PathSensitivity.RELATIVE) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsController.java new file mode 100644 index 000000000..78411b405 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsController.java @@ -0,0 +1,56 @@ +package fr.enimaloc.catapult.api.admin; + +import fr.enimaloc.catapult.api.ApiPaths; +import fr.enimaloc.catapult.service.battlenet.BattleNetServiceAccountAdmin; +import fr.enimaloc.catapult.service.battlenet.BattleNetServiceAccountAdmin.AccountDto; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping(ApiPaths.ADMIN + "/battlenet-accounts") +@RequiredArgsConstructor +@ConditionalOnBooleanProperty("battlenet.enabled") +public class ApiAdminBattleNetAccountsController { + + private final BattleNetServiceAccountAdmin accounts; + + @GetMapping + public List list() { + return accounts.list(); + } + + /** 202: the account is stored and its login runs in the background (poll the list for its auth state). */ + @PostMapping + public ResponseEntity create(@RequestBody Map body) { + return ResponseEntity.accepted().body(accounts.create(body.get("email"), body.get("password"))); + } + + /** The 2FA code of a login waiting for one; 409 when none is. */ + @PostMapping("/{id}/code") + public ResponseEntity submitCode(@PathVariable UUID id, @RequestBody Map body) { + accounts.submitCode(id, body.get("code")); + return ResponseEntity.accepted().build(); + } + + @PostMapping("/{id}/reauth") + public ResponseEntity reauth(@PathVariable UUID id) { + return ResponseEntity.accepted().body(accounts.reauth(id)); + } + + @PatchMapping("/{id}") + public AccountDto update(@PathVariable UUID id, @RequestBody Map body) { + return accounts.update(id, body); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id) { + accounts.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingController.java index 4c16458f4..8dc9486d9 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingController.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingController.java @@ -6,6 +6,7 @@ import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.service.binding.AdminGameMappingService; import fr.enimaloc.catapult.service.notification.AdminEventPublisher; import fr.enimaloc.catapult.service.twitch.TwitchCategory; @@ -86,4 +87,11 @@ public List categories(@RequestParam(required = false) String q) if (q == null || q.isBlank()) return List.of(); return categoryService.searchCategories(q.trim()); } + + /** The IGDB game search of the form (at most 10); empty for a blank query or when IGDB is unavailable. */ + @GetMapping("/igdb-games") + public List igdbGames(@RequestParam(required = false) String q) { + if (q == null || q.isBlank()) return List.of(); + return service.searchIgdbGames(q.trim()); + } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsController.java index c9cd1cfa8..2b68371de 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsController.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsController.java @@ -33,7 +33,7 @@ public ResponseEntity startDeviceCode() { /** 202 while the admin hasn't validated the code on microsoft.com/link; 200 + the created account afterwards. */ @PostMapping public ResponseEntity create(@RequestBody Map body) { - return accounts.create(body.get("deviceCode"), body.getOrDefault("label", "Minecraft account")) + return accounts.create(body.get("deviceCode")) .>map(ResponseEntity::ok) .orElseGet(ApiAdminMinecraftAccountsController::pending); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java index dc026dd32..1f23ebef4 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java @@ -8,6 +8,7 @@ import fr.enimaloc.catapult.common.dto.channel.DtddMappingProposalDto; import fr.enimaloc.catapult.common.dto.channel.DtddMappingStatusDto; import fr.enimaloc.catapult.common.dto.channel.GameDto; +import fr.enimaloc.catapult.common.dto.channel.BattleNetData; import fr.enimaloc.catapult.common.dto.channel.MinecraftData; import fr.enimaloc.catapult.common.dto.channel.ObsData; import fr.enimaloc.catapult.common.dto.channel.TwitchatData; @@ -51,6 +52,7 @@ import fr.enimaloc.catapult.service.igdb.AdminCclService; import fr.enimaloc.catapult.api.CustomBindingViews; import fr.enimaloc.catapult.service.igdb.IgdbService; +import fr.enimaloc.catapult.service.battlenet.BattleNetLinkStateService; import fr.enimaloc.catapult.service.minecraft.MinecraftLinkStateService; import fr.enimaloc.catapult.service.notification.TwitchatWidgetSettingsService; import lombok.RequiredArgsConstructor; @@ -92,6 +94,8 @@ public class ChannelDashboardAssembler { private final OAuthTokenRepository oAuthTokenRepository; // Only present when minecraft.enabled (the service is @ConditionalOnBooleanProperty). private final Optional minecraftLinkStates; + // Only present when battlenet.enabled. + private final Optional battleNetLinkStates; private final TwitchatWidgetSettingsService twitchatWidgetSettingsService; private final RaidRestoreGateService raidRestoreGate; private final CustomBindingViews customBindingViews; @@ -147,6 +151,7 @@ public ChannelPageData page(UserAccount viewer, UserAccount channel, String user steamData(channel, owner), xboxEnabled ? new XboxData(hasXboxToken(channel)) : null, owner ? minecraftData(channel) : null, + owner ? battleNetData(channel) : null, owner ? obsData(widget) : null, owner ? TwitchatData.of(widget.getTwitchatBranch()) : null, custom, @@ -199,7 +204,7 @@ private PagedBindings bindings(UserAccount channel, int page, String status, Str } Map boxArts = boxArts(bindings.getContent()); List content = bindings.getContent().stream() - .map(binding -> toBindingDto(binding, boxArts.get(binding.getTwitchGameId()))) + .map(binding -> toBindingDto(binding, boxArt(boxArts, binding.getTwitchGameId()))) .toList(); return new PagedBindings(bindings.getNumber(), bindings.getTotalPages(), bindings.getTotalElements(), content); } @@ -214,6 +219,11 @@ private Map boxArts(List bindings) { .collect(Collectors.toMap(TwitchCategoryCache::getId, TwitchCategoryCache::getBoxArtUrl)); } + /** A category's box art, absent for a binding with no Twitch category (an immutable map rejects a null key). */ + private static String boxArt(Map boxArts, String twitchGameId) { + return twitchGameId == null ? null : boxArts.get(twitchGameId); + } + private static BindingDto toBindingDto(GameBinding binding, String boxArtUrl) { return new BindingDto(binding.getId().toString(), binding.getStatus().name(), binding.getSourceType().name(), binding.getSourceName(), binding.getTwitchGameId(), binding.getTwitchGameName(), binding.isIgnored(), @@ -249,6 +259,15 @@ private MinecraftData minecraftData(UserAccount channel) { .orElse(null); } + /** The Battle.net link as the connect endpoint states it, null without Battle.net or when it's unavailable. */ + private BattleNetData battleNetData(UserAccount channel) { + return battleNetLinkStates + .map(service -> service.state(channel)) + .filter(state -> !BattleNetLinkStateService.UNAVAILABLE.equals(state.status())) + .map(state -> new BattleNetData(state.status(), state.battleTag(), state.serviceAccountBattleTag())) + .orElse(null); + } + /** The Twitchat relay's OBS settings, without the password itself. */ private static ObsData obsData(TwitchatWidgetSettings settings) { return new ObsData(settings.isEnabled(), settings.getObsHost(), settings.getObsPort(), @@ -331,11 +350,9 @@ public DtddMappingStatusDto dtddMapping(UserAccount viewer, UserAccount channel, return new DtddMappingStatusDto(current, pending, canValidate, id, viewerIsAdmin); } - /** IGDB entry of a detected game: by app id for Steam games, by name otherwise. */ + /** IGDB entry of a detected game, as {@link IgdbService#resolveDetected} resolves it. */ private Optional igdbGame(DetectedGame game) { - return game.getSourceType() == GameBinding.SourceType.STEAM - ? igdbService.findBySteamAppId(game.getSourceId()) - : igdbService.findByName(game.getSourceName()); + return igdbService.resolveDetected(game.getSourceType(), game.getSourceId(), game.getSourceName()); } private String dtddName(Long dtddId) { @@ -369,7 +386,7 @@ public Optional currentBinding(UserAccount channel) { return gameStateService.getLastKnownGame(channel) .flatMap(game -> gameBindingRepository .findByUserAndSourceIdAndSourceType(channel, game.getSourceId(), game.getSourceType())) - .map(binding -> toBindingDto(binding, boxArts(List.of(binding)).get(binding.getTwitchGameId()))); + .map(binding -> toBindingDto(binding, boxArt(boxArts(List.of(binding)), binding.getTwitchGameId()))); } /** The detected game, with the id of the channel's binding for it when there is one. */ diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectController.java new file mode 100644 index 000000000..5ed15b0af --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectController.java @@ -0,0 +1,54 @@ +package fr.enimaloc.catapult.api.connect; + +import fr.enimaloc.catapult.api.ApiPaths; +import fr.enimaloc.catapult.api.ApiUserResolver; +import fr.enimaloc.catapult.api.error.ApiProblem; +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; +import fr.enimaloc.catapult.common.error.ErrorCode; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.service.battlenet.BattleNetFriendService; +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; +import fr.enimaloc.catapult.service.battlenet.BattleNetLinkStateService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +/** + * A member's own Battle.net link. Linking itself goes through Blizzard's OAuth flow (see + * {@code CatapultOAuth2UserService}); this only states the link and leaves it. Deliberately not transactional: + * unenrolling commits its own transaction before the bot and the dashboards are told. + */ +@RestController +@RequestMapping(ApiPaths.USER + "/links/battlenet") +@RequiredArgsConstructor +@ConditionalOnBooleanProperty("battlenet.enabled") +public class ApiBattleNetConnectController { + + private final BattleNetFriendService friendService; + private final BattleNetLinkStateService linkStates; + private final BattleNetGateService gateService; + private final ApiUserResolver userResolver; + + @PreAuthorize("@authz.isSelf(#userId)") + @GetMapping + public BattleNetLinkStateResponse status(@PathVariable UUID userId, @AuthenticationPrincipal Jwt jwt) { + return linkStates.state(userResolver.viewer(jwt)); + } + + @PreAuthorize("@authz.isSelf(#userId)") + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unenroll(@PathVariable UUID userId, @AuthenticationPrincipal Jwt jwt) { + UserAccount user = userResolver.viewer(jwt); + if (!gateService.isAvailableFor(user)) { + throw new ApiProblem(ErrorCode.FEATURE_DISABLED, "Battle.net is not available for this account"); + } + friendService.unenroll(user); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/userapi/GameInfoAssembler.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/userapi/GameInfoAssembler.java index 90c20e845..0c4daaff4 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/userapi/GameInfoAssembler.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/userapi/GameInfoAssembler.java @@ -382,10 +382,7 @@ private Set localizeTws(Set tws, Locale locale) { } private Optional resolveIgdbId(DetectedGame detected) { - return (detected.getSourceId() != null - ? igdbService.findByExternalAppId(detected.getSourceType(), detected.getSourceId()) - .or(() -> igdbService.findByName(detected.getSourceName())) - : igdbService.findByName(detected.getSourceName())) + return igdbService.resolveDetected(detected.getSourceType(), detected.getSourceId(), detected.getSourceName()) .map(IgdbService.IgdbGame::id); } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/config/OAuth2ClientConfig.java b/catapult-api/src/main/java/fr/enimaloc/catapult/config/OAuth2ClientConfig.java index 88ef051d0..bbb1bef4a 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/config/OAuth2ClientConfig.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/config/OAuth2ClientConfig.java @@ -70,8 +70,8 @@ public ClientRegistrationRepository clientRegistrationRepository() { } if (configured(battleNetClientId, battleNetClientSecret)) { + // No "openid": it would switch Spring to the OIDC flow and bypass CatapultOAuth2UserService; Blizzard's userinfo needs no scope. registrations.add(authorizationCode("battlenet", battleNetClientId, battleNetClientSecret) - .scope("openid") .authorizationUri("https://oauth.battle.net/authorize") .tokenUri("https://oauth.battle.net/token") .userInfoUri("https://oauth.battle.net/userinfo") diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetFriendLink.java b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetFriendLink.java new file mode 100644 index 000000000..bc102325e --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetFriendLink.java @@ -0,0 +1,52 @@ +package fr.enimaloc.catapult.domain.battlenet; + +import fr.enimaloc.catapult.domain.account.UserAccount; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "battlenet_friend_link") +@Getter +@Setter +public class BattleNetFriendLink { + + public enum Status { + PENDING, ACCEPTED, REMOVED, INVITE_REJECTED + } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private UserAccount user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "service_account_id", nullable = false) + private BattleNetServiceAccount serviceAccount; + + /** BGS account id of the streamer (from OAuth userinfo.id). */ + @Column(nullable = false) + private long bnetAccountId; + + @Column(nullable = false) + private String battleTag; + + /** Id of the invitation sent by the bot, when known. */ + private Long invitationId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status = Status.PENDING; + + @Column(nullable = false) + private Instant requestedAt = Instant.now(); + + private Instant acceptedAt; +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetServiceAccount.java b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetServiceAccount.java new file mode 100644 index 000000000..130bde27d --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetServiceAccount.java @@ -0,0 +1,74 @@ +package fr.enimaloc.catapult.domain.battlenet; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "battlenet_service_account") +@Getter +@Setter +@ToString +public class BattleNetServiceAccount { + + public enum AuthState { + NEW, CODE_REQUIRED, READY, AUTH_FAILED + } + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ToString.Exclude + @Column(nullable = false) + private String email; + + /** Battle.net password, encrypted through TokenEncryptionService. */ + @ToString.Exclude + @Column(nullable = false, columnDefinition = "text") + private String passwordEnc; + + /** Playwright storageState (cookies), encrypted through TokenEncryptionService. */ + @ToString.Exclude + @Column(columnDefinition = "text") + private String storageStateEnc; + + /** Filled from OnLogonComplete; null before the first login. */ + private String battleTag; + + /** BGS account id (equals the OAuth userinfo id); null before the first login. */ + @Column(unique = true) + private Long bnetAccountId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AuthState authState = AuthState.NEW; + + @Column(nullable = false) + private int fillOrder; + + @Column(nullable = false) + private boolean friendLimitReached; + + @Column(nullable = false) + private boolean enabled = true; + + @Column(nullable = false) + private Instant createdAt = Instant.now(); + + @Column(nullable = false) + private Instant updatedAt = Instant.now(); + + /** Optimistic lock: admin edits and in-flight logins both write the account. */ + @Version + private long version; + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/binding/GameMapping.java b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/binding/GameMapping.java index f5cbe4578..4f9f7045f 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/binding/GameMapping.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/binding/GameMapping.java @@ -43,6 +43,10 @@ public class GameMapping { @Column(name = "ignored_by_default", nullable = false) private boolean ignoredByDefault = false; + /** The IGDB game this source is pinned to; null: resolved as without a mapping. */ + @Column(name = "igdb_id") + private String igdbId; + /** false: the bindings keep IGDB's CCLs; true: they get {@link #ccls}, even when empty. */ @Column(name = "ccls_overridden", nullable = false) private boolean cclsOverridden = false; diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/minecraft/MinecraftServiceAccount.java b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/minecraft/MinecraftServiceAccount.java index 274311bf2..dc352f593 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/minecraft/MinecraftServiceAccount.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/minecraft/MinecraftServiceAccount.java @@ -17,9 +17,6 @@ public class MinecraftServiceAccount { @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - @Column(nullable = false) - private String label; - @Column(nullable = false) private String minecraftUsername; diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/experiment/BattleNetIntegrationExperiment.java b/catapult-api/src/main/java/fr/enimaloc/catapult/experiment/BattleNetIntegrationExperiment.java new file mode 100644 index 000000000..01a52f93b --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/experiment/BattleNetIntegrationExperiment.java @@ -0,0 +1,20 @@ +package fr.enimaloc.catapult.experiment; + +import fr.enimaloc.catapult.domain.experiment.ExperimentAssignmentRule.RuleType; + +/** + * Gate of the Battle.net feature (account linking + presence detection). + * Control 0 % / enabled 100 %: once ACTIVE, everyone is in + * enabled, so activating it in the admin works as the feature's switch. + */ +@ExperimentSpec( + key = "battlenet.integration", + name = "Intégration Battle.net", + description = "Liaison de compte Battle.net et détection de présence via les comptes de service (protocole BGS)", + variants = { + @ExperimentSpec.Variant(key = "control", name = "Sans Battle.net", weight = 0, control = true), + @ExperimentSpec.Variant(key = "enabled", name = "Avec Battle.net", weight = 100) + }, + rules = @ExperimentSpec.Rule(type = RuleType.RANDOM, percent = 100) +) +public class BattleNetIntegrationExperiment {} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/BattleNetGameGetter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/BattleNetGameGetter.java index 0b836017e..ed36c86a6 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/BattleNetGameGetter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/BattleNetGameGetter.java @@ -1,21 +1,36 @@ package fr.enimaloc.catapult.getter; import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.binding.GameBinding; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; +import fr.enimaloc.catapult.service.battlenet.BattleNetSessionManager; +import fr.enimaloc.catapult.service.battlenet.BattleNetTitles; +import fr.enimaloc.catapult.simulation.SimulatedCalls; +import fr.enimaloc.catapult.simulation.SimulationGate; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Component; import java.util.Optional; /** - * Detects the current game via Battle.net (Blizzard). - * TODO: implement through the Blizzard OAuth2 API - * - OAuth2 scopes: openid - * - Endpoint: {@code https://oauth.battle.net/userinfo} (identity only) - * - Detecting the active game needs the per-game APIs (WoW, Overwatch, etc.) + * Detects the current game via Battle.net: the streamer is friends with one of the bots, whose BGS session receives + * their presence (pushed, kept in memory by {@link BattleNetSessionManager}). No per-cycle batch: reading the + * in-memory state costs nothing. */ @Component +@RequiredArgsConstructor @ConditionalOnBooleanProperty("battlenet.enabled") public class BattleNetGameGetter implements GameGetter { + + private final BattleNetSessionManager sessions; + private final BattleNetFriendLinkRepository linkRepository; + private final BattleNetGateService gateService; + private final ObjectProvider simulationGate; + @Override public String name() { return "Battle.net"; @@ -23,6 +38,14 @@ public String name() { @Override public Optional getCurrentGame(UserAccount user) { - return Optional.empty(); + if (!gateService.isAvailableReadOnly(user) + || SimulationGate.blocks(simulationGate, user.getId(), SimulatedCalls.BATTLENET_PRESENCE)) { + return Optional.empty(); + } + return linkRepository.findByUser(user) + .filter(link -> link.getStatus() == BattleNetFriendLink.Status.ACCEPTED) + .flatMap(link -> sessions.currentGame(link.getBnetAccountId())) + .map(fourcc -> new DetectedGame(fourcc, GameBinding.SourceType.BATTLENET, + BattleNetTitles.displayName(fourcc))); } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetter.java index f67369bc0..6277c1018 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetter.java @@ -87,7 +87,7 @@ public CompletableFuture prefetchBatch() { cache.put(MinecraftService.normalizeProfileId(presence.profileId()), presence.status()); } } catch (Exception e) { - log.warn("Minecraft presence failed for {}: {}", account.getLabel(), e.getMessage()); + log.warn("Minecraft presence failed for {}: {}", account.getMinecraftUsername(), e.getMessage()); } }); } @@ -110,7 +110,7 @@ private List membersOf(MinecraftServiceAccount account) { .toList(); } catch (RuntimeException e) { // the presence call goes on, unrecorded - log.warn("Could not list the members of {} for the debug recording: {}", account.getLabel(), e.toString()); + log.warn("Could not list the members of {} for the debug recording: {}", account.getMinecraftUsername(), e.toString()); return List.of(); } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetFriendLinkRepository.java b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetFriendLinkRepository.java new file mode 100644 index 000000000..186681ca6 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetFriendLinkRepository.java @@ -0,0 +1,43 @@ +package fr.enimaloc.catapult.repository.battlenet; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BattleNetFriendLinkRepository extends JpaRepository { + Optional findByUser(UserAccount user); + + /** + * Variant with the service account loaded (open-in-view is disabled: readers + * outside a transaction, such as controllers, cannot initialize the LAZY proxy). + */ + @EntityGraph(attributePaths = "serviceAccount") + Optional findWithServiceAccountByUser(UserAccount user); + List findByServiceAccount(BattleNetServiceAccount serviceAccount); + long countByServiceAccount(BattleNetServiceAccount serviceAccount); + long countByServiceAccountAndStatus(BattleNetServiceAccount serviceAccount, BattleNetFriendLink.Status status); + boolean existsByServiceAccountAndStatus(BattleNetServiceAccount serviceAccount, BattleNetFriendLink.Status status); + + /** + * Several links can match (nothing makes {@code bnetAccountId} unique per bot: two Catapult users may link the + * same Battle.net account), hence a list. + */ + List findByServiceAccountAndBnetAccountId(BattleNetServiceAccount serviceAccount, + long bnetAccountId); + + /** + * Attaches InvitationSent events, which carry account_id 0: matched by BattleTag instead. Several links can + * match (no unique constraint), hence a list. + */ + List findByServiceAccountAndBattleTagIgnoreCase(BattleNetServiceAccount serviceAccount, + String battleTag); + + List findByServiceAccountAndInvitationId(BattleNetServiceAccount serviceAccount, + long invitationId); +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetServiceAccountRepository.java b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetServiceAccountRepository.java new file mode 100644 index 000000000..5063d99c8 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetServiceAccountRepository.java @@ -0,0 +1,25 @@ +package fr.enimaloc.catapult.repository.battlenet; + +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BattleNetServiceAccountRepository extends JpaRepository { + List findByEnabledTrueOrderByFillOrderAsc(); + + List findByEnabledTrueAndAuthStateOrderByFillOrderAsc( + BattleNetServiceAccount.AuthState authState); + + long countByEnabledTrue(); + + long countByEnabledTrueAndFriendLimitReachedFalse(); + + long countByEnabledTrueAndAuthState(BattleNetServiceAccount.AuthState authState); + + long countByEnabledTrueAndAuthStateAndFriendLimitReachedFalse(BattleNetServiceAccount.AuthState authState); + + Optional findByBnetAccountId(long bnetAccountId); +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/security/CatapultOAuth2UserService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/security/CatapultOAuth2UserService.java index df27fb559..5a04629ac 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/security/CatapultOAuth2UserService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/security/CatapultOAuth2UserService.java @@ -12,9 +12,12 @@ import fr.enimaloc.catapult.service.access.InviteService; import fr.enimaloc.catapult.service.access.WhitelistService; import fr.enimaloc.catapult.service.account.AdminMigrationService; +import fr.enimaloc.catapult.service.battlenet.BattleNetFriendService; +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.logging.log4j.util.Strings; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; @@ -28,7 +31,7 @@ import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.client.RestClient; import java.util.List; @@ -50,6 +53,10 @@ public class CatapultOAuth2UserService implements OAuth2UserService battleNetFriends; + private final TransactionTemplate transactions; @Value("${app.owner-id:}") private String ownerId; @@ -69,16 +76,22 @@ public class CatapultOAuth2UserService implements OAuth2UserService handleTwitchLogin(userRequest, fetchTwitchUser(userRequest)); - case "steam" -> handleSecondaryLink(userRequest, OAuthToken.Provider.STEAM); - case "xbox" -> handleSecondaryLink(userRequest, OAuthToken.Provider.XBOX); -// case "battlenet" -> handleSecondaryLink(userRequest, OAuthToken.Provider.BATTLENET); + case "twitch" -> transactions.execute(status -> + handleTwitchLogin(userRequest, fetchTwitchUser(userRequest))); + case "steam" -> transactions.execute(status -> + handleSecondaryLink(userRequest, OAuthToken.Provider.STEAM)); + case "xbox" -> transactions.execute(status -> + handleSecondaryLink(userRequest, OAuthToken.Provider.XBOX)); + case "battlenet" -> handleBattleNetLink(userRequest); default -> delegate.loadUser(userRequest); }; } catch (OAuth2AuthenticationException e) { @@ -94,7 +107,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } /** - * Steam/Xbox/BattleNet are secondary providers (game detection), not login + * Steam/Xbox are secondary providers (game detection), not login * providers. When a user links one, they are already authenticated via * Twitch. We save the token for their account and return their existing * principal. @@ -114,6 +127,54 @@ private OAuth2User handleSecondaryLink(OAuth2UserRequest userRequest, OAuthToken return existingUser; } + /** + * Battle.net is a secondary link too, but it saves no token: the userinfo only tells which account to befriend + * (its {@code id} is the BGS account id) and the bot does the rest. Not transactional, see {@link #loadUser}. + */ + private OAuth2User handleBattleNetLink(OAuth2UserRequest userRequest) { + Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication(); + if (currentAuth == null || !(currentAuth.getPrincipal() instanceof CatapultOAuth2User existingUser)) { + log.error("BATTLENET link attempted without an authenticated Twitch session"); + throw new OAuth2AuthenticationException(new OAuth2Error("unauthorized")); + } + UserAccount user = existingUser.getUserAccount(); + BattleNetFriendService friends = battleNetFriends.getIfAvailable(); + if (friends == null || !battleNetGate.isAvailableFor(user)) { + throw new OAuth2AuthenticationException(new OAuth2Error("battlenet_unavailable")); + } + + OAuth2User info = delegate.loadUser(userRequest); + Long accountId = accountId(info); + String battleTag = info.getAttribute("battletag"); + if (accountId == null || battleTag == null || battleTag.isBlank()) { + throw new OAuth2AuthenticationException(new OAuth2Error("invalid_user_info_response")); + } + try { + friends.enroll(user, accountId, battleTag); + } catch (BattleNetFriendService.BattleNetEnrollmentException e) { + log.warn("Battle.net link refused for user {}: {}", user.getId(), e.getReason()); + throw new OAuth2AuthenticationException(new OAuth2Error("battlenet_full"), e); + } + log.info("BATTLENET linked for user {}", user.getId()); + return existingUser; + } + + /** The Battle.net account id: userinfo's {@code id}, or its {@code sub} (the same value, as a string). */ + private static Long accountId(OAuth2User info) { + Object id = info.getAttribute("id"); + if (id == null) { + id = info.getAttribute("sub"); + } + if (id instanceof Number number) { + return number.longValue(); + } + try { + return id == null ? null : Long.valueOf(id.toString()); + } catch (NumberFormatException e) { + return null; + } + } + /** * The detection chain only consults providers that have an enabled * GetterConfig row: linking must create/enable the provider's one diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/security/Oauth2LoginFailureHandler.java b/catapult-api/src/main/java/fr/enimaloc/catapult/security/Oauth2LoginFailureHandler.java index fc63cb58b..c1ac06c48 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/security/Oauth2LoginFailureHandler.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/security/Oauth2LoginFailureHandler.java @@ -36,7 +36,7 @@ public class Oauth2LoginFailureHandler implements AuthenticationFailureHandler { private static final int RETRY_COOKIE_MAX_AGE_SECONDS = 60; private static final Set BUSINESS_ERROR_CODES = Set.of( "not_whitelisted", "invalid_invite", "alpha_full", - "system_account_login_forbidden", "unauthorized"); + "system_account_login_forbidden", "unauthorized", "battlenet_unavailable", "battlenet_full"); @Value("${app.web-url:http://localhost:8080}") private String webUrl; diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandler.java b/catapult-api/src/main/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandler.java index 3d85a9277..7fbf23bad 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandler.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandler.java @@ -25,7 +25,7 @@ /** * Saves the refresh token after a successful OAuth2 login or secondary link - * (Twitch = login, Xbox = secondary link on an already-authenticated user). + * (Twitch = login, Xbox and Battle.net = secondary links on an already-authenticated user). * Spring's OAuth2UserService only exposes the access token; the refresh token * is only available via OAuth2AuthorizedClientRepository once the client is saved, * which happens before this handler is called. @@ -80,6 +80,15 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.sendRedirect(callback.encode().build().toUriString()); return; } + } else if ("battlenet".equals(registrationId)) { + // Battle.net is a secondary link that keeps no token (the bot befriends the account): the access + // token Spring just stored is forgotten at once (spec §10.2), then the user goes back to their + // channel's integrations tab. + authorizedClientRepository.removeAuthorizedClient("battlenet", authentication, request, response); + if (webUrl != null && !webUrl.isBlank()) { + response.sendRedirect(channelIntegrationsUrl(catUser)); + return; + } } else if ("xbox".equals(registrationId)) { // Xbox is a secondary link: the user is already authenticated via Twitch. // Only the refresh token needs saving — no login event, no JWT re-issuance. @@ -89,12 +98,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // they started, not the /dashboard default meant for the primary Twitch login on // catapult-api itself. if (webUrl != null && !webUrl.isBlank()) { - String channelUrl = UriComponentsBuilder.fromUriString(webUrl) - .path("/channel/{username}") - .fragment("integrations") - .buildAndExpand(catUser.getUserAccount().getTwitchUsername()) - .toUriString(); - response.sendRedirect(channelUrl); + response.sendRedirect(channelIntegrationsUrl(catUser)); return; } } @@ -103,6 +107,14 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo delegate.onAuthenticationSuccess(request, response, authentication); } + private String channelIntegrationsUrl(CatapultOAuth2User catUser) { + return UriComponentsBuilder.fromUriString(webUrl) + .path("/channel/{username}") + .fragment("integrations") + .buildAndExpand(catUser.getUserAccount().getTwitchUsername()) + .toUriString(); + } + /** * Spring's OAuth2UserService only exposes the access token; the refresh token * is only available via OAuth2AuthorizedClientRepository once the client is saved, diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendEventReceived.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendEventReceived.java new file mode 100644 index 000000000..70ee30d36 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendEventReceived.java @@ -0,0 +1,12 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.bgs.client.FriendEvent; + +import java.util.UUID; + +/** + * A change of a bot's friends list, republished off the BGS session loop by {@link BattleNetSessionManager}, in the + * order the session reported it. + */ +public record BattleNetFriendEventReceived(UUID serviceAccountId, FriendEvent event) { +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendService.java new file mode 100644 index 000000000..be8a98ec7 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendService.java @@ -0,0 +1,456 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.bgs.client.BgsClient; +import fr.enimaloc.catapult.bgs.client.FriendEvent; +import fr.enimaloc.catapult.bgs.client.FriendEvent.FriendAdded; +import fr.enimaloc.catapult.bgs.client.FriendEvent.FriendRemoved; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationReceived; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationRemoved; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationSent; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationSentRemoved; +import fr.enimaloc.catapult.bgs.core.BgsRpcException; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink.Status; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.domain.binding.GetterConfig; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.repository.binding.GetterConfigRepository; +import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionOperations; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * Friend links between Catapult users and the Battle.net bots (spec §10.1). + *

+ * {@link #enroll} writes a {@code PENDING} link on the first ready bot with room and has it send a friend invitation; + * the link then moves with the bot's friend events: {@code FriendAdded} accepts it, a sent invitation removed with a + * non-zero reason rejects it, {@code FriendRemoved} removes it. A received invitation is accepted only when it is the + * one a link of that bot waits for ({@code PENDING} or {@code INVITE_REJECTED}). Every change of a link is announced + * to its channel. + *

+ * Database writes run in short transactions of their own; every BGS call is made outside them, once they are + * committed. + */ +@Slf4j +@Service +@ConditionalOnBooleanProperty("battlenet.enabled") +public class BattleNetFriendService { + + /** The friends list cap of a Battle.net account. */ + static final int FRIEND_CAP = 600; + + /** + * BGS status answering a {@code SendInvitation} from a bot whose friends list is full. Taken from the error names + * of the client (ERROR_FRIENDS_INVITER_AT_MAX_FRIENDS); to confirm in real use. A bot holding + * {@link #FRIEND_CAP} accepted links is marked full on any failed invitation anyway. + */ + static final int STATUS_INVITER_AT_MAX_FRIENDS = 5012; + + private final BattleNetServiceAccountRepository accountRepository; + private final BattleNetFriendLinkRepository linkRepository; + private final GetterConfigRepository getterConfigRepository; + private final ChannelEventPublisher channelEventPublisher; + private final BattleNetSessionManager sessionManager; + private final TransactionOperations transactions; + private final Executor followUps; + + @Autowired + public BattleNetFriendService(BattleNetServiceAccountRepository accountRepository, + BattleNetFriendLinkRepository linkRepository, + GetterConfigRepository getterConfigRepository, + ChannelEventPublisher channelEventPublisher, + BattleNetSessionManager sessionManager, + TransactionOperations transactions) { + // The outcome of an invitation completes on the BGS session loop: its database follow-up leaves that thread + this(accountRepository, linkRepository, getterConfigRepository, channelEventPublisher, sessionManager, + transactions, task -> Thread.ofVirtual().name("battlenet-friends").start(task)); + } + + /** @param followUps runs what follows the outcome of an invitation sent at enrolment */ + BattleNetFriendService(BattleNetServiceAccountRepository accountRepository, + BattleNetFriendLinkRepository linkRepository, + GetterConfigRepository getterConfigRepository, + ChannelEventPublisher channelEventPublisher, + BattleNetSessionManager sessionManager, + TransactionOperations transactions, + Executor followUps) { + this.accountRepository = accountRepository; + this.linkRepository = linkRepository; + this.getterConfigRepository = getterConfigRepository; + this.channelEventPublisher = channelEventPublisher; + this.sessionManager = sessionManager; + this.transactions = transactions; + this.followUps = followUps; + } + + // --- streamer side --- + + /** + * Links {@code user} to the first enabled, {@code READY} bot with room (by fill order), replacing any existing + * link, then has the bot invite {@code battleTag}. A failed invitation keeps the link (the streamer can still add + * the bot himself), unless it shows the bot is full: the link then moves to the next bot with room, or is deleted + * when none is left. Without a bot to take the link, the existing one is left untouched. + */ + public BattleNetFriendLink enroll(UserAccount user, long bnetAccountId, String battleTag) { + List ready = accountRepository.findByEnabledTrueAndAuthStateOrderByFillOrderAsc( + AuthState.READY); + if (ready.isEmpty()) { + throw new BattleNetEnrollmentException(BattleNetEnrollmentException.Reason.NO_ACCOUNT_AVAILABLE, + "No enabled and ready Battle.net service account"); + } + BattleNetServiceAccount bot = ready.stream() + .filter(account -> !account.isFriendLimitReached()) + .findFirst() + .orElseThrow(() -> new BattleNetEnrollmentException(BattleNetEnrollmentException.Reason.NO_CAPACITY, + "No Battle.net service account has room for a new friend")); + + unenroll(user); + BattleNetFriendLink link = transactions.execute(status -> { + BattleNetFriendLink created = new BattleNetFriendLink(); + created.setUser(user); + created.setServiceAccount(bot); + created.setBnetAccountId(bnetAccountId); + created.setBattleTag(battleTag); + created.setStatus(Status.PENDING); + created.setRequestedAt(Instant.now()); + return linkRepository.save(created); + }); + log.info("Battle.net link requested for user {} via {}", user.getId(), bot.getId()); + channelEventPublisher.battleNetLinkChanged(user.getId(), Status.PENDING.name(), battleTag, bot.getBattleTag()); + + invite(bot, link.getId(), user.getId(), battleTag); + return link; + } + + /** Has {@code bot} invite {@code battleTag} for the link {@code linkId}; the outcome is handled off the session. */ + private void invite(BattleNetServiceAccount bot, UUID linkId, UUID ownerId, String battleTag) { + call(bot.getId(), client -> client.sendInvitation(battleTag)) + .whenCompleteAsync((ignored, failure) -> { + if (failure != null) { + invitationFailed(bot, linkId, ownerId, battleTag, unwrap(failure)); + } + }, followUps); + } + + /** + * Deletes the link of {@code user}, after asking its bot to revoke the pending invitation or to remove the friend. + * The bot's answer is not awaited: a failure is logged and the link is gone anyway. + */ + public void unenroll(UserAccount user) { + BattleNetFriendLink removed = transactions.execute(status -> { + Optional link = linkRepository.findWithServiceAccountByUser(user); + link.ifPresent(linkRepository::delete); + getterConfigRepository.findByUserAndProvider(user, GetterConfig.Provider.BATTLENET).ifPresent(config -> { + config.setEnabled(false); + getterConfigRepository.save(config); + }); + return link.orElse(null); + }); + if (removed == null) { + return; + } + log.info("Battle.net link of user {} deleted ({})", user.getId(), removed.getStatus()); + channelEventPublisher.battleNetLinkChanged(user.getId(), "NONE", null, null); + + UUID botId = removed.getServiceAccount().getId(); + if (removed.getStatus() == Status.PENDING && removed.getInvitationId() != null) { + long invitationId = removed.getInvitationId(); + call(botId, client -> client.revokeInvitation(invitationId)) + .whenComplete(logFailure("revokeInvitation", user.getId())); + } else if (removed.getStatus() == Status.ACCEPTED) { + long friendId = removed.getBnetAccountId(); + call(botId, client -> client.removeFriend(friendId)) + .whenComplete(logFailure("removeFriend", user.getId())); + } + } + + public Optional getLink(UserAccount user) { + // with the service account fetched: the controller reads it outside the session (OSIV disabled) + return linkRepository.findWithServiceAccountByUser(user); + } + + // --- bot side --- + + /** + * Moves the links of the bot with its friend events. Synchronous on purpose: the session manager hands the events + * off one at a time, in the order the session reported them, and a link's transitions depend on that order. + */ + @EventListener + public void onFriendEvent(BattleNetFriendEventReceived received) { + try { + // The BGS calls decided inside the transaction run once it is committed: an answer sent to the server + // cannot be taken back by a rollback + List afterCommit = new ArrayList<>(); + List changes = transactions.execute(status -> apply(received.serviceAccountId(), + received.event(), afterCommit)); + if (changes != null) { + changes.forEach(this::publish); + } + afterCommit.forEach(Runnable::run); + } catch (RuntimeException e) { + log.warn("Could not apply the Battle.net friend event {} of service account {}: {}", + received.event().getClass().getSimpleName(), received.serviceAccountId(), e.toString()); + } + } + + private List apply(UUID botId, FriendEvent event, List afterCommit) { + BattleNetServiceAccount bot = accountRepository.findById(botId).orElse(null); + if (bot == null) { + log.debug("Battle.net friend event for an unknown service account {}: ignored", botId); + return List.of(); + } + List changes = new ArrayList<>(); + switch (event) { + case FriendAdded added -> { + for (BattleNetFriendLink link : linkRepository.findByServiceAccountAndBnetAccountId(bot, + added.accountId())) { + if (link.getStatus() == Status.ACCEPTED) { + continue; + } + link.setStatus(Status.ACCEPTED); + link.setAcceptedAt(Instant.now()); + enableGetter(link.getUser()); + changes.add(save(link, bot)); + log.info("Battle.net link accepted for user {} via {}", link.getUser().getId(), bot.getId()); + } + if (changes.isEmpty()) { + log.debug("Battle.net friend added on {} without a link to accept", bot.getId()); + } + } + case FriendRemoved removed -> { + // Only an accepted link was a friendship: a PENDING one on the same account is a newer enrolment + for (BattleNetFriendLink link : linkRepository.findByServiceAccountAndBnetAccountId(bot, + removed.accountId())) { + if (link.getStatus() == Status.ACCEPTED) { + link.setStatus(Status.REMOVED); + changes.add(save(link, bot)); + log.info("Battle.net link removed by the streamer: user {} via {}", link.getUser().getId(), + bot.getId()); + } + } + if (changes.isEmpty()) { + log.debug("Battle.net friend removed on {} without an accepted link", bot.getId()); + } + } + case InvitationSent sent -> { + // The server gives no account id for the target (account_id 0): matched by BattleTag + Optional pending = linkRepository + .findByServiceAccountAndBattleTagIgnoreCase(bot, sent.targetName()).stream() + .filter(link -> link.getStatus() == Status.PENDING) + .max(Comparator.comparing(BattleNetFriendLink::getRequestedAt)); + pending.ifPresentOrElse(link -> { + link.setInvitationId(sent.invitationId()); + linkRepository.save(link); + }, () -> log.debug("Battle.net invitation sent by {} matches no pending link", bot.getId())); + } + case InvitationSentRemoved sentRemoved -> { + if (sentRemoved.reason() == 0) { + return List.of(); // accepted: the FriendAdded follows + } + // Matched by invitation id only: a re-enrolment of the same BattleTag must not be rejected by the + // revocation of its previous invitation + for (BattleNetFriendLink link : linkRepository.findByServiceAccountAndInvitationId(bot, + sentRemoved.invitationId())) { + // ACCEPTED already: the resync diff reported the FriendAdded of the same acceptance first + if (link.getStatus() == Status.ACCEPTED || link.getStatus() == Status.INVITE_REJECTED) { + continue; + } + link.setStatus(Status.INVITE_REJECTED); + changes.add(save(link, bot)); + log.info("Battle.net invitation of {} not accepted (reason {}) for user {}", bot.getId(), + sentRemoved.reason(), link.getUser().getId()); + } + } + case InvitationReceived invitation -> afterCommit.add(answer(bot, invitation)); + case InvitationRemoved ignored -> { + // the outcome shows up as a FriendAdded, or not at all + } + } + return changes; + } + + /** + * Decides the answer to a received invitation, to send after the commit: accepted when a link of this bot with the + * sender's account id waits for it ({@code PENDING}, or {@code INVITE_REJECTED}: the streamer declined the bot's + * invitation and adds the bot himself), declined otherwise. + */ + private Runnable answer(BattleNetServiceAccount bot, InvitationReceived invitation) { + boolean expected = linkRepository.findByServiceAccountAndBnetAccountId(bot, invitation.accountId()).stream() + .anyMatch(link -> link.getStatus() == Status.PENDING || link.getStatus() == Status.INVITE_REJECTED); + long invitationId = invitation.invitationId(); + UUID botId = bot.getId(); + if (expected) { + log.info("Accepting the Battle.net invitation of an enrolled streamer on {}", botId); + return () -> call(botId, client -> client.acceptInvitation(invitationId)) + .whenComplete((ignored, failure) -> { + if (failure != null) { + log.warn("acceptInvitation failed on {}: {}", botId, unwrap(failure).toString()); + } + }); + } + log.info("Declining a Battle.net invitation no link waits for on {}", botId); + return () -> call(botId, client -> client.declineInvitation(invitationId)).whenComplete((ignored, failure) -> { + if (failure != null) { + log.warn("declineInvitation failed on {}: {}", botId, unwrap(failure).toString()); + } + }); + } + + // --- internals --- + + /** + * A failed invitation keeps its link, unless it shows the bot is full (the failure says so, or the bot already + * holds a full friends list): the bot is then marked full and the link moved to the next bot with room, which + * invites in turn, or deleted when none is left. + */ + private void invitationFailed(BattleNetServiceAccount bot, UUID linkId, UUID ownerId, String battleTag, + Throwable failure) { + try { + boolean full = failure instanceof BgsRpcException rpc && rpc.status() == STATUS_INVITER_AT_MAX_FRIENDS + || linkRepository.countByServiceAccountAndStatus(bot, Status.ACCEPTED) >= FRIEND_CAP; + if (!full) { + log.warn("The Battle.net invitation sent by {} failed, the link stays pending: {}", bot.getId(), + failure.toString()); + return; + } + log.warn("The Battle.net invitation sent by {} shows its friends list is full: {}", bot.getId(), + failure.toString()); + markFull(bot.getId()); + relocate(bot.getId(), linkId, ownerId, battleTag); + } catch (RuntimeException e) { + log.warn("Could not handle the failed invitation of {}: {}", bot.getId(), e.toString()); + } + } + + /** Moves the pending link off the full bot {@code fullBotId} to the next bot with room, or deletes it. */ + private void relocate(UUID fullBotId, UUID linkId, UUID ownerId, String battleTag) { + BattleNetServiceAccount[] next = new BattleNetServiceAccount[1]; + Boolean moved = transactions.execute(status -> { + BattleNetFriendLink link = linkId == null ? null : linkRepository.findById(linkId).orElse(null); + // The link was replaced, removed or answered meanwhile: it is no longer this invitation's + if (link == null || link.getStatus() != Status.PENDING + || !fullBotId.equals(link.getServiceAccount().getId())) { + return null; + } + next[0] = accountRepository.findByEnabledTrueAndAuthStateOrderByFillOrderAsc(AuthState.READY).stream() + .filter(account -> !account.isFriendLimitReached() && !fullBotId.equals(account.getId())) + .findFirst().orElse(null); + if (next[0] == null) { + linkRepository.delete(link); + return false; + } + link.setServiceAccount(next[0]); + link.setInvitationId(null); + link.setRequestedAt(Instant.now()); + linkRepository.save(link); + return true; + }); + if (moved == null) { + return; + } + if (!moved) { + log.warn("No Battle.net service account has room left: the link of user {} is deleted", ownerId); + channelEventPublisher.battleNetLinkChanged(ownerId, "NONE", null, null); + return; + } + log.info("Battle.net link of user {} moved to {}", ownerId, next[0].getId()); + channelEventPublisher.battleNetLinkChanged(ownerId, Status.PENDING.name(), battleTag, next[0].getBattleTag()); + invite(next[0], linkId, ownerId, battleTag); + } + + /** Persists {@code friendLimitReached} on a fresh copy of the bot (an admin edit may have moved its version). */ + private void markFull(UUID botId) { + transactions.executeWithoutResult(status -> accountRepository.findById(botId).ifPresent(account -> { + if (account.isFriendLimitReached()) { + return; + } + account.setFriendLimitReached(true); + accountRepository.save(account); + log.warn("Battle.net service account {} marked full", account.getId()); + })); + } + + /** + * The detection chain only consults providers that have a GetterConfig row: an accepted link creates or enables + * the BATTLENET one, as {@code CatapultOAuth2UserService.enableGetter} does. + */ + private void enableGetter(UserAccount user) { + GetterConfig config = getterConfigRepository.findByUserAndProvider(user, GetterConfig.Provider.BATTLENET) + .orElseGet(() -> { + GetterConfig created = new GetterConfig(); + created.setUser(user); + created.setProvider(GetterConfig.Provider.BATTLENET); + created.setPriority(getterConfigRepository.findByUserOrderByPriorityAsc(user).size() + 1); + return created; + }); + config.setEnabled(true); + getterConfigRepository.save(config); + } + + private Change save(BattleNetFriendLink link, BattleNetServiceAccount bot) { + linkRepository.save(link); + return new Change(link.getUser().getId(), link.getStatus(), link.getBattleTag(), bot.getBattleTag()); + } + + private void publish(Change change) { + channelEventPublisher.battleNetLinkChanged(change.ownerId(), change.status().name(), change.battleTag(), + change.serviceAccountBattleTag()); + } + + private CompletableFuture call(UUID botId, Function> action) { + try { + return sessionManager.withClient(botId, action); + } catch (RuntimeException e) { + return CompletableFuture.failedFuture(e); + } + } + + private static BiConsumer logFailure(String what, UUID userId) { + return (ignored, failure) -> { + if (failure != null) { + log.warn("{} failed for the deleted Battle.net link of user {}: {}", what, userId, + unwrap(failure).toString()); + } + }; + } + + private static Throwable unwrap(Throwable failure) { + return failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure; + } + + /** A link change to announce once its transaction is committed. */ + private record Change(UUID ownerId, Status status, String battleTag, String serviceAccountBattleTag) { + } + + @Getter + public static class BattleNetEnrollmentException extends RuntimeException { + public enum Reason { NO_CAPACITY, NO_ACCOUNT_AVAILABLE } + + private final Reason reason; + + public BattleNetEnrollmentException(Reason reason, String message) { + super(message); + this.reason = reason; + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateService.java new file mode 100644 index 000000000..5bdb9cd4c --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateService.java @@ -0,0 +1,69 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.service.experiment.ExperimentService; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * Central decision on whether the Battle.net feature is available to a + * user: valid configuration AND experiment {@value #EXPERIMENT_KEY}. + * Deliberately unconditional: it must answer "unavailable" even + * when {@code battlenet.enabled=false} (the help depends on it). + */ +@Slf4j +@Service +public class BattleNetGateService { + + public static final String EXPERIMENT_KEY = "battlenet.integration"; + + private final ExperimentService experimentService; + private final UserAccountRepository userAccountRepository; + private final boolean battleNetEnabled; + private final String clientId; + private final String clientSecret; + + public BattleNetGateService(ExperimentService experimentService, + UserAccountRepository userAccountRepository, + @Value("${battlenet.enabled:false}") boolean battleNetEnabled, + @Value("${battlenet.client-id:}") String clientId, + @Value("${battlenet.client-secret:}") String clientSecret) { + this.experimentService = experimentService; + this.userAccountRepository = userAccountRepository; + this.battleNetEnabled = battleNetEnabled; + this.clientId = clientId == null ? "" : clientId.trim(); + this.clientSecret = clientSecret == null ? "" : clientSecret.trim(); + } + + @PostConstruct + void warnIfMisconfigured() { + if (battleNetEnabled && (clientId.isBlank() || clientSecret.isBlank())) { + log.warn("battlenet.enabled=true but battlenet.client-id or battlenet.client-secret is empty: " + + "the Battle.net feature will stay hidden"); + } + } + + public boolean isConfigured() { + return battleNetEnabled && !clientId.isBlank() && !clientSecret.isBlank(); + } + + /** User-facing paths: triggers the assignment to the experiment (page visit). */ + public boolean isAvailableFor(UserAccount user) { + return isConfigured() && experimentService.evaluateGate(user, EXPERIMENT_KEY); + } + + /** Like {@link #isAvailableFor(UserAccount)} for the account {@code userId}; an unknown account gives false. */ + public boolean isAvailableForAccount(UUID userId) { + return userAccountRepository.findById(userId).map(this::isAvailableFor).orElse(false); + } + + /** Background paths (scheduler/getter): read-only, no assignment. */ + public boolean isAvailableReadOnly(UserAccount user) { + return isConfigured() && experimentService.isRolledOut(user, EXPERIMENT_KEY); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateService.java new file mode 100644 index 000000000..fcea70b5a --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateService.java @@ -0,0 +1,67 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +/** + * A user's Battle.net link as the connect card shows it, shared by the connect endpoint and the + * channel dashboard so both say the same thing: {@value #UNAVAILABLE} when the feature isn't + * open to the user or no service account is usable (enabled and READY), the link's own status + * when there is one, {@value #FULL} when every usable service account is full, else {@value #NONE}. + */ +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty("battlenet.enabled") +public class BattleNetLinkStateService { + + public static final String NONE = "NONE"; + public static final String UNAVAILABLE = "UNAVAILABLE"; + /** No usable service account can take a new friend: transient, unlike {@value #UNAVAILABLE}. */ + public static final String FULL = "FULL"; + + private final BattleNetGateService gateService; + private final BattleNetFriendLinkRepository linkRepository; + private final BattleNetServiceAccountRepository accountRepository; + + public BattleNetLinkStateResponse state(UserAccount user) { + if (!gateService.isAvailableFor(user)) { + return of(UNAVAILABLE); + } + // No usable service account: unavailable, even to a linked user (their link can't be + // managed until one is usable again). + if (accountRepository.countByEnabledTrueAndAuthState(AuthState.READY) == 0) { + return of(UNAVAILABLE); + } + // Loads the service account: open-in-view is disabled, callers read its tag outside a session. + Optional link = linkRepository.findWithServiceAccountByUser(user); + if (link.isPresent()) { + return of(link.get()); + } + // Usable accounts, all full: only the way to link is hidden; a linked user keeps their link. + if (accountRepository.countByEnabledTrueAndAuthStateAndFriendLimitReachedFalse(AuthState.READY) == 0) { + return of(FULL); + } + return of(NONE); + } + + public static BattleNetLinkStateResponse of(String status) { + return new BattleNetLinkStateResponse(status, null, null); + } + + /** A link's state; its service account must be loaded. */ + public static BattleNetLinkStateResponse of(BattleNetFriendLink link) { + return new BattleNetLinkStateResponse( + link.getStatus().name(), + link.getBattleTag(), + link.getServiceAccount().getBattleTag()); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdmin.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdmin.java new file mode 100644 index 000000000..fc7a90674 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdmin.java @@ -0,0 +1,253 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.domain.binding.GetterConfig; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.repository.binding.GetterConfigRepository; +import fr.enimaloc.catapult.security.TokenEncryptionService; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginCoordinator; +import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.http.HttpStatus; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * An admin's pool of Battle.net bot accounts (spec §10.3): enrolment through the login coordinator, 2FA code, + * re-auth, edits and removal. Whenever an interactive login ends {@code READY}, the account's BGS session is + * (re)started. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty("battlenet.enabled") +public class BattleNetServiceAccountAdmin { + + /** + * What the admin UI sees of a bot account: never the e-mail (only masked, see {@link #displayName}), the + * password or the browser state. {@code waitingForCode}: an admin login waits for its 2FA code, so + * {@link #submitCode} can take one. + */ + public record AccountDto(UUID id, String displayName, String battleTag, String authState, String sessionState, + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount, + boolean waitingForCode) {} + + private final BattleNetServiceAccountRepository accountRepository; + private final BattleNetFriendLinkRepository linkRepository; + private final TokenEncryptionService encryption; + private final BattleNetLoginCoordinator loginCoordinator; + private final BattleNetSessionManager sessionManager; + private final GetterConfigRepository getterConfigRepository; + private final ChannelEventPublisher channelEventPublisher; + + public List list() { + return accountRepository.findAll().stream().map(this::toDto).toList(); + } + + /** Stores the account with encrypted credentials and starts its interactive login in the background. */ + public AccountDto create(String email, String password) { + requireText(email, "email"); + requireText(password, "password"); + BattleNetServiceAccount account = new BattleNetServiceAccount(); + account.setEmail(email); + account.setPasswordEnc(encryption.encrypt(password)); + account.setAuthState(AuthState.NEW); + account.setFillOrder((int) accountRepository.count()); + account.setUpdatedAt(Instant.now()); + BattleNetServiceAccount saved = accountRepository.save(account); + log.info("Battle.net service account created: {}", saved.getId()); + if (!startLogin(saved.getId())) { + // the account is stored: the admin can re-authenticate it, so a refused start is not a failed create + saved.setAuthState(AuthState.AUTH_FAILED); + saved = accountRepository.save(saved); + } + return toDto(saved); + } + + /** Feeds the 2FA code to the login waiting for it: 404 for an unknown account, 409 when none waits. */ + public void submitCode(UUID id, String code) { + findOrThrow(id); + requireText(code, "code"); + try { + loginCoordinator.submitCode(id, code); + } catch (IllegalStateException e) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "No login is waiting for a code"); + } + } + + /** Runs the interactive login again, for an account whose session needs an admin. */ + public AccountDto reauth(UUID id) { + BattleNetServiceAccount account = findOrThrow(id); + if (!startLogin(id)) throw loginInProgress(); + return toDto(account); + } + + /** + * 404 for an unknown account before 400 for a field of the wrong type. A password change while a login runs + * is refused (409) before anything is saved. + */ + public AccountDto update(UUID id, Map body) { + findOrThrow(id); + boolean passwordChanged = body.containsKey("password"); + if (passwordChanged && loginCoordinator.loginInProgress(id)) throw loginInProgress(); + boolean[] enabledChanged = new boolean[1]; + BattleNetServiceAccount saved = withRetry(() -> { + BattleNetServiceAccount account = findOrThrow(id); + enabledChanged[0] = false; + if (body.containsKey("enabled")) { + boolean enabled = field(body, "enabled", Boolean.class); + enabledChanged[0] = enabled != account.isEnabled(); + account.setEnabled(enabled); + } + if (body.containsKey("friendLimitReached")) { + account.setFriendLimitReached(field(body, "friendLimitReached", Boolean.class)); + } + if (body.containsKey("fillOrder")) { + account.setFillOrder(field(body, "fillOrder", Number.class).intValue()); + } + if (passwordChanged) { + account.setPasswordEnc( + encryption.encrypt(requireText(field(body, "password", String.class), "password"))); + account.setAuthState(AuthState.NEW); + } + account.setUpdatedAt(Instant.now()); + return accountRepository.save(account); + }); + if (passwordChanged) { + // the bot must authenticate again with the new password + sessionManager.stop(id); + if (!startLogin(id)) { + log.warn("Battle.net service account {}: the login after a password change could not start", id); + } + } else if (enabledChanged[0]) { + sessionManager.restart(id); + } + return toDto(saved); + } + + /** + * Stops the session first, then drops the links and the account. Each streamer who was linked through the bot + * is unlinked as by unenrolling: their Battle.net getter is disabled and, once deleted, their dashboards told. + */ + public void delete(UUID id) { + findOrThrow(id); + sessionManager.stop(id); + List owners = new ArrayList<>(); + BattleNetServiceAccount deleted = withRetry(() -> { + owners.clear(); + BattleNetServiceAccount account = findOrThrow(id); + List links = linkRepository.findByServiceAccount(account); + linkRepository.deleteAll(links); + for (BattleNetFriendLink link : links) { + UserAccount owner = link.getUser(); + owners.add(owner.getId()); + getterConfigRepository.findByUserAndProvider(owner, GetterConfig.Provider.BATTLENET) + .ifPresent(config -> { + config.setEnabled(false); + getterConfigRepository.save(config); + }); + } + accountRepository.delete(account); + return account; + }); + log.info("Battle.net service account deleted: {} ({} link(s) dropped)", deleted.getId(), owners.size()); + owners.forEach(owner -> channelEventPublisher.battleNetLinkChanged(owner, "NONE", null, null)); + } + + /** + * Runs a read-modify-write of the account again once when a login committed in between (optimistic lock), + * 409 when it still conflicts. + */ + private T withRetry(Supplier write) { + try { + return write.get(); + } catch (ObjectOptimisticLockingFailureException first) { + try { + return write.get(); + } catch (ObjectOptimisticLockingFailureException second) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "The account was modified concurrently"); + } + } + } + + private static ResponseStatusException loginInProgress() { + return new ResponseStatusException(HttpStatus.CONFLICT, "A login is already in progress"); + } + + /** False when the login could not start (one is already in progress); true once it runs in the background. */ + private boolean startLogin(UUID id) { + CompletableFuture login = loginCoordinator.startInteractive(id); + if (login.isCompletedExceptionally()) { + return false; + } + login.whenComplete((ignored, failure) -> { + if (failure == null) { + sessionManager.restart(id); + } else { + log.warn("Battle.net login of service account {} did not complete ({})", id, + failure.getClass().getSimpleName()); + } + }); + return true; + } + + private BattleNetServiceAccount findOrThrow(UUID id) { + return accountRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Unknown account")); + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, name + " is required"); + } + return value; + } + + /** A PATCH field of the expected JSON type, 400 otherwise. */ + private static T field(Map body, String name, Class type) { + Object value = body.get(name); + if (!type.isInstance(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid type for " + name); + } + return type.cast(value); + } + + /** The friend count is the ACCEPTED links in the database, so it needs no live session. */ + private AccountDto toDto(BattleNetServiceAccount account) { + return new AccountDto(account.getId(), displayName(account.getBattleTag(), account.getEmail()), + account.getBattleTag(), + account.getAuthState().name(), sessionManager.sessionState(account.getId()), + account.getFillOrder(), account.isFriendLimitReached(), account.isEnabled(), + linkRepository.countByServiceAccountAndStatus(account, BattleNetFriendLink.Status.ACCEPTED), + loginCoordinator.waitingForCode(account.getId())); + } + + /** The BattleTag once known; before the first login the masked e-mail; null without either. */ + static String displayName(String battleTag, String email) { + return battleTag != null && !battleTag.isBlank() ? battleTag : maskEmail(email); + } + + /** {@code bot@example.com} → {@code b***@example.com}: enough to tell the bots apart, never the whole address. */ + static String maskEmail(String email) { + if (email == null || email.isBlank()) return null; + String trimmed = email.trim(); + int at = trimmed.lastIndexOf('@'); + String first = trimmed.substring(0, 1); + return at <= 0 ? first + "***" : first + "***" + trimmed.substring(at); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManager.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManager.java new file mode 100644 index 000000000..7d2c04ba1 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManager.java @@ -0,0 +1,414 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.bgs.client.BgsClient; +import fr.enimaloc.catapult.bgs.client.BgsClient.BgsClientConfig; +import fr.enimaloc.catapult.bgs.client.BgsConnection; +import fr.enimaloc.catapult.bgs.client.BgsListener; +import fr.enimaloc.catapult.bgs.client.FriendEvent; +import fr.enimaloc.catapult.bgs.client.PresenceEvent; +import fr.enimaloc.catapult.bgs.core.BgsChannel; +import fr.enimaloc.catapult.bgs.core.BgsSession; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginCoordinator; +import fr.enimaloc.catapult.site.SiteRole; +import fr.enimaloc.catapult.site.SiteRoleChangedEvent; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionOperations; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Keeps one BGS session per enabled and {@code READY} Battle.net service account (spec §10.1), and the game each + * friend of the bots is playing, from their presence pushes. + * + *

Sessions open only when {@code battlenet.sessions.enabled=true} and this site is the active one: a second + * session on the same account evicts the first one (ForceDisconnect 60, spec §8), so a standby site, a dev + * environment or a restored dump must never open one. They close when the site goes standby and reopen when it + * becomes active again. + * + *

The session callbacks run on the session loop: everything slow (database writes, friend event handling) is + * handed to a single thread, which keeps the order in which the session reported the events. + */ +@Slf4j +@Service +@ConditionalOnBooleanProperty("battlenet.enabled") +public class BattleNetSessionManager implements SmartLifecycle { + + /** {@link #sessionState} of an account that has no session. */ + public static final String DISABLED = "DISABLED"; + + static final int BGS_PORT = 1119; + static final String LOCALE = "frFR"; + static final Duration MIN_BACKOFF = Duration.ofSeconds(5); + static final Duration MAX_BACKOFF = Duration.ofSeconds(120); + static final String REPLACED_COUNTER = "catapult.battlenet.session.replaced"; + static final String ADMIN_ACTION_COUNTER = "catapult.battlenet.session.admin_action_required"; + + private final BattleNetServiceAccountRepository repository; + private final BattleNetLoginCoordinator coordinator; + private final SiteRole siteRole; + private final ApplicationEventPublisher events; + private final MeterRegistry registry; + private final TransactionOperations transactions; + private final boolean sessionsEnabled; + private final String host; + private final Supplier channels; + private final ScheduledExecutorService scheduler; + private final Executor starter; + private final Executor mintExecutor; + private final Executor handoff; + private final List owned; + + private final Object lock = new Object(); + private final Map sessions = new ConcurrentHashMap<>(); + /** Game fourcc by BGS account id, for the friends of every open session. */ + private final Map games = new ConcurrentHashMap<>(); + private volatile boolean running; + + @Autowired + public BattleNetSessionManager(BattleNetServiceAccountRepository repository, + BattleNetLoginCoordinator coordinator, + SiteRole siteRole, + ApplicationEventPublisher events, + MeterRegistry registry, + TransactionOperations transactions, + @Value("${battlenet.sessions.enabled:false}") boolean sessionsEnabled, + @Value("${battlenet.bgs-host:eu.actual.battle.net}") String host) { + this(repository, coordinator, siteRole, events, registry, transactions, sessionsEnabled, host, + () -> connect(host), + // Two threads: a connect blocking one (TCP + TLS) does not hold the keep-alives back + Executors.newScheduledThreadPool(2, Thread.ofPlatform().name("battlenet-bgs-timers-", 0).daemon() + .factory()), + null, + Executors.newVirtualThreadPerTaskExecutor(), + Executors.newSingleThreadExecutor(Thread.ofPlatform().name("battlenet-sessions").daemon().factory())); + } + + /** + * @param starter runs the first connection of a session (a blocking TCP connect); the scheduler when null + * @param handoff runs, in order, what the session callbacks must not do on the session loop + */ + BattleNetSessionManager(BattleNetServiceAccountRepository repository, + BattleNetLoginCoordinator coordinator, + SiteRole siteRole, + ApplicationEventPublisher events, + MeterRegistry registry, + TransactionOperations transactions, + boolean sessionsEnabled, + String host, + Supplier channels, + ScheduledExecutorService scheduler, + Executor starter, + Executor mintExecutor, + Executor handoff) { + this.repository = repository; + this.coordinator = coordinator; + this.siteRole = siteRole; + this.events = events; + this.registry = registry; + this.transactions = transactions; + this.sessionsEnabled = sessionsEnabled; + this.host = host; + this.channels = channels; + this.scheduler = scheduler; + this.starter = starter != null ? starter : scheduler; + this.mintExecutor = mintExecutor; + this.handoff = handoff; + this.owned = Stream.of(scheduler, mintExecutor, handoff) + .filter(ExecutorService.class::isInstance).map(ExecutorService.class::cast).toList(); + } + + private static BgsChannel connect(String host) { + try { + return BgsSession.connect(host, BGS_PORT); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // --- lifecycle --- + + @Override + public void start() { + synchronized (lock) { + running = true; + if (!sessionsEnabled) { + log.info("Battle.net BGS sessions are disabled (battlenet.sessions.enabled=false)"); + return; + } + openEligible(); + } + } + + @Override + public void stop() { + synchronized (lock) { + running = false; + closeAll(); + } + } + + @Override + public boolean isRunning() { + return running; + } + + @PreDestroy + void shutdown() { + owned.forEach(ExecutorService::shutdownNow); + } + + /** Sessions follow the anti split-brain guard: open on the active site only. */ + @EventListener + public void onSiteRoleChanged(SiteRoleChangedEvent event) { + synchronized (lock) { + if (event.isActive()) { + openEligible(); + } else { + if (!sessions.isEmpty()) { + log.info("Site went standby: closing the {} Battle.net BGS session(s)", sessions.size()); + } + closeAll(); + } + } + } + + // --- per account --- + + /** Closes the account's session, if any, then reopens it when the account is still eligible. */ + public void restart(UUID accountId) { + synchronized (lock) { + close(accountId); + if (!canOpen()) { + return; + } + repository.findById(accountId) + .filter(account -> account.isEnabled() && account.getAuthState() == AuthState.READY) + .ifPresent(this::open); + } + } + + /** Closes the account's session for good, e.g. before the account is deleted. */ + public void stop(UUID accountId) { + synchronized (lock) { + close(accountId); + } + } + + /** The {@link BgsConnection.State} name of the account's session, or {@link #DISABLED} without one. */ + public String sessionState(UUID accountId) { + Session session = sessions.get(accountId); + return session == null ? DISABLED : session.connection.state().name(); + } + + /** The fourcc of the game the BGS account plays, as reported to the bot it is friends with. */ + public Optional currentGame(long bnetAccountId) { + return Optional.ofNullable(games.get(bnetAccountId)); + } + + /** + * Runs {@code action} on the account's client. Fails at once, with an {@link IllegalStateException}, when the + * account has no ready session: actions are never queued. + */ + public CompletableFuture withClient(UUID accountId, Function> action) { + Session session = sessions.get(accountId); + if (session == null || session.connection.state() != BgsConnection.State.READY || !session.client.ready()) { + return CompletableFuture.failedFuture( + new IllegalStateException("No ready BGS session for this Battle.net service account")); + } + try { + return action.apply(session.client); + } catch (RuntimeException e) { + return CompletableFuture.failedFuture(e); + } + } + + /** The session callbacks of the account; for tests. */ + BgsListener listenerOf(UUID accountId) { + return sessions.get(accountId); + } + + // --- internals, called with the lock held --- + + private boolean canOpen() { + return running && sessionsEnabled && siteRole.isActive(); + } + + private void openEligible() { + if (!canOpen()) { + return; + } + try { + repository.findByEnabledTrueAndAuthStateOrderByFillOrderAsc(AuthState.READY).forEach(this::open); + } catch (RuntimeException e) { + log.warn("Could not list the Battle.net service accounts to open their sessions: {}", e.toString()); + } + } + + private void open(BattleNetServiceAccount account) { + if (sessions.containsKey(account.getId())) { + return; + } + Session session = new Session(account); + sessions.put(account.getId(), session); + log.info("Opening the BGS session of Battle.net service account {}", account.getId()); + try { + starter.execute(session::start); + } catch (RejectedExecutionException e) { + log.warn("Could not start the BGS session of {}: the executor is shut down", account.getId()); + } + } + + private void close(UUID accountId) { + Session session = sessions.remove(accountId); + if (session != null) { + log.info("Closing the BGS session of Battle.net service account {}", accountId); + session.stop(); + } + } + + private void closeAll() { + List.copyOf(sessions.keySet()).forEach(this::close); + } + + private void handOff(String what, Runnable task) { + try { + handoff.execute(task); + } catch (RejectedExecutionException e) { + log.debug("Dropped {}: the executor is shut down", what); + } + } + + private void storeIdentity(UUID accountId, long bnetAccountId, String battleTag) { + try { + transactions.executeWithoutResult(status -> repository.findById(accountId).ifPresent(account -> { + if (Objects.equals(account.getBnetAccountId(), bnetAccountId) + && Objects.equals(account.getBattleTag(), battleTag)) { + return; + } + account.setBnetAccountId(bnetAccountId); + account.setBattleTag(battleTag); + repository.save(account); + })); + } catch (RuntimeException e) { + log.warn("Could not store the BattleTag of Battle.net service account {}: {}", accountId, e.toString()); + } + } + + /** One account's connection and its callbacks. */ + private final class Session implements BgsListener { + + private final UUID accountId; + private final BgsClient client; + private final BgsConnection connection; + /** Friends whose game this session reported, forgotten when it ends. */ + private final Set watched = ConcurrentHashMap.newKeySet(); + private final AtomicBoolean stopped = new AtomicBoolean(); + + Session(BattleNetServiceAccount account) { + this.accountId = account.getId(); + this.client = new BgsClient(new BgsClientConfig(host, BGS_PORT, account.getEmail(), LOCALE), + coordinator.ticketProviderFor(accountId), this, mintExecutor); + this.connection = new BgsConnection(client, channels, scheduler, MIN_BACKOFF, MAX_BACKOFF); + } + + void start() { + connection.start(); + // A stop that raced the start found nothing to stop yet + if (stopped.get()) { + connection.stop(); + } + } + + void stop() { + stopped.set(true); + connection.stop(); + forgetGames(); + } + + private boolean current() { + return sessions.get(accountId) == this; + } + + private void forgetGames() { + watched.forEach(games::remove); + watched.clear(); + } + + @Override + public void onReady(long bnetAccountId, String battleTag) { + log.info("BGS session of Battle.net service account {} is ready as {}", accountId, battleTag); + handOff("the BattleTag of " + accountId, () -> storeIdentity(accountId, bnetAccountId, battleTag)); + } + + @Override + public void onPresence(PresenceEvent event) { + if (!current()) { + return; + } + watched.add(event.accountId()); + if (event.game() == null) { + games.remove(event.accountId()); + } else { + games.put(event.accountId(), event.game()); + } + } + + @Override + public void onFriend(FriendEvent event) { + handOff("a friend event of " + accountId, + () -> events.publishEvent(new BattleNetFriendEventReceived(accountId, event))); + } + + @Override + public void onClosed(CloseReason reason) { + // The next session pushes the whole presence again + if (current()) { + forgetGames(); + } + switch (reason) { + case SESSION_REPLACED -> { + log.error("BGS session of Battle.net service account {} was replaced by another login on the " + + "same account: not reconnecting", accountId); + registry.counter(REPLACED_COUNTER, "account", accountId.toString()).increment(); + } + case CODE_REQUIRED -> { + log.warn("BGS session of Battle.net service account {} stopped: the login needs an admin " + + "(2FA code, rejected login or repeated timeouts)", accountId); + registry.counter(ADMIN_ACTION_COUNTER, "account", accountId.toString()).increment(); + } + default -> log.debug("BGS session of Battle.net service account {} closed: {}", accountId, reason); + } + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitles.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitles.java new file mode 100644 index 000000000..9b3654e15 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitles.java @@ -0,0 +1,26 @@ +package fr.enimaloc.catapult.service.battlenet; + +import java.util.Map; + +/** Display names of the Blizzard games, by the fourcc BGS reports in the presence. */ +public final class BattleNetTitles { + + private static final Map NAMES = Map.of( + "WTCG", "Hearthstone", + "Pro", "Overwatch 2", + "WoW", "World of Warcraft", + "Fen", "Diablo IV", + "D3", "Diablo III", + "OSI", "Diablo II: Resurrected", + "S2", "StarCraft II", + "Hero", "Heroes of the Storm", + "W3", "Warcraft III"); + + private BattleNetTitles() { + } + + /** The game's name, or the fourcc itself for a game missing from the table. */ + public static String displayName(String fourcc) { + return NAMES.getOrDefault(fourcc, fourcc); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginBrowser.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginBrowser.java new file mode 100644 index 000000000..12f1fd00a --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginBrowser.java @@ -0,0 +1,60 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import org.jspecify.annotations.Nullable; + +import java.time.Duration; + +/** + * Headless browser driving the Battle.net login page (spec §9). The page computes the SRP proof itself; the + * login ends on a redirect to {@code http://localhost:0/?ST=EU-...} whose {@code ST} value is the ticket. + */ +public interface BattleNetLoginBrowser { + + /** Opens the login page in a fresh context, loaded with the account's trusted storageState when known. */ + LoginPage open(@Nullable String storageState); + + interface LoginPage extends AutoCloseable { + + void submitCredentials(String email, String password); + + /** + * Blocks until the ticket redirect, the code page or a rejection, at most {@code timeout} + * ({@link Outcome.TimedOut} then). Technical failures (crash, navigation error) are thrown. + */ + Outcome awaitOutcome(Duration timeout); + + /** Fills the 2FA code page and submits it; follow with {@link #awaitOutcome}. */ + void submitCode(String code); + + /** The context's storageState (cookies and local storage) to reload on the next login. */ + String storageState(); + + @Override + void close(); + } + + sealed interface Outcome { + + record Ticket(String value) implements Outcome { + @Override + public String toString() { + return "Ticket[value=***]"; + } + } + + record CodeRequired() implements Outcome { + } + + /** + * The page answered with something that is neither a ticket nor a code request (wrong password, locked + * account...): retrying the same credentials is pointless. {@code reason} is shown to the admin and + * logged: it never carries a credential or a ticket. + */ + record Failed(String reason) implements Outcome { + } + + /** No answer within the timeout: worth retrying later. */ + record TimedOut() implements Outcome { + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinator.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinator.java new file mode 100644 index 000000000..58099d148 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinator.java @@ -0,0 +1,378 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import fr.enimaloc.catapult.bgs.client.TicketException; +import fr.enimaloc.catapult.bgs.client.TicketProvider; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.security.TokenEncryptionService; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginBrowser.LoginPage; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginFlow.Result; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionOperations; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +/** + * Runs the Battle.net logins of the service accounts (spec §9, §9.1) one at a time on a dedicated thread, + * so that a single Chromium is ever open. Admin logins may wait on the code page for a code entered in + * Catapult; automatic re-mints never wait and mark the account {@code CODE_REQUIRED} instead. + */ +@Slf4j +@Service +@ConditionalOnBooleanProperty("battlenet.enabled") +public class BattleNetLoginCoordinator { + + private final BattleNetServiceAccountRepository repository; + private final TokenEncryptionService encryption; + private final ObjectProvider browsers; + private final TransactionOperations transactions; + private final Duration ticketTimeout; + private final Duration codeTimeout; + private final ExecutorService executor = Executors.newSingleThreadExecutor( + Thread.ofPlatform().name("battlenet-login").daemon().factory()); + /** Admin logins from submission to completion, per service account. */ + private final Map interactiveFlows = new ConcurrentHashMap<>(); + private final int maxConsecutiveTimeouts; + /** Consecutive automatic re-mints that ended in a timeout, per service account (in memory). */ + private final Map timeouts = new ConcurrentHashMap<>(); + /** The automatic re-mint queued or running, per service account; guarded by itself. */ + private final Map remints = new HashMap<>(); + + public BattleNetLoginCoordinator(BattleNetServiceAccountRepository repository, + TokenEncryptionService encryption, + ObjectProvider browsers, + TransactionOperations transactions, + @Value("${battlenet.login.ticket-timeout:60s}") Duration ticketTimeout, + @Value("${battlenet.login.code-timeout:10m}") Duration codeTimeout, + @Value("${battlenet.login.max-consecutive-timeouts:3}") + int maxConsecutiveTimeouts) { + this.repository = repository; + this.encryption = encryption; + this.browsers = browsers; + this.transactions = transactions; + this.ticketTimeout = ticketTimeout; + this.codeTimeout = codeTimeout; + this.maxConsecutiveTimeouts = maxConsecutiveTimeouts; + } + + /** + * Admin login: completes once the account is {@code READY}, or fails with a {@link TicketException} + * (account then {@code AUTH_FAILED}). While the page asks for a code the account is {@code CODE_REQUIRED} + * and {@link #submitCode} resumes it. + */ + public CompletableFuture startInteractive(UUID accountId) { + timeouts.remove(accountId); + BattleNetLoginFlow flow = new BattleNetLoginFlow(true, ticketTimeout, codeTimeout, + () -> { + setAuthState(accountId, AuthState.CODE_REQUIRED); + log.info("Battle.net login of service account {} waits for a 2FA code", accountId); + }); + if (interactiveFlows.putIfAbsent(accountId, flow) != null) { + return CompletableFuture.failedFuture( + new IllegalStateException("A login is already in progress for this account")); + } + CompletableFuture done = new CompletableFuture<>(); + try { + executor.execute(() -> { + Throwable failure = null; + try { + login(accountId, flow, () -> true); + } catch (TicketException | RuntimeException e) { + failure = e; + } finally { + interactiveFlows.remove(accountId, flow); + } + if (failure == null) done.complete(null); + else done.completeExceptionally(failure); + }); + } catch (RejectedExecutionException e) { + interactiveFlows.remove(accountId, flow); + done.completeExceptionally(new IllegalStateException("The Battle.net login executor is shut down")); + } + return done; + } + + /** Fills the code page of the account's admin login; rejected when no login waits for a code. */ + public void submitCode(UUID accountId, String code) { + BattleNetLoginFlow flow = interactiveFlows.get(accountId); + if (flow == null) throw new IllegalStateException("No login is waiting for a code for this account"); + flow.submitCode(code); + } + + /** Whether an admin login of the account is running, waiting for a code or not. */ + public boolean loginInProgress(UUID accountId) { + return interactiveFlows.containsKey(accountId); + } + + public boolean waitingForCode(UUID accountId) { + BattleNetLoginFlow flow = interactiveFlows.get(accountId); + return flow != null && flow.waitingForCode(); + } + + /** + * Automatic re-mint for the account's BGS session: blocking, never waits for a code. Failures that need an + * admin throw {@link TicketException} with {@code codeRequired=true}, so the connection stops instead of + * retrying: a code request ({@code CODE_REQUIRED}) and a login turned down by Battle.net, such as a wrong + * password or a locked account ({@code AUTH_FAILED}; repeated failed logins could get the bot locked). + * Timeouts and technical failures (browser crash, navigation error, no browser bean, unreadable stored + * credentials) leave the auth state unchanged and throw with {@code codeRequired=false}: retryable. Except + * that a login page which never answers may be an undetected rejection: after + * {@code maxConsecutiveTimeouts} (default 3) automatic re-mints in a row ended in a timeout, the account + * becomes {@code AUTH_FAILED} and the exception has {@code codeRequired=true}. A ticket or an admin login + * resets the count; technical failures do not count. + */ + public TicketProvider ticketProviderFor(UUID accountId) { + return () -> mint(accountId); + } + + /** + * At most one automatic login per account: a caller asking while one is queued or running waits for that one. + * A caller that stops waiting (timeout, interrupt) withdraws; when none is left before the login reaches the + * browser, the login is dropped without opening Chromium. + */ + private String mint(UUID accountId) throws TicketException { + BattleNetLoginFlow running = interactiveFlows.get(accountId); + if (running != null) { + // Queuing behind an admin login that waits for a code would block for up to codeTimeout. + boolean codeRequired = running.waitingForCode(); + throw new TicketException(codeRequired ? "An admin login waits for a 2FA code" + : "An admin login is in progress", codeRequired); + } + Remint remint; + boolean queue; + synchronized (remints) { + remint = remints.get(accountId); + queue = remint == null; + if (queue) { + remint = new Remint(); + remints.put(accountId, remint); + } + remint.waiters++; + } + if (queue) { + queueRemint(accountId, remint); + } + // Another account's admin login may hold the executor while it waits for its code. + Duration maxWait = codeTimeout.plus(ticketTimeout.multipliedBy(3)); + try { + return remint.ticket.get(maxWait.toNanos(), TimeUnit.NANOSECONDS); + } catch (ExecutionException e) { + if (e.getCause() instanceof TicketException te) throw te; + throw new TicketException("Battle.net login failed (" + e.getCause().getClass().getSimpleName() + ")", + false); + } catch (TimeoutException e) { + withdraw(remint); + throw new TicketException("Timed out waiting for the Battle.net login", false); + } catch (InterruptedException e) { + withdraw(remint); + Thread.currentThread().interrupt(); + throw new TicketException("Interrupted while waiting for the Battle.net login", false); + } + } + + private void queueRemint(UUID accountId, Remint remint) { + BattleNetLoginFlow flow = new BattleNetLoginFlow(false, ticketTimeout, codeTimeout, () -> { }); + try { + executor.execute(() -> { + String ticket = null; + Throwable failure = null; + try { + ticket = login(accountId, flow, () -> stillWanted(accountId, remint)); + } catch (TicketException | RuntimeException e) { + failure = e; + } + // Removed before completing: a caller arriving now queues a new login rather than reuse this ticket + forget(accountId, remint); + if (failure == null) remint.ticket.complete(ticket); + else remint.ticket.completeExceptionally(failure); + }); + } catch (RejectedExecutionException e) { + forget(accountId, remint); + remint.ticket.completeExceptionally( + new TicketException("The Battle.net login executor is shut down", false)); + } + } + + /** How many callers wait for the account's automatic re-mint (0 when none is queued or running). */ + int remintWaiters(UUID accountId) { + synchronized (remints) { + Remint remint = remints.get(accountId); + return remint == null ? 0 : remint.waiters; + } + } + + private void withdraw(Remint remint) { + synchronized (remints) { + remint.waiters--; + } + } + + /** On the login thread, just before the browser: false (and the re-mint is dropped) once every caller left. */ + private boolean stillWanted(UUID accountId, Remint remint) { + synchronized (remints) { + if (remint.waiters > 0) return true; + remints.remove(accountId, remint); + return false; + } + } + + private void forget(UUID accountId, Remint remint) { + synchronized (remints) { + remints.remove(accountId, remint); + } + } + + /** Runs on the login thread: one attempt, then the resulting auth state is persisted. */ + private String login(UUID accountId, BattleNetLoginFlow flow, BooleanSupplier wanted) throws TicketException { + Credentials credentials; + try { + credentials = transactions.execute(status -> repository.findById(accountId) + .map(account -> new Credentials(account.getEmail(), encryption.decrypt(account.getPasswordEnc()), + account.getStorageStateEnc() == null ? null + : encryption.decrypt(account.getStorageStateEnc()))) + .orElse(null)); + } catch (RuntimeException e) { + return fail(accountId, flow, "Could not read the stored credentials (" + e.getClass().getSimpleName() + ")", + flow.interactive()); + } + if (credentials == null) throw new TicketException("Unknown Battle.net service account", false); + + BattleNetLoginBrowser browser = browsers.getIfAvailable(); + if (browser == null) { + return fail(accountId, flow, "No Battle.net login browser is available", flow.interactive()); + } + + if (!wanted.getAsBoolean()) { + log.info("Battle.net automatic login of service account {} dropped: nobody waits for it any more", + accountId); + throw new TicketException("Nobody waits for this Battle.net login any more", false); + } + Result result = attempt(browser, flow, credentials); + return switch (result) { + case Result.Success success -> { + timeouts.remove(accountId); + saveSuccess(accountId, success.storageState()); + log.info("Battle.net service account {} logged in", accountId); + yield success.ticket(); + } + case Result.CodeRequired ignored -> { + setAuthState(accountId, AuthState.CODE_REQUIRED); + log.warn("Battle.net service account {} asks for a 2FA code: an admin must log it in", accountId); + throw new TicketException("The Battle.net login asks for a 2FA code", true); + } + case Result.Rejected rejected -> { + setAuthState(accountId, AuthState.AUTH_FAILED); + log.warn("Battle.net turned down the login of service account {} ({} login): {}", accountId, + flow.interactive() ? "admin" : "automatic", rejected.reason()); + // codeRequired: an admin must act; retrying the same credentials may get the account locked. + throw new TicketException("Battle.net rejected the login: " + rejected.reason(), true); + } + case Result.Failed failed -> { + if (!flow.interactive() && BattleNetLoginFlow.TIMEOUT_REASON.equals(failed.reason()) + && timeouts.merge(accountId, 1, Integer::sum) >= maxConsecutiveTimeouts) { + timeouts.remove(accountId); + setAuthState(accountId, AuthState.AUTH_FAILED); + log.warn("Battle.net service account {}: {} automatic logins in a row timed out, " + + "an admin must log it in", accountId, maxConsecutiveTimeouts); + throw new TicketException("Battle.net login timed out repeatedly", true); + } + yield fail(accountId, flow, failed.reason(), flow.interactive()); + } + }; + } + + private Result attempt(BattleNetLoginBrowser browser, BattleNetLoginFlow flow, Credentials credentials) { + LoginPage page; + try { + page = browser.open(credentials.storageState()); + } catch (RuntimeException e) { + return new Result.Failed("Could not open the login page (" + e.getClass().getSimpleName() + ")"); + } + try { + return flow.run(page, credentials.email(), credentials.password()); + } finally { + try { + page.close(); + } catch (RuntimeException e) { + log.warn("Could not close the Battle.net login page ({})", e.getClass().getSimpleName()); + } + } + } + + private String fail(UUID accountId, BattleNetLoginFlow flow, String reason, boolean markFailed) + throws TicketException { + if (markFailed) setAuthState(accountId, AuthState.AUTH_FAILED); + log.warn("Battle.net login of service account {} failed ({} login): {}", accountId, + flow.interactive() ? "admin" : "automatic", reason); + throw new TicketException("Battle.net login failed: " + reason, false); + } + + private void saveSuccess(UUID accountId, @Nullable String storageState) { + @Nullable String encrypted = storageState == null ? null : encryption.encrypt(storageState); + update(accountId, account -> { + if (encrypted != null) account.setStorageStateEnc(encrypted); + account.setAuthState(AuthState.READY); + }); + } + + private void setAuthState(UUID accountId, AuthState state) { + update(accountId, account -> account.setAuthState(state)); + } + + /** + * Reloads and saves the account; an admin edit that slipped in between (optimistic lock) is picked up + * by one retry on a fresh copy, so the login's fields never overwrite the edit. + */ + private void update(UUID accountId, Consumer change) { + for (int attempt = 1; ; attempt++) { + try { + transactions.executeWithoutResult(status -> repository.findById(accountId).ifPresent(account -> { + change.accept(account); + repository.save(account); + })); + return; + } catch (ObjectOptimisticLockingFailureException e) { + if (attempt >= 2) throw e; + } + } + } + + @PreDestroy + public void shutdown() { + executor.shutdownNow(); + } + + /** One automatic re-mint and the callers waiting for it ({@code waiters} is guarded by {@code remints}). */ + private static final class Remint { + final CompletableFuture ticket = new CompletableFuture<>(); + int waiters; + } + + private record Credentials(String email, String password, @Nullable String storageState) { + @Override + public String toString() { + return "Credentials[***]"; + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlow.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlow.java new file mode 100644 index 000000000..691fdb938 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlow.java @@ -0,0 +1,131 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginBrowser.LoginPage; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginBrowser.Outcome; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * One login attempt on an open page (spec §9.1). An interactive attempt (started by an admin) waits on the + * code page for a code entered in Catapult, at most {@code codeTimeout} in total; an automatic one (re-mint + * on reconnection) stops there, since nobody is around to read the code. + */ +@Slf4j +public final class BattleNetLoginFlow { + + public sealed interface Result { + + record Success(String ticket, @Nullable String storageState) implements Result { + @Override + public String toString() { + return "Success[ticket=***, storageState=" + (storageState == null ? "null" : "***") + "]"; + } + } + + record CodeRequired() implements Result { + } + + /** Battle.net turned the login down: an admin must fix the account before any retry. */ + record Rejected(String reason) implements Result { + } + + /** Timeout or technical failure: a later retry may succeed. */ + record Failed(String reason) implements Result { + } + } + + /** Reason of the {@link Result.Failed} returned when the login page never answered. */ + static final String TIMEOUT_REASON = "No answer from the login page in time"; + + private final boolean interactive; + private final Duration ticketTimeout; + private final Duration codeTimeout; + private final Runnable onCodeRequired; + private final BlockingQueue codes = new LinkedBlockingQueue<>(); + private volatile boolean waitingForCode; + + /** {@code onCodeRequired} runs on the flow's thread each time an interactive attempt starts waiting. */ + public BattleNetLoginFlow(boolean interactive, Duration ticketTimeout, Duration codeTimeout, + Runnable onCodeRequired) { + this.interactive = interactive; + this.ticketTimeout = ticketTimeout; + this.codeTimeout = codeTimeout; + this.onCodeRequired = onCodeRequired; + } + + public boolean interactive() { + return interactive; + } + + public boolean waitingForCode() { + return waitingForCode; + } + + /** Hands the code to the waiting attempt. */ + public void submitCode(String code) { + if (!waitingForCode) throw new IllegalStateException("No login is waiting for a code"); + codes.offer(code); + } + + /** Runs the attempt on {@code page}; the caller closes the page. */ + public Result run(LoginPage page, String email, String password) { + try { + page.submitCredentials(email, password); + Outcome outcome = page.awaitOutcome(ticketTimeout); + long codeDeadline = 0; + while (true) { + switch (outcome) { + case Outcome.Ticket ticket -> { + return new Result.Success(ticket.value(), storageStateOf(page)); + } + case Outcome.Failed failed -> { + return new Result.Rejected(failed.reason()); + } + case Outcome.TimedOut ignored -> { + return new Result.Failed(TIMEOUT_REASON); + } + case Outcome.CodeRequired ignored -> { + if (!interactive) return new Result.CodeRequired(); + if (codeDeadline == 0) codeDeadline = System.nanoTime() + codeTimeout.toNanos(); + String code = awaitCode(codeDeadline); + if (code == null) return new Result.Failed("No code entered in time"); + page.submitCode(code); + outcome = page.awaitOutcome(ticketTimeout); + } + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new Result.Failed("Login interrupted"); + } catch (RuntimeException e) { + // The exception message may quote the page URL, which carries the ticket: keep the type only. + return new Result.Failed("Login page error (" + e.getClass().getSimpleName() + ")"); + } + } + + private @Nullable String awaitCode(long deadline) throws InterruptedException { + codes.clear(); + onCodeRequired.run(); + waitingForCode = true; + try { + return codes.poll(Math.max(0, deadline - System.nanoTime()), TimeUnit.NANOSECONDS); + } finally { + waitingForCode = false; + } + } + + private static @Nullable String storageStateOf(LoginPage page) { + try { + return page.storageState(); + } catch (RuntimeException e) { + log.warn("Could not read the storageState after a Battle.net login ({}): keeping the previous one", + e.getClass().getSimpleName()); + return null; + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowser.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowser.java new file mode 100644 index 000000000..02b0cc40b --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowser.java @@ -0,0 +1,165 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * {@link BattleNetLoginBrowser} backed by headless Chromium through Playwright (spec §9). Playwright and the + * browser are created lazily on the first {@link #open}, so the application starts on machines without + * Chromium. A fresh {@link BrowserContext} is opened per login. + *

+ * Thread-safety: the coordinator drives this bean from a single thread; the lazy creation is synchronized but + * the pages themselves are not meant to be shared between threads. + */ +@Slf4j +@Component +@ConditionalOnBooleanProperty("battlenet.enabled") +public class PlaywrightLoginBrowser implements BattleNetLoginBrowser { + + static final String LOGIN_URL = "https://eu.account.battle.net/login/?externalChallenge=login&app=app"; + + private static final String ACCOUNT_NAME = "input#accountName:visible"; + private static final String PASSWORD = "input#password:visible"; + private static final String SUBMIT = "#submit"; + /** A visible error element on the login form after a submit: Battle.net's explicit rejection. */ + private static final String LOGIN_ERROR = ".alert-error:visible, .form-error:visible, [role=alert]:visible"; + // UNVERIFIED: the real 2FA page's markup was never captured. These generic selectors were validated only by + // the spike: any visible input besides the account name and password is taken for the code field. + private static final String CODE_INPUT = "input:visible:not(#accountName):not(#password):not([type=hidden])"; + private static final String CODE_SUBMIT = "button[type=submit]:visible, #submit:visible, input[type=submit]:visible"; + + private static final Pattern TICKET = Pattern.compile("[?&]ST=(EU-[0-9a-fA-F]{32}-\\d+)"); + private static final double ACTION_TIMEOUT_MS = 15_000; + private static final long POLL_MS = 250; + + private @Nullable Playwright playwright; + private @Nullable Browser browser; + + /** The ticket carried by a {@code localhost} redirect URL, or null for any other URL. */ + static @Nullable String extractTicket(String url) { + if (!url.contains("localhost")) return null; + Matcher m = TICKET.matcher(url); + return m.find() ? m.group(1) : null; + } + + @Override + public LoginPage open(@Nullable String storageState) { + Browser b = browser(); + Browser.NewContextOptions options = new Browser.NewContextOptions(); + if (storageState != null) options.setStorageState(storageState); + BrowserContext context = b.newContext(options); + try { + Page page = context.newPage(); + page.setDefaultTimeout(ACTION_TIMEOUT_MS); + AtomicReference ticket = new AtomicReference<>(); + page.onRequest(request -> { + String t = extractTicket(request.url()); + if (t != null) ticket.compareAndSet(null, t); + }); + page.navigate(LOGIN_URL); + return new PlaywrightLoginPage(context, page, ticket); + } catch (RuntimeException e) { + context.close(); + throw e; + } + } + + private synchronized Browser browser() { + if (browser == null || !browser.isConnected()) { + if (playwright == null) playwright = Playwright.create(); + browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + } + return browser; + } + + @PreDestroy + synchronized void shutdown() { + try { + if (browser != null) browser.close(); + if (playwright != null) playwright.close(); + } catch (RuntimeException e) { + log.warn("Closing the Battle.net login browser failed ({})", e.getClass().getSimpleName()); + } finally { + browser = null; + playwright = null; + } + } + + private static final class PlaywrightLoginPage implements LoginPage { + + private final BrowserContext context; + private final Page page; + private final AtomicReference ticket; + + PlaywrightLoginPage(BrowserContext context, Page page, AtomicReference ticket) { + this.context = context; + this.page = page; + this.ticket = ticket; + } + + @Override + public void submitCredentials(String email, String password) { + Locator account = page.locator(ACCOUNT_NAME); + Locator pwd = page.locator(PASSWORD); + // Either the e-mail form or, with a remembered account, directly the password form shows up. + account.or(pwd).first().waitFor(); + if (account.count() > 0) { + account.fill(email); + page.click(SUBMIT); + pwd.waitFor(); + } + pwd.fill(password); + page.click(SUBMIT); + } + + @Override + public Outcome awaitOutcome(Duration timeout) { + long deadline = System.nanoTime() + timeout.toNanos(); + do { + String t = ticket.get(); + if (t != null) return new Outcome.Ticket(t); + if (page.locator(CODE_INPUT).count() > 0 && page.locator(PASSWORD).count() == 0 + && page.locator(ACCOUNT_NAME).count() == 0) { + return new Outcome.CodeRequired(); + } + if (page.locator(LOGIN_ERROR).count() > 0) { + // Fixed text: the page's own message could echo the account e-mail. + return new Outcome.Failed("login rejected"); + } + page.waitForTimeout(POLL_MS); + } while (System.nanoTime() < deadline); + String t = ticket.get(); + return t != null ? new Outcome.Ticket(t) : new Outcome.TimedOut(); + } + + @Override + public void submitCode(String code) { + page.locator(CODE_INPUT).first().fill(code); + page.locator(CODE_SUBMIT).first().click(); + } + + @Override + public String storageState() { + return context.storageState(); + } + + @Override + public void close() { + context.close(); + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/AdminGameMappingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/AdminGameMappingService.java index 45be8b8c0..aa0732165 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/AdminGameMappingService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/AdminGameMappingService.java @@ -4,6 +4,7 @@ import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.domain.account.UserAccount; import fr.enimaloc.catapult.domain.binding.GameBinding; import fr.enimaloc.catapult.domain.binding.GameBinding.SourceType; @@ -13,6 +14,7 @@ import fr.enimaloc.catapult.repository.account.UserAccountRepository; import fr.enimaloc.catapult.repository.binding.GameBindingRepository; import fr.enimaloc.catapult.repository.binding.GameMappingRepository; +import fr.enimaloc.catapult.service.igdb.IgdbService; import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; import fr.enimaloc.catapult.service.category.CategoryChain; import fr.enimaloc.catapult.service.category.CategoryOutcome; @@ -41,6 +43,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.function.Supplier; @@ -72,6 +75,7 @@ public class AdminGameMappingService { private final GameBindingRepository bindingRepository; private final UserAccountRepository userAccountRepository; private final BindingService bindingService; + private final IgdbService igdbService; private final GameStateService gameStateService; private final StreamStateService streamStateService; private final ChannelEventPublisher channelEventPublisher; @@ -80,6 +84,7 @@ public class AdminGameMappingService { public AdminGameMappingService(GameMappingRepository mappingRepository, GameBindingRepository bindingRepository, UserAccountRepository userAccountRepository, BindingService bindingService, + IgdbService igdbService, GameStateService gameStateService, StreamStateService streamStateService, ChannelEventPublisher channelEventPublisher, CategoryChain categoryChain, PlatformTransactionManager transactionManager) { @@ -87,6 +92,7 @@ public AdminGameMappingService(GameMappingRepository mappingRepository, GameBind this.bindingRepository = bindingRepository; this.userAccountRepository = userAccountRepository; this.bindingService = bindingService; + this.igdbService = igdbService; this.gameStateService = gameStateService; this.streamStateService = streamStateService; this.channelEventPublisher = channelEventPublisher; @@ -128,8 +134,12 @@ void ignoreSwitched(GameBinding binding) { } } - /** A validated request: the platform, the trimmed ID and category (both null: none, an ignore-only mapping). */ - private record Target(SourceType sourceType, String sourceId, String twitchGameId, String twitchGameName) {} + /** + * A validated request: the platform, the trimmed ID, the category (both null: none, an ignore-only mapping) and + * the IGDB game (null: none). + */ + private record Target(SourceType sourceType, String sourceId, String twitchGameId, String twitchGameName, + String igdbId) {} // ---- Listings @@ -161,10 +171,15 @@ public List detected(String sourceType, String q) { return rows.stream().map(r -> detectedGame(r, names)).toList(); } + /** The form's IGDB game search (see {@link IgdbService#searchGameOptions}); empty when IGDB is unavailable. */ + public List searchIgdbGames(String q) { + return igdbService.searchGameOptions(q); + } + // ---- Writes public GameMappingResult create(GameMappingRequest request, UserAccount admin) { - Target target = validate(request); + Target target = validate(request, admin); BindingService.MappingSuggestions suggestions = igdbSuggestions(target, request); return write(request.applyLive(), () -> { if (mappingRepository.findBySourceTypeAndSourceId(target.sourceType(), target.sourceId()).isPresent()) { @@ -187,7 +202,7 @@ public GameMappingResult create(GameMappingRequest request, UserAccount admin) { } public GameMappingResult update(UUID id, GameMappingRequest request, UserAccount admin) { - Target target = validate(request); + Target target = validate(request, admin); BindingService.MappingSuggestions suggestions = igdbSuggestions(target, request); return write(request.applyLive(), () -> { GameMapping mapping = existing(id); @@ -201,7 +216,8 @@ public GameMappingResult update(UUID id, GameMappingRequest request, UserAccount throw duplicate(); } // Now about another game: the old one's bindings go back to IGDB, and are no longer ignored by it. - changes.addAll(backToIgdb(mapping.getSourceType(), mapping.getSourceId())); + // Its stored row still names the old game: resolved without its IGDB game + changes.addAll(backToIgdb(mapping.getSourceType(), mapping.getSourceId(), true)); if (mapping.isIgnoredByDefault()) { applyIgnore(mapping.getSourceType(), mapping.getSourceId(), false, changes); } @@ -330,8 +346,12 @@ private BindingService.MappingSuggestions igdbSuggestions(Target target, GameMap } GameKey game = GameKey.of(target.sourceType(), target.sourceId()); String name = mostFrequentNames(List.of(game)).get(game); - return bindingService.igdbSuggestions(new DetectedGame(target.sourceId(), target.sourceType(), name), - request.ccls() == null, request.tws() == null); + DetectedGame detected = new DetectedGame(target.sourceId(), target.sourceType(), name); + // The request's IGDB game, not the saved mapping's (none yet on a create, the old one on an update): without + // one, the stored row's is left aside too + return target.igdbId() != null + ? bindingService.igdbSuggestions(target.igdbId(), detected, request.ccls() == null, request.tws() == null) + : bindingService.igdbSuggestionsUnmapped(detected, request.ccls() == null, request.tws() == null); } /** @@ -356,11 +376,20 @@ private List applyToBindings(GameMapping mapping, BindingService.Ma * are locked first and their status checked again, as in {@link #applyToBindings}. */ private List backToIgdb(SourceType sourceType, String sourceId) { + return backToIgdb(sourceType, sourceId, false); + } + + /** + * {@link #backToIgdb(SourceType, String)}; {@code withoutMapping} also leaves aside the IGDB game of the + * mapping stored for that game (one moved to another game, its row not updated yet). + */ + private List backToIgdb(SourceType sourceType, String sourceId, boolean withoutMapping) { List changed = new ArrayList<>(); for (GameBinding binding : bindingRepository.lockAllBySourceTypeAndSourceIdAndStatusIn( sourceType, sourceId, List.of(Status.MAPPED))) { if (binding.getStatus() != Status.MAPPED) continue; - changed.add(bindingService.resolveWithoutMapping(binding)); + changed.add(withoutMapping ? bindingService.resolveUnmapped(binding) + : bindingService.resolveWithoutMapping(binding)); } return changed; } @@ -400,6 +429,7 @@ private static void fill(GameMapping mapping, Target target, GameMappingRequest mapping.getTws().clear(); if (request.tws() != null) mapping.getTws().addAll(request.tws()); mapping.setIgnoredByDefault(request.ignoredByDefault()); + mapping.setIgdbId(target.igdbId()); mapping.setUpdatedBy(admin == null ? null : admin.getId()); } @@ -412,21 +442,51 @@ private static ResponseStatusException duplicate() { return new ResponseStatusException(HttpStatus.CONFLICT, "This game already has a mapping"); } - private static Target validate(GameMappingRequest request) { + private Target validate(GameMappingRequest request, UserAccount admin) { if (request == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing body"); SourceType platform = platform(request.sourceType()); String sourceId = request.sourceId() == null ? "" : request.sourceId().trim(); if (sourceId.isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing game ID"); + String igdbId = request.igdbId() == null || request.igdbId().isBlank() ? null : request.igdbId().trim(); + // IGDB's ids are numbers: anything else would fail every IGDB call made with it + if (igdbId != null && !igdbId.chars().allMatch(c -> c >= '0' && c <= '9')) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "IGDB game id must be numeric"); + } String twitchGameId = request.twitchGameId() == null ? "" : request.twitchGameId().trim(); String twitchGameName = request.twitchGameName() == null ? "" : request.twitchGameName().trim(); - if (twitchGameId.isEmpty() && twitchGameName.isEmpty() && request.ignoredByDefault()) { - // Ignored by default, the game needs no category: its bindings keep IGDB's - return new Target(platform, sourceId, null, null); + if (twitchGameId.isEmpty() && twitchGameName.isEmpty()) { + if (igdbId != null) { + // No category typed: IGDB's game gives it (one typed always wins, nothing is deduced then) + Optional deduced = deduceCategory(igdbId, admin); + if (deduced.isPresent()) { + return new Target(platform, sourceId, deduced.get().id(), deduced.get().name(), igdbId); + } + if (!request.ignoredByDefault()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "No Twitch category found for this IGDB game: enter one"); + } + } + if (request.ignoredByDefault()) { + // Ignored by default, the game needs no category: its bindings keep IGDB's + return new Target(platform, sourceId, null, null, igdbId); + } } if (twitchGameId.isEmpty() || twitchGameName.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing Twitch category"); } - return new Target(platform, sourceId, twitchGameId, twitchGameName); + return new Target(platform, sourceId, twitchGameId, twitchGameName, igdbId); + } + + /** The Twitch category of the IGDB game; none when it has none, or when IGDB or Twitch cannot be reached. */ + private Optional deduceCategory(String igdbId, UserAccount admin) { + try { + IgdbService.IgdbGame game = igdbService.findById(igdbId); + if (game == null || game.name() == null || game.name().isBlank()) return Optional.empty(); + return bindingService.twitchCategoryFor(igdbId, game.name(), admin); + } catch (RuntimeException e) { + log.warn("Twitch category of IGDB game {} not deduced: {}", igdbId, e.getMessage()); + return Optional.empty(); + } } /** A detected game's platform; MANUAL is not one (its ID is an IGDB ID set by the chat command). */ @@ -474,10 +534,16 @@ private List toDtos(List mappings, UserAccount admi m.isCclsOverridden() ? Set.copyOf(m.getCcls()) : null, m.isTwsOverridden() ? Set.copyOf(m.getTws()) : null, followers.getOrDefault(key, 0L), m.getUpdatedAt(), - m.getUpdatedBy() == null ? null : logins.get(m.getUpdatedBy()), m.isIgnoredByDefault()); + m.getUpdatedBy() == null ? null : logins.get(m.getUpdatedBy()), m.isIgnoredByDefault(), + m.getIgdbId(), igdbName(m.getIgdbId())); }).toList(); } + /** The IGDB game's name from the in-memory cache; null when it is not there (IGDB is not called to list). */ + private String igdbName(String igdbId) { + return igdbId == null ? null : igdbService.getGameCache().get(igdbId); + } + private static DetectedGameDto detectedGame(Object[] row, Map names) { GameKey key = GameKey.of(row[0], row[1]); return new DetectedGameDto(key.sourceType(), key.sourceId(), names.get(key), ((Number) row[2]).longValue()); diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java index bf32f9700..dd551fc80 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java @@ -234,11 +234,29 @@ public record MappingSuggestions(Set ccls, Set tws) { */ public MappingSuggestions igdbSuggestions(DetectedGame game, boolean ccls, boolean tws) { if (!ccls && !tws) return MappingSuggestions.NONE; - Optional igdbId = (game.getSourceName() != null ? resolveViaIgdb(game) - : game.getSourceId() != null - ? igdbService.findByExternalAppId(game.getSourceType(), game.getSourceId()) - : Optional.empty()) - .map(IgdbService.IgdbGame::id); + return igdbSuggestions(resolveViaIgdb(game).map(IgdbService.IgdbGame::id), game, ccls, tws); + } + + /** + * {@link #igdbSuggestions(DetectedGame, boolean, boolean)} with the game's stored mapping left aside + * ({@link IgdbService#resolveUnmapped}): for a write whose mapping names no IGDB game while its stored row, not + * updated yet, may still name the old one. None for Battle.net. + */ + public MappingSuggestions igdbSuggestionsUnmapped(DetectedGame game, boolean ccls, boolean tws) { + if (!ccls && !tws) return MappingSuggestions.NONE; + return igdbSuggestions(resolveUnmappedViaIgdb(game).map(IgdbService.IgdbGame::id), game, ccls, tws); + } + + /** + * {@link #igdbSuggestions(DetectedGame, boolean, boolean)} for the IGDB game the mapping itself names, not the + * one already saved (a write resolves its suggestions before the mapping is stored). + */ + public MappingSuggestions igdbSuggestions(String igdbGameId, DetectedGame game, boolean ccls, boolean tws) { + if (!ccls && !tws) return MappingSuggestions.NONE; + return igdbSuggestions(Optional.of(igdbGameId), game, ccls, tws); + } + + private MappingSuggestions igdbSuggestions(Optional igdbId, DetectedGame game, boolean ccls, boolean tws) { return new MappingSuggestions( ccls ? igdbId.map(igdbService::suggestCcls).orElse(Set.of()) : Set.of(), tws ? igdbId.map(id -> suggestTws(id, game)).orElse(Set.of()) : Set.of()); @@ -293,20 +311,28 @@ public GameBinding resolveWithoutMapping(GameBinding binding) { return updateWithoutMapping(binding.getUser(), detectedOf(binding), binding); } + /** + * {@link #resolveWithoutMapping} without the IGDB game of the stored mapping either + * ({@link IgdbService#resolveUnmapped}): for the bindings of a game its mapping is no longer about. + */ + @Transactional + public GameBinding resolveUnmapped(GameBinding binding) { + DetectedGame detectedGame = detectedOf(binding); + return updateFromIgdb(binding.getUser(), detectedGame, binding, resolveUnmappedViaIgdb(detectedGame)); + } + private GameBinding updateWithoutMapping(UserAccount user, DetectedGame detectedGame, GameBinding binding) { - Optional igdbGame = resolveViaIgdb(detectedGame); + return updateFromIgdb(user, detectedGame, binding, resolveViaIgdb(detectedGame)); + } + + private GameBinding updateFromIgdb(UserAccount user, DetectedGame detectedGame, GameBinding binding, + Optional igdbGame) { if (igdbGame.isPresent()) { String igdbId = igdbGame.get().id(); + // Null when the game comes from a mapping whose IGDB name is not known yet (IGDB unreachable) String gameName = igdbGame.get().name(); - String twitchId = igdbGameDetailsService.getDetails(igdbId) - .map(IgdbGameDetails::getWebsites) - .map(websites -> websites.get("twitch")) - // A category id (external_games' uid), not a Twitch website's URL kept under the same key - .filter(BindingService::isTwitchCategoryId) - .or(() -> igdbService.findTwitchGameId(igdbId)) - .or(() -> twitchService.findCategoryIdByName(user, gameName)) - .orElse(null); + String twitchId = twitchCategoryFor(igdbId, gameName, user).map(TwitchCategoryRef::id).orElse(null); binding.setTwitchGameId(twitchId); binding.setTwitchGameName(gameName); binding.setStatus(GameBinding.Status.AUTO); @@ -338,15 +364,43 @@ private GameBinding updateWithoutMapping(UserAccount user, DetectedGame detected return gameBindingRepository.save(binding); } + /** A Twitch category: its id and the name it is kept under (the IGDB game's). */ + public record TwitchCategoryRef(String id, String name) {} + + /** + * The Twitch category of an IGDB game: the Twitch id of its details' website (numeric ones only), else its + * external Twitch game, else a Twitch category search by {@code gameName} (skipped when null). The name is the + * IGDB game's, as the bindings keep it. + */ + public Optional twitchCategoryFor(String igdbId, String gameName, UserAccount actingUser) { + return igdbGameDetailsService.getDetails(igdbId) + .map(IgdbGameDetails::getWebsites) + .map(websites -> websites.get("twitch")) + // A category id (external_games' uid), not a Twitch website's URL kept under the same key + .filter(BindingService::isTwitchCategoryId) + .or(() -> igdbService.findTwitchGameId(igdbId)) + .or(() -> gameName == null ? Optional.empty() : twitchService.findCategoryIdByName(actingUser, gameName)) + .map(id -> new TwitchCategoryRef(id, gameName)); + } + /** Twitch's category ids are numeric; anything else (a website URL) is refused as the PATCH's game_id. */ static boolean isTwitchCategoryId(String id) { return !id.isEmpty() && id.chars().allMatch(c -> c >= '0' && c <= '9'); } + /** The detected game's IGDB game ({@link IgdbService#resolveDetected}); none without a platform id. */ private Optional resolveViaIgdb(DetectedGame detectedGame) { return detectedGame.getSourceId() != null - ? igdbService.findByExternalAppId(detectedGame.getSourceType(), detectedGame.getSourceId()) - .or(() -> igdbService.findByName(detectedGame.getSourceName())) + ? igdbService.resolveDetected(detectedGame.getSourceType(), detectedGame.getSourceId(), + detectedGame.getSourceName()) + : Optional.empty(); + } + + /** {@link #resolveViaIgdb} with the stored mapping left aside ({@link IgdbService#resolveUnmapped}). */ + private Optional resolveUnmappedViaIgdb(DetectedGame detectedGame) { + return detectedGame.getSourceId() != null + ? igdbService.resolveUnmapped(detectedGame.getSourceType(), detectedGame.getSourceId(), + detectedGame.getSourceName()) : Optional.empty(); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/GameContextService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/GameContextService.java index e6722f103..80a88936f 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/GameContextService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/GameContextService.java @@ -167,10 +167,8 @@ private GameContext minimalContext(DetectedGame detected) { } private Optional resolveIgdbId(DetectedGame detected) { - if (detected.getSourceType() == GameBinding.SourceType.STEAM) { - return igdbService.findBySteamAppId(detected.getSourceId()).map(IgdbService.IgdbGame::id); - } - return igdbService.findByName(detected.getSourceName()).map(IgdbService.IgdbGame::id); + return igdbService.resolveDetected(detected.getSourceType(), detected.getSourceId(), detected.getSourceName()) + .map(IgdbService.IgdbGame::id); } private Locale resolveLocale(UserAccount user) { diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEvents.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEvents.java index 9bc7c8e4f..c5e233d1e 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEvents.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEvents.java @@ -1,7 +1,6 @@ package fr.enimaloc.catapult.service.dtdd; import fr.enimaloc.catapult.common.dto.channel.DtddMappingCurrentDto; -import fr.enimaloc.catapult.domain.binding.GameBinding; import fr.enimaloc.catapult.domain.dtdd.DtddGameCache; import fr.enimaloc.catapult.domain.dtdd.DtddMappingProposal; import fr.enimaloc.catapult.getter.DetectedGame; @@ -84,11 +83,9 @@ private DtddMappingCurrentDto current(String igdbId) { mapping.getConfidence(), mapping.isVerified())).orElse(null); } - /** The IGDB game of a detected game: by app id for Steam games, by name otherwise. */ + /** The IGDB game of a detected game, as {@link IgdbService#resolveDetected} resolves it. */ private Optional igdbGameId(DetectedGame game) { - return (game.getSourceType() == GameBinding.SourceType.STEAM - ? igdbService.findBySteamAppId(game.getSourceId()) - : igdbService.findByName(game.getSourceName())) + return igdbService.resolveDetected(game.getSourceType(), game.getSourceId(), game.getSourceName()) .map(IgdbService.IgdbGame::id); } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbClient.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbClient.java index 373d985e3..577eeadbd 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbClient.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbClient.java @@ -112,11 +112,11 @@ public List findByWindowsExecutable(String exeName, String toke } /** - * Full-text search of a game by name. + * Full-text search of a game by name (at most 10): its id and name, release date and cover image. */ public List searchByName(String name, String token) { return apiObservations.observe("igdb", "search_by_name", () -> { - APICalypse query = new APICalypse().search(name).fields("id,name").limit(5); + APICalypse query = new APICalypse().search(name).fields("id,name,first_release_date,cover.image_id").limit(10); return fetchOrEmpty(token, "/games", "search '" + name + "'", query, IgdbClient::games); }); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbService.java index 8e7e0c944..61d95b91f 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbService.java @@ -2,11 +2,14 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.domain.binding.GameBinding; +import fr.enimaloc.catapult.domain.binding.GameMapping; import fr.enimaloc.catapult.domain.igdb.IgdbGameCacheEntry; import fr.enimaloc.catapult.domain.igdb.IgdbGameCcl; import fr.enimaloc.catapult.domain.igdb.IgdbGameExternalId; import fr.enimaloc.catapult.domain.twitch.TwitchCclDefinition; +import fr.enimaloc.catapult.repository.binding.GameMappingRepository; import fr.enimaloc.catapult.repository.igdb.IgdbGameCacheRepository; import fr.enimaloc.catapult.repository.igdb.IgdbGameCclRepository; import fr.enimaloc.catapult.repository.igdb.IgdbGameExternalIdRepository; @@ -26,6 +29,7 @@ import proto.*; import java.time.Instant; +import java.time.ZoneOffset; import java.util.*; import java.util.Collection; import java.util.concurrent.ConcurrentHashMap; @@ -38,11 +42,14 @@ public class IgdbService { private static final String TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"; + /** IGDB's cover image CDN path (small cover size); the image id and ".jpg" follow. */ + private static final String COVER_URL_PREFIX = "https://images.igdb.com/igdb/image/upload/t_cover_small/"; /** The DB cache key prefix of a game looked up by its Steam app id. */ public static final String KEY_STEAM_PREFIX = "steam:"; private static final String KEY_NAME_PREFIX = "name:"; private static final String KEY_EXE_PREFIX = "exe:"; private static final String KEY_XBOX_PREFIX = "xbox:"; + private static final String KEY_ID_PREFIX = "id:"; private static final int IGDB_BATCH_SIZE = 500; private final IgdbClient igdbClient; @@ -54,6 +61,7 @@ public class IgdbService { private final RestClient restClient; private final MeterRegistry meterRegistry; private final ObjectProvider simulationGate; + private final GameMappingRepository gameMappingRepository; @Value("${app.igdb.client-id:}") private String clientId; @@ -314,6 +322,46 @@ public List searchGames(String query) { .toList(); } + /** + * The form's IGDB game search: up to 10 games with their release year (UTC) and cover. Their names are + * remembered (id → name) so a mapping that picks one shows it. Empty for a blank query or when IGDB or its + * token is unavailable. + */ + public List searchGameOptions(String query) { + String trimmed = query == null ? "" : query.strip(); + if (clientId.isBlank() || trimmed.isEmpty()) return List.of(); + try { + String token = getOrRefreshAppToken(); + if (token.isBlank()) return List.of(); + List options = igdbClient.searchByName(trimmed, token).stream() + .filter(g -> !g.getName().isBlank()) + .map(IgdbService::toOption) + .limit(10) + .toList(); + options.forEach(o -> igdbGameCache.put(o.id(), o.name())); + return options; + } catch (RuntimeException e) { + log.warn("IGDB game search failed: {}", e.getMessage()); + return List.of(); + } + } + + private static IgdbGameOption toOption(Game game) { + Integer year = game.hasFirstReleaseDate() && game.getFirstReleaseDate().getSeconds() > 0 + ? Instant.ofEpochSecond(game.getFirstReleaseDate().getSeconds()).atZone(ZoneOffset.UTC).getYear() + : null; + String imageId = game.hasCover() ? game.getCover().getImageId() : ""; + String cover = imageId.isBlank() ? null : COVER_URL_PREFIX + imageId + ".jpg"; + return new IgdbGameOption(String.valueOf(game.getId()), game.getName(), year, cover); + } + + /** + * Resolves a game by its IGDB id (memory, DB cache, then IGDB); the name is null while none of them knows it. + */ + public IgdbGame findById(String igdbId) { + return mappedGame(igdbId); + } + /** * Warms the CCL cache for every already-known game (igdb_game_cache) that is * not yet in igdb_game_ccl_cache. Uses IGDB batch calls (max 500 per request). @@ -498,16 +546,78 @@ public Set fetchDescriptorIds(String igdbGameId) { } /** - * Re-applies the same IGDB resolution logic as BindingService: for STEAM bindings with a - * non-null sourceId, try Steam appId lookup first and fall back to name search. + * The IGDB game of a binding, resolved as {@link #resolveDetected} does. * Returns the resolved {@link IgdbGame#id()} or empty if none found. */ public Optional resolveIgdbIdForBinding(GameBinding b) { - if (b.getSourceType() == GameBinding.SourceType.STEAM && b.getSourceId() != null) { - Optional byApp = findBySteamAppId(b.getSourceId()); - if (byApp.isPresent()) return byApp.map(IgdbGame::id); + return resolveDetected(b.getSourceType(), b.getSourceId(), b.getSourceName()).map(IgdbGame::id); + } + + /** + * The IGDB game of a game detected on a platform: the admin mapping's IGDB game when the game's + * {@code (sourceType, sourceId)} mapping has one; else nothing for Battle.net, never looked up by name (its + * names are too ambiguous); else the platform id ({@link #findByExternalAppId}), then the name. + * + * @param sourceId the platform's id for the game, may be null (then no mapping nor platform lookup) + * @param sourceName the name the platform reports, may be null (then no name lookup) + */ + public Optional resolveDetected(GameBinding.SourceType sourceType, String sourceId, String sourceName) { + if (sourceId != null) { + Optional mappedIgdbId = gameMappingRepository.findBySourceTypeAndSourceId(sourceType, sourceId) + .map(GameMapping::getIgdbId) + .filter(id -> !id.isBlank()); + if (mappedIgdbId.isPresent()) return Optional.of(mappedGame(mappedIgdbId.get())); + } + return resolveUnmapped(sourceType, sourceId, sourceName); + } + + /** + * {@link #resolveDetected} with the game's mapping left aside: nothing for Battle.net, else the platform id + * ({@link #findByExternalAppId}), then the name. For a write whose mapping no longer names an IGDB game (or is + * no longer about this game) while its stored row still does. + * + * @param sourceId the platform's id for the game, may be null (then no platform lookup) + * @param sourceName the name the platform reports, may be null (then no name lookup) + */ + public Optional resolveUnmapped(GameBinding.SourceType sourceType, String sourceId, String sourceName) { + if (sourceType == GameBinding.SourceType.BATTLENET) return Optional.empty(); + Optional byPlatformId = sourceId != null ? findByExternalAppId(sourceType, sourceId) : Optional.empty(); + if (byPlatformId.isPresent() || sourceName == null || sourceName.isBlank()) return byPlatformId; + return findByName(sourceName); + } + + /** + * The mapped IGDB game {@code igdbId} with its name: from memory, else the DB cache, else IGDB (then remembered). + * The name is null while neither knows it (IGDB unavailable or unknown id). + */ + private IgdbGame mappedGame(String igdbId) { + String known = igdbGameCache.get(igdbId); + if (known != null) return new IgdbGame(igdbId, known); + + String key = KEY_ID_PREFIX + igdbId; + Optional fromDb = lookupInDb(key); + if (fromDb.isPresent()) { + countLookup("id", "db_hit"); + igdbGameCache.put(igdbId, fromDb.get().name()); + return fromDb.get(); + } + + String token = clientId.isBlank() ? "" : getOrRefreshAppToken(); + if (token.isBlank()) return new IgdbGame(igdbId, null); + List results; + try { + results = igdbClient.fetchGameById(igdbId, "name", token); + } catch (RuntimeException e) { + log.warn("Cannot fetch the name of mapped IGDB game {}: {}", igdbId, e.getMessage()); + return new IgdbGame(igdbId, null); } - return findByName(b.getSourceName()).map(IgdbGame::id); + countLookup("id", "miss"); + if (results.isEmpty() || results.getFirst().getName().isBlank()) return new IgdbGame(igdbId, null); + + IgdbGame resolved = new IgdbGame(igdbId, results.getFirst().getName()); + remember(resolved); + saveToDb(key, resolved); + return resolved; } private Set extractCcls(Game game) { diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendService.java index b6e6d3dfe..c6c96daf8 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendService.java @@ -92,7 +92,7 @@ public MinecraftFriendLink enroll(UserAccount user, String playerName) { } } else { log.warn("No token for {} — re-enrolling without removeFriend on the API side", - existing.getServiceAccount().getLabel()); + existing.getServiceAccount().getMinecraftUsername()); } } @@ -107,7 +107,7 @@ public MinecraftFriendLink enroll(UserAccount user, String playerName) { } catch (HttpClientErrorException e) { if (isInviteRejected(e)) { log.info("Minecraft invite rejected by the player (privacy) for {} via {}", - profile.name(), account.getLabel()); + profile.name(), account.getMinecraftUsername()); MinecraftFriendLink link = existing != null ? existing : new MinecraftFriendLink(); link.setUser(user); link.setServiceAccount(account); @@ -121,7 +121,7 @@ public MinecraftFriendLink enroll(UserAccount user, String playerName) { } // The limit error code is undocumented: any 4xx (other than INVITE_REJECTED) on the // add is treated as the limit being reached, and the body is logged to refine this. - log.warn("addFriend refused for {} ({}): {}", account.getLabel(), + log.warn("addFriend refused for {} ({}): {}", account.getMinecraftUsername(), e.getStatusCode(), e.getResponseBodyAsString()); limitMarker.markFull(account); continue; @@ -241,7 +241,7 @@ public void syncFriendLinks() { friendsList = debug.batch(FRIENDS_OPERATION, membersOf(links), BatchDebugFilter::minecraftFriends, () -> minecraftService.getFriends(accessToken)); } catch (Exception e) { - log.warn("getFriends failed for {}: {}", account.getLabel(), e.getMessage()); + log.warn("getFriends failed for {}: {}", account.getMinecraftUsername(), e.getMessage()); continue; } @@ -268,18 +268,18 @@ public void syncFriendLinks() { if (friend != null && link.getStatus() == MinecraftFriendLink.Status.PENDING) { link.setStatus(MinecraftFriendLink.Status.ACCEPTED); link.setAcceptedAt(Instant.now()); - log.info("Minecraft link accepted: {} via {}", link.getMinecraftName(), account.getLabel()); + log.info("Minecraft link accepted: {} via {}", link.getMinecraftName(), account.getMinecraftUsername()); changed = true; } else if (friend == null && link.getStatus() == MinecraftFriendLink.Status.ACCEPTED) { link.setStatus(MinecraftFriendLink.Status.REMOVED); - log.info("Minecraft link removed by the player: {} via {}", link.getMinecraftName(), account.getLabel()); + log.info("Minecraft link removed by the player: {} via {}", link.getMinecraftName(), account.getMinecraftUsername()); changed = true; } else if (link.getStatus() == MinecraftFriendLink.Status.INVITE_REJECTED) { if (friend != null) { link.setStatus(MinecraftFriendLink.Status.ACCEPTED); link.setAcceptedAt(Instant.now()); log.info("Minecraft link accepted (after an initial rejection): {} via {}", - link.getMinecraftName(), account.getLabel()); + link.getMinecraftName(), account.getMinecraftUsername()); changed = true; } else { MinecraftService.FriendsList.Friend incoming = @@ -291,11 +291,11 @@ public void syncFriendLinks() { link.setStatus(MinecraftFriendLink.Status.ACCEPTED); link.setAcceptedAt(Instant.now()); log.info("Minecraft link accepted (retry after a manual invite): {} via {}", - link.getMinecraftName(), account.getLabel()); + link.getMinecraftName(), account.getMinecraftUsername()); changed = true; } catch (Exception retryFailure) { log.info("addFriend retry still refused for {} via {}: {}", - link.getMinecraftName(), account.getLabel(), retryFailure.getMessage()); + link.getMinecraftName(), account.getMinecraftUsername(), retryFailure.getMessage()); } } } @@ -345,7 +345,7 @@ private void removeLink(MinecraftFriendLink link) { } } else { log.warn("No token for {} — link {} deleted without removeFriend on the API side", - link.getServiceAccount().getLabel(), link.getMinecraftProfileId()); + link.getServiceAccount().getMinecraftUsername(), link.getMinecraftProfileId()); } linkRepository.delete(link); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftServiceAccountAdmin.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftServiceAccountAdmin.java index a18dbc143..4c74a0593 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftServiceAccountAdmin.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftServiceAccountAdmin.java @@ -53,14 +53,13 @@ public MsaAuthClient.DeviceCodeStart startDeviceCode() { } /** Empty while the admin hasn't validated the code on microsoft.com/link; the created account afterwards. */ - public Optional create(String deviceCode, String label) { + public Optional create(String deviceCode) { requireDeviceCode(deviceCode); - return msaAuthClient.pollDeviceCode(deviceCode).map(tokens -> finalizeAccount(tokens, label)); + return msaAuthClient.pollDeviceCode(deviceCode).map(this::finalizeAccount); } - private AccountDto finalizeAccount(MsaAuthClient.MsaTokens tokens, String label) { + private AccountDto finalizeAccount(MsaAuthClient.MsaTokens tokens) { MinecraftServiceAccount account = new MinecraftServiceAccount(); - account.setLabel(label); account.setFillOrder((int) accountRepository.count()); account.setMinecraftUsername("(pending)"); @@ -69,7 +68,7 @@ private AccountDto finalizeAccount(MsaAuthClient.MsaTokens tokens, String label) account.setMinecraftUsername(profile.minecraftUsername()); account.setUpdatedAt(Instant.now()); MinecraftServiceAccount saved = accountRepository.save(account); - log.info("Minecraft service account enrolled: {} ({})", saved.getLabel(), profile.minecraftUsername()); + log.info("Minecraft service account enrolled: {} ({})", profile.minecraftUsername(), saved.getId()); return toDto(saved, 0); } @@ -96,7 +95,7 @@ private AccountDto reauthenticateAccount(MinecraftServiceAccount account, MsaAut // on the new chain rather than fall back on the old "failing" state tokenService.evict(account.getId()); MinecraftServiceAccount saved = accountRepository.save(account); - log.info("Minecraft service account re-authenticated: {} ({})", saved.getLabel(), profile.minecraftUsername()); + log.info("Minecraft service account re-authenticated: {} ({})", profile.minecraftUsername(), saved.getId()); return toDto(saved); } @@ -176,7 +175,7 @@ private AccountDto toDto(MinecraftServiceAccount account) { } private static AccountDto toDto(MinecraftServiceAccount account, int linkCount) { - return new AccountDto(account.getId(), account.getLabel(), account.getMinecraftUsername(), + return new AccountDto(account.getId(), account.getMinecraftUsername(), account.getMinecraftUsername(), account.getFillOrder(), account.isFriendLimitReached(), account.isEnabled(), linkCount); } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenService.java index 65c4b14f3..4deb017e0 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenService.java @@ -111,7 +111,7 @@ private Optional refreshChain(MinecraftServiceAccount account) { Optional chain = validateChain(account.getMsaRefreshToken()); if (chain.isEmpty()) { tokenState.put(account.getId(), 0); - log.warn("Minecraft auth chain failed for account {}", account.getLabel()); + log.warn("Minecraft auth chain failed for account {}", account.getMinecraftUsername()); return Optional.empty(); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisher.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisher.java index 4dc89ea37..b8796fd0c 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisher.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisher.java @@ -120,6 +120,17 @@ public void minecraftLinkChanged(UUID channelOwnerId, String status, String mine (id, login) -> new ChannelEvents.MinecraftLinkChanged(id, login, status, minecraftName)); } + /** + * The channel's Battle.net link is now {@code status} ({@code NONE} once gone), for {@code battleTag}, with the + * bot {@code serviceAccountBattleTag}. + */ + public void battleNetLinkChanged(UUID channelOwnerId, String status, String battleTag, + String serviceAccountBattleTag) { + publish(channelOwnerId, ChannelEvents.BATTLENET_LINK_CHANGED, + (id, login) -> new ChannelEvents.BattleNetLinkChanged(id, login, status, battleTag, + serviceAccountBattleTag)); + } + /** The channel's active custom binding is now {@code activeId}; null once deactivated. */ public void customBindingChanged(UUID channelOwnerId, UUID activeId) { String active = activeId == null ? null : activeId.toString(); diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/GameSourceResolver.java b/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/GameSourceResolver.java index 8d4c14dd3..58ce3ab7b 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/GameSourceResolver.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/GameSourceResolver.java @@ -15,7 +15,7 @@ *

  • Steam: the game's Steam app id on IGDB;
  • *
  • Xbox: its Microsoft Store product id on IGDB, which the Xbox getter reports once it has resolved the title;
  • *
  • Minecraft: {@code minecraft} / {@code Minecraft}, all the Minecraft getter ever reports;
  • - *
  • Battle.net: the name, as there is no id to report (Catapult resolves it by name).
  • + *
  • Battle.net: the name, as there is no id to report (Catapult resolves it through its game mapping only).
  • * */ @Component diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/external/SimulatedBattleNetGameGetter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/external/SimulatedBattleNetGameGetter.java index 20fd0bee2..a3f0f13ff 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/external/SimulatedBattleNetGameGetter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/simulation/external/SimulatedBattleNetGameGetter.java @@ -18,7 +18,8 @@ /** * The Battle.net presence in the account's {@code battlenet.presence} mode: the simulated data's game, or nothing on - * a failure. A subclass only because the detection chain asks for the concrete getter. + * a failure. A subclass only because the detection chain asks for the concrete getter: the superclass's state is + * never used. */ @Primary @Component @@ -31,6 +32,7 @@ public class SimulatedBattleNetGameGetter extends BattleNetGameGetter { public SimulatedBattleNetGameGetter(@Qualifier("battleNetGameGetter") ObjectProvider real, SimulatedCallRunner runner, SimulationRegistry registry) { + super(null, null, null, null); this.real = real; this.runner = runner; this.registry = registry; diff --git a/catapult-api/src/main/resources/application.properties b/catapult-api/src/main/resources/application.properties index ce4b50464..2c8a5720e 100644 --- a/catapult-api/src/main/resources/application.properties +++ b/catapult-api/src/main/resources/application.properties @@ -126,9 +126,12 @@ xbox.title-id-blacklist=${XBOX_TITLE_ID_BLACKLIST:704208617} # ============================================================ # Battle.net OAuth2 (optional) # ============================================================ -battlenet.enabled=false +battlenet.enabled=${BATTLENET_ENABLED:false} battlenet.client-id=${BATTLENET_CLIENT_ID:} battlenet.client-secret=${BATTLENET_CLIENT_SECRET:} +# BGS presence getter: one session per service account (single instance only, see split-brain guard) +battlenet.sessions.enabled=${BATTLENET_SESSIONS_ENABLED:false} +battlenet.bgs-host=${BATTLENET_BGS_HOST:eu.actual.battle.net} # ============================================================ # Minecraft OAuth2 (optional) diff --git a/catapult-api/src/main/resources/db/migration/V108__battlenet_presence.sql b/catapult-api/src/main/resources/db/migration/V108__battlenet_presence.sql new file mode 100644 index 000000000..832f62537 --- /dev/null +++ b/catapult-api/src/main/resources/db/migration/V108__battlenet_presence.sql @@ -0,0 +1,32 @@ +-- V108__battlenet_presence.sql +CREATE TABLE battlenet_service_account ( + id UUID PRIMARY KEY, + label TEXT NOT NULL, + email TEXT NOT NULL, + password_enc TEXT NOT NULL, + storage_state_enc TEXT, + battle_tag TEXT, + bnet_account_id BIGINT UNIQUE, + auth_state TEXT NOT NULL DEFAULT 'NEW', + fill_order INT NOT NULL DEFAULT 0, + friend_limit_reached BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE battlenet_friend_link ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL UNIQUE REFERENCES user_account(id) ON DELETE CASCADE, + service_account_id UUID NOT NULL REFERENCES battlenet_service_account(id) ON DELETE CASCADE, + bnet_account_id BIGINT NOT NULL, + battle_tag TEXT NOT NULL, + invitation_id BIGINT, + status TEXT NOT NULL DEFAULT 'PENDING', + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + accepted_at TIMESTAMPTZ +); + +CREATE INDEX idx_bnet_friend_link_service_account ON battlenet_friend_link(service_account_id); +CREATE INDEX idx_bnet_friend_link_status ON battlenet_friend_link(status); +CREATE INDEX idx_bnet_friend_link_bnet_account ON battlenet_friend_link(bnet_account_id); diff --git a/catapult-api/src/main/resources/db/migration/V109__battlenet_account_version.sql b/catapult-api/src/main/resources/db/migration/V109__battlenet_account_version.sql new file mode 100644 index 000000000..c52abc0f8 --- /dev/null +++ b/catapult-api/src/main/resources/db/migration/V109__battlenet_account_version.sql @@ -0,0 +1 @@ +ALTER TABLE battlenet_service_account ADD COLUMN version BIGINT NOT NULL DEFAULT 0; diff --git a/catapult-api/src/main/resources/db/migration/V110__game_mapping_igdb.sql b/catapult-api/src/main/resources/db/migration/V110__game_mapping_igdb.sql new file mode 100644 index 000000000..9eb401d98 --- /dev/null +++ b/catapult-api/src/main/resources/db/migration/V110__game_mapping_igdb.sql @@ -0,0 +1,16 @@ +-- A game mapping can pin its source to an IGDB game (null: resolved as without a mapping), which is how a platform +-- IGDB cannot look up by ID (Battle.net) gets its IGDB data. +ALTER TABLE game_mapping ADD COLUMN igdb_id TEXT; + +-- Battle.net titles (source_id: the Blizzard product code). Never overwrites a mapping an admin already made. +INSERT INTO game_mapping (source_type, source_id, twitch_game_id, twitch_game_name, igdb_id) +VALUES ('BATTLENET', 'WoW', '18122', 'World of Warcraft', '123'), + ('BATTLENET', 'WTCG', '138585', 'Hearthstone', '1279'), + ('BATTLENET', 'Pro', '515025', 'Overwatch', '125174'), + ('BATTLENET', 'Fen', '515024', 'Diablo IV', '125165'), + ('BATTLENET', 'D3', '313558', 'Diablo III', '120'), + ('BATTLENET', 'OSI', '1788326126', 'Diablo II: Resurrected', '142803'), + ('BATTLENET', 'S2', '490422', 'StarCraft II', '239'), + ('BATTLENET', 'Hero', '32959', 'Heroes of the Storm', '7313'), + ('BATTLENET', 'W3', '12924', 'Warcraft III', '111650') +ON CONFLICT (source_type, source_id) DO NOTHING; diff --git a/catapult-api/src/main/resources/db/migration/V111__minecraft_service_account_drop_label.sql b/catapult-api/src/main/resources/db/migration/V111__minecraft_service_account_drop_label.sql new file mode 100644 index 000000000..8fe2695d5 --- /dev/null +++ b/catapult-api/src/main/resources/db/migration/V111__minecraft_service_account_drop_label.sql @@ -0,0 +1,2 @@ +-- A Minecraft service account is shown by its Minecraft username: the free-text label is gone. +ALTER TABLE minecraft_service_account DROP COLUMN label; diff --git a/catapult-api/src/main/resources/db/migration/V112__battlenet_service_account_drop_label.sql b/catapult-api/src/main/resources/db/migration/V112__battlenet_service_account_drop_label.sql new file mode 100644 index 000000000..535eeb62b --- /dev/null +++ b/catapult-api/src/main/resources/db/migration/V112__battlenet_service_account_drop_label.sql @@ -0,0 +1,2 @@ +-- A Battle.net bot is shown by its BattleTag (its masked e-mail before the first login): the free-text label is gone. +ALTER TABLE battlenet_service_account DROP COLUMN label; diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java index 0baebd701..a97e71ed0 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java @@ -25,8 +25,8 @@ import org.junit.jupiter.params.provider.ValueSource; /** - * Code, comments, logs and test names are written in English, read from the sources of catapult-api, catapult-common - * and catapult-web as text. The texts the API stores or sends to viewers and streamers stay as they are: they are + * Code, comments, logs and test names are written in English, read from the sources of catapult-api, catapult-common, + * catapult-bgs and catapult-web as text. The texts the API stores or sends to viewers and streamers stay as they are: they are * listed in {@link #KEPT} with the reason. */ class EnglishConventionsTest { @@ -74,6 +74,9 @@ String key() { Map.entry( API + "experiment/MinecraftIntegrationExperiment.java LITERAL", "experiment name, description and variants, seeded once into the DB"), + Map.entry( + API + "experiment/BattleNetIntegrationExperiment.java LITERAL", + "experiment name, description and variants, seeded once into the DB"), Map.entry( API + "experiment/RaidRestoreGameExperiment.java LITERAL", "experiment name, description and variants, seeded once into the DB"), @@ -129,11 +132,12 @@ String key() { private static final Path ROOT = Path.of(".."); private static final String SELF = "catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java"; - private static final List MODULES = List.of("catapult-api", "catapult-common", "catapult-web"); + private static final List MODULES = List.of("catapult-api", "catapult-common", "catapult-bgs", "catapult-web"); /** Fewer files than this under a prefix means a tree went missing, not that it got English. */ private static final Map MIN_FILES = Map.of( "catapult-api/", 900, "catapult-common/", 150, + "catapult-bgs/", 6, "catapult-web/", 400, "catapult-web/src/main/resources/static/js/", 40, "catapult-web/src/test/js/", 40, diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/RouteConventionsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/RouteConventionsTest.java index 28df47f00..b58d0e887 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/RouteConventionsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/RouteConventionsTest.java @@ -41,7 +41,11 @@ "xbox.client-secret=full-context-test", "minecraft.enabled=true", "minecraft.msa-client-id=full-context-test", - "dtdd.enabled=true" + "dtdd.enabled=true", + "battlenet.enabled=true", + "battlenet.client-id=full-context-test", + "battlenet.client-secret=full-context-test", + "battlenet.sessions.enabled=false" }) @Import(FullContextContainers.class) @DirtiesContext diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/WebCallsMatchApiRoutesTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/WebCallsMatchApiRoutesTest.java index db8d014bb..4ee4ce6f3 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/WebCallsMatchApiRoutesTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/WebCallsMatchApiRoutesTest.java @@ -42,7 +42,11 @@ "xbox.client-secret=full-context-test", "minecraft.enabled=true", "minecraft.msa-client-id=full-context-test", - "dtdd.enabled=true" + "dtdd.enabled=true", + "battlenet.enabled=true", + "battlenet.client-id=full-context-test", + "battlenet.client-secret=full-context-test", + "battlenet.sessions.enabled=false" }) @Import(FullContextContainers.class) @DirtiesContext diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsControllerTest.java new file mode 100644 index 000000000..5b971bbad --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsControllerTest.java @@ -0,0 +1,135 @@ +package fr.enimaloc.catapult.api.admin; + +import fr.enimaloc.catapult.api.error.ProblemAdvice; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.repository.binding.GetterConfigRepository; +import fr.enimaloc.catapult.security.TokenEncryptionService; +import fr.enimaloc.catapult.service.battlenet.BattleNetServiceAccountAdmin; +import fr.enimaloc.catapult.service.battlenet.BattleNetSessionManager; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginCoordinator; +import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +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.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * HTTP statuses of the Battle.net pool admin. Admin-only access is the {@code /admin/**} rule of + * ApiSecurityConfig (403 for anyone else, see the access matrix), not something the controller checks. + */ +class ApiAdminBattleNetAccountsControllerTest { + + private final BattleNetServiceAccountRepository accounts = mock(BattleNetServiceAccountRepository.class); + private final BattleNetFriendLinkRepository links = mock(BattleNetFriendLinkRepository.class); + private final TokenEncryptionService encryption = mock(TokenEncryptionService.class); + private final BattleNetLoginCoordinator coordinator = mock(BattleNetLoginCoordinator.class); + private final BattleNetSessionManager sessions = mock(BattleNetSessionManager.class); + private final BattleNetServiceAccount account = new BattleNetServiceAccount(); + private MockMvc mvc; + + @BeforeEach + void setUp() { + account.setId(UUID.randomUUID()); + account.setEmail("bot-mailbox@example.com"); + account.setPasswordEnc("enc:pw"); + when(accounts.findById(account.getId())).thenReturn(Optional.of(account)); + when(accounts.findAll()).thenReturn(List.of(account)); + when(accounts.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(encryption.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0)); + when(coordinator.startInteractive(any())).thenReturn(new CompletableFuture<>()); + when(sessions.sessionState(any())).thenReturn("DISABLED"); + mvc = MockMvcBuilders.standaloneSetup(new ApiAdminBattleNetAccountsController( + new BattleNetServiceAccountAdmin(accounts, links, encryption, coordinator, sessions, + mock(GetterConfigRepository.class), mock(ChannelEventPublisher.class)))) + .setControllerAdvice(new ProblemAdvice()).build(); + } + + @Test + void listReturnsTheAccountsWithoutSecrets() throws Exception { + mvc.perform(get("/api/internal/admin/battlenet-accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].displayName").value("b***@example.com")) + .andExpect(jsonPath("$[0].label").doesNotExist()) + .andExpect(jsonPath("$[0].email").doesNotExist()) + .andExpect(jsonPath("$[0].passwordEnc").doesNotExist()); + } + + @Test + void createReturns202() throws Exception { + mvc.perform(post("/api/internal/admin/battlenet-accounts").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"a@b.c\",\"password\":\"pw\"}")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.authState").value("NEW")) + .andExpect(jsonPath("$.displayName").value("a***@b.c")); + } + + @Test + void createWithoutPasswordIs400() throws Exception { + mvc.perform(post("/api/internal/admin/battlenet-accounts").contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"a@b.c\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + void codeIsAccepted() throws Exception { + mvc.perform(post("/api/internal/admin/battlenet-accounts/{id}/code", account.getId()) + .contentType(MediaType.APPLICATION_JSON).content("{\"code\":\"123456\"}")) + .andExpect(status().isAccepted()); + } + + @Test + void codeWithNoLoginWaitingIs409() throws Exception { + doThrow(new IllegalStateException("none")).when(coordinator).submitCode(any(), any()); + + mvc.perform(post("/api/internal/admin/battlenet-accounts/{id}/code", account.getId()) + .contentType(MediaType.APPLICATION_JSON).content("{\"code\":\"123456\"}")) + .andExpect(status().isConflict()); + } + + @Test + void unknownAccountIs404() throws Exception { + mvc.perform(post("/api/internal/admin/battlenet-accounts/{id}/code", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON).content("{\"code\":\"123456\"}")) + .andExpect(status().isNotFound()); + mvc.perform(delete("/api/internal/admin/battlenet-accounts/{id}", UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + void reauthReturns202() throws Exception { + mvc.perform(post("/api/internal/admin/battlenet-accounts/{id}/reauth", account.getId())) + .andExpect(status().isAccepted()); + } + + @Test + void patchUpdatesAndDeleteReturns204() throws Exception { + mvc.perform(patch("/api/internal/admin/battlenet-accounts/{id}", account.getId()) + .contentType(MediaType.APPLICATION_JSON).content("{\"label\":\"renamed\",\"fillOrder\":3}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.fillOrder").value(3)) + .andExpect(jsonPath("$.displayName").value("b***@example.com")) // the legacy label is ignored + .andExpect(jsonPath("$.label").doesNotExist()); + mvc.perform(delete("/api/internal/admin/battlenet-accounts/{id}", account.getId())) + .andExpect(status().isNoContent()); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingControllerTest.java index 56406383a..723ab5e4b 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminGameMappingControllerTest.java @@ -49,6 +49,7 @@ class ApiAdminGameMappingControllerTest { @Mock GameBindingRepository bindingRepo; @Mock UserAccountRepository userRepo; @Mock BindingService bindingService; + @Mock fr.enimaloc.catapult.service.igdb.IgdbService igdbService; @Mock GameStateService gameStateService; @Mock StreamStateService streamStateService; @Mock ChannelEventPublisher channelEventPublisher; @@ -65,7 +66,7 @@ class ApiAdminGameMappingControllerTest { @BeforeEach void setUp() { AdminGameMappingService service = new AdminGameMappingService(mappingRepo, bindingRepo, userRepo, - bindingService, gameStateService, streamStateService, channelEventPublisher, categoryChain, + bindingService, igdbService, gameStateService, streamStateService, channelEventPublisher, categoryChain, transactionManager); controller = new ApiAdminGameMappingController(service, categories, userResolver, events); admin = new UserAccount(); @@ -146,4 +147,19 @@ void blankCategoryQuery_returnsEmpty() { assertThat(controller.categories(null)).isEmpty(); verifyNoInteractions(categories); } + + @Test + void igdbGamesSearchesByName() { + var option = new fr.enimaloc.catapult.common.dto.admin.IgdbGameOption("42", "Overwatch 2", 2022, "https://c/42.jpg"); + when(igdbService.searchGameOptions("overwatch")).thenReturn(List.of(option)); + + assertThat(controller.igdbGames(" overwatch ")).containsExactly(option); + } + + @Test + void blankQueryIsEmpty() { + assertThat(controller.igdbGames(" ")).isEmpty(); + assertThat(controller.igdbGames(null)).isEmpty(); + verify(igdbService, never()).searchGameOptions(any()); + } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsControllerTest.java index 446681601..2c550677a 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsControllerTest.java @@ -55,7 +55,7 @@ void setup() { void create_pendingDeviceCode_returns202() { when(msaAuthClient.pollDeviceCode("device-1")).thenReturn(Optional.empty()); - ResponseEntity response = controller.create(Map.of("deviceCode", "device-1", "label", "Bot1")); + ResponseEntity response = controller.create(Map.of("deviceCode", "device-1")); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); } @@ -68,13 +68,12 @@ void create_validatedDeviceCode_createsAccountWithEncryptedRefreshAndUsername() new MinecraftTokenService.ValidatedChain("mc-token", "enc:refresh-rotated", 86400))); when(minecraftService.getMinecraftProfileName("mc-token")).thenReturn("CatapultBot1"); - ResponseEntity response = controller.create(Map.of("deviceCode", "device-1", "label", "Bot1")); + ResponseEntity response = controller.create(Map.of("deviceCode", "device-1")); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); verify(accountRepository).save(org.mockito.ArgumentMatchers.argThat(account -> account.getMsaRefreshToken().equals("enc:refresh-rotated") - && account.getMinecraftUsername().equals("CatapultBot1") - && account.getLabel().equals("Bot1"))); + && account.getMinecraftUsername().equals("CatapultBot1"))); } @Test diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsFlowsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsFlowsTest.java index 1521bb4d8..97686c876 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsFlowsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminMinecraftAccountsFlowsTest.java @@ -62,7 +62,6 @@ class ApiAdminMinecraftAccountsFlowsTest { void setUp() { account = new MinecraftServiceAccount(); account.setId(UUID.randomUUID()); - account.setLabel("Pool #1"); account.setMinecraftUsername("Steve"); account.setEnabled(false); when(accounts.findById(any())).thenReturn(Optional.empty()); @@ -111,7 +110,7 @@ void list_includesEachAccountsLinkCount() { when(accounts.findAll()).thenReturn(List.of(account)); assertThat(controller.list()).containsExactly( - new AccountDto(account.getId(), "Pool #1", "Steve", 0, false, false, 3)); + new AccountDto(account.getId(), "Steve", "Steve", 0, false, false, 3)); } @Test @@ -178,15 +177,16 @@ void create_needsADeviceCode() { } @Test - void create_usesTheDefaultLabel_andFailsWhenTheChainDoesNot() { + void create_isShownByItsMinecraftUsername_andFailsWhenTheChainDoesNot() { when(msa.pollDeviceCode("dc")).thenReturn(Optional.of(msaTokens())); when(accounts.count()).thenReturn(2L); chainValidates("Alex"); - ResponseEntity created = controller.create(Map.of("deviceCode", "dc")); + // a legacy label in the body is ignored, like any unknown field + ResponseEntity created = controller.create(Map.of("deviceCode", "dc", "label", "Pool #2")); assertThat(created.getBody()).isInstanceOfSatisfying(AccountDto.class, dto -> { - assertThat(dto.label()).isEqualTo("Minecraft account"); + assertThat(dto.displayName()).isEqualTo("Alex"); assertThat(dto.minecraftUsername()).isEqualTo("Alex"); assertThat(dto.fillOrder()).isEqualTo(2); assertThat(dto.linkCount()).isZero(); @@ -208,6 +208,7 @@ void reauth_replacesTheRefreshToken_reEnables_andEvictsTheCache() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(account.getMsaRefreshToken()).isEqualTo("rotated"); assertThat(account.getMinecraftUsername()).isEqualTo("Steve2"); + assertThat(((AccountDto) response.getBody()).displayName()).isEqualTo("Steve2"); assertThat(account.isEnabled()).isTrue(); verify(tokens).evict(account.getId()); assertThat(((AccountDto) response.getBody()).linkCount()).isEqualTo(3); @@ -233,7 +234,7 @@ void reauth_needsAKnownAccountAndADeviceCode() { void update_appliesTheGivenFields() { AccountDto dto = controller.update(account.getId(), Map.of("enabled", true, "friendLimitReached", true, "fillOrder", 4)); - assertThat(dto).isEqualTo(new AccountDto(account.getId(), "Pool #1", "Steve", 4, true, true, 3)); + assertThat(dto).isEqualTo(new AccountDto(account.getId(), "Steve", "Steve", 4, true, true, 3)); assertThat(account.getUpdatedAt()).isNotNull(); controller.update(account.getId(), Map.of()); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java index 4a3cfc844..b5f7764b0 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java @@ -40,6 +40,13 @@ import fr.enimaloc.catapult.service.connections.SteamProfileDiagnostics; import fr.enimaloc.catapult.service.igdb.AdminCclService; import fr.enimaloc.catapult.service.igdb.IgdbService; +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; +import fr.enimaloc.catapult.service.battlenet.BattleNetLinkStateService; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.common.dto.channel.BattleNetData; import fr.enimaloc.catapult.service.minecraft.MinecraftFriendService; import fr.enimaloc.catapult.service.minecraft.MinecraftGateService; import fr.enimaloc.catapult.service.minecraft.MinecraftLinkStateService; @@ -90,6 +97,11 @@ class ChannelDashboardAssemblerTest { private final MinecraftFriendService minecraft = mock(MinecraftFriendService.class); private final MinecraftGateService minecraftGate = mock(MinecraftGateService.class); private final MinecraftServiceAccountRepository minecraftAccounts = mock(MinecraftServiceAccountRepository.class); + private final BattleNetGateService battleNetGate = mock(BattleNetGateService.class); + private final BattleNetFriendLinkRepository battleNetLinks = mock(BattleNetFriendLinkRepository.class); + private final BattleNetServiceAccountRepository battleNetAccounts = mock(BattleNetServiceAccountRepository.class); + private final BattleNetLinkStateService battleNetStates = + new BattleNetLinkStateService(battleNetGate, battleNetLinks, battleNetAccounts); private final MinecraftLinkStateService minecraftStates = new MinecraftLinkStateService(minecraft, minecraftGate, minecraftAccounts); private final TwitchatWidgetSettingsService twitchat = mock(TwitchatWidgetSettingsService.class); @@ -109,11 +121,12 @@ class ChannelDashboardAssemblerTest { private ChannelDashboardAssembler assembler(Optional steamClient, Optional minecraftService, + Optional battleNetService, boolean xboxEnabled) { SteamProfileDiagnostics diagnostics = new SteamProfileDiagnostics(steamClient, Optional.of(keyRotator), encryption); ChannelDashboardAssembler assembler = new ChannelDashboardAssembler(bindings, settingsRepository, streamState, gameState, ccls, tws, encryption, steamClient, diagnostics, igdb, dtddMappings, dtddCache, - dtddProposals, oauthTokens, minecraftService, twitchat, raidRestoreGate, + dtddProposals, oauthTokens, minecraftService, battleNetService, twitchat, raidRestoreGate, new fr.enimaloc.catapult.api.CustomBindingViews(customBindings, org.mockito.Mockito.mock(fr.enimaloc.catapult.service.premium.PremiumUiGate.class), org.mockito.Mockito.mock(fr.enimaloc.catapult.service.binding.schedule.CustomBindingScheduleService.class), @@ -125,7 +138,7 @@ private ChannelDashboardAssembler assembler(Optional steamClient } private ChannelDashboardAssembler assembler() { - return assembler(Optional.of(steam), Optional.of(minecraftStates), true); + return assembler(Optional.of(steam), Optional.of(minecraftStates), Optional.of(battleNetStates), true); } private static UserAccount account(String username) { @@ -343,6 +356,17 @@ void bindingsCarryTheirCategorysBoxArt_whenTheCacheKnowsIt() { .containsExactly("https://static-cdn.jtvnw.net/ttv-boxart/1-52x72.jpg", null, null); } + @Test + void aPageWhoseBindingsHaveNoTwitchCategory_buildsWithoutBoxArt() { + GameBinding incomplete = binding(owner, "1", "Incomplete"); + incomplete.setTwitchGameId(null); + when(bindings.findByUser(eq(owner), any())).thenReturn(new PageImpl<>(List.of(incomplete))); + + var content = assembler().page(owner, owner, "streamer", 0, null, null).bindings().content(); + + assertThat(content).extracting(BindingDto::boxArtUrl).containsExactly((String) null); + } + @Test void filtersByStatusFirst_thenBySource_caseInsensitive() { when(bindings.findByUserAndStatusIn(eq(owner), eq(Set.of(GameBinding.Status.MANUAL)), any())) @@ -391,7 +415,7 @@ void unboundCurrentGameHasNoBindingId() { @Test void unavailableProvidersAreNull() { - ChannelPageData page = assembler(Optional.empty(), Optional.empty(), false) + ChannelPageData page = assembler(Optional.empty(), Optional.empty(), Optional.empty(), false) .page(owner, owner, "streamer", 0, null, null); assertThat(page.steam()).isNull(); @@ -444,6 +468,32 @@ void minecraftIsFullWhenEveryServiceAccountIsFull() { .isEqualTo(new MinecraftData("FULL", null, null)); } + @Test + void dashboardCarriesBattleNetDataForTheOwnerOnly() { + BattleNetServiceAccount bot = new BattleNetServiceAccount(); + bot.setBattleTag("Bot#1111"); + BattleNetFriendLink link = new BattleNetFriendLink(); + link.setStatus(BattleNetFriendLink.Status.ACCEPTED); + link.setBattleTag("Name#1234"); + link.setServiceAccount(bot); + when(battleNetGate.isAvailableFor(owner)).thenReturn(true); + when(battleNetAccounts.countByEnabledTrueAndAuthState(BattleNetServiceAccount.AuthState.READY)) + .thenReturn(1L); + when(battleNetLinks.findWithServiceAccountByUser(owner)).thenReturn(Optional.of(link)); + when(bindings.findByUser(eq(owner), any())).thenReturn(new PageImpl<>(List.of())); + + assertThat(assembler().page(owner, owner, "streamer", 0, null, null).battleNet()) + .isEqualTo(new BattleNetData("ACCEPTED", "Name#1234", "Bot#1111")); + assertThat(assembler().page(moderator, owner, "streamer", 0, null, null).battleNet()).isNull(); + } + + @Test + void battleNetClosedToTheChannelIsNull() { + when(battleNetGate.isAvailableFor(owner)).thenReturn(false); + + assertThat(assembler().page(owner, owner, "streamer", 0, null, null).battleNet()).isNull(); + } + @Test void obsReportsAStoredPasswordWithoutSendingIt() { twitchat.getOrCreate(owner).setObsPasswordEncrypted("encrypted"); @@ -466,6 +516,18 @@ void isTheBindingOfTheGamePlayedNow() { .extracting(BindingDto::id).isEqualTo(valheim.getId().toString()); } + @Test + void hasNoBoxArt_whenTheBindingHasNoTwitchCategory() { + GameBinding incomplete = binding(owner, "1", "Incomplete"); + incomplete.setTwitchGameId(null); + when(gameState.getLastKnownGame(owner)) + .thenReturn(Optional.of(new DetectedGame("1", GameBinding.SourceType.STEAM, "Incomplete"))); + when(bindings.findByUserAndSourceIdAndSourceType(owner, "1", GameBinding.SourceType.STEAM)) + .thenReturn(Optional.of(incomplete)); + + assertThat(assembler().currentBinding(owner)).get().extracting(BindingDto::boxArtUrl).isNull(); + } + @Test void isAbsentWithoutAGameOrWithAnUnboundOne() { assertThat(assembler().currentBinding(owner)).isEmpty(); @@ -695,13 +757,13 @@ void nothingForGamesIgdbDoesntKnow() { playing(GameBinding.SourceType.XBOX, "1", "Halo"); assertThat(assembler().dtddMapping(moderator, owner, true)).isEqualTo(NONE); - verify(igdb).findByName("Halo"); + verify(igdb).resolveDetected(any(), any(), eq("Halo")); } @Test void unverifiedMappingWithoutProposals_canBeValidated() { playing(GameBinding.SourceType.STEAM, "892970", "Valheim"); - when(igdb.findBySteamAppId("892970")).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("892970"), any())).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); DtddGameMapping mapping = new DtddGameMapping(); mapping.setIgdbId("42"); mapping.setDtddId(7L); @@ -726,7 +788,7 @@ void unverifiedMappingWithoutProposals_canBeValidated() { @Test void dtddMapping_memberCannotValidateDirectly() { playing(GameBinding.SourceType.STEAM, "892970", "Valheim"); - when(igdb.findBySteamAppId("892970")).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("892970"), any())).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); DtddGameMapping mapping = new DtddGameMapping(); mapping.setIgdbId("42"); mapping.setDtddId(7L); @@ -742,7 +804,7 @@ void dtddMapping_memberCannotValidateDirectly() { @Test void dtddMapping_adminCanValidateDirectly() { playing(GameBinding.SourceType.STEAM, "892970", "Valheim"); - when(igdb.findBySteamAppId("892970")).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("892970"), any())).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); DtddGameMapping mapping = new DtddGameMapping(); mapping.setIgdbId("42"); mapping.setDtddId(7L); @@ -754,7 +816,7 @@ void dtddMapping_adminCanValidateDirectly() { @Test void pendingProposalsOrVerifiedMappings_cantBeValidated() { playing(GameBinding.SourceType.STEAM, "892970", "Valheim"); - when(igdb.findBySteamAppId("892970")).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("892970"), any())).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); DtddGameMapping mapping = new DtddGameMapping(); when(dtddMappings.findById("42")).thenReturn(Optional.of(mapping)); when(dtddProposals.countByIgdbIdAndStatus("42", DtddMappingProposal.Status.PENDING)).thenReturn(1L); @@ -777,7 +839,7 @@ void pendingProposalsOrVerifiedMappings_cantBeValidated() { @Test void unmappedGame() { playing(GameBinding.SourceType.STEAM, "1", "New"); - when(igdb.findBySteamAppId("1")).thenReturn(Optional.of(new IgdbService.IgdbGame("5", "New"))); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("1"), any())).thenReturn(Optional.of(new IgdbService.IgdbGame("5", "New"))); DtddMappingStatusDto status = assembler().dtddMapping(moderator, owner, true); @@ -810,7 +872,7 @@ void invalidatesWithoutPersonalToken() { @Test void nothingToDoWithoutSteam() { assembler().refreshSteamProfileCache(owner); - assembler(Optional.empty(), Optional.empty(), false).refreshSteamProfileCache(owner); + assembler(Optional.empty(), Optional.empty(), Optional.empty(), false).refreshSteamProfileCache(owner); verify(steam, never()).invalidateProfileCache(any(), any()); } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectControllerTest.java new file mode 100644 index 000000000..660187095 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectControllerTest.java @@ -0,0 +1,108 @@ +package fr.enimaloc.catapult.api.connect; + +import fr.enimaloc.catapult.api.ApiUserResolver; +import fr.enimaloc.catapult.api.error.ApiProblem; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.service.battlenet.BattleNetFriendService; +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; +import fr.enimaloc.catapult.service.battlenet.BattleNetLinkStateService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ApiBattleNetConnectControllerTest { + + @Mock private BattleNetFriendService friendService; + @Mock private BattleNetGateService gateService; + @Mock private UserAccountRepository userAccountRepository; + @Mock private BattleNetFriendLinkRepository linkRepository; + @Mock private BattleNetServiceAccountRepository accountRepository; + @Mock private Jwt jwt; + + private ApiBattleNetConnectController controller; + private UserAccount user; + + @BeforeEach + void setup() { + controller = new ApiBattleNetConnectController(friendService, + new BattleNetLinkStateService(gateService, linkRepository, accountRepository), + gateService, new ApiUserResolver(userAccountRepository)); + user = new UserAccount(); + UUID userId = UUID.randomUUID(); + when(jwt.getSubject()).thenReturn(userId.toString()); + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(user)); + when(gateService.isAvailableFor(user)).thenReturn(true); + when(accountRepository.countByEnabledTrueAndAuthState(BattleNetServiceAccount.AuthState.READY)) + .thenReturn(1L); + when(accountRepository.countByEnabledTrueAndAuthStateAndFriendLimitReachedFalse( + BattleNetServiceAccount.AuthState.READY)).thenReturn(1L); + } + + @Test + void get_noLink_returnsStatusNone() { + when(linkRepository.findWithServiceAccountByUser(user)).thenReturn(Optional.empty()); + + assertThat(controller.status(UUID.randomUUID(), jwt).status()).isEqualTo("NONE"); + } + + @Test + void get_pendingLink_exposesBothTags() { + var bot = new BattleNetServiceAccount(); + bot.setBattleTag("Bot#1111"); + var link = new BattleNetFriendLink(); + link.setUser(user); + link.setServiceAccount(bot); + link.setBattleTag("Name#1234"); + link.setStatus(BattleNetFriendLink.Status.PENDING); + when(linkRepository.findWithServiceAccountByUser(user)).thenReturn(Optional.of(link)); + + var response = controller.status(UUID.randomUUID(), jwt); + + assertThat(response.status()).isEqualTo("PENDING"); + assertThat(response.battleTag()).isEqualTo("Name#1234"); + assertThat(response.serviceAccountBattleTag()).isEqualTo("Bot#1111"); + } + + @Test + void get_closedGate_returnsUnavailable() { + when(gateService.isAvailableFor(user)).thenReturn(false); + + assertThat(controller.status(UUID.randomUUID(), jwt).status()).isEqualTo("UNAVAILABLE"); + } + + @Test + void delete_unenrolls() { + controller.unenroll(UUID.randomUUID(), jwt); + + verify(friendService).unenroll(user); + } + + @Test + void delete_closedGate_isRefused() { + when(gateService.isAvailableFor(user)).thenReturn(false); + + assertThatThrownBy(() -> controller.unenroll(UUID.randomUUID(), jwt)).isInstanceOf(ApiProblem.class); + verify(friendService, never()).unenroll(user); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/contract/FrozenRouteContractTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/contract/FrozenRouteContractTest.java index c856185a5..0ef3418d4 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/contract/FrozenRouteContractTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/contract/FrozenRouteContractTest.java @@ -204,7 +204,7 @@ private void fixtureRows() { @BeforeEach void thirdParties() throws Exception { when(twitchService.updateChannelReporting(any(), any())).thenReturn(ChannelUpdateOutcome.APPLIED); - when(igdbService.findByName(any())).thenReturn(Optional.of(new IgdbService.IgdbGame(IGDB_ID, "Frozen Game"))); + when(igdbService.resolveDetected(any(), any(), any())).thenReturn(Optional.of(new IgdbService.IgdbGame(IGDB_ID, "Frozen Game"))); SteamStoreService.SteamStorePage page = JsonMapper.builder() .build() .readValue( diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/GameInfoAssemblerLinksTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/GameInfoAssemblerLinksTest.java index 2792136f7..eb014e238 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/GameInfoAssemblerLinksTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/GameInfoAssemblerLinksTest.java @@ -25,6 +25,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -67,7 +68,7 @@ void setUp() { DetectedGame detected = new DetectedGame("620", GameBinding.SourceType.STEAM, "Portal 2"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "620")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("620"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("72", "Portal 2"))); IgdbGameDetails details = new IgdbGameDetails(); details.setIgdbId("72"); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV1ControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV1ControllerTest.java index 2227db4be..4fae7bbb1 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV1ControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV1ControllerTest.java @@ -32,6 +32,8 @@ import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -114,7 +116,7 @@ void gameInfo_steamGameWithBindingAndIgdbDetails_returnsNormalizedPayload() thro when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "440", GameBinding.SourceType.STEAM)) .thenReturn(Optional.of(binding)); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "440")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("440"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("1234", "Team Fortress 2"))); when(igdbGameDetailsService.getDetails("1234")).thenReturn(Optional.of(details)); when(twLabelService.resolve("violence", Locale.ENGLISH)).thenReturn("Violence"); @@ -157,7 +159,7 @@ void gameInfo_steamDescriptionAvailable_takesPriorityOverIgdbSummary() throws Ex when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "440", GameBinding.SourceType.STEAM)) .thenReturn(Optional.empty()); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "440")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("440"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("1234", "Team Fortress 2"))); when(igdbGameDetailsService.getDetails("1234")).thenReturn(Optional.of(details)); when(steamStoreService.fetchDescription("440", Locale.FRENCH)) @@ -183,7 +185,7 @@ void gameInfo_noStoreDescription_fallsBackToIgdbSummary() throws Exception { when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "440", GameBinding.SourceType.STEAM)) .thenReturn(Optional.empty()); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "440")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("440"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("1234", "Team Fortress 2"))); when(igdbGameDetailsService.getDetails("1234")).thenReturn(Optional.of(details)); when(steamStoreService.fetchDescription("440", Locale.ENGLISH)).thenReturn(Optional.empty()); @@ -204,7 +206,7 @@ void gameInfo_noBindingOrIgdbMatch_returnsPartialPayload() throws Exception { when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, null, GameBinding.SourceType.MANUAL)) .thenReturn(Optional.empty()); - when(igdbService.findByName("Some Unlisted Game")).thenReturn(Optional.empty()); + when(igdbService.resolveDetected(any(), any(), eq("Some Unlisted Game"))).thenReturn(Optional.empty()); mvc.perform(get("/api/game/{uuid}", token)) .andExpect(status().isOk()) @@ -237,7 +239,7 @@ void gameInfo_langParam_localizesStoreName() throws Exception { when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, null, GameBinding.SourceType.MANUAL)) .thenReturn(Optional.empty()); - when(igdbService.findByName("Some Unlisted Game")).thenReturn(Optional.empty()); + when(igdbService.resolveDetected(any(), any(), eq("Some Unlisted Game"))).thenReturn(Optional.empty()); mvc.perform(get("/api/game/{uuid}?lang=fr", token)) .andExpect(status().isOk()) @@ -255,7 +257,7 @@ void gameInfo_alwaysReturnsVersion1() throws Exception { when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, null, GameBinding.SourceType.MANUAL)) .thenReturn(Optional.empty()); - when(igdbService.findByName("Some Unlisted Game")).thenReturn(Optional.empty()); + when(igdbService.resolveDetected(any(), any(), eq("Some Unlisted Game"))).thenReturn(Optional.empty()); mvc.perform(get("/api/game/{uuid}", token)) .andExpect(status().isOk()) @@ -274,7 +276,7 @@ void gameInfo_blankSlug_hasNullIgdbUrl() throws Exception { when(widgetTokenService.resolve(token)).thenReturn(Optional.of(user)); when(gameStateService.getLastKnownGame(user)) .thenReturn(Optional.of(new DetectedGame("440", GameBinding.SourceType.STEAM, "Team Fortress 2"))); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "440")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("440"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("1234", "Team Fortress 2"))); when(igdbGameDetailsService.getDetails("1234")).thenReturn(Optional.of(details)); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV2ControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV2ControllerTest.java index 8f58cbc5e..201faaffa 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV2ControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/userapi/UserApiV2ControllerTest.java @@ -51,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; @@ -142,7 +143,7 @@ void nothingDetected_isNotInGame() throws Exception { @Test void steamGame_isAggregatedFromEveryPlatform() throws Exception { playing(GameBinding.SourceType.STEAM, "892970", "Valheim"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "892970")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("892970"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); IgdbGameDetails details = new IgdbGameDetails(); details.setSlug("valheim"); @@ -201,7 +202,7 @@ void xboxGame() throws Exception { @Test void igdbFallsBackToTheName_andDtddIsResolvedWhenEnabled() throws Exception { playing(GameBinding.SourceType.MINECRAFT, null, "Minecraft"); - when(igdbService.findByName("Minecraft")).thenReturn(Optional.of(new IgdbService.IgdbGame("7", "Minecraft"))); + when(igdbService.resolveDetected(any(), any(), eq("Minecraft"))).thenReturn(Optional.of(new IgdbService.IgdbGame("7", "Minecraft"))); DtddGameMapping mapping = new DtddGameMapping(); mapping.setDtddId(99L); when(dtddMappingService.resolve("7", "Minecraft")).thenReturn(mapping); @@ -219,7 +220,7 @@ void igdbFallsBackToTheName_andDtddIsResolvedWhenEnabled() throws Exception { @Test void unmappedDtdd_isNull() throws Exception { playing(GameBinding.SourceType.MINECRAFT, null, "Minecraft"); - when(igdbService.findByName("Minecraft")).thenReturn(Optional.of(new IgdbService.IgdbGame("7", "Minecraft"))); + when(igdbService.resolveDetected(any(), any(), eq("Minecraft"))).thenReturn(Optional.of(new IgdbService.IgdbGame("7", "Minecraft"))); when(dtddMappingService.resolve("7", "Minecraft")).thenReturn(new DtddGameMapping()); mvc.perform(get("/api/user/game/{uuid}", token)).andExpect(jsonPath("$.dtdd").value(nullValue())); @@ -271,7 +272,7 @@ void summary_carriesTheStoreAndIgdbPages() throws Exception { playing(GameBinding.SourceType.STEAM, "892970", "Valheim"); when(steamStoreService.fetchData(any(), any())).thenReturn(Optional.of(VALHEIM)); when(steamStoreService.fetchIADisclosure(any(), any())).thenReturn(Optional.empty()); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "892970")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("892970"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); IgdbGameDetails details = new IgdbGameDetails(); details.setSlug("valheim"); @@ -449,7 +450,7 @@ private IgdbGameDetails igdbWith(Map websites) { /** A Steam game IGDB knows as 42, with the given IGDB details. */ private void steamGameWithIgdb(IgdbGameDetails details) { playing(GameBinding.SourceType.STEAM, "892970", "Valheim"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "892970")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("892970"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); when(igdbGameDetailsService.getDetails("42")).thenReturn(Optional.of(details)); when(steamStoreService.fetchData(any(), any())).thenReturn(Optional.of(VALHEIM)); @@ -461,7 +462,7 @@ private void manualGameWithIgdb(IgdbGameDetails details) { // A null summary makes the summary's description lookup throw (findFirst on a null): out of this task. details.setSummary("Viking survival."); playing(GameBinding.SourceType.MANUAL, "m1", "Valheim"); - when(igdbService.findByName("Valheim")).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); + when(igdbService.resolveDetected(any(), any(), eq("Valheim"))).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); when(igdbGameDetailsService.getDetails("42")).thenReturn(Optional.of(details)); } @@ -525,7 +526,7 @@ void steamRelatedAppsLinkTheirDetailRoute() throws Exception { @Test void dtddObjectLinksItsMediaPageAndDetailRoute() throws Exception { playing(GameBinding.SourceType.MINECRAFT, null, "Minecraft"); - when(igdbService.findByName("Minecraft")).thenReturn(Optional.of(new IgdbService.IgdbGame("7", "Minecraft"))); + when(igdbService.resolveDetected(any(), any(), eq("Minecraft"))).thenReturn(Optional.of(new IgdbService.IgdbGame("7", "Minecraft"))); DtddGameMapping mapping = new DtddGameMapping(); mapping.setDtddId(99L); when(dtddMappingService.resolve("7", "Minecraft")).thenReturn(mapping); @@ -692,7 +693,7 @@ void summaryTwsAreLabelsOnceEachInOrder() throws Exception { @Test void summaryOfAnIgdbOnlyGameWithoutSummaryIsNotAnError() throws Exception { playing(GameBinding.SourceType.MANUAL, "m1", "Valheim"); - when(igdbService.findByName("Valheim")).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); + when(igdbService.resolveDetected(any(), any(), eq("Valheim"))).thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Valheim"))); when(igdbGameDetailsService.getDetails("42")).thenReturn(Optional.of(details("valheim"))); mvc.perform(get("/api/user/summary/{uuid}", token)) diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/config/OAuth2ClientConfigTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/config/OAuth2ClientConfigTest.java index e8c40ae97..5a95d4bf6 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/config/OAuth2ClientConfigTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/config/OAuth2ClientConfigTest.java @@ -52,7 +52,11 @@ void optionalProviders_needBothCredentials() { assertThat(repository.findByRegistrationId("xbox")).isNull(); ClientRegistration battlenet = repository.findByRegistrationId("battlenet"); - assertThat(battlenet.getScopes()).containsExactly("openid"); + // "openid" would switch Spring to the OIDC flow and bypass CatapultOAuth2UserService. + assertThat(battlenet.getScopes()).doesNotContain("openid"); + assertThat(battlenet.getProviderDetails().getAuthorizationUri()).isEqualTo("https://oauth.battle.net/authorize"); + assertThat(battlenet.getProviderDetails().getTokenUri()).isEqualTo("https://oauth.battle.net/token"); + assertThat(battlenet.getProviderDetails().getUserInfoEndpoint().getUri()).isEqualTo("https://oauth.battle.net/userinfo"); assertThat(battlenet.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName()).isEqualTo("sub"); } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/experiment/GateExperimentDeclarationTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/experiment/GateExperimentDeclarationTest.java new file mode 100644 index 000000000..8979f55bd --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/experiment/GateExperimentDeclarationTest.java @@ -0,0 +1,41 @@ +package fr.enimaloc.catapult.experiment; + +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; +import fr.enimaloc.catapult.service.binding.RaidRestoreGateService; +import fr.enimaloc.catapult.service.drops.DropsGate; +import fr.enimaloc.catapult.service.minecraft.MinecraftGateService; +import fr.enimaloc.catapult.service.title.StreamTitleService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Every experiment key a gate checks must be declared by an {@link ExperimentSpec} class, or the gate stays closed. */ +class GateExperimentDeclarationTest { + + @Test + void everyGateExperimentKeyIsDeclaredByASpec() throws ClassNotFoundException { + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(ExperimentSpec.class)); + Set declared = new HashSet<>(); + for (BeanDefinition def : scanner.findCandidateComponents("fr.enimaloc.catapult.experiment")) { + Class type = Class.forName(def.getBeanClassName()); + declared.add(type.getAnnotation(ExperimentSpec.class).key()); + } + + List gateKeys = List.of( + MinecraftGateService.EXPERIMENT_KEY, + BattleNetGateService.EXPERIMENT_KEY, + StreamTitleService.EXPERIMENT_KEY, + RaidRestoreGateService.EXPERIMENT_KEY, + DropsGate.EXPERIMENT_KEY); + + assertThat(declared).containsAll(gateKeys); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/BattleNetGameGetterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/BattleNetGameGetterTest.java new file mode 100644 index 000000000..604a22369 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/BattleNetGameGetterTest.java @@ -0,0 +1,98 @@ +package fr.enimaloc.catapult.getter; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.binding.GameBinding; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; +import fr.enimaloc.catapult.service.battlenet.BattleNetSessionManager; +import fr.enimaloc.catapult.simulation.SimulatedCalls; +import fr.enimaloc.catapult.simulation.SimulationGate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class BattleNetGameGetterTest { + + private final BattleNetSessionManager sessions = mock(BattleNetSessionManager.class); + private final BattleNetFriendLinkRepository links = mock(BattleNetFriendLinkRepository.class); + private final BattleNetGateService gate = mock(BattleNetGateService.class); + private final ObjectProvider simulationGate = mock(ObjectProvider.class); + private final BattleNetGameGetter getter = new BattleNetGameGetter(sessions, links, gate, simulationGate); + + private UserAccount user; + private BattleNetFriendLink link; + + @BeforeEach + void setUp() { + user = new UserAccount(); + user.setId(UUID.randomUUID()); + link = new BattleNetFriendLink(); + link.setUser(user); + link.setBnetAccountId(42L); + link.setBattleTag("Streamer#1234"); + link.setStatus(BattleNetFriendLink.Status.ACCEPTED); + when(gate.isAvailableReadOnly(user)).thenReturn(true); + when(links.findByUser(user)).thenReturn(Optional.of(link)); + when(sessions.currentGame(42L)).thenReturn(Optional.of("WTCG")); + } + + @Test + void acceptedLinkWithGameIsDetected() { + assertThat(getter.getCurrentGame(user)) + .contains(new DetectedGame("WTCG", GameBinding.SourceType.BATTLENET, "Hearthstone")); + } + + @Test + void acceptedLinkWithoutGameIsNothing() { + when(sessions.currentGame(42L)).thenReturn(Optional.empty()); + + assertThat(getter.getCurrentGame(user)).isEmpty(); + } + + @Test + void pendingLinkIsNothing() { + link.setStatus(BattleNetFriendLink.Status.PENDING); + + assertThat(getter.getCurrentGame(user)).isEmpty(); + } + + @Test + void noLinkIsNothing() { + when(links.findByUser(user)).thenReturn(Optional.empty()); + + assertThat(getter.getCurrentGame(user)).isEmpty(); + } + + @Test + void unknownFourccUsesTheFourccAsName() { + when(sessions.currentGame(42L)).thenReturn(Optional.of("ZZZZ")); + + assertThat(getter.getCurrentGame(user)) + .contains(new DetectedGame("ZZZZ", GameBinding.SourceType.BATTLENET, "ZZZZ")); + } + + @Test + void closedGateIsNothing() { + when(gate.isAvailableReadOnly(user)).thenReturn(false); + + assertThat(getter.getCurrentGame(user)).isEmpty(); + } + + @Test + void simulatedPresenceIsNothing() { + SimulationGate active = mock(SimulationGate.class); + when(simulationGate.getIfAvailable()).thenReturn(active); + when(active.real(user.getId(), SimulatedCalls.BATTLENET_PRESENCE)).thenReturn(false); + + assertThat(getter.getCurrentGame(user)).isEmpty(); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterSimulationTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterSimulationTest.java index 00c57cee6..14e57a5c8 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterSimulationTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterSimulationTest.java @@ -45,7 +45,7 @@ class MinecraftPresenceGetterSimulationTest { @BeforeEach void setUp() { bot.setId(UUID.randomUUID()); - bot.setLabel("CatapultBot1"); + bot.setMinecraftUsername("CatapultBot1"); member.setId(UUID.randomUUID()); MinecraftFriendLink link = new MinecraftFriendLink(); link.setUser(member); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterTest.java index 0d909e2d9..e26047187 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/getter/MinecraftPresenceGetterTest.java @@ -53,7 +53,7 @@ void setup() { user = new UserAccount(); bot1 = new MinecraftServiceAccount(); bot1.setId(UUID.randomUUID()); - bot1.setLabel("CatapultBot1"); + bot1.setMinecraftUsername("CatapultBot1"); bot1.setEnabled(true); link = new MinecraftFriendLink(); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/migration/GameMappingIgdbSeedMigrationTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/migration/GameMappingIgdbSeedMigrationTest.java new file mode 100644 index 000000000..e237d6c4e --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/migration/GameMappingIgdbSeedMigrationTest.java @@ -0,0 +1,51 @@ +package fr.enimaloc.catapult.migration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.core.io.ClassPathResource; + +/** Reads V110 as text: the seed needs a database to run, the content it promises does not. */ +class GameMappingIgdbSeedMigrationTest { + + private static String sql() throws IOException { + return new ClassPathResource("db/migration/V110__game_mapping_igdb.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "WoW|18122|World of Warcraft|123", + "WTCG|138585|Hearthstone|1279", + "Pro|515025|Overwatch|125174", + "Fen|515024|Diablo IV|125165", + "D3|313558|Diablo III|120", + "OSI|1788326126|Diablo II: Resurrected|142803", + "S2|490422|StarCraft II|239", + "Hero|32959|Heroes of the Storm|7313", + "W3|12924|Warcraft III|111650"}) + void seedInsertsTheBlizzardTitle(String fourcc, String twitchId, String twitchName, String igdbId) + throws IOException { + assertThat(sql()).contains("('BATTLENET', '" + fourcc + "', '" + twitchId + "', '" + twitchName + "', '" + + igdbId + "')"); + } + + @Test + void seedInsertsTheNineBlizzardTitles() throws IOException { + assertThat(sql().split("\\('BATTLENET'", -1)).hasSize(10); + } + + @Test + void seedNeverOverwritesAnExistingMapping() throws IOException { + assertThat(sql().strip()).endsWith("ON CONFLICT (source_type, source_id) DO NOTHING;"); + } + + @Test + void theColumnIsNullable() throws IOException { + assertThat(sql()).contains("ALTER TABLE game_mapping ADD COLUMN igdb_id TEXT;"); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/perf/RouteBudgetTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/perf/RouteBudgetTest.java index 1a9113fea..03f4c4e30 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/perf/RouteBudgetTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/perf/RouteBudgetTest.java @@ -1,5 +1,6 @@ package fr.enimaloc.catapult.perf; +import fr.enimaloc.catapult.domain.binding.GameBinding; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -196,7 +197,7 @@ void measure() throws Exception { /** IGDB knows the owner's game, the Steam store and the Microsoft Store have a page for it. */ private void stubLookups() throws JacksonException { - when(igdbService.findBySteamAppId(BudgetFixtures.STEAM_APP_ID)) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq(BudgetFixtures.STEAM_APP_ID), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame(BudgetFixtures.IGDB_ID, "Budget game 0"))); SteamStoreService.SteamStorePage page = JsonMapper.builder().build().readValue( "{\"type\":\"game\",\"name\":\"Budget game 0\",\"required_age\":0,\"is_free\":false," diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java new file mode 100644 index 000000000..79a48686a --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java @@ -0,0 +1,88 @@ +package fr.enimaloc.catapult.repository.battlenet; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest(properties = { + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;NON_KEYWORDS=KEY,VALUE", + "logging.level.org.hibernate.tool.schema=ERROR" +}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class BattleNetRepositoriesTest { + + @Autowired BattleNetServiceAccountRepository accounts; + @Autowired BattleNetFriendLinkRepository links; + @Autowired TestEntityManager em; + + private BattleNetServiceAccount account(String name, long bnetAccountId) { + BattleNetServiceAccount a = new BattleNetServiceAccount(); + a.setEmail(name + "@example.test"); + a.setPasswordEnc("enc-password"); + a.setBnetAccountId(bnetAccountId); + a.setBattleTag(name + "#1234"); + return em.persist(a); + } + + private UserAccount user(String login) { + UserAccount user = new UserAccount(); + user.setTwitchUsername(login); + user.setStatus(UserAccount.Status.ACTIVE); + return em.persist(user); + } + + @Test + void savesAndFindsALinkByServiceAccountAndBnetAccountId() { + BattleNetServiceAccount bot = account("bot", 42L); + BattleNetFriendLink link = new BattleNetFriendLink(); + link.setUser(user("streamer")); + link.setServiceAccount(bot); + link.setBnetAccountId(100000002L); + link.setBattleTag("Streamer#2928"); + em.persistAndFlush(link); + em.clear(); + + assertThat(links.findByServiceAccountAndBnetAccountId(bot, 100000002L)) + .singleElement().extracting(BattleNetFriendLink::getStatus).isEqualTo(BattleNetFriendLink.Status.PENDING); + assertThat(links.findByServiceAccountAndBattleTagIgnoreCase(bot, "streamer#2928")).hasSize(1); + assertThat(links.findByServiceAccountAndBnetAccountId(bot, 7L)).isEmpty(); + assertThat(accounts.findByBnetAccountId(42L)).isPresent(); + } + + @Test + void filtersEnabledAccountsByAuthStateInFillOrder() { + BattleNetServiceAccount second = account("second", 2L); + second.setFillOrder(2); + second.setAuthState(BattleNetServiceAccount.AuthState.READY); + BattleNetServiceAccount first = account("first", 1L); + first.setFillOrder(1); + first.setAuthState(BattleNetServiceAccount.AuthState.READY); + BattleNetServiceAccount fresh = account("fresh", 3L); + fresh.setFriendLimitReached(true); + em.flush(); + + assertThat(accounts.findByEnabledTrueAndAuthStateOrderByFillOrderAsc(BattleNetServiceAccount.AuthState.READY)) + .extracting(BattleNetServiceAccount::getBattleTag).containsExactly("first#1234", "second#1234"); + assertThat(accounts.countByEnabledTrue()).isEqualTo(3); + assertThat(accounts.countByEnabledTrueAndFriendLimitReachedFalse()).isEqualTo(2); + } + + @Test + void accountToStringHidesSecrets() { + BattleNetServiceAccount a = new BattleNetServiceAccount(); + a.setEmail("secret@example.test"); + a.setPasswordEnc("enc-password-blob"); + a.setStorageStateEnc("enc-storage-blob"); + + assertThat(a.toString()).doesNotContain("secret@example.test", "enc-password-blob", "enc-storage-blob"); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceLoginTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceLoginTest.java index 00b784186..98fda720e 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceLoginTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceLoginTest.java @@ -73,7 +73,12 @@ void setUp() { RestClient.Builder builder = RestClient.builder(); twitch = MockRestServiceServer.bindTo(builder).build(); service = new CatapultOAuth2UserService(accounts, tokens, settings, getters, encryption, events, - builder.build(), whitelist, migrations, invites); + builder.build(), whitelist, migrations, invites, + mock(fr.enimaloc.catapult.service.battlenet.BattleNetGateService.class), + new org.springframework.beans.factory.support.StaticListableBeanFactory() + .getBeanProvider(fr.enimaloc.catapult.service.battlenet.BattleNetFriendService.class), + new org.springframework.transaction.support.TransactionTemplate( + mock(org.springframework.transaction.PlatformTransactionManager.class))); ReflectionTestUtils.setField(service, "ownerId", "owner-1"); ReflectionTestUtils.setField(service, "defaultNoGameName", "Just Chatting"); ReflectionTestUtils.setField(service, "defaultNoGameId", "509658"); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceTest.java index 5302ede6a..18abaf3f4 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/security/CatapultOAuth2UserServiceTest.java @@ -11,6 +11,8 @@ import fr.enimaloc.catapult.service.access.InviteService; import fr.enimaloc.catapult.service.access.WhitelistService; import fr.enimaloc.catapult.service.account.AdminMigrationService; +import fr.enimaloc.catapult.service.battlenet.BattleNetFriendService; +import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,7 +23,13 @@ import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.test.util.ReflectionTestUtils; @@ -34,6 +42,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; @SuppressWarnings({"unchecked", "rawtypes"}) @@ -50,6 +59,11 @@ class CatapultOAuth2UserServiceTest { @Mock private WhitelistService whitelistService; @Mock private AdminMigrationService adminMigrationService; @Mock private InviteService inviteService; + @Mock private BattleNetGateService battleNetGate; + @Mock private BattleNetFriendService battleNetFriends; + @Mock private ObjectProvider battleNetFriendsProvider; + @Mock private DefaultOAuth2UserService battleNetUserInfo; + @Mock private PlatformTransactionManager transactionManager; @Mock private RestClient.RequestHeadersUriSpec getSpec; @Mock private RestClient.RequestHeadersSpec headersSpec; @@ -62,7 +76,8 @@ void setUp() { service = new CatapultOAuth2UserService( userAccountRepository, oAuthTokenRepository, userSettingsRepository, getterConfigRepository, tokenEncryptionService, eventPublisher, restClient, whitelistService, - adminMigrationService, inviteService + adminMigrationService, inviteService, battleNetGate, battleNetFriendsProvider, + new TransactionTemplate(transactionManager) ); ReflectionTestUtils.setField(service, "ownerId", ""); ReflectionTestUtils.setField(service, "defaultNoGameName", ""); @@ -249,4 +264,117 @@ void handleSecondaryLink_noAuthenticatedTwitchSession_throws() { org.springframework.security.oauth2.core.OAuth2AuthenticationException.class, () -> ReflectionTestUtils.invokeMethod(service, "handleSecondaryLink", buildRequest(), OAuthToken.Provider.XBOX)); } + + private OAuth2UserRequest battleNetRequest() { + ClientRegistration reg = ClientRegistration.withRegistrationId("battlenet") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientId("client-id") + .redirectUri("http://localhost/callback") + .authorizationUri("https://oauth.battle.net/authorize") + .tokenUri("https://oauth.battle.net/token") + .userInfoUri("https://oauth.battle.net/userinfo") + .userNameAttributeName("sub") + .build(); + OAuth2AccessToken token = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "bnet-token", Instant.now(), Instant.now().plusSeconds(3600)); + return new OAuth2UserRequest(reg, token); + } + + private CatapultOAuth2User signedInStreamer(UserAccount account) { + CatapultOAuth2User principal = new CatapultOAuth2User(null, account, false); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken(principal, null)); + return principal; + } + + private void battleNetUserInfoIs(Map attributes) { + ReflectionTestUtils.setField(service, "delegate", battleNetUserInfo); + when(battleNetUserInfo.loadUser(any())).thenReturn( + new DefaultOAuth2User(List.of(), attributes, "sub")); + } + + @Test + void battlenetLinkEnrollsWithUserInfoIdAndTag() { + UserAccount account = new UserAccount(); + account.setId(UUID.randomUUID()); + CatapultOAuth2User principal = signedInStreamer(account); + when(battleNetFriendsProvider.getIfAvailable()).thenReturn(battleNetFriends); + when(battleNetGate.isAvailableFor(account)).thenReturn(true); + battleNetUserInfoIs(Map.of("sub", "1234567890", "id", 1234567890L, "battletag", "Name#1234")); + + var user = service.loadUser(battleNetRequest()); + + verify(battleNetFriends).enroll(account, 1234567890L, "Name#1234"); + assertThat(user).isSameAs(principal); + } + + @Test + void battlenetLinkFallsBackToTheSubWhenTheIdIsMissing() { + UserAccount account = new UserAccount(); + account.setId(UUID.randomUUID()); + signedInStreamer(account); + when(battleNetFriendsProvider.getIfAvailable()).thenReturn(battleNetFriends); + when(battleNetGate.isAvailableFor(account)).thenReturn(true); + battleNetUserInfoIs(Map.of("sub", "42", "battletag", "Name#1234")); + + service.loadUser(battleNetRequest()); + + verify(battleNetFriends).enroll(account, 42L, "Name#1234"); + } + + @Test + void battlenetLinkWithoutTwitchSessionIsUnauthorized() { + assertThatThrownBy(() -> service.loadUser(battleNetRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(e -> ((OAuth2AuthenticationException) e).getError().getErrorCode()) + .isEqualTo("unauthorized"); + verifyNoInteractions(battleNetFriends); + } + + @Test + void battlenetLinkWithClosedGateFails() { + UserAccount account = new UserAccount(); + account.setId(UUID.randomUUID()); + signedInStreamer(account); + when(battleNetFriendsProvider.getIfAvailable()).thenReturn(battleNetFriends); + when(battleNetGate.isAvailableFor(account)).thenReturn(false); + + assertThatThrownBy(() -> service.loadUser(battleNetRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(e -> ((OAuth2AuthenticationException) e).getError().getErrorCode()) + .isEqualTo("battlenet_unavailable"); + verifyNoInteractions(battleNetFriends); + } + + @Test + void battlenetLinkWithNoRoomFails() { + UserAccount account = new UserAccount(); + account.setId(UUID.randomUUID()); + signedInStreamer(account); + when(battleNetFriendsProvider.getIfAvailable()).thenReturn(battleNetFriends); + when(battleNetGate.isAvailableFor(account)).thenReturn(true); + battleNetUserInfoIs(Map.of("sub", "1", "id", 1L, "battletag", "Name#1234")); + when(battleNetFriends.enroll(account, 1L, "Name#1234")).thenThrow( + new BattleNetFriendService.BattleNetEnrollmentException( + BattleNetFriendService.BattleNetEnrollmentException.Reason.NO_CAPACITY, "full")); + + assertThatThrownBy(() -> service.loadUser(battleNetRequest())) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(e -> ((OAuth2AuthenticationException) e).getError().getErrorCode()) + .isEqualTo("battlenet_full"); + } + + @Test + void battlenetTokenIsNotSaved() { + UserAccount account = new UserAccount(); + account.setId(UUID.randomUUID()); + signedInStreamer(account); + when(battleNetFriendsProvider.getIfAvailable()).thenReturn(battleNetFriends); + when(battleNetGate.isAvailableFor(account)).thenReturn(true); + battleNetUserInfoIs(Map.of("sub", "1", "id", 1L, "battletag", "Name#1234")); + + service.loadUser(battleNetRequest()); + + verifyNoInteractions(oAuthTokenRepository); + verifyNoInteractions(transactionManager); + } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandlerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandlerTest.java index 585ea2c1f..224af6dcb 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandlerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/security/TwitchLoginSuccessHandlerTest.java @@ -218,6 +218,21 @@ void xboxToken_withWebUrlConfigured_redirectsToChannelSettings() throws Exceptio assertThat(response.getRedirectedUrl()).isEqualTo("https://web.example.com/channel/jeb_#integrations"); } + @Test + void battleNetLink_forgetsTheBlizzardTokenAndRedirectsToTheChannel() throws Exception { + ReflectionTestUtils.setField(handler, "webUrl", "https://web.example.com"); + userAccount.setTwitchUsername("jeb_"); + var authToken = mock(OAuth2AuthenticationToken.class); + when(authToken.getAuthorizedClientRegistrationId()).thenReturn("battlenet"); + when(authToken.getPrincipal()).thenReturn(catapultUser); + + handler.onAuthenticationSuccess(request, response, authToken); + + verify(authorizedClientRepository).removeAuthorizedClient("battlenet", authToken, request, response); + verify(oAuthTokenRepository, never()).save(any()); + assertThat(response.getRedirectedUrl()).isEqualTo("https://web.example.com/channel/jeb_#integrations"); + } + @Test void twitchToken_withWebUrl_sendsCatapultWebsStateBackAndForgetsIt() throws Exception { ReflectionTestUtils.setField(handler, "webUrl", "https://web.example.com"); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/security/access/AccessMatrixSupport.java b/catapult-api/src/test/java/fr/enimaloc/catapult/security/access/AccessMatrixSupport.java index 4a50ed259..ce607036d 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/security/access/AccessMatrixSupport.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/security/access/AccessMatrixSupport.java @@ -121,7 +121,11 @@ "xbox.client-secret=full-context-test", "minecraft.enabled=true", "minecraft.msa-client-id=full-context-test", - "dtdd.enabled=true" + "dtdd.enabled=true", + "battlenet.enabled=true", + "battlenet.client-id=full-context-test", + "battlenet.client-secret=full-context-test", + "battlenet.sessions.enabled=false" }) @Import(FullContextContainers.class) @AutoConfigureMockMvc diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendServiceTest.java new file mode 100644 index 000000000..76099acec --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendServiceTest.java @@ -0,0 +1,573 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.bgs.client.BgsClient; +import fr.enimaloc.catapult.bgs.client.FriendEvent; +import fr.enimaloc.catapult.bgs.core.BgsRpcException; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink.Status; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.domain.binding.GetterConfig; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.repository.binding.GetterConfigRepository; +import fr.enimaloc.catapult.service.battlenet.BattleNetFriendService.BattleNetEnrollmentException; +import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.transaction.TransactionException; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.support.TransactionCallback; +import org.springframework.transaction.support.TransactionOperations; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BattleNetFriendServiceTest { + + private static final long STREAMER_ID = 100000002L; + private static final String STREAMER_TAG = "Streamer#2928"; + + @Mock private BattleNetServiceAccountRepository accountRepository; + @Mock private BattleNetFriendLinkRepository linkRepository; + @Mock private GetterConfigRepository getterConfigRepository; + @Mock private ChannelEventPublisher publisher; + @Mock private BattleNetSessionManager sessionManager; + @Mock private BgsClient client; + + private BattleNetFriendService service; + private UserAccount user; + private BattleNetServiceAccount bot1; + private BattleNetServiceAccount bot2; + /** The links in "the database", queried by the repository stubs. */ + private final List links = new ArrayList<>(); + private final RecordingTransactions transactions = new RecordingTransactions(); + /** BGS actions run while a transaction was open: must stay empty. */ + private final List callsInsideTransaction = new ArrayList<>(); + /** The bot each BGS action ran on, in order. */ + private final List callsOn = new ArrayList<>(); + + /** Runs callbacks directly and records whether one is running. */ + private static final class RecordingTransactions implements TransactionOperations { + boolean inside; + int count; + + @Override + public T execute(TransactionCallback action) throws TransactionException { + inside = true; + count++; + try { + return action.doInTransaction(new SimpleTransactionStatus()); + } finally { + inside = false; + } + } + } + + private CompletableFuture recorded(String what, CompletableFuture result) { + if (transactions.inside) callsInsideTransaction.add(what); + return result; + } + + @AfterEach + void noBgsCallInsideATransaction() { + assertThat(callsInsideTransaction).isEmpty(); + } + + @BeforeEach + void setUp() { + service = new BattleNetFriendService(accountRepository, linkRepository, getterConfigRepository, publisher, + sessionManager, transactions, Runnable::run); + user = new UserAccount(); + user.setId(UUID.randomUUID()); + bot1 = bot("bot1", 1, "CatapultBot#1111"); + bot2 = bot("bot2", 2, "CatapultBot#2222"); + + when(accountRepository.findByEnabledTrueAndAuthStateOrderByFillOrderAsc(AuthState.READY)) + .thenReturn(List.of(bot1, bot2)); + when(accountRepository.findById(bot1.getId())).thenReturn(Optional.of(bot1)); + when(accountRepository.findById(bot2.getId())).thenReturn(Optional.of(bot2)); + when(linkRepository.save(any())).thenAnswer(inv -> { + BattleNetFriendLink link = inv.getArgument(0); + if (link.getId() == null) link.setId(UUID.randomUUID()); + if (!links.contains(link)) links.add(link); + return link; + }); + when(linkRepository.findWithServiceAccountByUser(any())).thenAnswer(inv -> links.stream() + .filter(link -> link.getUser() == inv.getArgument(0)).findFirst()); + when(linkRepository.findById(any())).thenAnswer(inv -> links.stream() + .filter(link -> inv.getArgument(0).equals(link.getId())).findFirst()); + doAnswer(inv -> links.remove((BattleNetFriendLink) inv.getArgument(0))) + .when(linkRepository).delete(any()); + when(linkRepository.findByServiceAccountAndBnetAccountId(any(), anyLong())).thenAnswer(inv -> links.stream() + .filter(link -> link.getServiceAccount() == inv.getArgument(0) + && link.getBnetAccountId() == (long) inv.getArgument(1)).toList()); + when(linkRepository.findByServiceAccountAndBattleTagIgnoreCase(any(), anyString())).thenAnswer(inv -> + links.stream().filter(link -> link.getServiceAccount() == inv.getArgument(0) + && link.getBattleTag().equalsIgnoreCase(inv.getArgument(1))).toList()); + when(linkRepository.findByServiceAccountAndInvitationId(any(), anyLong())).thenAnswer(inv -> links.stream() + .filter(link -> link.getServiceAccount() == inv.getArgument(0) + && Long.valueOf((long) inv.getArgument(1)).equals(link.getInvitationId())).toList()); + when(getterConfigRepository.findByUserAndProvider(any(), any())).thenReturn(Optional.empty()); + when(getterConfigRepository.findByUserOrderByPriorityAsc(any())).thenReturn(List.of()); + + when(sessionManager.withClient(any(), any())).thenAnswer(inv -> { + Function> action = inv.getArgument(1); + callsOn.add(inv.getArgument(0)); + return recorded("withClient", action.apply(client)); + }); + when(client.sendInvitation(anyString())).thenReturn(CompletableFuture.completedFuture(null)); + when(client.acceptInvitation(anyLong())).thenReturn(CompletableFuture.completedFuture(null)); + when(client.declineInvitation(anyLong())).thenReturn(CompletableFuture.completedFuture(null)); + when(client.revokeInvitation(anyLong())).thenReturn(CompletableFuture.completedFuture(null)); + when(client.removeFriend(anyLong())).thenReturn(CompletableFuture.completedFuture(null)); + } + + private static BattleNetServiceAccount bot(String email, int fillOrder, String battleTag) { + BattleNetServiceAccount bot = new BattleNetServiceAccount(); + bot.setId(UUID.randomUUID()); + bot.setEmail(email); + bot.setFillOrder(fillOrder); + bot.setBattleTag(battleTag); + bot.setAuthState(AuthState.READY); + return bot; + } + + private BattleNetFriendLink link(BattleNetServiceAccount bot, Status status, Long invitationId) { + BattleNetFriendLink link = new BattleNetFriendLink(); + link.setId(UUID.randomUUID()); + link.setUser(user); + link.setServiceAccount(bot); + link.setBnetAccountId(STREAMER_ID); + link.setBattleTag(STREAMER_TAG); + link.setStatus(status); + link.setInvitationId(invitationId); + link.setRequestedAt(Instant.now()); + links.add(link); + return link; + } + + private void receive(BattleNetServiceAccount bot, FriendEvent event) { + service.onFriendEvent(new BattleNetFriendEventReceived(bot.getId(), event)); + } + + // --- enroll --- + + @Test + void enrollPicksFirstReadyBotByFillOrderAndInvites() { + bot1.setFriendLimitReached(true); + + BattleNetFriendLink link = service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(link.getServiceAccount()).isSameAs(bot2); + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + assertThat(link.getBnetAccountId()).isEqualTo(STREAMER_ID); + assertThat(links).containsExactly(link); + verify(sessionManager).withClient(eq(bot2.getId()), any()); + verify(client).sendInvitation(STREAMER_TAG); + verify(publisher).battleNetLinkChanged(user.getId(), "PENDING", STREAMER_TAG, "CatapultBot#2222"); + } + + @Test + void enrollReplacesTheExistingLink() { + BattleNetFriendLink old = link(bot2, Status.ACCEPTED, null); + + BattleNetFriendLink link = service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(links).containsExactly(link).doesNotContain(old); + verify(client).removeFriend(STREAMER_ID); + verify(publisher).battleNetLinkChanged(user.getId(), "NONE", null, null); + } + + @Test + void enrollKeepsTheLinkWhenTheInvitationFails() { + when(client.sendInvitation(anyString())).thenReturn(CompletableFuture.failedFuture(new BgsRpcException(7))); + + BattleNetFriendLink link = service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(links).containsExactly(link); + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + assertThat(bot1.isFriendLimitReached()).isFalse(); + } + + @Test + void enrollKeepsTheLinkWithoutAReadySession() { + doReturn(CompletableFuture.failedFuture(new IllegalStateException("No ready BGS session"))) + .when(sessionManager).withClient(any(), any()); + + BattleNetFriendLink link = service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(links).containsExactly(link); + } + + @Test + void fullBotIsMarkedAndTheLinkMovesToTheNextBotWhichInvites() { + when(client.sendInvitation(anyString())) + .thenReturn(CompletableFuture.failedFuture( + new BgsRpcException(BattleNetFriendService.STATUS_INVITER_AT_MAX_FRIENDS))) + .thenReturn(CompletableFuture.completedFuture(null)); + + BattleNetFriendLink link = service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(bot1.isFriendLimitReached()).isTrue(); + verify(accountRepository).save(bot1); + assertThat(links).containsExactly(link); + assertThat(link.getServiceAccount()).isSameAs(bot2); + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + assertThat(callsOn).containsExactly(bot1.getId(), bot2.getId()); + verify(client, times(2)).sendInvitation(STREAMER_TAG); + var order = inOrder(publisher); + order.verify(publisher).battleNetLinkChanged(user.getId(), "PENDING", STREAMER_TAG, "CatapultBot#1111"); + order.verify(publisher).battleNetLinkChanged(user.getId(), "PENDING", STREAMER_TAG, "CatapultBot#2222"); + } + + @Test + void anyFailureAtTheFriendCapMovesTheLink() { + when(client.sendInvitation(anyString())) + .thenReturn(CompletableFuture.failedFuture(new BgsRpcException(7))) + .thenReturn(CompletableFuture.completedFuture(null)); + when(linkRepository.countByServiceAccountAndStatus(bot1, Status.ACCEPTED)) + .thenReturn((long) BattleNetFriendService.FRIEND_CAP); + + BattleNetFriendLink link = service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(bot1.isFriendLimitReached()).isTrue(); + assertThat(link.getServiceAccount()).isSameAs(bot2); + } + + @Test + void fullBotWithNoOtherBotDeletesTheLink() { + bot2.setFriendLimitReached(true); + when(client.sendInvitation(anyString())).thenReturn(CompletableFuture.failedFuture( + new BgsRpcException(BattleNetFriendService.STATUS_INVITER_AT_MAX_FRIENDS))); + + service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(bot1.isFriendLimitReached()).isTrue(); + assertThat(links).isEmpty(); + assertThat(callsOn).containsExactly(bot1.getId()); + var order = inOrder(publisher); + order.verify(publisher).battleNetLinkChanged(user.getId(), "PENDING", STREAMER_TAG, "CatapultBot#1111"); + order.verify(publisher).battleNetLinkChanged(eq(user.getId()), eq("NONE"), isNull(), isNull()); + } + + @Test + void enrollWithoutReadyBotThrowsNoAccountAvailable() { + when(accountRepository.findByEnabledTrueAndAuthStateOrderByFillOrderAsc(AuthState.READY)) + .thenReturn(List.of()); + BattleNetFriendLink existing = link(bot1, Status.ACCEPTED, null); + + assertThatThrownBy(() -> service.enroll(user, STREAMER_ID, STREAMER_TAG)) + .isInstanceOf(BattleNetEnrollmentException.class) + .extracting(e -> ((BattleNetEnrollmentException) e).getReason()) + .isEqualTo(BattleNetEnrollmentException.Reason.NO_ACCOUNT_AVAILABLE); + assertThat(links).containsExactly(existing); + } + + @Test + void enrollWithEveryBotFullThrowsNoCapacity() { + bot1.setFriendLimitReached(true); + bot2.setFriendLimitReached(true); + + assertThatThrownBy(() -> service.enroll(user, STREAMER_ID, STREAMER_TAG)) + .isInstanceOf(BattleNetEnrollmentException.class) + .extracting(e -> ((BattleNetEnrollmentException) e).getReason()) + .isEqualTo(BattleNetEnrollmentException.Reason.NO_CAPACITY); + verify(client, never()).sendInvitation(anyString()); + } + + // --- received invitations --- + + @Test + void expectedInvitationIsAccepted() { + link(bot1, Status.PENDING, null); + + receive(bot1, new FriendEvent.InvitationReceived(77L, STREAMER_ID, STREAMER_TAG)); + + verify(client).acceptInvitation(77L); + verify(client, never()).declineInvitation(anyLong()); + } + + @Test + void invitationFromAStrangerIsDeclined() { + link(bot1, Status.PENDING, null); + + receive(bot1, new FriendEvent.InvitationReceived(78L, 999L, "Stranger#1")); + + verify(client).declineInvitation(78L); + verify(client, never()).acceptInvitation(anyLong()); + } + + @Test + void invitationForAnotherBotIsDeclined() { + link(bot2, Status.PENDING, null); + + receive(bot1, new FriendEvent.InvitationReceived(79L, STREAMER_ID, STREAMER_TAG)); + + verify(sessionManager).withClient(eq(bot1.getId()), any()); + verify(client).declineInvitation(79L); + verify(client, never()).acceptInvitation(anyLong()); + } + + @Test + void receivedInvitationRemovalIsIgnored() { + BattleNetFriendLink link = link(bot1, Status.PENDING, null); + + receive(bot1, new FriendEvent.InvitationRemoved(77L, STREAMER_ID, STREAMER_TAG, 3)); + + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + verify(publisher, never()).battleNetLinkChanged(any(), any(), any(), any()); + } + + // --- sent invitations and friends --- + + @Test + void invitationSentIsMatchedByBattleTagIgnoringCase() { + BattleNetFriendLink link = link(bot1, Status.PENDING, null); + + receive(bot1, new FriendEvent.InvitationSent(55L, "streamer#2928")); + + assertThat(link.getInvitationId()).isEqualTo(55L); + } + + @Test + void invitationSentPrefersThePendingLinkAmongSeveralMatches() { + BattleNetFriendLink rejected = link(bot1, Status.INVITE_REJECTED, 54L); + UserAccount other = new UserAccount(); + other.setId(UUID.randomUUID()); + BattleNetFriendLink pending = link(bot1, Status.PENDING, null); + pending.setUser(other); + + receive(bot1, new FriendEvent.InvitationSent(55L, "STREAMER#2928")); + + assertThat(pending.getInvitationId()).isEqualTo(55L); + assertThat(rejected.getInvitationId()).isEqualTo(54L); + } + + @Test + void friendAddedMarksAcceptedAndEnablesTheGetter() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 55L); + + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + + assertThat(link.getStatus()).isEqualTo(Status.ACCEPTED); + assertThat(link.getAcceptedAt()).isNotNull(); + ArgumentCaptor config = ArgumentCaptor.forClass(GetterConfig.class); + verify(getterConfigRepository).save(config.capture()); + assertThat(config.getValue().getProvider()).isEqualTo(GetterConfig.Provider.BATTLENET); + assertThat(config.getValue().isEnabled()).isTrue(); + assertThat(config.getValue().getUser()).isSameAs(user); + verify(publisher).battleNetLinkChanged(user.getId(), "ACCEPTED", STREAMER_TAG, "CatapultBot#1111"); + } + + @Test + void friendAddedOnAnotherBotIsIgnored() { + BattleNetFriendLink link = link(bot2, Status.PENDING, 55L); + + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + verify(publisher, never()).battleNetLinkChanged(any(), any(), any(), any()); + } + + @Test + void sentInvitationRemovedWithReasonZeroThenFriendAddedIsAccepted() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 55L); + + receive(bot1, new FriendEvent.InvitationSentRemoved(55L, STREAMER_TAG, 0)); + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + + assertThat(link.getStatus()).isEqualTo(Status.ACCEPTED); + verify(publisher, never()).battleNetLinkChanged(any(), eq("INVITE_REJECTED"), any(), any()); + } + + @Test + void sentInvitationRemovedWithNonZeroReasonIsRejected() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 55L); + + receive(bot1, new FriendEvent.InvitationSentRemoved(55L, STREAMER_TAG, 3)); + + assertThat(link.getStatus()).isEqualTo(Status.INVITE_REJECTED); + verify(publisher).battleNetLinkChanged(user.getId(), "INVITE_REJECTED", STREAMER_TAG, "CatapultBot#1111"); + } + + @Test + void sentInvitationRemovedOfAnotherInvitationLeavesTheLinkPending() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 56L); + + receive(bot1, new FriendEvent.InvitationSentRemoved(55L, STREAMER_TAG, 2)); + + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + } + + @Test + void rejectedThenStreamerAddsTheBotIsAccepted() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 55L); + + receive(bot1, new FriendEvent.InvitationSentRemoved(55L, STREAMER_TAG, 3)); + assertThat(link.getStatus()).isEqualTo(Status.INVITE_REJECTED); + receive(bot1, new FriendEvent.InvitationReceived(90L, STREAMER_ID, STREAMER_TAG)); + verify(client).acceptInvitation(90L); + verify(client, never()).declineInvitation(anyLong()); + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + + assertThat(link.getStatus()).isEqualTo(Status.ACCEPTED); + } + + @Test + void noBgsCallRunsInsideATransaction() { + // every client action records whether a transaction was open (checked after each test too) + service.enroll(user, STREAMER_ID, STREAMER_TAG); + receive(bot1, new FriendEvent.InvitationReceived(90L, STREAMER_ID, STREAMER_TAG)); + receive(bot1, new FriendEvent.InvitationReceived(91L, 999L, "Stranger#1")); + receive(bot1, new FriendEvent.InvitationSent(55L, STREAMER_TAG)); + service.unenroll(user); + link(bot1, Status.ACCEPTED, null); + service.unenroll(user); + + verify(client).sendInvitation(STREAMER_TAG); + verify(client).acceptInvitation(90L); + verify(client).declineInvitation(91L); + verify(client).revokeInvitation(55L); + verify(client).removeFriend(STREAMER_ID); + assertThat(transactions.count).isPositive(); + assertThat(callsInsideTransaction).isEmpty(); + } + + @Test + void goneWhileOfflineThenFriendAddedIsAccepted() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 55L); + + receive(bot1, new FriendEvent.InvitationSentRemoved(55L, STREAMER_TAG, + FriendEvent.REASON_GONE_WHILE_OFFLINE)); + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + + assertThat(link.getStatus()).isEqualTo(Status.ACCEPTED); + } + + @Test + void friendAddedThenGoneWhileOfflineStaysAccepted() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 55L); + + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + receive(bot1, new FriendEvent.InvitationSentRemoved(55L, STREAMER_TAG, + FriendEvent.REASON_GONE_WHILE_OFFLINE)); + + assertThat(link.getStatus()).isEqualTo(Status.ACCEPTED); + verify(publisher, never()).battleNetLinkChanged(any(), eq("INVITE_REJECTED"), any(), any()); + } + + @Test + void friendRemovedMarksRemoved() { + BattleNetFriendLink link = link(bot1, Status.ACCEPTED, 55L); + + receive(bot1, new FriendEvent.FriendRemoved(STREAMER_ID, STREAMER_TAG)); + + assertThat(link.getStatus()).isEqualTo(Status.REMOVED); + verify(publisher).battleNetLinkChanged(user.getId(), "REMOVED", STREAMER_TAG, "CatapultBot#1111"); + } + + @Test + void eventOfAnUnknownBotIsIgnored() { + BattleNetFriendLink link = link(bot1, Status.PENDING, 55L); + + service.onFriendEvent(new BattleNetFriendEventReceived(UUID.randomUUID(), + new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG))); + + assertThat(link.getStatus()).isEqualTo(Status.PENDING); + verify(publisher, never()).battleNetLinkChanged(any(), any(), any(), any()); + } + + @Test + void eventOfAnUnknownFriendIsIgnored() { + receive(bot1, new FriendEvent.FriendAdded(999L, "Stranger#1")); + receive(bot1, new FriendEvent.InvitationSent(12L, "Stranger#1")); + + verify(linkRepository, never()).save(any()); + verify(publisher, never()).battleNetLinkChanged(any(), any(), any(), any()); + } + + // --- unenroll --- + + @Test + void unenrollRevokesPendingAndRemovesAccepted() { + link(bot1, Status.PENDING, 55L); + service.unenroll(user); + verify(client).revokeInvitation(55L); + assertThat(links).isEmpty(); + + link(bot1, Status.ACCEPTED, 55L); + service.unenroll(user); + verify(client).removeFriend(STREAMER_ID); + assertThat(links).isEmpty(); + } + + @Test + void unenrollDeletesTheLinkWhenTheCallFails() { + link(bot1, Status.ACCEPTED, null); + when(client.removeFriend(anyLong())).thenReturn(CompletableFuture.failedFuture(new BgsRpcException(7))); + + service.unenroll(user); + + assertThat(links).isEmpty(); + verify(publisher).battleNetLinkChanged(user.getId(), "NONE", null, null); + } + + @Test + void unenrollWithoutLinkDoesNothing() { + service.unenroll(user); + + verify(sessionManager, never()).withClient(any(), any()); + verify(publisher, never()).battleNetLinkChanged(any(), any(), any(), any()); + } + + @Test + void everyStatusChangePublishesBattleNetLinkChanged() { + service.enroll(user, STREAMER_ID, STREAMER_TAG); + receive(bot1, new FriendEvent.InvitationSent(55L, STREAMER_TAG)); + receive(bot1, new FriendEvent.InvitationSentRemoved(55L, STREAMER_TAG, 3)); + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + receive(bot1, new FriendEvent.FriendRemoved(STREAMER_ID, STREAMER_TAG)); + service.unenroll(user); + + var inOrder = inOrder(publisher); + inOrder.verify(publisher).battleNetLinkChanged(user.getId(), "PENDING", STREAMER_TAG, "CatapultBot#1111"); + inOrder.verify(publisher).battleNetLinkChanged(user.getId(), "INVITE_REJECTED", STREAMER_TAG, + "CatapultBot#1111"); + inOrder.verify(publisher).battleNetLinkChanged(user.getId(), "ACCEPTED", STREAMER_TAG, "CatapultBot#1111"); + inOrder.verify(publisher).battleNetLinkChanged(user.getId(), "REMOVED", STREAMER_TAG, "CatapultBot#1111"); + inOrder.verify(publisher).battleNetLinkChanged(eq(user.getId()), eq("NONE"), isNull(), isNull()); + inOrder.verifyNoMoreInteractions(); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateServiceTest.java new file mode 100644 index 000000000..2ae048166 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateServiceTest.java @@ -0,0 +1,91 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.service.experiment.ExperimentService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BattleNetGateServiceTest { + + @Mock private ExperimentService experimentService; + @Mock private UserAccountRepository userAccountRepository; + + private final UserAccount user = new UserAccount(); + + private BattleNetGateService gate(boolean enabled, String clientId, String secret, boolean inExperiment) { + when(experimentService.evaluateGate(any(), eq(BattleNetGateService.EXPERIMENT_KEY))).thenReturn(inExperiment); + when(experimentService.isRolledOut(any(), eq(BattleNetGateService.EXPERIMENT_KEY))).thenReturn(inExperiment); + return new BattleNetGateService(experimentService, userAccountRepository, enabled, clientId, secret); + } + + @Test + void isConfigured_requiresEnabledAndClientId() { + assertThat(gate(true, "client-id", "secret", true).isConfigured()).isTrue(); + assertThat(gate(false, "client-id", "secret", true).isConfigured()).isFalse(); + assertThat(gate(true, "", "secret", true).isConfigured()).isFalse(); + assertThat(gate(true, " ", "secret", true).isConfigured()).isFalse(); + } + + @Test + void isConfigured_requiresClientSecret() { + assertThat(gate(true, "client-id", "", true).isConfigured()).isFalse(); + assertThat(gate(true, "client-id", " ", true).isConfigured()).isFalse(); + } + + @Test + void isAvailableFor_requiresConfigAndExperiment() { + assertThat(gate(true, "client-id", "secret", true).isAvailableFor(user)).isTrue(); + assertThat(gate(true, "client-id", "secret", false).isAvailableFor(user)).isFalse(); + assertThat(gate(false, "client-id", "secret", true).isAvailableFor(user)).isFalse(); + assertThat(gate(true, "", "secret", true).isAvailableFor(user)).isFalse(); + } + + @Test + void isAvailableForAccount_asksTheGateAboutTheAccount() { + java.util.UUID id = java.util.UUID.randomUUID(); + when(userAccountRepository.findById(id)).thenReturn(java.util.Optional.of(user)); + + assertThat(gate(true, "client-id", "secret", true).isAvailableForAccount(id)).isTrue(); + assertThat(gate(true, "client-id", "secret", false).isAvailableForAccount(id)).isFalse(); + } + + @Test + void isAvailableForAccount_unknownAccountIsUnavailable() { + BattleNetGateService gate = gate(true, "client-id", "secret", true); + + assertThat(gate.isAvailableForAccount(java.util.UUID.randomUUID())).isFalse(); + org.mockito.Mockito.verify(experimentService, org.mockito.Mockito.never()).evaluateGate(any(), any()); + } + + @Test + void isAvailableReadOnly_usesIsRolledOut() { + BattleNetGateService gate = gate(true, "client-id", "secret", true); + + assertThat(gate.isAvailableReadOnly(user)).isTrue(); + + org.mockito.Mockito.verify(experimentService).isRolledOut(user, BattleNetGateService.EXPERIMENT_KEY); + org.mockito.Mockito.verify(experimentService, org.mockito.Mockito.never()).evaluateGate(any(), any()); + } + + @Test + void misconfigured_neverTouchesExperimentService() { + BattleNetGateService gate = gate(true, "", "secret", true); + + assertThat(gate.isAvailableFor(user)).isFalse(); + assertThat(gate.isAvailableReadOnly(user)).isFalse(); + + org.mockito.Mockito.verifyNoInteractions(experimentService); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateServiceTest.java new file mode 100644 index 000000000..116a467dd --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateServiceTest.java @@ -0,0 +1,83 @@ +package fr.enimaloc.catapult.service.battlenet; + +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BattleNetLinkStateServiceTest { + + @Mock private BattleNetGateService gate; + @Mock private BattleNetFriendLinkRepository linkRepository; + @Mock private BattleNetServiceAccountRepository accountRepository; + @InjectMocks private BattleNetLinkStateService service; + + private final UserAccount user = new UserAccount(); + + private void open(long ready, long readyWithRoom) { + when(gate.isAvailableFor(user)).thenReturn(true); + when(accountRepository.countByEnabledTrueAndAuthState(AuthState.READY)).thenReturn(ready); + when(accountRepository.countByEnabledTrueAndAuthStateAndFriendLimitReachedFalse(AuthState.READY)) + .thenReturn(readyWithRoom); + when(linkRepository.findWithServiceAccountByUser(user)).thenReturn(Optional.empty()); + } + + @Test + void noLinkAndFreeBotIsNone() { + open(2, 1); + + assertThat(service.state(user)).isEqualTo(new BattleNetLinkStateResponse("NONE", null, null)); + } + + @Test + void noReadyBotIsUnavailable() { + open(0, 0); + + assertThat(service.state(user).status()).isEqualTo("UNAVAILABLE"); + } + + @Test + void allBotsFullIsFull() { + open(2, 0); + + assertThat(service.state(user).status()).isEqualTo("FULL"); + } + + @Test + void pendingLinkExposesTheDesignatedBotTag() { + open(1, 0); + BattleNetServiceAccount bot = new BattleNetServiceAccount(); + bot.setBattleTag("Bot#1234"); + BattleNetFriendLink link = new BattleNetFriendLink(); + link.setServiceAccount(bot); + link.setBattleTag("Streamer#4321"); + when(linkRepository.findWithServiceAccountByUser(user)).thenReturn(Optional.of(link)); + + assertThat(service.state(user)) + .isEqualTo(new BattleNetLinkStateResponse("PENDING", "Streamer#4321", "Bot#1234")); + } + + @Test + void gateClosedIsUnavailable() { + when(gate.isAvailableFor(user)).thenReturn(false); + + assertThat(service.state(user).status()).isEqualTo("UNAVAILABLE"); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdminTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdminTest.java new file mode 100644 index 000000000..76be0e634 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdminTest.java @@ -0,0 +1,326 @@ +package fr.enimaloc.catapult.service.battlenet; + +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetFriendLink; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.domain.binding.GetterConfig; +import fr.enimaloc.catapult.repository.battlenet.BattleNetFriendLinkRepository; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.repository.binding.GetterConfigRepository; +import fr.enimaloc.catapult.security.TokenEncryptionService; +import fr.enimaloc.catapult.service.battlenet.BattleNetServiceAccountAdmin.AccountDto; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginCoordinator; +import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BattleNetServiceAccountAdminTest { + + private final BattleNetServiceAccountRepository accounts = mock(BattleNetServiceAccountRepository.class); + private final BattleNetFriendLinkRepository links = mock(BattleNetFriendLinkRepository.class); + private final TokenEncryptionService encryption = mock(TokenEncryptionService.class); + private final BattleNetLoginCoordinator coordinator = mock(BattleNetLoginCoordinator.class); + private final BattleNetSessionManager sessions = mock(BattleNetSessionManager.class); + private final GetterConfigRepository getterConfigs = mock(GetterConfigRepository.class); + private final ChannelEventPublisher channelEvents = mock(ChannelEventPublisher.class); + private final BattleNetServiceAccountAdmin admin = new BattleNetServiceAccountAdmin(accounts, links, encryption, + coordinator, sessions, getterConfigs, channelEvents); + private final BattleNetServiceAccount account = new BattleNetServiceAccount(); + private CompletableFuture login; + + @BeforeEach + void setUp() { + account.setId(UUID.randomUUID()); + account.setEmail("bot-mailbox@example.com"); + account.setPasswordEnc("enc:old"); + account.setStorageStateEnc("enc:state"); + account.setBattleTag("Bot#1234"); + account.setAuthState(AuthState.READY); + login = new CompletableFuture<>(); + when(accounts.findAll()).thenReturn(List.of(account)); + when(accounts.findById(account.getId())).thenReturn(Optional.of(account)); + when(accounts.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(encryption.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0)); + when(coordinator.startInteractive(any())).thenReturn(login); + when(sessions.sessionState(any())).thenReturn("READY"); + } + + @Test + void createEncryptsPasswordAndStartsLogin() { + when(accounts.save(any())).thenAnswer(inv -> { + BattleNetServiceAccount saved = inv.getArgument(0); + saved.setId(account.getId()); + return saved; + }); + + AccountDto dto = admin.create("mail@example.com", "s3cret"); + + verify(accounts).save(org.mockito.ArgumentMatchers.argThat(saved -> + saved.getPasswordEnc().equals("enc:s3cret") && saved.getEmail().equals("mail@example.com") + && saved.getAuthState() == AuthState.NEW)); + verify(coordinator).startInteractive(account.getId()); + assertThat(dto.authState()).isEqualTo("NEW"); + // no BattleTag before the first login: the masked e-mail stands in + assertThat(dto.displayName()).isEqualTo("m***@example.com"); + assertThat(dto.battleTag()).isNull(); + } + + @Test + void createNeedsAnEmailAndAPassword() { + assertThatThrownBy(() -> admin.create(" ", "s3cret")) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + assertThatThrownBy(() -> admin.create("mail@example.com", null)) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void displayNamePrefersTheBattleTag() { + assertThat(admin.list()).singleElement().satisfies(dto -> assertThat(dto.displayName()).isEqualTo("Bot#1234")); + assertThat(BattleNetServiceAccountAdmin.displayName("Bot#1234", "bot@example.com")).isEqualTo("Bot#1234"); + assertThat(BattleNetServiceAccountAdmin.displayName(null, "bot@example.com")).isEqualTo("b***@example.com"); + assertThat(BattleNetServiceAccountAdmin.displayName(" ", "bot@example.com")).isEqualTo("b***@example.com"); + assertThat(BattleNetServiceAccountAdmin.displayName(null, null)).isNull(); + } + + @Test + void maskEmailKeepsTheFirstCharacterAndTheDomain() { + assertThat(BattleNetServiceAccountAdmin.maskEmail("bot-mailbox@example.com")).isEqualTo("b***@example.com"); + assertThat(BattleNetServiceAccountAdmin.maskEmail("b@example.com")).isEqualTo("b***@example.com"); + assertThat(BattleNetServiceAccountAdmin.maskEmail(null)).isNull(); + assertThat(BattleNetServiceAccountAdmin.maskEmail("")).isNull(); + assertThat(BattleNetServiceAccountAdmin.maskEmail(" ")).isNull(); + } + + @Test + void sessionIsRestartedOnceTheLoginIsReady() { + admin.reauth(account.getId()); + verify(sessions, never()).restart(any()); + + login.complete(null); + + verify(sessions).restart(account.getId()); + } + + @Test + void sessionIsNotRestartedWhenTheLoginFails() { + admin.reauth(account.getId()); + + login.completeExceptionally(new IllegalStateException("rejected")); + + verify(sessions, never()).restart(any()); + } + + @Test + void reauthWhileALoginRunsIsAConflict() { + when(coordinator.startInteractive(any())).thenReturn( + CompletableFuture.failedFuture(new IllegalStateException("A login is already in progress"))); + + assertThatThrownBy(() -> admin.reauth(account.getId())) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void dtoNeverCarriesSecrets() throws Exception { + account.setPasswordEnc("enc:hunter2"); + + String json = new ObjectMapper().writeValueAsString(admin.list()); + + assertThat(json).contains("Bot#1234").contains("\"sessionState\":\"READY\"") + .doesNotContain("email", "password", "storageState", "hunter2", "bot-mailbox", "enc:state"); + } + + @Test + void friendCountIsTheAcceptedLinks() { + when(links.countByServiceAccountAndStatus(account, BattleNetFriendLink.Status.ACCEPTED)).thenReturn(3L); + when(accounts.findAll()).thenReturn(List.of(account)); + + assertThat(admin.list()).singleElement().extracting(AccountDto::friendCount).isEqualTo(3L); + } + + @Test + void waitingForCodeTellsWhetherALoginWaitsForTheCode() { + account.setAuthState(AuthState.CODE_REQUIRED); + + assertThat(admin.list()).singleElement().extracting(AccountDto::waitingForCode).isEqualTo(false); + when(coordinator.waitingForCode(account.getId())).thenReturn(true); + assertThat(admin.list()).singleElement().extracting(AccountDto::waitingForCode).isEqualTo(true); + } + + @Test + void codeIsForwardedToTheCoordinator() { + admin.submitCode(account.getId(), "123456"); + + verify(coordinator).submitCode(account.getId(), "123456"); + } + + @Test + void codeWithNoFlowWaitingIsAConflict() { + org.mockito.Mockito.doThrow(new IllegalStateException("none")).when(coordinator).submitCode(any(), any()); + + assertThatThrownBy(() -> admin.submitCode(account.getId(), "123456")) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void unknownAccountIsNotFound() { + assertThatThrownBy(() -> admin.submitCode(UUID.randomUUID(), "1")) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + void deleteStopsTheSessionFirst() { + BattleNetFriendLink link = new BattleNetFriendLink(); + link.setUser(new UserAccount()); + when(links.findByServiceAccount(account)).thenReturn(List.of(link)); + + admin.delete(account.getId()); + + InOrder order = inOrder(sessions, links, accounts); + order.verify(sessions).stop(account.getId()); + order.verify(links).deleteAll(List.of(link)); + order.verify(accounts).delete(account); + } + + @Test + void deleteUnlinksEveryStreamerOfTheBotAndTellsThemAfterwards() { + UserAccount streamer = new UserAccount(); + streamer.setId(UUID.randomUUID()); + BattleNetFriendLink link = new BattleNetFriendLink(); + link.setUser(streamer); + link.setServiceAccount(account); + GetterConfig getter = new GetterConfig(); + getter.setEnabled(true); + when(links.findByServiceAccount(account)).thenReturn(List.of(link)); + when(getterConfigs.findByUserAndProvider(streamer, GetterConfig.Provider.BATTLENET)) + .thenReturn(Optional.of(getter)); + + admin.delete(account.getId()); + + assertThat(getter.isEnabled()).isFalse(); + InOrder order = inOrder(getterConfigs, accounts, channelEvents); + order.verify(getterConfigs).save(getter); + order.verify(accounts).delete(account); + order.verify(channelEvents).battleNetLinkChanged(streamer.getId(), "NONE", null, null); + } + + @Test + void updatePasswordReEncrypts() { + AccountDto dto = admin.update(account.getId(), Map.of("password", "n3w")); + + assertThat(account.getPasswordEnc()).isEqualTo("enc:n3w"); + assertThat(account.getAuthState()).isEqualTo(AuthState.NEW); + verify(sessions).stop(account.getId()); + verify(coordinator).startInteractive(account.getId()); + assertThat(dto.authState()).isEqualTo("NEW"); + } + + @Test + void updateEditsPlainFieldsWithoutLoggingIn() { + // a legacy label is ignored, like any unknown field + AccountDto dto = admin.update(account.getId(), + Map.of("label", "renamed", "fillOrder", 4, "friendLimitReached", false, "enabled", true)); + + assertThat(account.getFillOrder()).isEqualTo(4); + assertThat(dto.displayName()).isEqualTo("Bot#1234"); + verify(coordinator, never()).startInteractive(any()); + } + + @Test + void updateWithAWrongTypeIsABadRequest() { + assertThatThrownBy(() -> admin.update(account.getId(), Map.of("enabled", "yes"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void updateRetriesOnceWhenALoginCommittedInBetween() { + java.util.concurrent.atomic.AtomicInteger saves = new java.util.concurrent.atomic.AtomicInteger(); + when(accounts.save(any())).thenAnswer(inv -> { + if (saves.incrementAndGet() == 1) throw conflict(); + return inv.getArgument(0); + }); + + AccountDto dto = admin.update(account.getId(), Map.of("fillOrder", 7)); + + assertThat(saves.get()).isEqualTo(2); + assertThat(dto.fillOrder()).isEqualTo(7); + } + + @Test + void updateThatKeepsConflictingIsA409() { + when(accounts.save(any())).thenThrow(conflict()); + + assertThatThrownBy(() -> admin.update(account.getId(), Map.of("fillOrder", 7))) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + } + + @Test + void deleteRetriesOnceOnAConflict() { + org.mockito.Mockito.doThrow(conflict()).doNothing().when(accounts).delete(account); + + admin.delete(account.getId()); + + verify(accounts, org.mockito.Mockito.times(2)).delete(account); + } + + @Test + void passwordChangeWhileALoginRunsIsRefusedWithoutChanges() { + when(coordinator.loginInProgress(account.getId())).thenReturn(true); + + assertThatThrownBy(() -> admin.update(account.getId(), Map.of("password", "n3w"))) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(account.getPasswordEnc()).isEqualTo("enc:old"); + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + verify(accounts, never()).save(any()); + verify(sessions, never()).stop(any(UUID.class)); + } + + @Test + void createWhoseLoginCannotStartStillSucceedsAsAuthFailed() { + when(accounts.save(any())).thenAnswer(inv -> { + BattleNetServiceAccount saved = inv.getArgument(0); + saved.setId(account.getId()); + return saved; + }); + when(coordinator.startInteractive(any())).thenReturn( + CompletableFuture.failedFuture(new IllegalStateException("executor shut down"))); + + AccountDto dto = admin.create("mail@example.com", "s3cret"); + + assertThat(dto.authState()).isEqualTo("AUTH_FAILED"); + verify(accounts, org.mockito.Mockito.times(2)).save(any()); + } + + private static org.springframework.orm.ObjectOptimisticLockingFailureException conflict() { + return new org.springframework.orm.ObjectOptimisticLockingFailureException( + BattleNetServiceAccount.class, "id"); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManagerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManagerTest.java new file mode 100644 index 000000000..394facb28 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManagerTest.java @@ -0,0 +1,291 @@ +package fr.enimaloc.catapult.service.battlenet; + +import bgs.protocol.connection.v1.ConnectionServiceProto.ConnectResponse; +import bgs.protocol.connection.v1.ConnectionServiceProto.DisconnectNotification; +import fr.enimaloc.catapult.bgs.client.FriendEvent; +import fr.enimaloc.catapult.bgs.client.PresenceEvent; +import fr.enimaloc.catapult.bgs.client.TicketException; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginCoordinator; +import fr.enimaloc.catapult.site.SiteRole; +import fr.enimaloc.catapult.site.SiteRoleChangedEvent; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.support.TransactionOperations; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import static fr.enimaloc.catapult.bgs.proto.BgsServices.CONNECTION_CONNECT; +import static fr.enimaloc.catapult.bgs.proto.BgsServices.CONNECTION_FORCE_DISCONNECT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class BattleNetSessionManagerTest { + + private final BattleNetServiceAccountRepository repository = mock(BattleNetServiceAccountRepository.class); + private final BattleNetLoginCoordinator coordinator = mock(BattleNetLoginCoordinator.class); + private final SiteRole siteRole = mock(SiteRole.class); + private final ApplicationEventPublisher events = mock(ApplicationEventPublisher.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final List channels = new ArrayList<>(); + private ScheduledExecutorService scheduler; + private TicketException mintFailure; + + private BattleNetServiceAccount bot1; + private BattleNetServiceAccount bot2; + + @BeforeEach + void setUp() { + scheduler = Executors.newSingleThreadScheduledExecutor(); + bot1 = account("Bot1"); + bot2 = account("Bot2"); + when(siteRole.isActive()).thenReturn(true); + when(coordinator.ticketProviderFor(any())).thenReturn(() -> { + if (mintFailure != null) { + throw mintFailure; + } + return "EU-test"; + }); + when(repository.findByEnabledTrueAndAuthStateOrderByFillOrderAsc(AuthState.READY)) + .thenReturn(List.of(bot1, bot2)); + when(repository.findById(bot1.getId())).thenReturn(Optional.of(bot1)); + when(repository.findById(bot2.getId())).thenReturn(Optional.of(bot2)); + } + + @AfterEach + void tearDown() { + scheduler.shutdownNow(); + } + + private static BattleNetServiceAccount account(String name) { + BattleNetServiceAccount account = new BattleNetServiceAccount(); + account.setId(UUID.randomUUID()); + account.setEmail(name.toLowerCase() + "@example.test"); + account.setAuthState(AuthState.READY); + account.setEnabled(true); + return account; + } + + private BattleNetSessionManager manager(boolean sessionsEnabled) { + return new BattleNetSessionManager(repository, coordinator, siteRole, events, registry, + TransactionOperations.withoutTransaction(), sessionsEnabled, "bgs.test", + () -> { + FakeBgsChannel channel = new FakeBgsChannel(); + channels.add(channel); + return channel; + }, + scheduler, Runnable::run, Runnable::run, Runnable::run); + } + + private BattleNetSessionManager started() { + BattleNetSessionManager manager = manager(true); + manager.start(); + return manager; + } + + private static void connect(FakeBgsChannel channel) { + // server_id is a required field nobody reads: a partial message is enough here + channel.reply(CONNECTION_CONNECT, ConnectResponse.newBuilder().setCiid("ciid").buildPartial()); + } + + @Test + void noSessionWithoutTheSessionsFlag() { + BattleNetSessionManager manager = manager(false); + manager.start(); + manager.onSiteRoleChanged(new SiteRoleChangedEvent(this, true)); + manager.restart(bot1.getId()); + + assertThat(channels).isEmpty(); + assertThat(manager.sessionState(bot1.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + verifyNoInteractions(repository); + } + + @Test + void noSessionOnStandbySite() { + when(siteRole.isActive()).thenReturn(false); + + BattleNetSessionManager manager = started(); + + assertThat(channels).isEmpty(); + assertThat(manager.sessionState(bot1.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + } + + @Test + void standbyClosesAllSessionsAndActiveReopensThem() { + BattleNetSessionManager manager = started(); + assertThat(channels).hasSize(2); + assertThat(manager.sessionState(bot1.getId())).isEqualTo("CONNECTING"); + + when(siteRole.isActive()).thenReturn(false); + manager.onSiteRoleChanged(new SiteRoleChangedEvent(this, false)); + + assertThat(channels).allMatch(c -> c.closed); + assertThat(manager.sessionState(bot1.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + assertThat(manager.sessionState(bot2.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + + when(siteRole.isActive()).thenReturn(true); + manager.onSiteRoleChanged(new SiteRoleChangedEvent(this, true)); + + assertThat(channels).hasSize(4); + assertThat(channels.subList(2, 4)).noneMatch(c -> c.closed); + assertThat(manager.sessionState(bot1.getId())).isEqualTo("CONNECTING"); + assertThat(manager.sessionState(bot2.getId())).isEqualTo("CONNECTING"); + } + + @Test + void lifecycleStopClosesEverySession() { + BattleNetSessionManager manager = started(); + + manager.stop(); + + assertThat(manager.isRunning()).isFalse(); + assertThat(channels).allMatch(c -> c.closed); + assertThat(manager.sessionState(bot1.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + } + + @Test + void onlyEnabledReadyBotsGetASession() { + BattleNetServiceAccount waitingForCode = account("Bot3"); + waitingForCode.setAuthState(AuthState.CODE_REQUIRED); + BattleNetServiceAccount disabled = account("Bot4"); + disabled.setEnabled(false); + when(repository.findById(waitingForCode.getId())).thenReturn(Optional.of(waitingForCode)); + when(repository.findById(disabled.getId())).thenReturn(Optional.of(disabled)); + + BattleNetSessionManager manager = started(); + manager.restart(waitingForCode.getId()); + manager.restart(disabled.getId()); + + verify(repository).findByEnabledTrueAndAuthStateOrderByFillOrderAsc(AuthState.READY); + assertThat(channels).hasSize(2); + assertThat(manager.sessionState(waitingForCode.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + assertThat(manager.sessionState(disabled.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + } + + @Test + void restartReplacesTheSessionAndStopClosesIt() { + BattleNetSessionManager manager = started(); + + manager.restart(bot1.getId()); + assertThat(channels).hasSize(3); + assertThat(channels.getFirst().closed).isTrue(); + assertThat(manager.sessionState(bot1.getId())).isEqualTo("CONNECTING"); + + manager.stop(bot1.getId()); + assertThat(channels.get(2).closed).isTrue(); + assertThat(manager.sessionState(bot1.getId())).isEqualTo(BattleNetSessionManager.DISABLED); + assertThat(manager.sessionState(bot2.getId())).isEqualTo("CONNECTING"); + } + + @Test + void presenceEventUpdatesCurrentGame() { + BattleNetSessionManager manager = started(); + + manager.listenerOf(bot1.getId()).onPresence(new PresenceEvent(42L, "WTCG", 0x57544347, List.of())); + + assertThat(manager.currentGame(42L)).contains("WTCG"); + assertThat(manager.currentGame(43L)).isEmpty(); + } + + @Test + void gameEndClearsCurrentGame() { + BattleNetSessionManager manager = started(); + manager.listenerOf(bot1.getId()).onPresence(new PresenceEvent(42L, "WTCG", 0x57544347, List.of())); + + manager.listenerOf(bot1.getId()).onPresence(new PresenceEvent(42L, null, 0, List.of())); + + assertThat(manager.currentGame(42L)).isEmpty(); + } + + @Test + void closingASessionForgetsTheGamesItReported() { + BattleNetSessionManager manager = started(); + manager.listenerOf(bot1.getId()).onPresence(new PresenceEvent(42L, "WTCG", 0x57544347, List.of())); + manager.listenerOf(bot2.getId()).onPresence(new PresenceEvent(77L, "Pro", 0x50726F, List.of())); + + manager.stop(bot1.getId()); + + assertThat(manager.currentGame(42L)).isEmpty(); + assertThat(manager.currentGame(77L)).contains("Pro"); + } + + @Test + void sessionReplacedIsCountedAndNotRestarted() { + BattleNetSessionManager manager = started(); + + channels.getFirst().push(CONNECTION_FORCE_DISCONNECT, + DisconnectNotification.newBuilder().setErrorCode(60).build()); + + assertThat(manager.sessionState(bot1.getId())).isEqualTo("STOPPED_REPLACED"); + assertThat(registry.counter(BattleNetSessionManager.REPLACED_COUNTER, "account", bot1.getId().toString()).count()) + .isEqualTo(1.0); + assertThat(channels).hasSize(2); // no new channel for Bot1 + // tagged by the account id, never its e-mail + assertThat(registry.find(BattleNetSessionManager.REPLACED_COUNTER).tag("account", bot1.getEmail()).counter()) + .isNull(); + } + + @Test + void codeRequiredLeavesTheAccountInCodeRequired() { + bot1.setAuthState(AuthState.CODE_REQUIRED); + mintFailure = new TicketException("2FA code required", true); + BattleNetSessionManager manager = started(); + + connect(channels.getFirst()); + + assertThat(manager.sessionState(bot1.getId())).isEqualTo("STOPPED_CODE_REQUIRED"); + assertThat(registry.counter(BattleNetSessionManager.ADMIN_ACTION_COUNTER, "account", bot1.getId().toString()).count()) + .isEqualTo(1.0); + assertThat(bot1.getAuthState()).isEqualTo(AuthState.CODE_REQUIRED); + verify(repository, never()).save(any()); + } + + @Test + void onReadyFillsBattleTagAndAccountId() { + BattleNetSessionManager manager = started(); + + manager.listenerOf(bot1.getId()).onReady(555L, "Bot1#1234"); + + assertThat(bot1.getBattleTag()).isEqualTo("Bot1#1234"); + assertThat(bot1.getBnetAccountId()).isEqualTo(555L); + verify(repository).save(bot1); + } + + @Test + void friendEventsAreRepublished() { + BattleNetSessionManager manager = started(); + FriendEvent event = new FriendEvent.FriendAdded(42L, "Streamer#1234"); + + manager.listenerOf(bot1.getId()).onFriend(event); + + verify(events).publishEvent(new BattleNetFriendEventReceived(bot1.getId(), event)); + } + + @Test + void withClientFailsFastWhenNotReady() { + BattleNetSessionManager manager = started(); + + CompletableFuture connecting = manager.withClient(bot1.getId(), client -> client.sendInvitation("A#1")); + CompletableFuture unknown = manager.withClient(UUID.randomUUID(), client -> client.sendInvitation("A#1")); + + assertThat(connecting).isCompletedExceptionally(); + assertThat(unknown).isCompletedExceptionally(); + assertThat(connecting.exceptionNow()).isInstanceOf(IllegalStateException.class); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitlesTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitlesTest.java new file mode 100644 index 000000000..6f8ddbe99 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitlesTest.java @@ -0,0 +1,26 @@ +package fr.enimaloc.catapult.service.battlenet; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class BattleNetTitlesTest { + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "WTCG|Hearthstone", + "Pro|Overwatch 2", + "WoW|World of Warcraft", + "Fen|Diablo IV", + "D3|Diablo III", + "OSI|Diablo II: Resurrected", + "S2|StarCraft II", + "Hero|Heroes of the Storm", + "W3|Warcraft III", + "ZZZZ|ZZZZ" + }) + void displayNameMapsKnownFourccsAndFallsBackToTheFourcc(String fourcc, String name) { + assertThat(BattleNetTitles.displayName(fourcc)).isEqualTo(name); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/FakeBgsChannel.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/FakeBgsChannel.java new file mode 100644 index 000000000..4b38ed0c7 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/FakeBgsChannel.java @@ -0,0 +1,99 @@ +package fr.enimaloc.catapult.service.battlenet; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import fr.enimaloc.catapult.bgs.core.BgsChannel; +import fr.enimaloc.catapult.bgs.proto.ServiceMethod; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * An in-memory {@link BgsChannel}: no socket. Calls stay pending until the test answers them, notifications are + * pushed by the test, and the loop runs every task on the calling thread. + */ +final class FakeBgsChannel implements BgsChannel { + + private final Map>> pending = new HashMap<>(); + private final Map> handlers = new HashMap<>(); + private final List> closeListeners = new ArrayList<>(); + boolean closed; + + @Override + @SuppressWarnings("unchecked") + public CompletableFuture call(ServiceMethod method, Message request) { + CompletableFuture reply = new CompletableFuture<>(); + pending.computeIfAbsent(key(method.serviceHash(), method.methodId()), k -> new ArrayList<>()).add(reply); + return (CompletableFuture) (CompletableFuture) reply; + } + + @Override + public void callNoReply(ServiceMethod method, Message request) { + } + + @Override + public CompletableFuture callRaw(int serviceHash, int methodId, ByteString body, boolean withCiid) { + return new CompletableFuture<>(); + } + + @Override + public void on(ServiceMethod method, Consumer handler) { + handlers.put(key(method.serviceHash(), method.methodId()), body -> { + try { + handler.accept(method.requestParser().parseFrom(body)); + } catch (InvalidProtocolBufferException e) { + throw new IllegalStateException(e); + } + }); + } + + @Override + public void onRaw(int serviceHash, int methodId, Consumer handler) { + handlers.put(key(serviceHash, methodId), handler); + } + + @Override + public void setCiid(String ciid) { + } + + @Override + public void onClose(Consumer listener) { + closeListeners.add(listener); + } + + @Override + public Executor loop() { + return Runnable::run; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + List.copyOf(closeListeners).forEach(l -> l.accept(null)); + } + + /** Answers the oldest pending call of this method. */ + void reply(ServiceMethod method, R response) { + pending.get(key(method.serviceHash(), method.methodId())).removeFirst().complete(response); + } + + void push(ServiceMethod method, Message notification) { + Consumer handler = handlers.get(key(method.serviceHash(), method.methodId())); + if (handler != null) { + handler.accept(notification.toByteString()); + } + } + + private static String key(int serviceHash, int methodId) { + return serviceHash + ":" + methodId; + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinatorTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinatorTest.java new file mode 100644 index 000000000..701d7d348 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinatorTest.java @@ -0,0 +1,391 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import fr.enimaloc.catapult.bgs.client.TicketException; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount; +import fr.enimaloc.catapult.domain.battlenet.BattleNetServiceAccount.AuthState; +import fr.enimaloc.catapult.repository.battlenet.BattleNetServiceAccountRepository; +import fr.enimaloc.catapult.security.TokenEncryptionService; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginBrowser.Outcome; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.transaction.support.TransactionOperations; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BattleNetLoginCoordinatorTest { + + private static final String EMAIL = "bot-mailbox@example.com"; + private static final String PASSWORD = "hunter2-password"; + private static final String TICKET = "EU-0123456789abcdef-ticket"; + private static final String OLD_STATE = "{\"cookies\":[\"old\"]}"; + + private final TokenEncryptionService encryption = new TokenEncryptionService("0123456789abcdef0123456789abcdef"); + private final BattleNetServiceAccountRepository repository = mock(BattleNetServiceAccountRepository.class); + @SuppressWarnings("unchecked") + private final ObjectProvider browserProvider = mock(ObjectProvider.class); + private final FakeLoginBrowser browser = new FakeLoginBrowser(); + private final BattleNetServiceAccount account = new BattleNetServiceAccount(); + private final Logger logger = (Logger) LoggerFactory.getLogger(BattleNetLoginCoordinator.class.getPackageName()); + private final ListAppender logs = new ListAppender<>(); + private BattleNetLoginCoordinator coordinator; + /** The account whose admin login keeps the login thread busy, in the re-mint queueing tests. */ + private BattleNetServiceAccount other; + + @BeforeEach + void setUp() { + account.setId(UUID.randomUUID()); + account.setEmail(EMAIL); + account.setPasswordEnc(encryption.encrypt(PASSWORD)); + account.setStorageStateEnc(encryption.encrypt(OLD_STATE)); + when(repository.findById(account.getId())).thenReturn(Optional.of(account)); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(browserProvider.getIfAvailable()).thenReturn(browser); + coordinator = coordinator(Duration.ofMinutes(10)); + logs.start(); + logger.addAppender(logs); + } + + @AfterEach + void tearDown() { + logger.detachAppender(logs); + coordinator.shutdown(); + } + + private BattleNetLoginCoordinator coordinator(Duration codeTimeout) { + return new BattleNetLoginCoordinator(repository, encryption, browserProvider, + TransactionOperations.withoutTransaction(), Duration.ofSeconds(60), codeTimeout, 3); + } + + @Test + void ticketOnFirstTrySavesEncryptedStorageStateAndMarksReady() throws Exception { + browser.script(new Outcome.Ticket(TICKET)); + + coordinator.startInteractive(account.getId()).get(5, TimeUnit.SECONDS); + + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + assertThat(account.getStorageStateEnc()).doesNotContain("fresh"); + assertThat(encryption.decrypt(account.getStorageStateEnc())).isEqualTo(browser.storageStateAfterLogin); + assertThat(browser.lastPage().openedWith).isEqualTo(OLD_STATE); + assertThat(browser.lastPage().credentials).containsExactly(EMAIL, PASSWORD); + assertThat(browser.lastPage().closed).isTrue(); + } + + @Test + void anAdminEditRacingTheLoginIsRetriedOnceOnAFreshCopy() throws Exception { + browser.script(new Outcome.Ticket(TICKET)); + java.util.concurrent.atomic.AtomicInteger saves = new java.util.concurrent.atomic.AtomicInteger(); + when(repository.save(any())).thenAnswer(inv -> { + if (saves.incrementAndGet() == 1) { + throw new org.springframework.orm.ObjectOptimisticLockingFailureException( + BattleNetServiceAccount.class, account.getId()); + } + return inv.getArgument(0); + }); + + coordinator.startInteractive(account.getId()).get(5, TimeUnit.SECONDS); + + assertThat(saves.get()).isEqualTo(2); + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + } + + @Test + void automaticReMintReturnsTheTicket() throws Exception { + browser.script(new Outcome.Ticket(TICKET)); + + assertThat(coordinator.ticketProviderFor(account.getId()).mint()).isEqualTo(TICKET); + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + assertThat(browser.lastPage().closed).isTrue(); + } + + @Test + void codeRequiredInteractiveWaitsThenResumesWithTheCode() throws Exception { + browser.script(new Outcome.CodeRequired(), new Outcome.Ticket(TICKET)); + + CompletableFuture login = coordinator.startInteractive(account.getId()); + awaitWaiting(); + assertThat(account.getAuthState()).isEqualTo(AuthState.CODE_REQUIRED); + coordinator.submitCode(account.getId(), "123456"); + login.get(5, TimeUnit.SECONDS); + + assertThat(browser.lastPage().codes).containsExactly("123456"); + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + assertThat(coordinator.waitingForCode(account.getId())).isFalse(); + } + + @Test + void codeNeverEnteredFailsAfterTenMinutes() { + coordinator.shutdown(); + coordinator = coordinator(Duration.ofMillis(50)); + browser.script(new Outcome.CodeRequired()); + + CompletableFuture login = coordinator.startInteractive(account.getId()); + + assertThatThrownBy(() -> login.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(TicketException.class); + assertThat(account.getAuthState()).isEqualTo(AuthState.AUTH_FAILED); + assertThat(browser.lastPage().closed).isTrue(); + } + + @Test + void interactiveFailureMarksAuthFailed() { + browser.script(new Outcome.Failed("wrong password")); + + CompletableFuture login = coordinator.startInteractive(account.getId()); + + assertThatThrownBy(() -> login.get(5, TimeUnit.SECONDS)).hasCauseInstanceOf(TicketException.class); + assertThat(account.getAuthState()).isEqualTo(AuthState.AUTH_FAILED); + } + + @Test + void submitCodeWithoutAWaitingFlowIsRejected() { + assertThatThrownBy(() -> coordinator.submitCode(account.getId(), "123456")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void automaticReMintNeedingACodeMarksCodeRequiredAndThrows() { + browser.script(new Outcome.CodeRequired()); + + assertThatThrownBy(() -> coordinator.ticketProviderFor(account.getId()).mint()) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isTrue()); + assertThat(account.getAuthState()).isEqualTo(AuthState.CODE_REQUIRED); + assertThat(browser.lastPage().codes).isEmpty(); + assertThat(browser.lastPage().closed).isTrue(); + } + + @Test + void automaticReMintRejectedByBattleNetMarksAuthFailedAndStops() { + browser.script(new Outcome.Failed("wrong password")); + + assertThatThrownBy(() -> coordinator.ticketProviderFor(account.getId()).mint()) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isTrue()); + assertThat(account.getAuthState()).isEqualTo(AuthState.AUTH_FAILED); + } + + @Test + void automaticReMintTimeoutLeavesTheStateAndStaysRetryable() { + account.setAuthState(AuthState.READY); + browser.script(new Outcome.TimedOut()); + + assertThatThrownBy(() -> coordinator.ticketProviderFor(account.getId()).mint()) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isFalse()); + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + } + + @Test + void thirdConsecutiveTimeoutMarksAuthFailedAndStops() { + account.setAuthState(AuthState.READY); + browser.script(new Outcome.TimedOut(), new Outcome.TimedOut(), new Outcome.TimedOut()); + var provider = coordinator.ticketProviderFor(account.getId()); + + for (int i = 0; i < 2; i++) { + assertThatThrownBy(provider::mint) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isFalse()); + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + } + assertThatThrownBy(provider::mint) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isTrue()); + assertThat(account.getAuthState()).isEqualTo(AuthState.AUTH_FAILED); + } + + @Test + void aTicketBetweenTimeoutsResetsTheCount() throws Exception { + account.setAuthState(AuthState.READY); + browser.script(new Outcome.TimedOut(), new Outcome.TimedOut(), new Outcome.Ticket(TICKET), + new Outcome.TimedOut(), new Outcome.TimedOut()); + var provider = coordinator.ticketProviderFor(account.getId()); + + for (int i = 0; i < 2; i++) assertThatThrownBy(provider::mint).isInstanceOf(TicketException.class); + assertThat(provider.mint()).isEqualTo(TICKET); + for (int i = 0; i < 2; i++) { + assertThatThrownBy(provider::mint) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isFalse()); + } + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + } + + @Test + void automaticReMintWithoutABrowserStaysRetryable() { + account.setAuthState(AuthState.READY); + when(browserProvider.getIfAvailable()).thenReturn(null); + + assertThatThrownBy(() -> coordinator.ticketProviderFor(account.getId()).mint()) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isFalse()); + assertThat(account.getAuthState()).isEqualTo(AuthState.READY); + } + + @Test + void reMintWhileAnInteractiveFlowWaitsForACodeFailsFast() throws Exception { + browser.script(new Outcome.CodeRequired(), new Outcome.Ticket(TICKET)); + CompletableFuture login = coordinator.startInteractive(account.getId()); + awaitWaiting(); + + assertThatThrownBy(() -> coordinator.ticketProviderFor(account.getId()).mint()) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isTrue()); + assertThat(browser.pages).hasSize(1); + + coordinator.submitCode(account.getId(), "123456"); + login.get(5, TimeUnit.SECONDS); + } + + @Test + void concurrentReMintsOfOneAccountShareOneBrowserLogin() throws Exception { + CompletableFuture blocking = blockTheLoginThread(); + CompletableFuture first = CompletableFuture.supplyAsync(this::mintQuietly); + CompletableFuture second = CompletableFuture.supplyAsync(this::mintQuietly); + awaitWaiters(2); + + coordinator.submitCode(other.getId(), "123456"); + blocking.get(5, TimeUnit.SECONDS); + + assertThat(first.get(5, TimeUnit.SECONDS)).isEqualTo(TICKET); + assertThat(second.get(5, TimeUnit.SECONDS)).isEqualTo(TICKET); + assertThat(browser.pages).filteredOn(page -> OLD_STATE.equals(page.openedWith)).hasSize(1); + } + + @Test + void aReMintWhoseCallerGaveUpBeforeItsTurnNeverOpensTheBrowser() throws Exception { + CompletableFuture blocking = blockTheLoginThread(); + CompletableFuture failure = new CompletableFuture<>(); + Thread caller = Thread.ofVirtual().start(() -> { + try { + coordinator.ticketProviderFor(account.getId()).mint(); + failure.complete(null); + } catch (TicketException e) { + failure.complete(e); + } + }); + awaitWaiters(1); + caller.interrupt(); + assertThat(failure.get(5, TimeUnit.SECONDS)).isInstanceOf(TicketException.class); + + coordinator.submitCode(other.getId(), "123456"); + blocking.get(5, TimeUnit.SECONDS); + // the login thread runs in order: once this one is done, the dropped re-mint has had its turn + coordinator.startInteractive(other.getId()).get(5, TimeUnit.SECONDS); + + assertThat(browser.pages).filteredOn(page -> OLD_STATE.equals(page.openedWith)).isEmpty(); + assertThat(account.getAuthState()).isNotEqualTo(AuthState.AUTH_FAILED); + } + + /** Another account's admin login waiting for its code: the login thread is busy until the code comes. */ + private CompletableFuture blockTheLoginThread() throws InterruptedException { + other = new BattleNetServiceAccount(); + other.setId(UUID.randomUUID()); + other.setEmail("other-mailbox@example.com"); + other.setPasswordEnc(encryption.encrypt(PASSWORD)); + when(repository.findById(other.getId())).thenReturn(Optional.of(other)); + browser.script(new Outcome.CodeRequired(), new Outcome.Ticket(TICKET), new Outcome.Ticket(TICKET)); + CompletableFuture login = coordinator.startInteractive(other.getId()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!coordinator.waitingForCode(other.getId())) { + if (System.nanoTime() > deadline) throw new AssertionError("the blocking login never waited"); + Thread.sleep(5); + } + return login; + } + + private String mintQuietly() { + try { + return coordinator.ticketProviderFor(account.getId()).mint(); + } catch (TicketException e) { + throw new java.util.concurrent.CompletionException(e); + } + } + + private void awaitWaiters(int count) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (coordinator.remintWaiters(account.getId()) < count) { + if (System.nanoTime() > deadline) throw new AssertionError("the re-mint callers never queued"); + Thread.sleep(5); + } + } + + @Test + void secondInteractiveLoginForTheSameAccountIsRejected() throws Exception { + browser.script(new Outcome.CodeRequired(), new Outcome.Ticket(TICKET)); + CompletableFuture first = coordinator.startInteractive(account.getId()); + awaitWaiting(); + + assertThatThrownBy(() -> coordinator.startInteractive(account.getId()).get(5, TimeUnit.SECONDS)) + .hasCauseInstanceOf(IllegalStateException.class); + + coordinator.submitCode(account.getId(), "123456"); + first.get(5, TimeUnit.SECONDS); + } + + @Test + void missingBrowserFailsTheLoginWithAuthFailed() { + when(browserProvider.getIfAvailable()).thenReturn(null); + + CompletableFuture login = coordinator.startInteractive(account.getId()); + + assertThatThrownBy(() -> login.get(5, TimeUnit.SECONDS)).hasCauseInstanceOf(TicketException.class); + assertThat(account.getAuthState()).isEqualTo(AuthState.AUTH_FAILED); + } + + @Test + void unknownAccountFailsTheLogin() { + UUID unknown = UUID.randomUUID(); + when(repository.findById(unknown)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> coordinator.ticketProviderFor(unknown).mint()) + .isInstanceOfSatisfying(TicketException.class, e -> assertThat(e.codeRequired()).isFalse()); + } + + @Test + void passwordAndTicketNeverLogged() throws Exception { + browser.script(new Outcome.Ticket(TICKET), new Outcome.CodeRequired(), new Outcome.Failed("timeout")); + coordinator.startInteractive(account.getId()).get(5, TimeUnit.SECONDS); + List errors = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + try { + coordinator.ticketProviderFor(account.getId()).mint(); + } catch (TicketException e) { + errors.add(e); + } + } + + assertThat(logs.list).isNotEmpty(); + List lines = new ArrayList<>(); + for (ILoggingEvent event : logs.list) { + lines.add(event.getFormattedMessage()); + for (IThrowableProxy t = event.getThrowableProxy(); t != null; t = t.getCause()) { + lines.add(t.getMessage()); + } + } + errors.forEach(e -> lines.add(e.getMessage())); + assertThat(lines).isNotEmpty().allSatisfy(line -> assertThat(String.valueOf(line)) + .doesNotContain(PASSWORD).doesNotContain(TICKET).doesNotContain(EMAIL) + .doesNotContain("cookies")); + } + + private void awaitWaiting() throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!coordinator.waitingForCode(account.getId())) { + if (System.nanoTime() > deadline) throw new AssertionError("login never waited for a code"); + Thread.sleep(5); + } + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlowTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlowTest.java new file mode 100644 index 000000000..6b6af4bfb --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlowTest.java @@ -0,0 +1,115 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginBrowser.Outcome; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginFlow.Result; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BattleNetLoginFlowTest { + + private static final Duration TICKET_TIMEOUT = Duration.ofSeconds(60); + + private final FakeLoginBrowser browser = new FakeLoginBrowser(); + private final AtomicInteger codeRequests = new AtomicInteger(); + + private BattleNetLoginFlow flow(boolean interactive, Duration codeTimeout) { + return new BattleNetLoginFlow(interactive, TICKET_TIMEOUT, codeTimeout, codeRequests::incrementAndGet); + } + + @Test + void ticketOnFirstTryReturnsTheTicketAndTheNewStorageState() { + browser.script(new Outcome.Ticket("EU-ticket")); + var page = browser.open(null); + + Result result = flow(true, Duration.ofMinutes(10)).run(page, "bot@example.com", "secret"); + + assertThat(result).isEqualTo(new Result.Success("EU-ticket", "{\"cookies\":[\"fresh\"]}")); + assertThat(browser.lastPage().credentials).containsExactly("bot@example.com", "secret"); + assertThat(codeRequests).hasValue(0); + } + + @Test + void interactiveFlowWaitsForTheCodeThenResumes() throws Exception { + browser.script(new Outcome.CodeRequired(), new Outcome.Ticket("EU-ticket")); + var page = browser.open(null); + BattleNetLoginFlow flow = flow(true, Duration.ofMinutes(10)); + + CompletableFuture running = CompletableFuture.supplyAsync(() -> flow.run(page, "e", "p")); + awaitWaiting(flow); + flow.submitCode("123456"); + + assertThat(running.get(5, TimeUnit.SECONDS)).isInstanceOf(Result.Success.class); + assertThat(browser.lastPage().codes).containsExactly("123456"); + assertThat(codeRequests).hasValue(1); + assertThat(flow.waitingForCode()).isFalse(); + } + + @Test + void interactiveFlowFailsWhenTheCodeNeverComes() { + browser.script(new Outcome.CodeRequired()); + var page = browser.open(null); + + Result result = flow(true, Duration.ofMillis(50)).run(page, "e", "p"); + + assertThat(result).isInstanceOf(Result.Failed.class); + assertThat(browser.lastPage().codes).isEmpty(); + } + + @Test + void automaticFlowStopsAtTheCodePageWithoutWaiting() { + browser.script(new Outcome.CodeRequired()); + var page = browser.open(null); + + Result result = flow(false, Duration.ofMinutes(10)).run(page, "e", "p"); + + assertThat(result).isEqualTo(new Result.CodeRequired()); + assertThat(codeRequests).hasValue(0); + } + + @Test + void submitCodeOutsideTheCodeWaitIsRejected() { + BattleNetLoginFlow flow = flow(true, Duration.ofMinutes(10)); + + assertThatThrownBy(() -> flow.submitCode("123456")).isInstanceOf(IllegalStateException.class); + } + + @Test + void failedOutcomeIsARejection() { + browser.script(new Outcome.Failed("wrong password")); + + Result result = flow(false, Duration.ofMinutes(10)).run(browser.open(null), "e", "p"); + + assertThat(result).isEqualTo(new Result.Rejected("wrong password")); + } + + @Test + void timeoutIsARetryableFailure() { + browser.script(new Outcome.TimedOut()); + + Result result = flow(false, Duration.ofMinutes(10)).run(browser.open(null), "e", "p"); + + assertThat(result).isInstanceOf(Result.Failed.class); + } + + @Test + void ticketValuesAreMaskedInToString() { + assertThat(new Outcome.Ticket("EU-secret-ticket").toString()).doesNotContain("EU-secret-ticket"); + assertThat(new Result.Success("EU-secret-ticket", "{\"state\"}").toString()) + .doesNotContain("EU-secret-ticket").doesNotContain("state"); + } + + static void awaitWaiting(BattleNetLoginFlow flow) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!flow.waitingForCode()) { + if (System.nanoTime() > deadline) throw new AssertionError("flow never waited for a code"); + Thread.sleep(5); + } + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/FakeLoginBrowser.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/FakeLoginBrowser.java new file mode 100644 index 000000000..e8648aafd --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/FakeLoginBrowser.java @@ -0,0 +1,73 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginBrowser.Outcome; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Scripted browser: every page returns the queued outcomes in order, then fails. */ +class FakeLoginBrowser implements BattleNetLoginBrowser { + + final Deque outcomes = new ArrayDeque<>(); + final List pages = new CopyOnWriteArrayList<>(); + String storageStateAfterLogin = "{\"cookies\":[\"fresh\"]}"; + + FakeLoginBrowser script(Outcome... script) { + outcomes.addAll(List.of(script)); + return this; + } + + @Override + public LoginPage open(String storageState) { + FakePage page = new FakePage(storageState); + pages.add(page); + return page; + } + + FakePage lastPage() { + return pages.getLast(); + } + + class FakePage implements LoginPage { + + final String openedWith; + final List credentials = new ArrayList<>(); + final List codes = new CopyOnWriteArrayList<>(); + volatile boolean closed; + + FakePage(String openedWith) { + this.openedWith = openedWith; + } + + @Override + public void submitCredentials(String email, String password) { + credentials.add(email); + credentials.add(password); + } + + @Override + public synchronized Outcome awaitOutcome(Duration timeout) { + Outcome next = outcomes.poll(); + return next != null ? next : new Outcome.Failed("script exhausted"); + } + + @Override + public void submitCode(String code) { + codes.add(code); + } + + @Override + public String storageState() { + return storageStateAfterLogin; + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowserTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowserTest.java new file mode 100644 index 000000000..d2d311dba --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowserTest.java @@ -0,0 +1,22 @@ +package fr.enimaloc.catapult.service.battlenet.login; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlaywrightLoginBrowserTest { + + private static final String TICKET = "EU-0123456789abcdef0123456789ABCDEF-123456"; + + @Test + void extractsTicketFromLocalhostRedirect() { + assertThat(PlaywrightLoginBrowser.extractTicket("http://localhost:0/?ST=" + TICKET)).isEqualTo(TICKET); + assertThat(PlaywrightLoginBrowser.extractTicket("http://localhost:0/?a=b&ST=" + TICKET)).isEqualTo(TICKET); + } + + @Test + void ignoresNonLocalhostUrls() { + assertThat(PlaywrightLoginBrowser.extractTicket("https://eu.account.battle.net/?ST=" + TICKET)).isNull(); + assertThat(PlaywrightLoginBrowser.extractTicket("http://localhost:0/?ST=nope")).isNull(); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java index 0a4de32c7..69123ed47 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java @@ -89,7 +89,7 @@ void setUp() { raidRestoreHold, gameStateService, mock(UserAccountRepository.class), new CategoryApplier(twitchService, streamStateService), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class))); bindingService = spy(new BindingService(bindingRepo, igdbService, igdbGameDetailsService, twitchService, twResolverService, mappingRepo, new GameBindingLookup(bindingRepo), chain, mock(ExtensionLayoutApplier.class), org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class))); - service = new AdminGameMappingService(mappingRepo, bindingRepo, userRepo, bindingService, gameStateService, + service = new AdminGameMappingService(mappingRepo, bindingRepo, userRepo, bindingService, igdbService, gameStateService, streamStateService, channelEventPublisher, chain, transactionManager); admin = user("admin"); @@ -516,7 +516,7 @@ void igdbSuggestions_areResolvedBeforeTheTransaction() { bindingsOfTheGame(auto); when(bindingRepo.countSourceNames(anyCollection(), anyCollection())).thenReturn(List.of( new Object[]{SourceType.STEAM, "730", "Counter-Strike 2", 1L})); - when(igdbService.findByExternalAppId(SourceType.STEAM, "730")) + when(igdbService.resolveUnmapped(eq(SourceType.STEAM), eq("730"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Counter-Strike 2"))); when(igdbService.suggestCcls("42")).thenReturn(Set.of("ViolentGraphic")); @@ -553,7 +553,7 @@ void streamOfflineAfterTheCommit_isSkipped_neitherCountedNorPending() { private static GameMappingRequest ignoring(String sourceId, String twitchId, boolean applyLive) { return new GameMappingRequest("STEAM", sourceId, twitchId, twitchId == null ? null : "Category " + twitchId, - null, null, applyLive, true); + null, null, applyLive, true, null); } @Test @@ -606,12 +606,12 @@ void ignoredByDefault_leavesManualBindingsAndTheStreamersChoiceAlone() { @Test void withoutCategory_isRefused_unlessIgnoredByDefault() { assertThatThrownBy(() -> service.create( - new GameMappingRequest("STEAM", "730", " ", null, null, null, false, false), admin)) + new GameMappingRequest("STEAM", "730", " ", null, null, null, false, false, null), admin)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("Missing Twitch category"); // Half a category is refused all the same assertThatThrownBy(() -> service.create( - new GameMappingRequest("STEAM", "730", "32399", " ", null, null, false, true), admin)) + new GameMappingRequest("STEAM", "730", "32399", " ", null, null, false, true, null), admin)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("Missing Twitch category"); } @@ -711,4 +711,259 @@ void applyLive_pushesTheBindingsItJustIgnored() { verify(chain).apply(eq(onIt), any(MappingChanged.class)); verify(chain, never()).apply(eq(alreadyIgnored), any()); } + + // ---- IGDB game on a mapping + + private static GameMappingRequest withIgdb(String twitchId, String twitchName, boolean ignored, String igdbId) { + return new GameMappingRequest("STEAM", "730", twitchId, twitchName, null, null, false, ignored, igdbId); + } + + private void igdbKnows(String igdbId, String name) { + when(igdbService.findById(igdbId)).thenReturn(new IgdbService.IgdbGame(igdbId, name)); + } + + @Test + void typedCategoryWinsOverIgdb() { + igdbKnows("42", "Counter-Strike 2"); + when(igdbService.findTwitchGameId("42")).thenReturn(Optional.of("999")); + + GameMappingResult result = service.create(withIgdb("32399", "Typed", false, "42"), admin); + + assertThat(result.mapping().twitchGameId()).isEqualTo("32399"); + assertThat(result.mapping().twitchGameName()).isEqualTo("Typed"); + assertThat(result.mapping().igdbId()).isEqualTo("42"); + verify(bindingService, never()).twitchCategoryFor(any(), any(), any()); + } + + @Test + void emptyCategoryIsDeducedFromIgdb() { + igdbKnows("42", "Counter-Strike 2"); + when(igdbService.findTwitchGameId("42")).thenReturn(Optional.of("32399")); + + GameMappingResult result = service.create(withIgdb(" ", null, false, "42"), admin); + + assertThat(result.mapping().twitchGameId()).isEqualTo("32399"); + assertThat(result.mapping().twitchGameName()).isEqualTo("Counter-Strike 2"); + GameMapping stored = mappings.values().iterator().next(); + assertThat(stored.getTwitchGameId()).isEqualTo("32399"); + assertThat(stored.getTwitchGameName()).isEqualTo("Counter-Strike 2"); + assertThat(stored.getIgdbId()).isEqualTo("42"); + } + + @Test + void deductionFailureIsA400ForAnActiveMapping() { + igdbKnows("42", "Counter-Strike 2"); + when(igdbService.findTwitchGameId("42")).thenReturn(Optional.empty()); + when(twitchService.findCategoryIdByName(any(), any())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.create(withIgdb(null, null, false, "42"), admin)) + .isInstanceOfSatisfying(ResponseStatusException.class, e -> { + assertThat(e.getStatusCode().value()).isEqualTo(400); + assertThat(e.getReason()).isEqualTo("No Twitch category found for this IGDB game: enter one"); + }); + assertThat(mappings).isEmpty(); + } + + @Test + void deductionFailureIsAcceptedForAnIgnoredMapping() { + igdbKnows("42", "Counter-Strike 2"); + when(igdbService.findTwitchGameId("42")).thenReturn(Optional.empty()); + when(twitchService.findCategoryIdByName(any(), any())).thenReturn(Optional.empty()); + + GameMappingResult result = service.create(withIgdb(null, null, true, "42"), admin); + + assertThat(result.mapping().twitchGameId()).isNull(); + assertThat(result.mapping().igdbId()).isEqualTo("42"); + assertThat(result.mapping().ignoredByDefault()).isTrue(); + } + + @Test + void igdbUnavailableIsA400NotA500() { + // The name cannot be resolved (token blank): no deduction + igdbKnows("42", null); + assertThatThrownBy(() -> service.create(withIgdb(null, null, false, "42"), admin)) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode().value()).isEqualTo(400)); + + // The client throws + when(igdbService.findById("43")).thenThrow(new IllegalStateException("igdb down")); + assertThatThrownBy(() -> service.create(withIgdb(null, null, false, "43"), admin)) + .isInstanceOfSatisfying(ResponseStatusException.class, + e -> assertThat(e.getStatusCode().value()).isEqualTo(400)); + } + + @Test + void listShowsTheCachedIgdbNameOrNull() { + GameMapping known = new GameMapping(); + known.setId(UUID.randomUUID()); + known.setSourceType(SourceType.BATTLENET); + known.setSourceId("Pro"); + known.setIgdbId("42"); + GameMapping unknown = new GameMapping(); + unknown.setId(UUID.randomUUID()); + unknown.setSourceType(SourceType.BATTLENET); + unknown.setSourceId("Zeus"); + unknown.setIgdbId("43"); + when(mappingRepo.findAll(any(Pageable.class))).thenReturn(new PageImpl<>(List.of(known, unknown))); + when(igdbService.getGameCache()).thenReturn(Map.of("42", "Overwatch 2")); + + var rows = service.list(0, 25).getContent(); + + assertThat(rows.get(0).igdbGameName()).isEqualTo("Overwatch 2"); + assertThat(rows.get(1).igdbGameName()).isNull(); + verify(igdbService, never()).findById(any()); + } + + @Test + void createWithAnIgdbIdTakesItsSuggestionsFromThatId() { + GameBinding auto = binding(user("a"), "730", Status.AUTO); + bindingsOfTheGame(auto); + // No mapping is stored yet: resolveDetected knows nothing, only the request's id leads to the game + when(igdbService.resolveDetected(any(), any(), any())).thenReturn(Optional.empty()); + when(igdbService.suggestCcls("42")).thenReturn(Set.of("ViolentGraphic")); + + service.create(new GameMappingRequest("STEAM", "730", "32399", "CS", null, Set.of(), false, false, "42"), + admin); + + assertThat(auto.getCcls()).containsExactly("ViolentGraphic"); + verify(igdbService).suggestCcls("42"); + } + + @Test + void updateChangingTheIgdbIdTakesItsSuggestionsFromTheNewId() { + GameMapping old = new GameMapping(); + old.setId(UUID.randomUUID()); + old.setSourceType(SourceType.STEAM); + old.setSourceId("730"); + old.setTwitchGameId("old"); + old.setTwitchGameName("Old"); + old.setIgdbId("41"); + mappings.put(old.getId(), old); + GameBinding mapped = binding(user("a"), "730", Status.MAPPED); + bindingsOfTheGame(mapped); + // What is persisted still says 41: the new id must win + when(igdbService.resolveDetected(any(), any(), any())) + .thenReturn(Optional.of(new IgdbService.IgdbGame("41", "Old game"))); + when(igdbService.suggestCcls("41")).thenReturn(Set.of("Old")); + when(igdbService.suggestCcls("42")).thenReturn(Set.of("ViolentGraphic")); + + service.update(old.getId(), + new GameMappingRequest("STEAM", "730", "32399", "CS", null, Set.of(), false, false, "42"), admin); + + assertThat(mapped.getCcls()).containsExactly("ViolentGraphic"); + assertThat(old.getIgdbId()).isEqualTo("42"); + verify(igdbService, never()).suggestCcls("41"); + } + + private GameMapping storedMapping(SourceType sourceType, String sourceId, String igdbId) { + GameMapping m = new GameMapping(); + m.setId(UUID.randomUUID()); + m.setSourceType(sourceType); + m.setSourceId(sourceId); + m.setTwitchGameId("488552"); + m.setTwitchGameName("Overwatch 2"); + m.setIgdbId(igdbId); + mappings.put(m.getId(), m); + return m; + } + + private static GameBinding battlenetBinding(UserAccount owner, Status status) { + GameBinding b = binding(owner, "Pro", status); + b.setSourceType(SourceType.BATTLENET); + b.setSourceName("Overwatch"); + return b; + } + + private void battlenetBindings(GameBinding... bindings) { + when(bindingRepo.lockAllBySourceTypeAndSourceIdAndStatusIn(eq(SourceType.BATTLENET), eq("Pro"), + anyCollection())).thenAnswer(inv -> new ArrayList<>(List.of(bindings))); + } + + /** What the not-yet-updated mapping row says: the old IGDB game, through the mapping-aware resolution. */ + private void storedRowResolvesTo(String igdbId) { + when(igdbService.resolveDetected(any(), any(), any())) + .thenReturn(Optional.of(new IgdbService.IgdbGame(igdbId, "Old game"))); + when(igdbService.suggestCcls(igdbId)).thenReturn(Set.of("Old")); + when(igdbService.findTwitchGameId(igdbId)).thenReturn(Optional.of("488552")); + } + + @Test + void updateClearingTheIgdbIdTakesNoSuggestionsFromTheOldOne() { + GameMapping old = storedMapping(SourceType.BATTLENET, "Pro", "123"); + GameBinding mapped = battlenetBinding(user("a"), Status.MAPPED); + mapped.getCcls().add("Old"); + battlenetBindings(mapped); + storedRowResolvesTo("123"); + + service.update(old.getId(), new GameMappingRequest("BATTLENET", "Pro", "488552", "Overwatch 2", null, null, + false, false, null), admin); + + assertThat(old.getIgdbId()).isNull(); + // Battle.net without an IGDB game: no IGDB data at all (spec 3.4) + assertThat(mapped.getCcls()).isEmpty(); + assertThat(mapped.getTws()).isEmpty(); + verify(igdbService, never()).suggestCcls("123"); + verify(igdbService, never()).fetchDescriptorIds("123"); + } + + @Test + void updateClearingTheIgdbIdTakesSteamSuggestionsFromThePlatformId() { + GameMapping old = storedMapping(SourceType.STEAM, "730", "41"); + GameBinding mapped = binding(user("a"), "730", Status.MAPPED); + bindingsOfTheGame(mapped); + storedRowResolvesTo("41"); + when(igdbService.resolveUnmapped(eq(SourceType.STEAM), eq("730"), any())) + .thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Counter-Strike 2"))); + when(igdbService.suggestCcls("42")).thenReturn(Set.of("ViolentGraphic")); + + service.update(old.getId(), + new GameMappingRequest("STEAM", "730", "32399", "CS", null, Set.of(), false, false, " "), admin); + + assertThat(old.getIgdbId()).isNull(); + assertThat(mapped.getCcls()).containsExactly("ViolentGraphic"); + verify(igdbService, never()).suggestCcls("41"); + } + + @Test + void movingAMappingResolvesTheOldGameWithoutItsIgdbGame() { + GameMapping old = storedMapping(SourceType.BATTLENET, "Pro", "123"); + GameBinding oldGame = battlenetBinding(user("a"), Status.MAPPED); + battlenetBindings(oldGame); + storedRowResolvesTo("123"); + + service.update(old.getId(), request("730", "32399", false), admin); + + assertThat(old.getSourceType()).isEqualTo(SourceType.STEAM); + // No longer about Battle.net "Pro": its bindings get no IGDB game, hence incomplete + assertThat(oldGame.getStatus()).isEqualTo(Status.INCOMPLETE); + verify(igdbService, never()).suggestCcls("123"); + } + + @Test + void aNonNumericIgdbIdIsA400ForAnActiveMapping() { + assertThatThrownBy(() -> service.create(withIgdb("32399", "Typed", false, "abc"), admin)) + .isInstanceOfSatisfying(ResponseStatusException.class, e -> { + assertThat(e.getStatusCode().value()).isEqualTo(400); + assertThat(e.getReason()).isEqualTo("IGDB game id must be numeric"); + }); + assertThat(mappings).isEmpty(); + verify(mappingRepo, never()).saveAndFlush(any()); + verifyNoInteractions(igdbService); + } + + @Test + void aNonNumericIgdbIdIsA400ForAnIgnoredMapping() { + GameMapping old = storedMapping(SourceType.STEAM, "730", "41"); + + assertThatThrownBy(() -> service.update(old.getId(), withIgdb(null, null, true, "12a"), admin)) + .isInstanceOfSatisfying(ResponseStatusException.class, e -> { + assertThat(e.getStatusCode().value()).isEqualTo(400); + assertThat(e.getReason()).isEqualTo("IGDB game id must be numeric"); + }); + assertThatThrownBy(() -> service.create(withIgdb(null, null, true, "-1"), admin)) + .isInstanceOf(ResponseStatusException.class); + assertThat(old.getIgdbId()).isEqualTo("41"); + assertThat(mappings).hasSize(1); + verify(mappingRepo, never()).saveAndFlush(any()); + } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java index b40a02a20..61d618864 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java @@ -32,6 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -187,7 +188,7 @@ void editsOfUnknownBindings_doNothing() { void resetTws_reResolvesTheSuggestion_andPushesTheNewCategory() { binding.setTwOverride(true); playing(GameBinding.SourceType.STEAM, "620"); - when(igdb.findByExternalAppId(GameBinding.SourceType.STEAM, "620")) + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("620"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("72", "Portal 2"))); when(igdbDetails.getDetails("72")).thenReturn(Optional.empty()); when(igdb.findTwitchGameId("72")).thenReturn(Optional.of("tw-72")); @@ -212,7 +213,7 @@ void resetTws_categoryUnchanged_isNotPushed() { binding.setTwitchGameId("tw-72"); binding.setStatus(GameBinding.Status.MANUAL); playing(GameBinding.SourceType.STEAM, "620"); - when(igdb.findByExternalAppId(GameBinding.SourceType.STEAM, "620")) + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("620"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("72", "Portal 2"))); when(igdbDetails.getDetails("72")).thenReturn(Optional.empty()); when(igdb.findTwitchGameId("72")).thenReturn(Optional.of("tw-72")); @@ -229,7 +230,7 @@ void refreshingIncompleteBindings_pushesAResolvedPlayedBinding() { binding.setStatus(GameBinding.Status.INCOMPLETE); playing(GameBinding.SourceType.STEAM, "620"); when(bindings.findAllByStatusAndIgnoredFalse(GameBinding.Status.INCOMPLETE)).thenReturn(List.of(binding)); - when(igdb.findByExternalAppId(GameBinding.SourceType.STEAM, "620")) + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("620"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("72", "Portal 2"))); when(igdbDetails.getDetails("72")).thenReturn(Optional.empty()); when(igdb.findTwitchGameId("72")).thenReturn(Optional.of("tw-72")); @@ -254,7 +255,7 @@ void refreshingIncompleteBindings_aTwitchErrorDoesNotStopTheRefresh() { playing(GameBinding.SourceType.STEAM, "620"); when(bindings.findAllByStatusAndIgnoredFalse(GameBinding.Status.INCOMPLETE)) .thenReturn(List.of(binding, other)); - when(igdb.findByExternalAppId(any(), anyString())) + when(igdb.resolveDetected(any(), anyString(), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("72", "Portal 2"))); when(igdbDetails.getDetails("72")).thenReturn(Optional.empty()); when(igdb.findTwitchGameId("72")).thenReturn(Optional.of("tw-72")); @@ -279,7 +280,7 @@ void refreshingIncompleteBindings_aDecisionErrorDoesNotStopTheRefresh() { binding.setStatus(GameBinding.Status.INCOMPLETE); when(bindings.findAllByStatusAndIgnoredFalse(GameBinding.Status.INCOMPLETE)) .thenReturn(List.of(binding, other)); - when(igdb.findByExternalAppId(any(), anyString())) + when(igdb.resolveDetected(any(), anyString(), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("72", "Portal 2"))); when(igdbDetails.getDetails("72")).thenReturn(Optional.empty()); when(igdb.findTwitchGameId("72")).thenReturn(Optional.of("tw-72")); @@ -323,8 +324,8 @@ void deleteAndFind() { @Test void refreshingIncompleteBindings_countsTheResolvedOnes() { when(bindings.findAllByStatusAndIgnoredFalse(GameBinding.Status.INCOMPLETE)).thenReturn(List.of(binding)); - when(igdb.findByExternalAppId(any(), anyString())).thenReturn(Optional.empty()); - when(igdb.findByName("Portal 2")).thenReturn(Optional.empty()); + when(igdb.resolveDetected(any(), anyString(), any())).thenReturn(Optional.empty()); + when(igdb.resolveDetected(any(), any(), eq("Portal 2"))).thenReturn(Optional.empty()); service.refreshIncompleteBindings(); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java index 8868b8e15..9fbecf96c 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java @@ -30,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -74,7 +75,7 @@ void setUp() { when(mappings.findBySourceTypeAndSourceId(GameBinding.SourceType.STEAM, "730")).thenReturn(Optional.of(mapping)); // IGDB knows the game: its suggestions are what a mapping that replaces nothing keeps. - when(igdb.findByExternalAppId(GameBinding.SourceType.STEAM, "730")) + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("730"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("igdb-1", "Counter-Strike 2"))); when(igdb.suggestCcls("igdb-1")).thenReturn(Set.of("ViolentGraphic")); when(twResolver.suggest(any())).thenReturn(Set.of("tw-igdb")); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java index 291851d7f..4252f46ae 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java @@ -26,6 +26,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -64,7 +65,7 @@ void setUp() { return b; }); when(mappings.findBySourceTypeAndSourceId(any(), any())).thenReturn(Optional.empty()); - when(igdb.findByExternalAppId(GameBinding.SourceType.STEAM, "730")) + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("730"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("igdb-1", "Counter-Strike 2"))); when(igdbDetails.getDetails(anyString())).thenReturn(Optional.empty()); when(igdb.findTwitchGameId("igdb-1")).thenReturn(Optional.of("32399")); @@ -103,7 +104,7 @@ void notWhenTheStoreMayBeCalled_norForAnotherPlatform() { service.resolveOrCreate(user, steamGame); when(twResolver.steamRateLimitedUntil()).thenReturn(Optional.of(UNTIL)); - when(igdb.findByExternalAppId(GameBinding.SourceType.XBOX, "x1")) + when(igdb.resolveDetected(eq(GameBinding.SourceType.XBOX), eq("x1"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("igdb-2", "Halo"))); service.resolveOrCreate(user, new DetectedGame("x1", GameBinding.SourceType.XBOX, "Halo")); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java index 917b30b51..82a4e2705 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java @@ -223,7 +223,7 @@ void resolveOrCreate_noExisting_igdbFound_createsAutoBinding() { when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "steam-123", GameBinding.SourceType.STEAM)) .thenReturn(Optional.empty()); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("1234", "Portal"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-123")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-123"), any())).thenReturn(Optional.of(igdbGame)); when(igdbService.findTwitchGameId("1234")).thenReturn(Optional.of("twitch-123")); when(igdbService.suggestCcls("1234")).thenReturn(Set.of()); when(gameBindingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -244,7 +244,7 @@ void resolveOrCreate_usesIgdbExternalGamesTwitchId_beforeNameMatching() { when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "steam-123", GameBinding.SourceType.STEAM)) .thenReturn(Optional.empty()); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("25076", "Red Dead Redemption 2"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-123")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-123"), any())).thenReturn(Optional.of(igdbGame)); IgdbGameDetails details = new IgdbGameDetails(); details.setIgdbId("25076"); @@ -271,7 +271,7 @@ void resolveOrCreate_ignoresATwitchWebsiteUrl_forTheCategoryId() { when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "steam-123", GameBinding.SourceType.STEAM)) .thenReturn(Optional.empty()); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("1234", "Portal"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-123")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-123"), any())).thenReturn(Optional.of(igdbGame)); IgdbGameDetails details = new IgdbGameDetails(); details.setIgdbId("1234"); @@ -294,7 +294,7 @@ void resolveOrCreate_noExisting_igdbNotFound_createsIncompleteBinding() { DetectedGame game = new DetectedGame(null, GameBinding.SourceType.MANUAL, "Unknown Game"); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, null, GameBinding.SourceType.MANUAL)) .thenReturn(Optional.empty()); - when(igdbService.findByName("Unknown Game")).thenReturn(Optional.empty()); + when(igdbService.resolveDetected(any(), any(), eq("Unknown Game"))).thenReturn(Optional.empty()); when(gameBindingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); GameBinding result = bindingService.resolveOrCreate(user, game); @@ -302,13 +302,32 @@ void resolveOrCreate_noExisting_igdbNotFound_createsIncompleteBinding() { assertThat(result.getStatus()).isEqualTo(GameBinding.Status.INCOMPLETE); } + @Test + void resolveOrCreate_battleNetWithoutMapping_createsIncompleteBinding_withoutAnyNameSearch() { + DetectedGame game = new DetectedGame("Pro", GameBinding.SourceType.BATTLENET, "Overwatch"); + when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "Pro", GameBinding.SourceType.BATTLENET)) + .thenReturn(Optional.empty()); + when(gameMappingRepository.findBySourceTypeAndSourceId(GameBinding.SourceType.BATTLENET, "Pro")) + .thenReturn(Optional.empty()); + when(igdbService.resolveDetected(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch")).thenReturn(Optional.empty()); + when(gameBindingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + GameBinding result = bindingService.resolveOrCreate(user, game); + + assertThat(result.getStatus()).isEqualTo(GameBinding.Status.INCOMPLETE); + verify(igdbService).resolveDetected(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch"); + verify(igdbService, never()).findByName(any()); + verify(igdbService, never()).findByExternalAppId(any(), any()); + verify(igdbService, never()).suggestCcls(any()); + } + @Test void resolveOrCreate_noExisting_igdbFoundButNoTwitchId_createsIncompleteBinding() { DetectedGame game = new DetectedGame("steam-123", GameBinding.SourceType.STEAM, "Portal"); when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "steam-123", GameBinding.SourceType.STEAM)) .thenReturn(Optional.empty()); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("1234", "Portal"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-123")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-123"), any())).thenReturn(Optional.of(igdbGame)); when(igdbService.findTwitchGameId("1234")).thenReturn(Optional.empty()); when(twitchService.findCategoryIdByName(user, "Portal")).thenReturn(Optional.empty()); when(igdbService.suggestCcls("1234")).thenReturn(Set.of()); @@ -329,7 +348,7 @@ void resolveOrCreate_existingIncomplete_igdbFound_updatesBinding() { when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "steam-123", GameBinding.SourceType.STEAM)) .thenReturn(Optional.of(incompleteBinding)); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("1234", "Portal"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-123")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-123"), any())).thenReturn(Optional.of(igdbGame)); when(igdbService.findTwitchGameId("1234")).thenReturn(Optional.empty()); when(twitchService.findCategoryIdByName(user, "Portal")).thenReturn(Optional.of("twitch-456")); when(igdbService.suggestCcls("1234")).thenReturn(Set.of()); @@ -364,7 +383,7 @@ void refreshIncompleteBindings_incompleteResolved_savesAsAuto() { when(gameBindingRepository.findAllByStatusAndIgnoredFalse(GameBinding.Status.INCOMPLETE)) .thenReturn(List.of(incomplete)); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("42", "Half-Life 3"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-999")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-999"), any())).thenReturn(Optional.of(igdbGame)); when(igdbService.findTwitchGameId("42")).thenReturn(Optional.of("twitch-42")); when(igdbService.suggestCcls("42")).thenReturn(Set.of()); when(gameBindingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -406,7 +425,7 @@ void refreshIncompleteBindings_stillUnresolvable_remainsIncomplete() { when(gameBindingRepository.findAllByStatusAndIgnoredFalse(GameBinding.Status.INCOMPLETE)) .thenReturn(List.of(incomplete)); - when(igdbService.findByName("Unknown Indie Game")).thenReturn(Optional.empty()); + when(igdbService.resolveDetected(any(), any(), eq("Unknown Indie Game"))).thenReturn(Optional.empty()); when(gameBindingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); bindingService.refreshIncompleteBindings(); @@ -420,7 +439,7 @@ void updateBindingFromIgdb_setsTwsFromResolver_whenNotOverridden() { when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "steam-123", GameBinding.SourceType.STEAM)) .thenReturn(Optional.empty()); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("1234", "Portal"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-123")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-123"), any())).thenReturn(Optional.of(igdbGame)); when(igdbService.findTwitchGameId("1234")).thenReturn(Optional.of("twitch-123")); when(igdbService.suggestCcls("1234")).thenReturn(Set.of()); when(igdbService.fetchDescriptorIds("1234")).thenReturn(Set.of(10L, 11L)); @@ -447,7 +466,7 @@ void updateBindingFromIgdb_skipsTwResolver_whenTwOverrideTrue() { when(gameBindingRepository.findAllByStatusAndIgnoredFalse(GameBinding.Status.INCOMPLETE)) .thenReturn(List.of(overridden)); IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("777", "Half-Life"); - when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-555")).thenReturn(Optional.of(igdbGame)); + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("steam-555"), any())).thenReturn(Optional.of(igdbGame)); when(igdbService.findTwitchGameId("777")).thenReturn(Optional.of("twitch-777")); when(igdbService.suggestCcls("777")).thenReturn(Set.of()); when(gameBindingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/GameContextServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/GameContextServiceTest.java index 828c44b7e..5da92514e 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/GameContextServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/GameContextServiceTest.java @@ -30,6 +30,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -76,7 +77,7 @@ void setUp() { void steam_game_hydrates_full_context() { DetectedGame detected = new DetectedGame("570", GameBinding.SourceType.STEAM, "Dota 2"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findBySteamAppId("570")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("8173", "Dota 2"))); IgdbGameDetails details = new IgdbGameDetails(); @@ -115,7 +116,7 @@ void steam_game_hydrates_full_context() { void igdb_miss_keeps_name_from_detected() { DetectedGame detected = new DetectedGame("xbox-id-1", GameBinding.SourceType.XBOX, "Unknown"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findByName("Unknown")).thenReturn(Optional.empty()); + when(igdbService.resolveDetected(any(), any(), eq("Unknown"))).thenReturn(Optional.empty()); Optional ctxOpt = service.get(user); assertThat(ctxOpt).isPresent(); @@ -145,23 +146,23 @@ void game_change_invalidates_cache() { when(gameStateService.getLastKnownGame(user)) .thenReturn(Optional.of(first)) .thenReturn(Optional.of(second)); - when(igdbService.findBySteamAppId("570")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("8173", "Dota 2"))); - when(igdbService.findBySteamAppId("440")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("440"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("206", "Team Fortress 2"))); when(igdbGameDetailsService.getDetails(any())).thenReturn(Optional.empty()); assertThat(service.get(user).orElseThrow().name()).isEqualTo("Dota 2"); assertThat(service.get(user).orElseThrow().name()).isEqualTo("Team Fortress 2"); - verify(igdbService).findBySteamAppId("570"); - verify(igdbService).findBySteamAppId("440"); + verify(igdbService).resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any()); + verify(igdbService).resolveDetected(eq(GameBinding.SourceType.STEAM), eq("440"), any()); } @Test void cache_hit_skips_igdb_lookup_when_same_game() { DetectedGame detected = new DetectedGame("570", GameBinding.SourceType.STEAM, "Dota 2"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findBySteamAppId("570")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("8173", "Dota 2"))); when(igdbGameDetailsService.getDetails("8173")).thenReturn(Optional.empty()); @@ -169,14 +170,14 @@ void cache_hit_skips_igdb_lookup_when_same_game() { service.get(user); service.get(user); - verify(igdbService, times(1)).findBySteamAppId("570"); + verify(igdbService, times(1)).resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any()); } @Test void igdb_throws_returns_minimal_context_with_name() { DetectedGame detected = new DetectedGame("zwaard-id", GameBinding.SourceType.XBOX, "Zwaard"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findByName("Zwaard")).thenThrow(new RuntimeException("IGDB down")); + when(igdbService.resolveDetected(any(), any(), eq("Zwaard"))).thenThrow(new RuntimeException("IGDB down")); GameContext ctx = service.get(user).orElseThrow(); assertThat(ctx.name()).isEqualTo("Zwaard"); @@ -189,7 +190,7 @@ void igdb_throws_returns_minimal_context_with_name() { void activeTws_filteredByBlockedTws_andLabelsResolved_whenFeatureEnabled() { DetectedGame detected = new DetectedGame("570", GameBinding.SourceType.STEAM, "Dota 2"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findBySteamAppId("570")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("8173", "Dota 2"))); when(igdbGameDetailsService.getDetails("8173")).thenReturn(Optional.empty()); @@ -221,7 +222,7 @@ void activeTws_filteredByBlockedTws_andLabelsResolved_whenFeatureEnabled() { void activeTws_emptyWhenTwDisabledOnBinding() { DetectedGame detected = new DetectedGame("570", GameBinding.SourceType.STEAM, "Dota 2"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findBySteamAppId("570")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("8173", "Dota 2"))); when(igdbGameDetailsService.getDetails("8173")).thenReturn(Optional.empty()); @@ -245,7 +246,7 @@ void activeTws_emptyWhenTwDisabledOnBinding() { void activeTws_emptyWhenUserFeatureDisabled() { DetectedGame detected = new DetectedGame("570", GameBinding.SourceType.STEAM, "Dota 2"); when(gameStateService.getLastKnownGame(user)).thenReturn(Optional.of(detected)); - when(igdbService.findBySteamAppId("570")) + when(igdbService.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("570"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("8173", "Dota 2"))); when(igdbGameDetailsService.getDetails("8173")).thenReturn(Optional.empty()); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/MinecraftBatchDebugTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/MinecraftBatchDebugTest.java index 1e19bc548..87268532b 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/MinecraftBatchDebugTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/MinecraftBatchDebugTest.java @@ -87,7 +87,7 @@ private MinecraftFriendLink link(String profileId, String name) { @BeforeEach void setUp() { account.setId(UUID.randomUUID()); - account.setLabel("svc-1"); + account.setMinecraftUsername("svc-1"); when(registry.modeOf(any())).thenReturn(Optional.empty()); RestClient.Builder builder = RestClient.builder() .requestInterceptor(new HttpTraceLoggingInterceptor(registry, recorder)); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEventsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEventsTest.java index 8eb7e55ae..e4478b666 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEventsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/dtdd/DtddMappingEventsTest.java @@ -48,9 +48,9 @@ private void playing() { steamPlayer, new DetectedGame("413150", GameBinding.SourceType.STEAM, "Stardew Valley"), namedPlayer, new DetectedGame(null, GameBinding.SourceType.MANUAL, "Stardew Valley"), otherPlayer, new DetectedGame("620", GameBinding.SourceType.STEAM, "Portal 2"))); - when(igdb.findBySteamAppId("413150")).thenReturn(Optional.of(new IgdbService.IgdbGame("100", "Stardew Valley"))); - when(igdb.findByName("Stardew Valley")).thenReturn(Optional.of(new IgdbService.IgdbGame("100", "Stardew Valley"))); - when(igdb.findBySteamAppId("620")).thenReturn(Optional.of(new IgdbService.IgdbGame("200", "Portal 2"))); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("413150"), any())).thenReturn(Optional.of(new IgdbService.IgdbGame("100", "Stardew Valley"))); + when(igdb.resolveDetected(any(), any(), eq("Stardew Valley"))).thenReturn(Optional.of(new IgdbService.IgdbGame("100", "Stardew Valley"))); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("620"), any())).thenReturn(Optional.of(new IgdbService.IgdbGame("200", "Portal 2"))); } @Test @@ -112,7 +112,7 @@ void insideATransaction_waitsForTheCommit() { void aGameIgdbCantResolve_isSkipped() { when(gameState.lastKnownGames()).thenReturn(Map.of(steamPlayer, new DetectedGame("1", GameBinding.SourceType.STEAM, "Unknown"))); - when(igdb.findBySteamAppId("1")).thenThrow(new IllegalStateException("IGDB down")); + when(igdb.resolveDetected(eq(GameBinding.SourceType.STEAM), eq("1"), any())).thenThrow(new IllegalStateException("IGDB down")); events.mappingChanged("100"); events.mappingChanged(null); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbClientTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbClientTest.java index 0972959f2..7f87c92ef 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbClientTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbClientTest.java @@ -64,7 +64,7 @@ void searchByName() { gamesReturn(List.of(DOOM)); assertThat(client.searchByName("doom", "token")).containsExactly(DOOM); - assertThat(lastQuery.get()).contains("search \"doom\"").contains("f id,name").contains("l 5"); + assertThat(lastQuery.get()).contains("search \"doom\"").contains("f id,name,first_release_date,cover.image_id").contains("l 10"); } @Test diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDescriptorHelpersTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDescriptorHelpersTest.java index b72a75910..a89fb42ad 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDescriptorHelpersTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDescriptorHelpersTest.java @@ -37,6 +37,7 @@ @MockitoSettings(strictness = Strictness.LENIENT) class IgdbServiceDescriptorHelpersTest { + @Mock fr.enimaloc.catapult.repository.binding.GameMappingRepository gameMappingRepository; @Mock IgdbClient igdbClient; @Mock IgdbGameCacheRepository cacheRepository; @Mock IgdbGameExternalIdRepository externalIdRepository; @@ -85,6 +86,7 @@ void resolveIgdbIdForBinding_steamFirst_thenNameFallback() { IgdbService.IgdbGame steamHit = new IgdbService.IgdbGame("igdb_steam_100", "Stardew Valley"); doReturn(Optional.of(steamHit)).when(igdbService).findBySteamAppId("100"); + when(gameMappingRepository.findBySourceTypeAndSourceId(any(), any())).thenReturn(Optional.empty()); GameBinding b = new GameBinding(); b.setSourceType(GameBinding.SourceType.STEAM); b.setSourceId("100"); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDetectedGameTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDetectedGameTest.java new file mode 100644 index 000000000..d723e51bc --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDetectedGameTest.java @@ -0,0 +1,193 @@ +package fr.enimaloc.catapult.service.igdb; + +import fr.enimaloc.catapult.domain.binding.GameBinding; +import fr.enimaloc.catapult.domain.binding.GameMapping; +import fr.enimaloc.catapult.domain.igdb.IgdbGameCacheEntry; +import fr.enimaloc.catapult.repository.binding.GameMappingRepository; +import fr.enimaloc.catapult.repository.igdb.IgdbGameCacheRepository; +import fr.enimaloc.catapult.repository.igdb.IgdbGameCclRepository; +import fr.enimaloc.catapult.repository.igdb.IgdbGameExternalIdRepository; +import fr.enimaloc.catapult.repository.twitch.TwitchCclDefinitionRepository; +import fr.enimaloc.catapult.service.steam.SteamStoreService; +import fr.enimaloc.catapult.simulation.SimulationGates; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; +import proto.ExternalGame; +import proto.Game; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** {@link IgdbService#resolveDetected}: a mapping's IGDB game first, never a name search for Battle.net. */ +class IgdbServiceDetectedGameTest { + + private static final long STEAM = 1; + + private final IgdbClient igdb = mock(IgdbClient.class); + private final IgdbGameCacheRepository cache = mock(IgdbGameCacheRepository.class); + private final IgdbGameExternalIdRepository externalIds = mock(IgdbGameExternalIdRepository.class); + private final SteamStoreService steamStore = mock(SteamStoreService.class); + private final GameMappingRepository mappings = mock(GameMappingRepository.class); + private final IgdbService service = spy(new IgdbService(igdb, cache, externalIds, + mock(IgdbGameCclRepository.class), mock(TwitchCclDefinitionRepository.class), steamStore, + mock(RestClient.class), new SimpleMeterRegistry(), SimulationGates.none(), mappings)); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "clientId", "client"); + ReflectionTestUtils.setField(service, "clientSecret", "secret"); + ReflectionTestUtils.setField(service, "cacheTtlHours", 24); + ReflectionTestUtils.setField(service, "steamSourceId", STEAM); + ReflectionTestUtils.setField(service, "appAccessToken", "token"); + ReflectionTestUtils.setField(service, "tokenExpiresAt", Instant.now().plusSeconds(3600)); + when(cache.findById(anyString())).thenReturn(Optional.empty()); + when(externalIds.findBySourceIdAndUid(anyLong(), anyString())).thenReturn(Optional.empty()); + when(steamStore.resolveEffectiveApp(anyString())).thenReturn(Optional.empty()); + when(mappings.findBySourceTypeAndSourceId(any(), anyString())).thenReturn(Optional.empty()); + } + + private void mapped(GameBinding.SourceType sourceType, String sourceId, String igdbId) { + GameMapping mapping = new GameMapping(); + mapping.setSourceType(sourceType); + mapping.setSourceId(sourceId); + mapping.setIgdbId(igdbId); + when(mappings.findBySourceTypeAndSourceId(sourceType, sourceId)).thenReturn(Optional.of(mapping)); + } + + @SuppressWarnings("unchecked") + private void knownName(String igdbId, String name) { + ((Map) ReflectionTestUtils.getField(service, "igdbGameCache")).put(igdbId, name); + } + + @Test + void mappedIgdbIdWinsOverPlatformAndName() { + mapped(GameBinding.SourceType.STEAM, "570", "123"); + knownName("123", "Mapped Game"); + + assertThat(service.resolveDetected(GameBinding.SourceType.STEAM, "570", "Dota 2")) + .contains(new IgdbService.IgdbGame("123", "Mapped Game")); + + verify(service, never()).findBySteamAppId(anyString()); + verify(service, never()).findByName(anyString()); + verify(igdb, never()).searchByName(anyString(), anyString()); + verify(igdb, never()).findExternalGameByUid(anyString(), anyLong(), anyString()); + } + + @Test + void battleNetWithoutMappingIsNotSearchedByName() { + assertThat(service.resolveDetected(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch")).isEmpty(); + + verify(service, never()).findByName(anyString()); + verifyNoInteractions(igdb); + } + + @Test + void battleNetMappingWithoutIgdbIdIsNotSearchedByName() { + mapped(GameBinding.SourceType.BATTLENET, "Pro", null); + + assertThat(service.resolveDetected(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch")).isEmpty(); + + verify(service, never()).findByName(anyString()); + verifyNoInteractions(igdb); + } + + @Test + void steamWithoutMappingKeepsAppIdThenName() { + when(igdb.findExternalGameByUid("570", STEAM, "token")).thenReturn(List.of()); + when(igdb.searchByName("Dota 2", "token")).thenReturn(List.of(Game.newBuilder().setId(7).setName("Dota 2").build())); + + assertThat(service.resolveDetected(GameBinding.SourceType.STEAM, "570", "Dota 2")) + .contains(new IgdbService.IgdbGame("7", "Dota 2")); + + InOrder order = inOrder(igdb); + order.verify(igdb).findExternalGameByUid("570", STEAM, "token"); + order.verify(igdb).searchByName("Dota 2", "token"); + } + + @Test + void steamWithoutMappingStopsAtTheAppId() { + when(igdb.findExternalGameByUid("570", STEAM, "token")).thenReturn(List.of(ExternalGame.newBuilder() + .setGame(Game.newBuilder().setId(7).setName("Dota 2")).build())); + + assertThat(service.resolveDetected(GameBinding.SourceType.STEAM, "570", "Dota 2")) + .contains(new IgdbService.IgdbGame("7", "Dota 2")); + + verify(igdb, never()).searchByName(anyString(), anyString()); + } + + @Test + void resolveUnmappedLeavesTheMappingsIgdbGameAside() { + mapped(GameBinding.SourceType.STEAM, "570", "123"); + mapped(GameBinding.SourceType.BATTLENET, "Pro", "456"); + when(igdb.findExternalGameByUid("570", STEAM, "token")).thenReturn(List.of(ExternalGame.newBuilder() + .setGame(Game.newBuilder().setId(7).setName("Dota 2")).build())); + + assertThat(service.resolveUnmapped(GameBinding.SourceType.STEAM, "570", "Dota 2")) + .contains(new IgdbService.IgdbGame("7", "Dota 2")); + assertThat(service.resolveUnmapped(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch")).isEmpty(); + + verify(mappings, never()).findBySourceTypeAndSourceId(any(), anyString()); + verify(service, never()).findByName(anyString()); + } + + @Test + void resolveIgdbIdForBindingUsesTheMapping() { + mapped(GameBinding.SourceType.BATTLENET, "Pro", "456"); + knownName("456", "Overwatch 2"); + GameBinding binding = new GameBinding(); + binding.setSourceType(GameBinding.SourceType.BATTLENET); + binding.setSourceId("Pro"); + binding.setSourceName("Overwatch"); + + assertThat(service.resolveIgdbIdForBinding(binding)).contains("456"); + + verify(service, never()).findByName(anyString()); + } + + @Test + void mappedGameNameIsFetchedOnceThenRemembered() { + mapped(GameBinding.SourceType.BATTLENET, "Pro", "456"); + when(igdb.fetchGameById(eq("456"), anyString(), eq("token"))) + .thenReturn(List.of(Game.newBuilder().setId(456).setName("Overwatch 2").build())); + + assertThat(service.resolveDetected(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch")) + .contains(new IgdbService.IgdbGame("456", "Overwatch 2")); + assertThat(service.resolveDetected(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch")) + .contains(new IgdbService.IgdbGame("456", "Overwatch 2")); + + verify(igdb, times(1)).fetchGameById(eq("456"), anyString(), eq("token")); + verify(cache).save(any(IgdbGameCacheEntry.class)); + verify(igdb, never()).searchByName(anyString(), anyString()); + } + + @Test + void mappedGameKeepsItsIdWhenIgdbIsUnavailable() { + mapped(GameBinding.SourceType.BATTLENET, "Pro", "456"); + ReflectionTestUtils.setField(service, "clientId", ""); + + assertThat(service.resolveDetected(GameBinding.SourceType.BATTLENET, "Pro", "Overwatch")) + .contains(new IgdbService.IgdbGame("456", null)); + + verifyNoInteractions(igdb); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceFlowsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceFlowsTest.java index f33ee71b0..a882c9cc8 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceFlowsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceFlowsTest.java @@ -1,5 +1,6 @@ package fr.enimaloc.catapult.service.igdb; +import fr.enimaloc.catapult.repository.binding.GameMappingRepository; import com.api.igdb.exceptions.RequestException; import fr.enimaloc.catapult.domain.binding.GameBinding; import fr.enimaloc.catapult.domain.igdb.IgdbGameCacheEntry; @@ -63,7 +64,7 @@ class IgdbServiceFlowsTest { private final SteamStoreService steamStore = mock(SteamStoreService.class); private final SimpleMeterRegistry meters = new SimpleMeterRegistry(); private final IgdbService service = new IgdbService(igdb, cache, externalIds, ccls, cclDefinitions, steamStore, - mock(RestClient.class), meters, SimulationGates.none()); + mock(RestClient.class), meters, SimulationGates.none(), mock(GameMappingRepository.class)); @BeforeEach void setUp() { diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceGameSearchTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceGameSearchTest.java new file mode 100644 index 000000000..35d138bb3 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceGameSearchTest.java @@ -0,0 +1,113 @@ +package fr.enimaloc.catapult.service.igdb; + +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; +import fr.enimaloc.catapult.repository.binding.GameMappingRepository; +import fr.enimaloc.catapult.repository.igdb.IgdbGameCacheRepository; +import fr.enimaloc.catapult.repository.igdb.IgdbGameCclRepository; +import fr.enimaloc.catapult.repository.igdb.IgdbGameExternalIdRepository; +import fr.enimaloc.catapult.repository.twitch.TwitchCclDefinitionRepository; +import fr.enimaloc.catapult.service.steam.SteamStoreService; +import fr.enimaloc.catapult.simulation.SimulationGates; +import com.google.protobuf.Timestamp; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; +import proto.Cover; +import proto.Game; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** {@link IgdbService#searchGameOptions}: the mapping form's IGDB game search. */ +class IgdbServiceGameSearchTest { + + private final IgdbClient igdb = mock(IgdbClient.class); + private final IgdbService service = new IgdbService(igdb, mock(IgdbGameCacheRepository.class), + mock(IgdbGameExternalIdRepository.class), mock(IgdbGameCclRepository.class), + mock(TwitchCclDefinitionRepository.class), mock(SteamStoreService.class), mock(RestClient.class), + new SimpleMeterRegistry(), SimulationGates.none(), mock(GameMappingRepository.class)); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "clientId", "client"); + ReflectionTestUtils.setField(service, "clientSecret", "secret"); + ReflectionTestUtils.setField(service, "appAccessToken", "token"); + ReflectionTestUtils.setField(service, "tokenExpiresAt", Instant.now().plusSeconds(3600)); + } + + private static Game.Builder game(long id, String name) { + return Game.newBuilder().setId(id).setName(name); + } + + private static Timestamp at(String instant) { + return Timestamp.newBuilder().setSeconds(Instant.parse(instant).getEpochSecond()).build(); + } + + @Test + void blankQueryIsEmptyWithoutCallingIgdb() { + assertThat(service.searchGameOptions(" ")).isEmpty(); + assertThat(service.searchGameOptions(null)).isEmpty(); + verifyNoInteractions(igdb); + } + + @Test + void blankTokenIsEmpty() { + ReflectionTestUtils.setField(service, "clientSecret", ""); + ReflectionTestUtils.setField(service, "appAccessToken", null); + + assertThat(service.searchGameOptions("doom")).isEmpty(); + verifyNoInteractions(igdb); + } + + @Test + void aClientFailureIsEmptyNotAnException() { + when(igdb.searchByName(anyString(), anyString())).thenThrow(new IllegalStateException("igdb down")); + + assertThat(service.searchGameOptions("doom")).isEmpty(); + } + + @Test + void gamesWithoutANameAreFilteredOut() { + when(igdb.searchByName("doom", "token")).thenReturn(List.of(game(1, "").build(), game(2, "Doom").build())); + + assertThat(service.searchGameOptions(" doom ")).extracting(IgdbGameOption::id).containsExactly("2"); + } + + @Test + void theReleaseYearIsInUtc() { + when(igdb.searchByName("doom", "token")).thenReturn(List.of( + game(1, "Last second").setFirstReleaseDate(at("2020-12-31T23:59:59Z")).build(), + game(2, "First second").setFirstReleaseDate(at("2021-01-01T00:00:00Z")).build(), + game(3, "Undated").build())); + + assertThat(service.searchGameOptions("doom")).extracting(IgdbGameOption::releaseYear) + .containsExactly(2020, 2021, null); + } + + @Test + void theCoverUrlComesFromTheImageIdOrIsNull() { + when(igdb.searchByName("doom", "token")).thenReturn(List.of( + game(1, "Covered").setCover(Cover.newBuilder().setImageId("co1abc").build()).build(), + game(2, "Bare").build())); + + assertThat(service.searchGameOptions("doom")).extracting(IgdbGameOption::coverUrl) + .containsExactly("https://images.igdb.com/igdb/image/upload/t_cover_small/co1abc.jpg", null); + } + + @Test + void theFoundNamesAreRemembered() { + when(igdb.searchByName("doom", "token")).thenReturn(List.of(game(1, "Doom").build(), game(2, "Doom II").build())); + + service.searchGameOptions("doom"); + + assertThat(service.getGameCache()).containsEntry("1", "Doom").containsEntry("2", "Doom II"); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceSimulationTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceSimulationTest.java index dca524e4f..ba81fccc9 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceSimulationTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceSimulationTest.java @@ -1,5 +1,6 @@ package fr.enimaloc.catapult.service.igdb; +import fr.enimaloc.catapult.repository.binding.GameMappingRepository; import fr.enimaloc.catapult.repository.igdb.IgdbGameCacheRepository; import fr.enimaloc.catapult.repository.igdb.IgdbGameCclRepository; import fr.enimaloc.catapult.repository.igdb.IgdbGameExternalIdRepository; @@ -30,7 +31,7 @@ private IgdbService service(ObjectProvider provider) { IgdbService service = new IgdbService(mock(IgdbClient.class), mock(IgdbGameCacheRepository.class), mock(IgdbGameExternalIdRepository.class), mock(IgdbGameCclRepository.class), mock(TwitchCclDefinitionRepository.class), mock(SteamStoreService.class), restClient, - new SimpleMeterRegistry(), provider); + new SimpleMeterRegistry(), provider, mock(GameMappingRepository.class)); ReflectionTestUtils.setField(service, "clientId", "client"); ReflectionTestUtils.setField(service, "clientSecret", "secret"); return service; diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceSimulationTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceSimulationTest.java index 39fc88ccc..36181d126 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceSimulationTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceSimulationTest.java @@ -49,7 +49,7 @@ class MinecraftFriendServiceSimulationTest { @BeforeEach void setUp() { bot.setId(UUID.randomUUID()); - bot.setLabel("CatapultBot1"); + bot.setMinecraftUsername("CatapultBot1"); member.setId(UUID.randomUUID()); link.setUser(member); link.setServiceAccount(bot); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceTest.java index 98f6d2791..c4924fdba 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftFriendServiceTest.java @@ -72,10 +72,10 @@ void setup() { ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); } - private MinecraftServiceAccount account(String label, int order) { + private MinecraftServiceAccount account(String username, int order) { var a = new MinecraftServiceAccount(); a.setId(UUID.randomUUID()); - a.setLabel(label); + a.setMinecraftUsername(username); a.setFillOrder(order); a.setEnabled(true); return a; diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenServiceTest.java index 1f9be37bb..6b03fd72c 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/minecraft/MinecraftTokenServiceTest.java @@ -39,7 +39,7 @@ void setup() { account = new MinecraftServiceAccount(); account.setId(UUID.randomUUID()); - account.setLabel("CatapultBot1"); + account.setMinecraftUsername("CatapultBot1"); account.setMsaRefreshToken("enc-refresh"); when(encryption.decrypt(anyString())).thenAnswer(inv -> { @@ -106,7 +106,7 @@ void evict_forcesRefreshOnNextCall() { void getToken_transientAccountWithoutId_validatesWithoutPersistingOrCaching() { // account not persisted yet (enrolment validation): a null id must neither NPE nor write to the database var transientAccount = new MinecraftServiceAccount(); - transientAccount.setLabel("Nouveau"); + transientAccount.setMinecraftUsername("Nouveau"); transientAccount.setMsaRefreshToken("enc-refresh"); assertThat(service.getToken(transientAccount)).contains("mc-token"); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisherTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisherTest.java index 1564c9310..0585205e8 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisherTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/notification/ChannelEventPublisherTest.java @@ -156,6 +156,15 @@ void minecraftLinkChanged_publishesTheLinkState() { new ChannelEvents.MinecraftLinkChanged(ownerId.toString(), "streamername", "PENDING", "jeb_")); } + @Test + void battleNetLinkChanged_publishesTheLinkState() { + publisher.battleNetLinkChanged(ownerId, "PENDING", "Streamer#2928", "CatapultBot#1234"); + + assertThat(published(ChannelEvents.BATTLENET_LINK_CHANGED)).isEqualTo( + new ChannelEvents.BattleNetLinkChanged(ownerId.toString(), "streamername", "PENDING", + "Streamer#2928", "CatapultBot#1234")); + } + @Test void customBindingChanged_publishesTheActiveOne() { UUID active = UUID.randomUUID(); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/simulation/account/FictitiousAccountServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/simulation/account/FictitiousAccountServiceTest.java index 88ff9affd..78a802c3c 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/simulation/account/FictitiousAccountServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/simulation/account/FictitiousAccountServiceTest.java @@ -106,7 +106,12 @@ void setUp() { CatapultOAuth2UserService members = new CatapultOAuth2UserService(accounts, tokens, settings, getters, mock(TokenEncryptionService.class), events, mock(RestClient.class), mock(WhitelistService.class), - mock(AdminMigrationService.class), mock(InviteService.class)); + mock(AdminMigrationService.class), mock(InviteService.class), + mock(fr.enimaloc.catapult.service.battlenet.BattleNetGateService.class), + new org.springframework.beans.factory.support.StaticListableBeanFactory() + .getBeanProvider(fr.enimaloc.catapult.service.battlenet.BattleNetFriendService.class), + new org.springframework.transaction.support.TransactionTemplate( + mock(org.springframework.transaction.PlatformTransactionManager.class))); SimulationProperties properties = new SimulationProperties(); properties.setStateFile(dir.resolve("simulation.json").toString()); registry = new SimulationRegistry(properties, accounts, tokens, mock(MinecraftFriendLinkRepository.class), diff --git a/catapult-api/src/test/resources/access-matrix.csv b/catapult-api/src/test/resources/access-matrix.csv index bb1dad6a5..181cd672a 100644 --- a/catapult-api/src/test/resources/access-matrix.csv +++ b/catapult-api/src/test/resources/access-matrix.csv @@ -66,6 +66,8 @@ POST,/api/internal/users/{userId}/chat-commands/presets/{presetKey},userId=self, DELETE,/api/internal/users/{userId}/chat-commands/{id},userId=self,,401,401,GATED,GATED,GATED,GATED,401 PUT,/api/internal/users/{userId}/chat-commands/{id},userId=self,"{""name"":""!matrix"",""template"":""matrix"",""permission"":""VIEWERS"",""enabled"":false}",401,401,GATED,GATED,GATED,GATED,401 POST,/api/internal/users/{userId}/chat-commands/{id}/test,userId=self,,401,401,GATED,GATED,GATED,GATED,401 +DELETE,/api/internal/users/{userId}/links/battlenet,userId=self,,401,401,GATED,GATED,GATED,GATED,401 +GET,/api/internal/users/{userId}/links/battlenet,userId=self,,401,401,PASS,PASS,PASS,PASS,401 DELETE,/api/internal/users/{userId}/links/minecraft,userId=self,,401,401,GATED,GATED,GATED,GATED,401 GET,/api/internal/users/{userId}/links/minecraft,userId=self,,401,401,PASS,PASS,PASS,PASS,401 PUT,/api/internal/users/{userId}/links/minecraft,userId=self,,401,401,GATED,GATED,GATED,GATED,401 @@ -110,6 +112,8 @@ GET,/api/internal/users/{userId}/seen-features,unmapped,,401,401,403,403,PASS,40 PUT,/api/internal/users/{userId}/seen-features/{key},unmapped,,401,401,403,403,PASS,403,401 GET,/api/internal/users/{userId}/feedback,unmapped,,401,401,403,403,PASS,403,401 DELETE,/api/internal/users/{userId}/feedback/{id}/subscription,unmapped,,401,401,403,403,PASS,403,401 +DELETE,/api/internal/users/{userId}/links/battlenet,unmapped,,401,401,403,403,GATED,403,401 +GET,/api/internal/users/{userId}/links/battlenet,unmapped,,401,401,403,403,PASS,403,401 GET,/api/internal/users/{userId}/links/minecraft,unmapped,,401,401,403,403,PASS,403,401 PUT,/api/internal/users/{userId}/links/minecraft,unmapped,,401,401,403,403,GATED,403,401 POST,/api/internal/users/{userId}/experiment-feedback,unmapped,"{""experimentId"":""00000000-0000-0000-0000-00000000dead"",""npsScore"":5}",401,401,403,403,PASS,403,401 @@ -256,6 +260,7 @@ GET,/api/internal/admin/game-mappings,,,401,PASS,403,403,403,PASS,401 POST,/api/internal/admin/game-mappings,,"{""applyLive"":false}",401,401,403,403,403,PASS,401 GET,/api/internal/admin/game-mappings/categories,,,401,PASS,403,403,403,PASS,401 GET,/api/internal/admin/game-mappings/detected,,,401,PASS,403,403,403,PASS,401 +GET,/api/internal/admin/game-mappings/igdb-games,,,401,PASS,403,403,403,PASS,401 GET,/api/internal/admin/game-mappings/missing,,,401,PASS,403,403,403,PASS,401 DELETE,/api/internal/admin/game-mappings/{id},,,401,PASS,403,403,403,PASS,401 # handler reads the JWT itself: 401 without one @@ -305,6 +310,12 @@ POST,/api/internal/admin/social-links,,"{""platform"":""DISCORD"",""url"":""http PUT,/api/internal/admin/social-links/order,,,401,PASS,403,403,403,PASS,401 DELETE,/api/internal/admin/social-links/{id},id=1,,401,PASS,403,403,403,PASS,401 PUT,/api/internal/admin/social-links/{id},id=1,"{""platform"":""DISCORD"",""url"":""https://matrix.example"",""visible"":true}",401,PASS,403,403,403,PASS,401 +GET,/api/internal/admin/battlenet-accounts,,,401,PASS,403,403,403,PASS,401 +POST,/api/internal/admin/battlenet-accounts,,,401,PASS,403,403,403,PASS,401 +DELETE,/api/internal/admin/battlenet-accounts/{id},,,401,PASS,403,403,403,PASS,401 +PATCH,/api/internal/admin/battlenet-accounts/{id},,,401,PASS,403,403,403,PASS,401 +POST,/api/internal/admin/battlenet-accounts/{id}/code,,,401,PASS,403,403,403,PASS,401 +POST,/api/internal/admin/battlenet-accounts/{id}/reauth,,,401,PASS,403,403,403,PASS,401 GET,/api/internal/admin/minecraft-accounts,,,401,PASS,403,403,403,PASS,401 POST,/api/internal/admin/minecraft-accounts,,,401,PASS,403,403,403,PASS,401 POST,/api/internal/admin/minecraft-accounts/device-code,,,401,PASS,403,403,403,PASS,401 diff --git a/catapult-api/src/test/resources/route-budgets.csv b/catapult-api/src/test/resources/route-budgets.csv index 25ae126f2..c1f5e2eb3 100644 --- a/catapult-api/src/test/resources/route-budgets.csv +++ b/catapult-api/src/test/resources/route-budgets.csv @@ -28,8 +28,8 @@ GET,/api/internal/admin/experiments/{id}/assignments,?page=0,admin,6, # personal API token GET,/api/me/custom-bindings,,ptoken,12, GET,/api/me/drops,,ptoken,11,fixed:the token and the drops gate then the current game's binding; the campaigns are Catapult's copy of ttvdrops -GET,/api/me/game,,ptoken,8,fixed:the one game the channel plays -GET,/api/me/game/summary,,ptoken,8,fixed:the one game the channel plays +GET,/api/me/game,,ptoken,13,fixed:the one game the channel plays and resolved with its IGDB and DTDD data +GET,/api/me/game/summary,,ptoken,13,fixed:the one game the channel plays and resolved with its IGDB and DTDD data GET,/api/me/bindings/{bindingId},,ptoken,5,fixed:one binding # public, but GET /api/connect/steam/callback: it only redirects (no SQL) and every row must answer 2xx GET,/api/internal/changelog,,anon,0,fixed:read from a file @@ -51,11 +51,11 @@ GET,/api/v3/api-docs,,anon,0,fixed:the API description GET,/api/v3/api-docs/swagger-config,,anon,0,fixed:the API description # legacy public routes, open to the owner (api.legacy-routes experiment): the gate reads the experiment, and # for a widget token its assignment, on top of the route itself -GET,/api/game/{uuid},,anon,11,fixed:the one game the channel plays +GET,/api/game/{uuid},,anon,12,fixed:the one game the channel plays and resolved with its IGDB and DTDD data GET,/api/user/catapult/{bindingId},,anon,3,fixed:one binding GET,/api/user/dtdd/{dtddId},dtddId=1,anon,2,fixed:one DTDD title -GET,/api/user/game/{uuid},,anon,11,fixed:the one game the channel plays +GET,/api/user/game/{uuid},,anon,16,fixed:the one game the channel plays and resolved with its IGDB and DTDD data GET,/api/user/igdb/{igdbId},igdbId=1,anon,2,fixed:one IGDB game GET,/api/user/steam/{appId},appId=990000,anon,1,fixed:one Steam app -GET,/api/user/summary/{uuid},,anon,11,fixed:the one game the channel plays +GET,/api/user/summary/{uuid},,anon,16,fixed:the one game the channel plays and resolved with its IGDB and DTDD data GET,/api/user/xbox/{productId},productId=9NBLGGH4R315,anon,1,fixed:one Microsoft Store product diff --git a/catapult-bgs/build.gradle.kts b/catapult-bgs/build.gradle.kts new file mode 100644 index 000000000..53ac876e7 --- /dev/null +++ b/catapult-bgs/build.gradle.kts @@ -0,0 +1,148 @@ +import com.google.protobuf.DescriptorProtos +import org.gradle.process.ExecOperations +import org.springframework.boot.gradle.plugin.SpringBootPlugin +import javax.inject.Inject + +buildscript { + repositories { + mavenCentral() + } + dependencies { + // Reads and patches bgs.desc in the generation task + classpath("com.google.protobuf:protobuf-java:4.29.3") + } +} + +plugins { + `java-library` + // Not applied (a library, no boot jar): only on the classpath for the BOM coordinates below + id("org.springframework.boot") apply false + id("io.spring.dependency-management") +} + +description = "catapult-bgs" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +dependencyManagement { + imports { + // The Boot version the other modules build with (gradle.properties) + mavenBom(SpringBootPlugin.BOM_COORDINATES) + } +} + +// The runtime and the compiler must stay on the same version +val protobufVersion = "4.29.3" + +dependencies { + api("com.google.protobuf:protobuf-java:$protobufVersion") + + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.assertj:assertj-core") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +// protoc binary, resolved from Maven Central for the build machine's OS and architecture +val protocClassifier: String = run { + val os = System.getProperty("os.name").lowercase() + val arch = System.getProperty("os.arch").lowercase() + val osPart = when { + os.contains("linux") -> "linux" + os.contains("mac") || os.contains("darwin") -> "osx" + os.contains("windows") -> "windows" + else -> error("Unsupported OS for protoc: $os") + } + val archPart = when (arch) { + "amd64", "x86_64" -> "x86_64" + "aarch64", "arm64" -> "aarch_64" + else -> error("Unsupported architecture for protoc: $arch") + } + "$osPart-$archPart" +} +val protoc = configurations.create("protoc") { + isCanBeConsumed = false + isCanBeResolved = true +} +dependencies { + protoc("com.google.protobuf:protoc:$protobufVersion:$protocClassifier@exe") +} + +/** + * Generates the Java classes of every .proto file packed in bgs.desc (a FileDescriptorSet), so the + * schemas are not duplicated as .proto sources. + */ +@CacheableTask +abstract class GenerateBgsProto @Inject constructor(private val exec: ExecOperations) : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val descriptorSet: RegularFileProperty + + @get:InputFiles + @get:Classpath + abstract val protocExecutable: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + // Keeps bgs.desc pristine: two files can share a basename (embed_types.proto, ...) and some have no package, + // which would make protoc write the same Java file twice or generate into the default package + private fun patch(set: DescriptorProtos.FileDescriptorSet): DescriptorProtos.FileDescriptorSet { + fun camel(path: String) = path.removeSuffix(".proto").split('/', '_') + .joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } + fun javaPackage(f: DescriptorProtos.FileDescriptorProto) = + if (f.options.hasJavaPackage()) f.options.javaPackage + else if (f.`package`.isEmpty()) "bgs.protocol.unpackaged" else f.`package` + fun outerClass(f: DescriptorProtos.FileDescriptorProto) = + if (f.options.hasJavaOuterClassname()) f.options.javaOuterClassname + else camel(f.name.substringAfterLast('/')) + + val builder = set.toBuilder() + builder.fileBuilderList.forEach { f -> + if (f.`package`.isEmpty() && !f.options.hasJavaPackage()) f.optionsBuilder.javaPackage = javaPackage(f.build()) + } + val colliding = builder.fileBuilderList.groupBy { javaPackage(it.build()) to outerClass(it.build()) } + .filterValues { it.size > 1 }.values.flatten() + colliding.forEach { it.optionsBuilder.javaOuterClassname = camel(it.name) } + return builder.build() + } + + @TaskAction + fun generate() { + val protoc = protocExecutable.singleFile + protoc.setExecutable(true) + val desc = descriptorSet.get().asFile + val out = outputDir.get().asFile + out.deleteRecursively() + out.mkdirs() + + val patched = patch(DescriptorProtos.FileDescriptorSet.parseFrom(desc.readBytes())) + val patchedFile = File(out.parentFile, "bgs-patched.desc") + patchedFile.writeBytes(patched.toByteArray()) + // descriptor.proto ships with protobuf-java + val files = patched.fileList.map { it.name }.filter { it != "google/protobuf/descriptor.proto" } + check(files.isNotEmpty()) { "No .proto file found in $desc" } + + exec.exec { + commandLine(listOf(protoc.absolutePath, "--descriptor_set_in=${patchedFile.absolutePath}", + "--java_out=${out.absolutePath}") + files) + } + } +} + +val generateBgsProto = tasks.register("generateBgsProto") { + descriptorSet = layout.projectDirectory.file("src/main/resources/bgs.desc") + protocExecutable.from(protoc) + outputDir = layout.buildDirectory.dir("generated/sources/bgs-proto/java") +} + +sourceSets.main { + java.srcDir(generateBgsProto) +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsClient.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsClient.java new file mode 100644 index 000000000..497eef93f --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsClient.java @@ -0,0 +1,734 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.RpcProto.NoData; +import bgs.protocol.account.v1.AccountServiceOuterClass.AccountStateNotification; +import bgs.protocol.account.v1.AccountTypes.GameAccountList; +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.authentication.v2.client.AuthenticationListenerOuterClass.ExternalChallengeNotification; +import bgs.protocol.authentication.v2.client.AuthenticationListenerOuterClass.LogonCompleteNotification; +import bgs.protocol.authentication.v2.client.AuthenticationTypes.LogonRecord; +import bgs.protocol.connection.v1.ConnectionServiceProto.ConnectResponse; +import bgs.protocol.connection.v1.ConnectionServiceProto.DisconnectNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.FriendAddedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.FriendRemovedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.ReceivedInvitationAddedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.ReceivedInvitationRemovedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.SentInvitationAddedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.SentInvitationRemovedNotification; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetFriendsResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetReceivedInvitationsResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetSentInvitationsResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.SubscribeRequest; +import bgs.protocol.friends.v2.client.FriendsTypes.Friend; +import bgs.protocol.friends.v2.client.FriendsTypes.ReceivedInvitation; +import bgs.protocol.friends.v2.client.FriendsTypes.RemovedFriendAssignment; +import bgs.protocol.friends.v2.client.FriendsTypes.RemovedInvitationAssignment; +import bgs.protocol.friends.v2.client.FriendsTypes.SentInvitation; +import bgs.protocol.presence.v2.client.PresenceServiceOuterClass.BatchSubscribeRequest; +import bgs.protocol.presence.v2.client.PresenceServiceOuterClass.BatchUnsubscribeRequest; +import com.google.protobuf.Message; +import fr.enimaloc.catapult.bgs.client.BgsListener.CloseReason; +import fr.enimaloc.catapult.bgs.core.BgsChannel; +import fr.enimaloc.catapult.bgs.core.BgsRpcException; +import fr.enimaloc.catapult.bgs.proto.ServiceMethod; + +import java.io.IOException; +import java.lang.System.Logger.Level; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.LongFunction; + +import static fr.enimaloc.catapult.bgs.proto.BgsServices.*; + +/** + * A Battle.net bot session: logon, friends list and friends' presence, on a session opened by the caller (Task 5 + * reconnects by calling {@link #start} again on a new session with the same client). + * + *

    State of a session lives on its loop ({@link BgsChannel#loop()}); listener callbacks run there. The friends list + * and the invitations survive across sessions: each new session resyncs them and reports the difference. The local + * friends state changes only on pushes and sync results, never on the reply to an action. + */ +public final class BgsClient { + + private static final System.Logger LOG = System.getLogger(BgsClient.class.getName()); + + /** ForceDisconnect code sent when another session opens the same account. */ + private static final int SESSION_REPLACED_CODE = 60; + private static final int ERROR_INVALID_ARGS = 7; + + /** Connection settings. {@code email} goes into the logon options; it is masked in {@link #toString()}. */ + public record BgsClientConfig(String host, int port, String email, String locale) { + + @Override + public String toString() { + return "BgsClientConfig[host=" + host + ", port=" + port + ", email=***, locale=" + locale + "]"; + } + } + + private record Invitation(long accountId, String battleTag) { + } + + private final BgsClientConfig cfg; + private final TicketProvider tickets; + private final BgsListener listener; + private final Executor mintExecutor; + // Kept across sessions; written on the current session loop, read from any thread + private final Map friends = new ConcurrentHashMap<>(); + private final Map receivedInvitations = new ConcurrentHashMap<>(); + private final Map sentInvitations = new ConcurrentHashMap<>(); + private final PresenceTracker presence = new PresenceTracker(); + private volatile Session current; + + /** Mints tickets on virtual threads. */ + public BgsClient(BgsClientConfig cfg, TicketProvider tickets, BgsListener listener) { + this(cfg, tickets, listener, Executors.newVirtualThreadPerTaskExecutor()); + } + + /** {@code mintExecutor} runs the blocking {@link TicketProvider#mint()}, away from the session loop. */ + public BgsClient(BgsClientConfig cfg, TicketProvider tickets, BgsListener listener, Executor mintExecutor) { + this.cfg = cfg; + this.tickets = tickets; + this.listener = listener; + this.mintExecutor = mintExecutor; + } + + /** + * Runs the session sequence on an open channel: Connect, ticket mint, Logon, account subscribe, game account + * selection, then the friends sync and the presence watch. + * + * @return completes once the session is ready, meaning logon complete and game account selected (the presence + * watch starts later, on the self presence push); fails with {@link TicketException} when minting fails, + * {@link BgsRpcException} when the logon is refused (with the logon error code), or the cause of an early close + * @throws IllegalStateException when the previous session is still open + */ + public CompletableFuture start(BgsChannel channel) { + Session previous = current; + if (previous != null && !previous.closed.isDone()) { + throw new IllegalStateException("A BGS session is already running"); + } + Session session = new Session(channel); + current = session; + try { + channel.loop().execute(session::begin); + } catch (RejectedExecutionException e) { + session.started.completeExceptionally(new IOException("BGS session already closed", e)); + session.onChannelClosed(e); + } + return session.started; + } + + /** + * Completes with the reason the current session ended: once per session, along with + * {@link BgsListener#onClosed}. A local {@link #close()} reports {@link CloseReason#LOCAL}. + * + * @throws IllegalStateException before the first {@link #start} + */ + public CompletableFuture closed() { + Session session = current; + if (session == null) { + throw new IllegalStateException("No BGS session started"); + } + return session.closed; + } + + /** True while the current session is logged on with its game account selected. */ + public boolean ready() { + Session session = current; + return session != null && session.ready && !session.closed.isDone(); + } + + /** Invites a BattleTag; the invitation shows up later as an {@link FriendEvent.InvitationSent} push. */ + public CompletableFuture sendInvitation(String battleTag) { + return action("SendInvitation", FRIENDS_SEND_INVITATION, BgsRequests.sendInvitation(battleTag)); + } + + public CompletableFuture acceptInvitation(long invitationId) { + return action("AcceptInvitation", FRIENDS_ACCEPT_INVITATION, BgsRequests.acceptInvitation(invitationId)); + } + + /** Declines a received invitation (IgnoreInvitation: friends v2 has no Decline). */ + public CompletableFuture declineInvitation(long invitationId) { + return action("IgnoreInvitation", FRIENDS_IGNORE_INVITATION, BgsRequests.declineInvitation(invitationId)); + } + + /** Cancels an invitation the bot sent. Not tested against the real servers yet. */ + public CompletableFuture revokeInvitation(long invitationId) { + return action("RevokeInvitation", FRIENDS_REVOKE_INVITATION, BgsRequests.revokeInvitation(invitationId)); + } + + public CompletableFuture removeFriend(long accountId) { + return action("RemoveFriend", FRIENDS_REMOVE_FRIEND, BgsRequests.removeFriend(accountId)); + } + + /** A copy of the friends list: account id to BattleTag. */ + public Map friends() { + return Map.copyOf(friends); + } + + /** Sends a KeepAlive (no reply expected) on the current session when it is ready; a no-op otherwise. */ + public void keepAlive() { + Session session = current; + if (session == null || !session.ready || session.closed.isDone()) { + return; + } + try { + session.channel.loop().execute(() -> { + if (session.live() && session.ready) { + session.channel.callNoReply(CONNECTION_KEEP_ALIVE, NoData.getDefaultInstance()); + } + }); + } catch (RejectedExecutionException e) { + // The loop is already shut down: the session is closed + } + } + + /** Closes the current session, if any; its close reason is {@link CloseReason#LOCAL}. */ + public void close() { + Session session = current; + if (session != null) { + try { + session.channel.loop().execute(session::closeLocally); + } catch (RejectedExecutionException e) { + // The loop is already shut down: the session is closed + } + } + } + + /** Closes the current session as a lost connection ({@link CloseReason#IO}), so a supervisor retries. */ + public void abort() { + Session session = current; + if (session != null) { + try { + session.channel.loop().execute(session::abort); + } catch (RejectedExecutionException e) { + // The loop is already shut down: the session is closed + } + } + } + + /** + * Fails at once, without queueing, when no session is ready. The reply never changes the local state: the push + * that follows (often before the reply) does. + */ + private CompletableFuture action(String what, ServiceMethod method, Message request) { + Session session = current; + if (session == null || !session.ready || session.closed.isDone()) { + return CompletableFuture.failedFuture(new IllegalStateException("The BGS session is not ready")); + } + CompletableFuture result = new CompletableFuture<>(); + session.channel.call(method, request).whenComplete((reply, error) -> { + if (error == null) { + result.complete(null); + return; + } + Throwable cause = unwrap(error); + LOG.log(Level.WARNING, "BGS " + what + " failed: " + describe(cause)); + result.completeExceptionally(cause); + }); + return result; + } + + private void notifyListener(String what, Runnable call) { + try { + call.run(); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, "BGS listener " + what + " failed", e); + } + } + + private static Throwable unwrap(Throwable error) { + return error instanceof CompletionException && error.getCause() != null ? error.getCause() : error; + } + + private static String describe(Throwable error) { + return error instanceof BgsRpcException rpc ? "status " + rpc.status() : error.getClass().getSimpleName(); + } + + /** One session: its sequence flags, handlers and outcome. Everything below runs on the channel loop. */ + private final class Session { + + final BgsChannel channel; + final CompletableFuture started = new CompletableFuture<>(); + final CompletableFuture closed = new CompletableFuture<>(); + volatile boolean ready; + private CloseReason closeReason; + private boolean selecting; + private boolean presenceReady; + private int connectedRegion; + private long accountId; + private String battleTag = ""; + private List logonGameAccounts = List.of(); + + Session(BgsChannel channel) { + this.channel = channel; + } + + /** Pushes and replies of a session that ended, or was replaced, are ignored. */ + private boolean live() { + return current == this && !closed.isDone(); + } + + void begin() { + presence.clear(); + channel.onClose(this::onChannelClosed); + channel.on(CONNECTION_FORCE_DISCONNECT, this::onForceDisconnect); + channel.on(AUTHENTICATION_LISTENER_ON_LOGON_COMPLETE, this::onLogonComplete); + channel.on(AUTHENTICATION_LISTENER_ON_LOGON_QUEUE_UPDATE, + n -> LOG.log(Level.INFO, "BGS logon queued")); + channel.on(AUTHENTICATION_LISTENER_ON_EXTERNAL_CHALLENGE, this::onExternalChallenge); + channel.on(ACCOUNT_LISTENER_ON_ACCOUNT_STATE_UPDATED, this::onAccountState); + channel.on(FRIENDS_LISTENER_ON_FRIEND_ADDED, this::onFriendAdded); + channel.on(FRIENDS_LISTENER_ON_FRIEND_REMOVED, this::onFriendRemoved); + channel.on(FRIENDS_LISTENER_ON_RECEIVED_INVITATION_ADDED, this::onReceivedInvitationAdded); + channel.on(FRIENDS_LISTENER_ON_RECEIVED_INVITATION_REMOVED, this::onReceivedInvitationRemoved); + channel.on(FRIENDS_LISTENER_ON_SENT_INVITATION_ADDED, this::onSentInvitationAdded); + channel.on(FRIENDS_LISTENER_ON_SENT_INVITATION_REMOVED, this::onSentInvitationRemoved); + channel.on(PRESENCE_LISTENER_ON_PRESENCE_STATE_UPDATED, n -> { + if (live()) { + presence.apply(n).forEach(e -> notifyListener("onPresence", () -> listener.onPresence(e))); + } + }); + channel.on(PRESENCE_LISTENER_ON_SUBSCRIBE_FAILURE, + n -> LOG.log(Level.WARNING, "BGS presence subscription failure pushed")); + channel.onRaw(SELF_PRESENCE_HASH, 1, body -> onSelfPresence()); + channel.onRaw(SELF_PRESENCE_HASH, 2, body -> LOG.log(Level.WARNING, + "BGS account opened by another session: a ForceDisconnect is coming")); + + channel.call(CONNECTION_CONNECT, BgsRequests.connect()).whenComplete((reply, error) -> { + if (!live()) { + return; + } + if (error != null) { + fail(CloseReason.IO, unwrap(error)); + return; + } + onConnected(reply); + }); + } + + private void onConnected(ConnectResponse reply) { + channel.setCiid(reply.getCiid()); + connectedRegion = reply.getConnectedRegion(); + // The ticket is minted only now, between Connect and Logon: earlier, the logon raises OnExternalChallenge + mintThen(ticket -> channel.call(AUTHENTICATION_LOGON, BgsRequests.logon(ticket, cfg)) + .whenComplete((ack, error) -> { + if (error != null && live()) { + LOG.log(Level.ERROR, "BGS logon refused: " + describe(unwrap(error))); + fail(CloseReason.LOGON_FAILED, unwrap(error)); + } + })); + } + + /** + * Mints off the loop, then continues on it; a mint failure ends the session. A session that ends while + * the mint runs interrupts it, so the provider knows nobody waits for that ticket any more. + */ + private void mintThen(Consumer next) { + CompletableFuture minted; + AtomicReference minter = new AtomicReference<>(); + try { + minted = CompletableFuture.supplyAsync(() -> { + Thread self = Thread.currentThread(); + minter.set(self); + try { + if (closed.isDone()) { + throw new TicketException("The BGS session ended before minting", false); + } + return tickets.mint(); + } catch (TicketException e) { + throw new CompletionException(e); + } catch (RuntimeException e) { + throw new CompletionException(new TicketException("Ticket minting failed", false, e)); + } finally { + if (!minter.compareAndSet(self, null)) { + // the session ended just as the mint returned: drop its interrupt, the thread lives on + Thread.interrupted(); + } + } + }, mintExecutor); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, "BGS ticket minting could not be scheduled: " + describe(e)); + fail(CloseReason.IO, e); + return; + } + closed.whenComplete((reason, error) -> { + Thread running = minter.getAndSet(null); + if (running != null) { + running.interrupt(); + } + }); + minted.whenCompleteAsync((ticket, error) -> { + if (!live()) { + return; + } + if (error == null) { + next.accept(ticket); + return; + } + Throwable cause = unwrap(error); + boolean codeRequired = cause instanceof TicketException te && te.codeRequired(); + LOG.log(Level.WARNING, "BGS ticket minting failed (code required: " + codeRequired + "): " + + cause.getMessage()); + fail(codeRequired ? CloseReason.CODE_REQUIRED : CloseReason.IO, cause); + }, channel.loop()); + } + + private void onLogonComplete(LogonCompleteNotification n) { + if (!live() || accountId != 0) { + return; + } + if (n.getErrorCode() != 0) { + LOG.log(Level.ERROR, "BGS logon failed with error code " + n.getErrorCode()); + fail(CloseReason.LOGON_FAILED, new BgsRpcException(n.getErrorCode())); + return; + } + LogonRecord record = n.getRecord(); + if (record.getAccountId() == 0) { + LOG.log(Level.ERROR, "BGS logon complete without an account record"); + fail(CloseReason.LOGON_FAILED, new IllegalStateException("Logon complete without an account record")); + return; + } + accountId = record.getAccountId(); + battleTag = record.getBattleTag(); + logonGameAccounts = record.getGameAccountList(); + LOG.log(Level.INFO, "BGS logon complete for account " + accountId); + // Presence goes on at OnAccountStateUpdated: the game account to select comes with it + channel.call(ACCOUNT_SUBSCRIBE, BgsRequests.accountSubscribe(accountId)).whenComplete((r, error) -> { + if (error != null) { + LOG.log(Level.WARNING, "BGS account subscribe failed: " + describe(unwrap(error))); + if (live()) { + fail(CloseReason.IO, unwrap(error)); + } + } + }); + } + + private void onExternalChallenge(ExternalChallengeNotification n) { + if (!live()) { + return; + } + // Never seen live: the ticket minted between Connect and Logon avoids it + LOG.log(Level.WARNING, "BGS external challenge received (payload type '" + n.getPayloadType() + + "', logon " + (accountId != 0 ? "complete" : "pending") + + "): untested path, re-minting a ticket and sending VerifyAuthToken on the same session"); + mintThen(ticket -> channel.call(AUTHENTICATION_VERIFY_AUTH_TOKEN, BgsRequests.verifyAuthToken(ticket)) + .whenComplete((ack, error) -> { + if (error == null) { + LOG.log(Level.WARNING, "BGS VerifyAuthToken accepted"); + } else { + LOG.log(Level.ERROR, "BGS VerifyAuthToken failed: " + describe(unwrap(error))); + } + })); + } + + private void onAccountState(AccountStateNotification n) { + // The select is done once per session: later account states are ignored + if (!live() || selecting || accountId == 0) { + return; + } + List candidates = new ArrayList<>(logonGameAccounts); + for (GameAccountList list : n.getAccountState().getGameAccountsList()) { + list.getHandleList().forEach(h -> candidates.add(GameAccountHandle.newBuilder() + .setId(h.getId()).setTitleId(h.getProgram()).setRegion(h.getRegion()).build())); + } + GameAccountHandle chosen = GameAccountSelector.select(candidates, connectedRegion); + if (chosen == null) { + LOG.log(Level.ERROR, "BGS account " + accountId + " has no game account to select: no presence"); + fail(CloseReason.IO, new IllegalStateException("No game account to select")); + return; + } + selecting = true; + channel.callRaw(SELECT_GAME_ACCOUNT_HASH, 1, BgsRequests.selectGameAccount(chosen), true) + .whenComplete((body, error) -> { + if (!live()) { + return; + } + if (error != null) { + LOG.log(Level.ERROR, "BGS game account select failed: " + describe(unwrap(error))); + fail(CloseReason.IO, unwrap(error)); + return; + } + onSelected(); + }); + } + + private void onSelected() { + ready = true; + LOG.log(Level.INFO, "BGS session ready for account " + accountId); + started.complete(null); + notifyListener("onReady", () -> listener.onReady(accountId, battleTag)); + // Presence subscriptions wait for the self presence push (onSelfPresence) + channel.call(FRIENDS_SUBSCRIBE, SubscribeRequest.getDefaultInstance()).whenComplete((r, error) -> { + if (error != null) { + LOG.log(Level.WARNING, "BGS friends subscribe failed: " + describe(unwrap(error))); + } + }); + syncFriends(); + syncReceivedInvitations(); + syncSentInvitations(); + } + + private void onSelfPresence() { + if (!live() || presenceReady) { + return; + } + presenceReady = true; + batchSubscribe(List.of(accountId)); + if (!friends.isEmpty()) { + batchSubscribe(friends.keySet().stream().sorted().toList()); + } + } + + private void onForceDisconnect(DisconnectNotification n) { + if (!live()) { + return; + } + int code = n.getErrorCode(); + if (code == SESSION_REPLACED_CODE) { + LOG.log(Level.ERROR, "BGS session replaced by another session on the same account (ForceDisconnect " + + code + ")"); + fail(CloseReason.SESSION_REPLACED, new IOException("Session replaced (ForceDisconnect 60)")); + } else { + LOG.log(Level.WARNING, "BGS ForceDisconnect with error code " + code); + fail(CloseReason.FORCE_DISCONNECT, new IOException("ForceDisconnect " + code)); + } + } + + /** Records why the session ends (first reason wins), fails a pending start and closes the channel. */ + private void fail(CloseReason reason, Throwable cause) { + if (closeReason == null) { + closeReason = reason; + } + started.completeExceptionally(cause); + channel.close(); + } + + void abort() { + if (!closed.isDone()) { + fail(CloseReason.IO, new IOException("BGS session aborted: it did not become ready in time")); + } + } + + void closeLocally() { + if (!closed.isDone()) { + fail(CloseReason.LOCAL, new IOException("BGS session closed by the client")); + } + } + + void onChannelClosed(Throwable cause) { + ready = false; + CloseReason reason = closeReason != null ? closeReason : CloseReason.IO; + started.completeExceptionally(cause != null ? cause : new IOException("BGS session closed before ready")); + if (closed.complete(reason)) { + LOG.log(reason == CloseReason.IO ? Level.INFO : Level.WARNING, "BGS session closed: " + reason); + notifyListener("onClosed", () -> listener.onClosed(reason)); + } + } + + // --- presence watch --- + + private void batchSubscribe(List ids) { + channel.call(PRESENCE_BATCH_SUBSCRIBE, BatchSubscribeRequest.newBuilder().addAllAccountIds(ids).build()) + .whenComplete((r, error) -> { + Throwable cause = error == null ? null : unwrap(error); + if (cause instanceof BgsRpcException rpc && rpc.status() == ERROR_INVALID_ARGS) { + LOG.log(Level.ERROR, "BGS presence BatchSubscribe refused with status 7: sent before" + + " the self presence push, an ordering bug"); + } else if (cause != null) { + LOG.log(Level.WARNING, "BGS presence BatchSubscribe failed: " + describe(cause)); + } + }); + } + + private void watch(long id) { + if (presenceReady) { + batchSubscribe(List.of(id)); + } + } + + private void unwatch(long id) { + if (presenceReady) { + channel.call(PRESENCE_BATCH_UNSUBSCRIBE, BatchUnsubscribeRequest.newBuilder().addAccountIds(id).build()) + .whenComplete((r, error) -> { + if (error != null) { + LOG.log(Level.WARNING, "BGS presence BatchUnsubscribe failed: " + + describe(unwrap(error))); + } + }); + } + presence.forget(id).ifPresent(e -> notifyListener("onPresence", () -> listener.onPresence(e))); + } + + // --- friends sync: page through, then report the difference with the known state --- + + private void paged(ServiceMethod method, LongFunction request, + Function> items, Function continuation, + List acc, long next, Consumer> done) { + channel.call(method, request.apply(next)).whenComplete((reply, error) -> { + if (!live()) { + return; + } + if (error != null) { + LOG.log(Level.WARNING, "BGS friends sync (" + method.methodId() + ") failed: " + + describe(unwrap(error))); + return; + } + acc.addAll(items.apply(reply)); + long more = continuation.apply(reply); + if (more != 0) { + paged(method, request, items, continuation, acc, more, done); + } else { + done.accept(acc); + } + }); + } + + private void syncFriends() { + paged(FRIENDS_GET_FRIENDS, BgsRequests::getFriends, GetFriendsResponse::getFriendsList, + GetFriendsResponse::getContinuation, new ArrayList<>(), 0, list -> { + Map now = new LinkedHashMap<>(); + list.forEach(f -> now.put(f.getId(), f.getBattleTag())); + friends.keySet().stream().filter(id -> !now.containsKey(id)).sorted().toList() + .forEach(this::friendRemoved); + now.forEach((id, tag) -> { + if (!friends.containsKey(id)) { + friendAdded(id, tag); + } + }); + }); + } + + private void syncReceivedInvitations() { + paged(FRIENDS_GET_RECEIVED_INVITATIONS, BgsRequests::getReceivedInvitations, + GetReceivedInvitationsResponse::getInvitationsList, + GetReceivedInvitationsResponse::getContinuation, new ArrayList<>(), 0, list -> { + Map now = new LinkedHashMap<>(); + list.forEach(i -> now.put(i.getId(), i)); + receivedInvitations.keySet().stream().filter(id -> !now.containsKey(id)).sorted().toList() + .forEach(id -> receivedInvitationRemoved(id, 0)); + now.values().forEach(this::receivedInvitationAdded); + }); + } + + private void syncSentInvitations() { + paged(FRIENDS_GET_SENT_INVITATIONS, BgsRequests::getSentInvitations, + GetSentInvitationsResponse::getInvitationsList, + GetSentInvitationsResponse::getContinuation, new ArrayList<>(), 0, list -> { + Map now = new LinkedHashMap<>(); + list.forEach(i -> now.put(i.getId(), i.getTargetName())); + // The outcome of an offline removal is unknown: not to be read as accepted (reason 0) + sentInvitations.keySet().stream().filter(id -> !now.containsKey(id)).sorted().toList() + .forEach(id -> sentInvitationRemoved(id, FriendEvent.REASON_GONE_WHILE_OFFLINE)); + now.forEach(this::sentInvitationAdded); + }); + } + + // --- friends pushes (FriendsListener 1-6) --- + + private void onFriendAdded(FriendAddedNotification n) { + if (live()) { + for (Friend f : n.getFriendsList()) { + if (!friends.containsKey(f.getId())) { + friendAdded(f.getId(), f.getBattleTag()); + } + } + } + } + + private void onFriendRemoved(FriendRemovedNotification n) { + if (live()) { + for (RemovedFriendAssignment a : n.getAssignmentsList()) { + if (friends.containsKey(a.getId())) { + friendRemoved(a.getId()); + } + } + } + } + + private void onReceivedInvitationAdded(ReceivedInvitationAddedNotification n) { + if (live()) { + n.getInvitationsList().forEach(this::receivedInvitationAdded); + } + } + + private void onReceivedInvitationRemoved(ReceivedInvitationRemovedNotification n) { + if (live()) { + for (RemovedInvitationAssignment a : n.getAssignmentsList()) { + receivedInvitationRemoved(a.getInvitationId(), a.getReason()); + } + } + } + + private void onSentInvitationAdded(SentInvitationAddedNotification n) { + if (live()) { + for (SentInvitation i : n.getInvitationsList()) { + sentInvitationAdded(i.getId(), i.getTargetName()); + } + } + } + + private void onSentInvitationRemoved(SentInvitationRemovedNotification n) { + if (live()) { + for (RemovedInvitationAssignment a : n.getAssignmentsList()) { + sentInvitationRemoved(a.getInvitationId(), a.getReason()); + } + } + } + + // --- state changes: each one emits its event only when it changes something --- + + private void friendAdded(long id, String tag) { + friends.put(id, tag); + watch(id); + emit(new FriendEvent.FriendAdded(id, tag)); + } + + private void friendRemoved(long id) { + String tag = friends.remove(id); + unwatch(id); + emit(new FriendEvent.FriendRemoved(id, tag)); + } + + private void receivedInvitationAdded(ReceivedInvitation i) { + Invitation inv = new Invitation(i.getInviter().getAccountId(), i.getInviter().getBattleTag()); + if (receivedInvitations.putIfAbsent(i.getId(), inv) == null) { + emit(new FriendEvent.InvitationReceived(i.getId(), inv.accountId(), inv.battleTag())); + } + } + + private void receivedInvitationRemoved(long id, int reason) { + Invitation inv = receivedInvitations.remove(id); + if (inv != null) { + emit(new FriendEvent.InvitationRemoved(id, inv.accountId(), inv.battleTag(), reason)); + } + } + + private void sentInvitationAdded(long id, String targetName) { + if (sentInvitations.putIfAbsent(id, targetName) == null) { + emit(new FriendEvent.InvitationSent(id, targetName)); + } + } + + private void sentInvitationRemoved(long id, int reason) { + String targetName = sentInvitations.remove(id); + if (targetName != null) { + emit(new FriendEvent.InvitationSentRemoved(id, targetName, reason)); + } + } + + private void emit(FriendEvent event) { + notifyListener("onFriend", () -> listener.onFriend(event)); + } + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsConnection.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsConnection.java new file mode 100644 index 000000000..3d6428834 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsConnection.java @@ -0,0 +1,214 @@ +package fr.enimaloc.catapult.bgs.client; + +import fr.enimaloc.catapult.bgs.client.BgsListener.CloseReason; +import fr.enimaloc.catapult.bgs.core.BgsChannel; + +import java.lang.System.Logger.Level; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * Keeps one {@link BgsClient} connected: opens a session through the factory, sends a KeepAlive while it is ready + * and reconnects with an exponential backoff when it drops. It stops for good when the session is replaced by + * another login, when the login asks for a 2FA code, or on {@link #stop()}. + * + *

    Reconnection is driven by {@link BgsClient#closed()} and by the failure of the {@link BgsClient#start} future; + * the ticket is minted again by the client on every attempt. + */ +public final class BgsConnection { + + public enum State { CONNECTING, READY, BACKOFF, STOPPED_REPLACED, STOPPED_CODE_REQUIRED, STOPPED } + + static final Duration KEEP_ALIVE_INTERVAL = Duration.ofSeconds(30); + + static final Duration DEFAULT_START_TIMEOUT = Duration.ofMinutes(2); + + private static final System.Logger LOG = System.getLogger(BgsConnection.class.getName()); + + private final BgsClient client; + private final Supplier sessionFactory; + private final ScheduledExecutorService scheduler; + private final Duration minBackoff; + private final Duration maxBackoff; + private final Duration startTimeout; + private final Object lock = new Object(); + + private volatile State state = State.STOPPED; + private boolean started; + private long attempt; + private Duration nextBackoff; + private ScheduledFuture retry; + private ScheduledFuture keepAlive; + private ScheduledFuture watchdog; + + public BgsConnection(BgsClient client, Supplier sessionFactory, ScheduledExecutorService scheduler, + Duration minBackoff, Duration maxBackoff) { + this(client, sessionFactory, scheduler, minBackoff, maxBackoff, DEFAULT_START_TIMEOUT); + } + + /** + * @param startTimeout how long an attempt may stay CONNECTING before it is aborted and retried; it must cover + * the ticket minting and a logon queue + */ + public BgsConnection(BgsClient client, Supplier sessionFactory, ScheduledExecutorService scheduler, + Duration minBackoff, Duration maxBackoff, Duration startTimeout) { + this.startTimeout = startTimeout; + this.client = client; + this.sessionFactory = sessionFactory; + this.scheduler = scheduler; + this.minBackoff = minBackoff; + this.maxBackoff = maxBackoff; + this.nextBackoff = minBackoff; + } + + public State state() { + return state; + } + + /** Opens the first session; a no-op once started. */ + public void start() { + synchronized (lock) { + if (started) { + return; + } + started = true; + connect(); + } + } + + /** Closes the session and cancels any retry; never reconnects afterwards. */ + public void stop() { + synchronized (lock) { + attempt++; + cancelTimers(); + if (!isTerminal()) { + transition(State.STOPPED); + } + } + client.close(); + } + + private boolean isTerminal() { + return state == State.STOPPED || state == State.STOPPED_REPLACED || state == State.STOPPED_CODE_REQUIRED; + } + + /** Opens a session. Called with the lock held. */ + private void connect() { + long id = ++attempt; + transition(State.CONNECTING); + CompletableFuture ready; + CompletableFuture closed; + try { + ready = client.start(sessionFactory.get()); + closed = client.closed(); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, "BGS session could not be opened: " + e); + backoff(); + return; + } + watchdog = scheduler.schedule(() -> onStartTimeout(id), startTimeout.toMillis(), TimeUnit.MILLISECONDS); + ready.whenComplete((v, error) -> onStarted(id, error)); + closed.thenAccept(reason -> onClosed(id, reason)); + } + + private void onStartTimeout(long id) { + synchronized (lock) { + if (id != attempt || state != State.CONNECTING) { + return; + } + LOG.log(Level.WARNING, "BGS session did not become ready within " + startTimeout.toSeconds() + " s"); + } + client.abort(); + } + + private void onStarted(long id, Throwable error) { + synchronized (lock) { + if (id != attempt || state != State.CONNECTING) { + return; + } + cancelWatchdog(); + if (error == null) { + nextBackoff = minBackoff; + transition(State.READY); + keepAlive = scheduler.scheduleWithFixedDelay(client::keepAlive, KEEP_ALIVE_INTERVAL.toMillis(), + KEEP_ALIVE_INTERVAL.toMillis(), TimeUnit.MILLISECONDS); + return; + } + Throwable cause = error instanceof CompletionException && error.getCause() != null + ? error.getCause() : error; + if (cause instanceof TicketException te && te.codeRequired()) { + attempt++; + transition(State.STOPPED_CODE_REQUIRED); + client.close(); + } + // any other failure ends the session, and its close reason drives the retry + } + } + + private void onClosed(long id, CloseReason reason) { + synchronized (lock) { + if (id != attempt || isTerminal()) { + return; + } + cancelTimers(); + switch (reason) { + case SESSION_REPLACED -> { + LOG.log(Level.ERROR, "BGS session replaced by another login: not reconnecting"); + transition(State.STOPPED_REPLACED); + } + case CODE_REQUIRED -> transition(State.STOPPED_CODE_REQUIRED); + case LOCAL -> transition(State.STOPPED); + default -> { + LOG.log(Level.WARNING, "BGS session lost (" + reason + ")"); + backoff(); + } + } + } + } + + /** Schedules the next attempt and doubles the delay up to the cap. Called with the lock held. */ + private void backoff() { + Duration delay = nextBackoff; + nextBackoff = nextBackoff.multipliedBy(2).compareTo(maxBackoff) > 0 ? maxBackoff : nextBackoff.multipliedBy(2); + transition(State.BACKOFF); + LOG.log(Level.INFO, "BGS reconnecting in " + delay.toSeconds() + " s"); + retry = scheduler.schedule(() -> { + synchronized (lock) { + if (state == State.BACKOFF) { + connect(); + } + } + }, delay.toMillis(), TimeUnit.MILLISECONDS); + } + + private void cancelWatchdog() { + if (watchdog != null) { + watchdog.cancel(false); + watchdog = null; + } + } + + private void cancelTimers() { + cancelWatchdog(); + if (retry != null) { + retry.cancel(false); + retry = null; + } + if (keepAlive != null) { + keepAlive.cancel(false); + keepAlive = null; + } + } + + private void transition(State next) { + if (state != next) { + LOG.log(Level.INFO, "BGS connection " + state + " -> " + next); + state = next; + } + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsListener.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsListener.java new file mode 100644 index 000000000..6e210804f --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsListener.java @@ -0,0 +1,33 @@ +package fr.enimaloc.catapult.bgs.client; + +/** + * Receives what a {@link BgsClient} session reports. Every method runs on the session loop: it must not block, a slow + * consumer hands the event over to another executor. + */ +public interface BgsListener { + + /** The session is ready: logon complete and game account selected. */ + void onReady(long accountId, String battleTag); + + void onPresence(PresenceEvent event); + + void onFriend(FriendEvent event); + + /** The session ended; called once per session. */ + void onClosed(CloseReason reason); + + enum CloseReason { + /** Transport error, end of stream, or a failure before the session was ready. */ + IO, + /** Closed by the client itself ({@link BgsClient#close()}). */ + LOCAL, + /** ForceDisconnect pushed by the server with an error code other than 60. */ + FORCE_DISCONNECT, + /** ForceDisconnect 60: another session opened the same account; reconnecting would evict it in a loop. */ + SESSION_REPLACED, + /** OnLogonComplete carried an error code, or the logon call itself was refused. */ + LOGON_FAILED, + /** Ticket minting needs a 2FA code typed by a human. */ + CODE_REQUIRED + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsRequests.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsRequests.java new file mode 100644 index 000000000..a1d323e70 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsRequests.java @@ -0,0 +1,155 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.EntityProto.EntityId; +import bgs.protocol.SemanticVersionOuterClass.SemanticVersion; +import bgs.protocol.account.v1.AccountServiceOuterClass.SubscriptionUpdateRequest; +import bgs.protocol.account.v1.AccountTypes.AccountFieldOptions; +import bgs.protocol.account.v1.AccountTypes.SubscriberReference; +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.authentication.v2.client.AuthenticationServiceOuterClass.LogonRequest; +import bgs.protocol.authentication.v2.client.AuthenticationServiceOuterClass.VerifyAuthTokenRequest; +import bgs.protocol.authentication.v2.client.AuthenticationTypes.LogonOptions; +import bgs.protocol.connection.v1.ConnectionServiceProto.ClientSdkVersionInfo; +import bgs.protocol.connection.v1.ConnectionServiceProto.ConnectRequest; +import bgs.protocol.connection.v1.ConnectionServiceProto.MeteringLevel; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.AcceptInvitationRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetFriendsRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetReceivedInvitationsRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetSentInvitationsRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.IgnoreInvitationRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.RemoveFriendRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.RevokeInvitationRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.SendInvitationRequest; +import bgs.protocol.friends.v2.client.FriendsTypes.AcceptInvitationOptions; +import bgs.protocol.friends.v2.client.FriendsTypes.GetFriendsOptions; +import bgs.protocol.friends.v2.client.FriendsTypes.SendInvitationOptions; +import bgs.protocol.friends.v2.client.FriendsTypes.SendInvitationTarget; +import bgs.protocol.v2.AttributeTypes.Attribute; +import bgs.protocol.v2.AttributeTypes.Variant; +import com.google.protobuf.ByteString; +import com.google.protobuf.CodedOutputStream; +import fr.enimaloc.catapult.bgs.client.BgsClient.BgsClientConfig; +import fr.enimaloc.catapult.bgs.proto.Fourcc; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; + +/** Builds the request bodies of the session sequence and the friend actions, with the launcher's values. */ +final class BgsRequests { + + /** The launcher itself ("App"): the title the bot logs on with. */ + static final int LAUNCHER_TITLE_ID = Fourcc.toId("App"); + private static final String PLATFORM = "Win"; + private static final int APPLICATION_VERSION = 25308; + private static final String AUTH_PLATFORM = "BNET"; + /** Marks an account (not a game account) in an EntityId. */ + private static final long ACCOUNT_ENTITY_HIGH = 0x0100000000000000L; + /** Any id works for the minimal subscribe; the spike used 1. */ + private static final long ACCOUNT_SUBSCRIBER_OBJECT_ID = 1; + /** Without this attribute SendInvitation answers status 7. */ + private static final Attribute INVITATION_REASON = Attribute.newBuilder().setName("App-FriendInvitationReason") + .setValue(Variant.newBuilder().setStringValue("enter_battletag")).build(); + private static final int FRIEND_LEVEL = 1; + + private BgsRequests() { + } + + static ConnectRequest connect() { + return ConnectRequest.newBuilder() + .setUseBindlessRpc(true) + .setMeteringLevel(MeteringLevel.METERING_LEVEL_LEGACY) + .setClientSdkInfo(ClientSdkVersionInfo.newBuilder() + .setSdkName("BGS C++ SDK") + .setSdkVersion(version(8, 1, 0, "8.1.0")) + .setProtocolVersion(version(3, 18, 2, "3.18.2-dd0d4dc339"))) + .build(); + } + + static LogonRequest logon(String ticket, BgsClientConfig cfg) { + LogonOptions.Builder options = LogonOptions.newBuilder() + .setAuthToken(ByteString.copyFrom(ticket, StandardCharsets.UTF_8)) + .setAuthPlatform(AUTH_PLATFORM); + if (cfg.email() != null && !cfg.email().isEmpty()) { + options.setEmail(cfg.email()); + } + return LogonRequest.newBuilder() + .setTitleId(LAUNCHER_TITLE_ID) + .setPlatform(PLATFORM) + .setLocale(cfg.locale()) + .setApplicationVersion(APPLICATION_VERSION) + .setLogonOptions(options) + .build(); + } + + static VerifyAuthTokenRequest verifyAuthToken(String ticket) { + return VerifyAuthTokenRequest.newBuilder().setAuthToken(ByteString.copyFrom(ticket, StandardCharsets.UTF_8)) + .build(); + } + + /** The minimal subscribe validated by the spike: one ref, no account tags. */ + static SubscriptionUpdateRequest accountSubscribe(long accountId) { + return SubscriptionUpdateRequest.newBuilder() + .addRef(SubscriberReference.newBuilder() + .setObjectId(ACCOUNT_SUBSCRIBER_OBJECT_ID) + .setEntityId(EntityId.newBuilder().setHigh(ACCOUNT_ENTITY_HIGH).setLow(accountId)) + .setAccountOptions(AccountFieldOptions.newBuilder().setAllFields_(true))) + .build(); + } + + /** Body of the "select game account" call, absent from bgs.desc: {@code { 1: GameAccountHandle }}. */ + static ByteString selectGameAccount(GameAccountHandle handle) { + ByteString.Output out = ByteString.newOutput(); + CodedOutputStream coded = CodedOutputStream.newInstance(out); + try { + coded.writeMessage(1, handle); + coded.flush(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return out.toByteString(); + } + + static GetFriendsRequest getFriends(long continuation) { + return GetFriendsRequest.newBuilder().setContinuation(continuation) + .setOptions(GetFriendsOptions.newBuilder().setFetchNames(true)).build(); + } + + static GetReceivedInvitationsRequest getReceivedInvitations(long continuation) { + return GetReceivedInvitationsRequest.newBuilder().setContinuation(continuation).build(); + } + + static GetSentInvitationsRequest getSentInvitations(long continuation) { + return GetSentInvitationsRequest.newBuilder().setContinuation(continuation).build(); + } + + static SendInvitationRequest sendInvitation(String battleTag) { + return SendInvitationRequest.newBuilder() + .setTarget(SendInvitationTarget.newBuilder().setBattleTag(battleTag)) + .setOptions(SendInvitationOptions.newBuilder().setLevel(FRIEND_LEVEL).addAttributes(INVITATION_REASON)) + .build(); + } + + static AcceptInvitationRequest acceptInvitation(long invitationId) { + return AcceptInvitationRequest.newBuilder().setInvitationId(invitationId) + .setOptions(AcceptInvitationOptions.newBuilder().setLevel(FRIEND_LEVEL)).build(); + } + + /** Friends v2 has no Decline: ignoring is declining (the inviter sees reason 3). */ + static IgnoreInvitationRequest declineInvitation(long invitationId) { + return IgnoreInvitationRequest.newBuilder().setInvitationId(invitationId).build(); + } + + static RevokeInvitationRequest revokeInvitation(long invitationId) { + return RevokeInvitationRequest.newBuilder().setInvitationId(invitationId).build(); + } + + static RemoveFriendRequest removeFriend(long accountId) { + return RemoveFriendRequest.newBuilder().setTargetAccountId(accountId).build(); + } + + private static SemanticVersion version(int major, int minor, int patch, String text) { + return SemanticVersion.newBuilder().setMajorVersion(major).setMinorVersion(minor).setPatchVersion(patch) + .setVersionString(text).build(); + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/FriendEvent.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/FriendEvent.java new file mode 100644 index 000000000..853041b7b --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/FriendEvent.java @@ -0,0 +1,40 @@ +package fr.enimaloc.catapult.bgs.client; + +/** + * A change of the bot's friends list, from a FriendsListener push or from the resync diff of a new session. Each + * event is emitted once: an element already known (or already gone) produces nothing. + */ +public sealed interface FriendEvent { + + /** + * Reason of an {@link InvitationSentRemoved} found by the resync diff: the invitation vanished while offline and + * its outcome is unknown. Accepted offline, it shows up as a {@link FriendAdded} in the same resync; declined or + * revoked, it does not. + */ + int REASON_GONE_WHILE_OFFLINE = -1; + + record FriendAdded(long accountId, String battleTag) implements FriendEvent { + } + + record FriendRemoved(long accountId, String battleTag) implements FriendEvent { + } + + record InvitationReceived(long invitationId, long accountId, String battleTag) implements FriendEvent { + } + + /** A received invitation is gone (accepted, declined, revoked or expired); {@code reason} is the server's code. */ + record InvitationRemoved(long invitationId, long accountId, String battleTag, int reason) implements FriendEvent { + } + + /** {@code targetName} is the BattleTag the invitation was sent to: the server gives no account id for it. */ + record InvitationSent(long invitationId, String targetName) implements FriendEvent { + } + + /** + * A sent invitation is gone. From a push, {@code reason} is the server's: 0 means accepted (a {@link FriendAdded} + * follows), any other value means declined or revoked. From the resync diff it is + * {@link FriendEvent#REASON_GONE_WHILE_OFFLINE}. + */ + record InvitationSentRemoved(long invitationId, String targetName, int reason) implements FriendEvent { + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/GameAccountSelector.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/GameAccountSelector.java new file mode 100644 index 000000000..9df521cb4 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/GameAccountSelector.java @@ -0,0 +1,42 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; + +import fr.enimaloc.catapult.bgs.proto.Fourcc; + +import java.util.List; +import java.util.function.Predicate; + +/** Picks the game account to select before presence works (spec 5.1). */ +public final class GameAccountSelector { + + private static final int LAUNCHER = Fourcc.toId("App"); + /** Europe: the region of eu.actual.battle.net. Region 1 (US) is refused there. */ + private static final int DEFAULT_REGION = 2; + + private GameAccountSelector() { + } + + /** + * The first candidate matching, in order: title {@code App} in the connected region, the connected region, title + * {@code App}, then any candidate. A connected region of 0 (unknown) counts as 2 (EU). + * + * @return the chosen handle, or null when there is no candidate + */ + public static GameAccountHandle select(List candidates, int connectedRegion) { + int region = connectedRegion == 0 ? DEFAULT_REGION : connectedRegion; + List> rules = List.of( + h -> h.getTitleId() == LAUNCHER && h.getRegion() == region, + h -> h.getRegion() == region, + h -> h.getTitleId() == LAUNCHER, + h -> true); + for (Predicate rule : rules) { + for (GameAccountHandle candidate : candidates) { + if (rule.test(candidate)) { + return candidate; + } + } + } + return null; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceEvent.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceEvent.java new file mode 100644 index 000000000..31dc9ac88 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceEvent.java @@ -0,0 +1,21 @@ +package fr.enimaloc.catapult.bgs.client; + +import java.util.List; + +/** + * A friend's game change: {@code game} is the fourcc of the game now played (e.g. "WTCG"), or null when the friend + * stopped playing; {@code titleId} is its numeric form (0 when there is no game). + */ +public record PresenceEvent(long accountId, String game, int titleId, List rawFields) { + + public PresenceEvent { + rawFields = List.copyOf(rawFields); + } + + /** + * One presence field of the delta that caused the change. {@code gaTitle} is null for non-game-account groups; + * {@code value} is the Variant's set value (String, Long, Double, Boolean or ByteString), null when deleted. + */ + public record RawPresenceField(int group, int field, String gaTitle, Object value, boolean deleted) { + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceTracker.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceTracker.java new file mode 100644 index 000000000..88cc6b60f --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceTracker.java @@ -0,0 +1,110 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.presence.v2.PresenceListenerOuterClass.PresenceStateUpdatedNotification; +import bgs.protocol.presence.v2.PresenceTypes.PresenceField; +import bgs.protocol.presence.v2.PresenceTypes.PresenceFieldState; +import com.google.protobuf.InvalidProtocolBufferException; +import fr.enimaloc.catapult.bgs.client.PresenceEvent.RawPresenceField; +import fr.enimaloc.catapult.bgs.proto.Fourcc; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Merges the presence deltas pushed by BGS into one state per friend and reports game changes only. + * The account level is authoritative: the game is the fourcc of the {@code GameAccountHandle} stored at + * {@code (null, 1, 21)}, except the launcher alone ({@code App}), which is no game. Not thread-safe. + */ +public final class PresenceTracker { + + private static final String LAUNCHER = "App"; + private static final PresenceKey ACTIVE_GAME_ACCOUNT = new PresenceKey(null, 1, 21); + + /** gaTitle is null unless group is 2 (game account level). */ + record PresenceKey(String gaTitle, int group, int field) { + } + + private final Map> states = new HashMap<>(); + private final Map games = new HashMap<>(); + + /** Applies a push and returns one event per friend whose game changed. */ + public List apply(PresenceStateUpdatedNotification update) { + List events = new ArrayList<>(); + for (PresenceFieldState st : update.getStatesList()) { + String gaTitle = st.hasGameAccount() ? Fourcc.of(st.getGameAccount().getTitleId()) : null; + applyState(st.getAccountId(), gaTitle, st.getFieldsList()).ifPresent(events::add); + } + return events; + } + + private Optional applyState(long accountId, String gaTitle, List fields) { + Map state = states.computeIfAbsent(accountId, k -> new HashMap<>()); + List raw = new ArrayList<>(); + for (PresenceField f : fields) { + int group = f.getKey().getGroup(); + int field = f.getKey().getField(); + String title = group == 2 ? gaTitle : null; + Object value = f.getDeleted() ? null : valueOf(f.getValue()); + raw.add(new RawPresenceField(group, field, title, value, f.getDeleted())); + PresenceKey key = new PresenceKey(title, group, field); + if (f.getDeleted()) { + state.remove(key); + } else { + state.put(key, value); + } + } + String game = gameOf(state); + if (Objects.equals(game, games.get(accountId))) { + return Optional.empty(); + } + if (game == null) { + games.remove(accountId); + } else { + games.put(accountId, game); + } + return Optional.of(new PresenceEvent(accountId, game, game == null ? 0 : Fourcc.toId(game), raw)); + } + + /** Drops a friend; emits a game end when they were in a game. */ + public Optional forget(long accountId) { + states.remove(accountId); + return games.remove(accountId) == null + ? Optional.empty() + : Optional.of(new PresenceEvent(accountId, null, 0, List.of())); + } + + /** Forgets everything without emitting (reconnection: the state is rebuilt from new pushes). */ + public void clear() { + states.clear(); + games.clear(); + } + + private static String gameOf(Map state) { + if (!(state.get(ACTIVE_GAME_ACCOUNT) instanceof com.google.protobuf.ByteString blob) || blob.isEmpty()) { + return null; + } + try { + String title = Fourcc.of(GameAccountHandle.parseFrom(blob).getTitleId()); + return LAUNCHER.equals(title) ? null : title; + } catch (InvalidProtocolBufferException e) { + return null; + } + } + + private static Object valueOf(bgs.protocol.v2.AttributeTypes.Variant v) { + return switch (v.getTypeCase()) { + case BOOL_VALUE -> v.getBoolValue(); + case INT_VALUE -> v.getIntValue(); + case FLOAT_VALUE -> v.getFloatValue(); + case STRING_VALUE -> v.getStringValue(); + case BLOB_VALUE -> v.getBlobValue(); + case UINT_VALUE -> v.getUintValue(); + case TYPE_NOT_SET -> null; + }; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketException.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketException.java new file mode 100644 index 000000000..a266e96cc --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketException.java @@ -0,0 +1,22 @@ +package fr.enimaloc.catapult.bgs.client; + +/** Ticket minting failed. The message never contains a ticket. */ +public class TicketException extends Exception { + + private final boolean codeRequired; + + public TicketException(String message, boolean codeRequired) { + super(message); + this.codeRequired = codeRequired; + } + + public TicketException(String message, boolean codeRequired, Throwable cause) { + super(message, cause); + this.codeRequired = codeRequired; + } + + /** True when the login asks for a 2FA code: retrying without a human is pointless. */ + public boolean codeRequired() { + return codeRequired; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketProvider.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketProvider.java new file mode 100644 index 000000000..4b97cc835 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketProvider.java @@ -0,0 +1,8 @@ +package fr.enimaloc.catapult.bgs.client; + +/** Mints a fresh Battle.net login ticket ({@code EU-...}). Blocking: never called on the session loop. */ +@FunctionalInterface +public interface TicketProvider { + + String mint() throws TicketException; +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsChannel.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsChannel.java new file mode 100644 index 000000000..74a394eb8 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsChannel.java @@ -0,0 +1,37 @@ +package fr.enimaloc.catapult.bgs.core; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Message; +import fr.enimaloc.catapult.bgs.proto.ServiceMethod; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * What a BGS client needs from a session, with no transport detail, so it can be faked in tests. + * Handlers and future completions run on {@link #loop()}. + */ +public interface BgsChannel extends AutoCloseable { + + CompletableFuture call(ServiceMethod method, Message request); + + void callNoReply(ServiceMethod method, Message request); + + CompletableFuture callRaw(int serviceHash, int methodId, ByteString body, boolean withCiid); + + void on(ServiceMethod method, Consumer handler); + + void onRaw(int serviceHash, int methodId, Consumer handler); + + void setCiid(String ciid); + + /** Called once when the session ends; the cause is null for a local {@link #close()}. */ + void onClose(Consumer listener); + + /** The single-thread executor that carries the session: callbacks run on it. */ + Executor loop(); + + @Override + void close(); +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsRpcException.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsRpcException.java new file mode 100644 index 000000000..b54e678c7 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsRpcException.java @@ -0,0 +1,16 @@ +package fr.enimaloc.catapult.bgs.core; + +/** A BGS call answered with a non-zero status (for example 7 = ERROR_INVALID_ARGS). */ +public final class BgsRpcException extends RuntimeException { + + private final int status; + + public BgsRpcException(int status) { + super("BGS call failed with status " + status); + this.status = status; + } + + public int status() { + return status; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsSession.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsSession.java new file mode 100644 index 000000000..8031c994f --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsSession.java @@ -0,0 +1,299 @@ +package fr.enimaloc.catapult.bgs.core; + +import bgs.protocol.RpcProto.Header; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import fr.enimaloc.catapult.bgs.proto.BgsServices; +import fr.enimaloc.catapult.bgs.proto.ServiceMethod; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.AccessFlag; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Consumer; + +/** + * BGS RPC over a {@link BgsTransport}. A virtual reader thread decodes frames and posts them to a + * single-thread executor ({@link #loop()}) that does every write, owns the pending-call table and runs + * every callback. A callback that throws is swallowed so it cannot kill the session. + */ +public final class BgsSession implements BgsChannel { + + private static final System.Logger LOG = System.getLogger(BgsSession.class.getName()); + + private static final int REPLY = 0xFE; + + /** Known server-to-client methods, to know whether an unregistered incoming call expects an ack. */ + private static final Map> KNOWN = knownMethods(); + + private record Handler(ServiceMethod method, Consumer body) { + } + + private final BgsTransport transport; + private final OutputStream out; + private final ExecutorService loop = Executors.newSingleThreadExecutor( + r -> Thread.ofPlatform().name("bgs-session").daemon().unstarted(r)); + private final Map> pending = new HashMap<>(); + private final Map handlers = new ConcurrentHashMap<>(); + private final List> closeListeners = new ArrayList<>(); + private volatile String ciid = ""; + private int nextToken; + private volatile boolean closed; + private volatile Throwable closeCause; + + private BgsSession(BgsTransport transport) { + this.transport = transport; + this.out = transport.out(); + } + + public static BgsSession connect(String host, int port) throws IOException { + return open(BgsTransport.tls(host, port)); + } + + public static BgsSession open(BgsTransport transport) { + BgsSession session = new BgsSession(transport); + Thread.ofVirtual().name("bgs-reader").start(session::readLoop); + return session; + } + + // --- API (any thread) --- + + @Override + public CompletableFuture call(ServiceMethod method, Message request) { + CompletableFuture result = new CompletableFuture<>(); + boolean withCiid = method.service().contains("friends") || method.service().contains("presence"); + post(() -> { + if (!method.hasResponse()) { + send(method.serviceHash(), method.methodId(), request.toByteString(), withCiid, null); + result.complete(null); + return; + } + CompletableFuture raw = new CompletableFuture<>(); + raw.whenComplete((body, error) -> { + if (error != null) { + result.completeExceptionally(error); + return; + } + try { + result.complete(method.responseParser().parseFrom(body)); + } catch (InvalidProtocolBufferException e) { + result.completeExceptionally(e); + } + }); + send(method.serviceHash(), method.methodId(), request.toByteString(), withCiid, raw); + }, result); + return result; + } + + @Override + public void callNoReply(ServiceMethod method, Message request) { + boolean withCiid = method.service().contains("friends") || method.service().contains("presence"); + post(() -> send(method.serviceHash(), method.methodId(), request.toByteString(), withCiid, null), null); + } + + @Override + public CompletableFuture callRaw(int serviceHash, int methodId, ByteString body, boolean withCiid) { + CompletableFuture result = new CompletableFuture<>(); + post(() -> send(serviceHash, methodId, body, withCiid, result), result); + return result; + } + + @Override + public void on(ServiceMethod method, Consumer handler) { + handlers.put(key(method.serviceHash(), method.methodId()), new Handler(method, body -> { + try { + handler.accept(method.requestParser().parseFrom(body)); + } catch (InvalidProtocolBufferException e) { + // an undecodable notification is dropped; the ack is already sent + LOG.log(System.Logger.Level.WARNING, "Undecodable BGS notification " + describe(method.serviceHash(), + method.methodId()), e); + } + })); + } + + @Override + public void onRaw(int serviceHash, int methodId, Consumer handler) { + handlers.put(key(serviceHash, methodId), new Handler(null, handler)); + } + + @Override + public void setCiid(String ciid) { + this.ciid = ciid == null ? "" : ciid; + } + + @Override + public void onClose(Consumer listener) { + // Once the loop is shut down nothing can be posted: notify the late listener directly + Runnable notifyNow = () -> runQuietly("close listener", () -> listener.accept(closeCause)); + if (closed) { + notifyNow.run(); + return; + } + try { + loop.execute(() -> { + if (closed) { + notifyNow.run(); + } else { + closeListeners.add(listener); + } + }); + } catch (RejectedExecutionException e) { + notifyNow.run(); + } + } + + @Override + public Executor loop() { + return loop; + } + + @Override + public void close() { + post(() -> shutdown(null), null); + } + + // --- loop thread --- + + private void send(int serviceHash, int methodId, ByteString body, boolean withCiid, + CompletableFuture reply) { + if (closed) { + if (reply != null) { + reply.completeExceptionally(new IOException("Session closed")); + } + return; + } + int token = nextToken++; + Header.Builder header = Header.newBuilder().setServiceId(0).setServiceHash(serviceHash) + .setMethodId(methodId).setToken(token); + if (withCiid && !ciid.isEmpty()) { + header.setCiid(ciid); + } + if (reply != null) { + pending.put(token, reply); + } + write(header.build(), body); + } + + private void write(Header header, ByteString body) { + try { + out.write(FrameCodec.encode(header, body)); + out.flush(); + } catch (IOException e) { + shutdown(e); + } + } + + private void onFrame(FrameCodec.Frame frame) { + Header h = frame.header(); + if (h.getServiceId() == REPLY) { + CompletableFuture reply = pending.remove(h.getToken()); + if (reply == null) { + return; + } + if (h.getStatus() != 0) { + reply.completeExceptionally(new BgsRpcException(h.getStatus())); + } else { + reply.complete(frame.body()); + } + return; + } + long key = key(h.getServiceHash(), h.getMethodId()); + Handler handler = handlers.get(key); + ServiceMethod method = handler != null && handler.method() != null ? handler.method() : KNOWN.get(key); + // Same as the reference client: unknown methods and methods with a response get an empty ack + if (method == null || method.hasResponse()) { + write(Header.newBuilder().setServiceId(REPLY).setToken(h.getToken()).build(), ByteString.EMPTY); + } + if (handler != null) { + runQuietly("handler " + describe(h.getServiceHash(), h.getMethodId()), () -> handler.body().accept(frame.body())); + } + } + + private void shutdown(Throwable cause) { + if (closed) { + return; + } + closeCause = cause; + closed = true; + runQuietly("transport close", transport::close); + Throwable failure = cause != null ? cause : new IOException("Session closed"); + pending.values().forEach(f -> f.completeExceptionally(failure)); + pending.clear(); + closeListeners.forEach(l -> runQuietly("close listener", () -> l.accept(cause))); + closeListeners.clear(); + loop.shutdown(); + } + + // --- reader thread --- + + private void readLoop() { + try { + var in = transport.in(); + while (true) { + FrameCodec.Frame frame = FrameCodec.read(in); + post(() -> onFrame(frame), null); + } + } catch (Throwable t) { + post(() -> shutdown(t), null); + } + } + + // --- helpers --- + + private void post(Runnable task, CompletableFuture onRejected) { + try { + loop.execute(() -> runQuietly("session task", task::run)); + } catch (RejectedExecutionException e) { + if (onRejected != null) { + onRejected.completeExceptionally(new IOException("Session closed")); + } + } + } + + private interface Action { + void run() throws Exception; + } + + /** A failing callback must not take the session down: it is logged (never with the body) and dropped. */ + private static void runQuietly(String what, Action action) { + try { + action.run(); + } catch (Exception e) { + LOG.log(System.Logger.Level.WARNING, "BGS session " + what + " failed", e); + } + } + + private static String describe(int serviceHash, int methodId) { + return "0x" + Integer.toHexString(serviceHash).toUpperCase() + "." + methodId; + } + + private static long key(int serviceHash, int methodId) { + return ((long) serviceHash << 32) | (methodId & 0xFFFFFFFFL); + } + + private static Map> knownMethods() { + Map> map = new HashMap<>(); + for (Field f : BgsServices.class.getFields()) { + if (f.accessFlags().contains(AccessFlag.STATIC) && ServiceMethod.class.isAssignableFrom(f.getType())) { + try { + ServiceMethod m = (ServiceMethod) f.get(null); + map.put(key(m.serviceHash(), m.methodId()), m); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + } + return map; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsTransport.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsTransport.java new file mode 100644 index 000000000..c48af50b3 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsTransport.java @@ -0,0 +1,76 @@ +package fr.enimaloc.catapult.bgs.core; + +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.time.Duration; + +/** The byte pipe under a {@link BgsSession}: lets tests inject an in-memory transport. */ +public interface BgsTransport extends AutoCloseable { + + InputStream in(); + + OutputStream out(); + + @Override + void close() throws IOException; + + /** How long the TCP connect may take. */ + Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + /** How long the TLS handshake may wait for the server: a silent peer must not hang the session's opener. */ + Duration HANDSHAKE_TIMEOUT = Duration.ofSeconds(15); + + /** Plain TLS with the default trust store, SNI = host and hostname verification. */ + static BgsTransport tls(String host, int port) throws IOException { + return tls(host, port, HANDSHAKE_TIMEOUT); + } + + /** {@link #tls(String, int)} with another handshake timeout; reads block without limit once it is done. */ + static BgsTransport tls(String host, int port, Duration handshakeTimeout) throws IOException { + Socket raw = new Socket(); + SSLSocket socket; + try { + raw.connect(new InetSocketAddress(host, port), (int) CONNECT_TIMEOUT.toMillis()); + raw.setSoTimeout((int) handshakeTimeout.toMillis()); + socket = (SSLSocket) ((SSLSocketFactory) SSLSocketFactory.getDefault()).createSocket(raw, host, port, true); + SSLParameters params = socket.getSSLParameters(); + params.setEndpointIdentificationAlgorithm("HTTPS"); + socket.setSSLParameters(params); + socket.startHandshake(); + // the session loop reads frames as they come: no read timeout once connected + socket.setSoTimeout(0); + } catch (IOException | RuntimeException e) { + raw.close(); + throw e; + } + return new BgsTransport() { + @Override + public InputStream in() { + try { + return socket.getInputStream(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @Override + public OutputStream out() { + try { + return socket.getOutputStream(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @Override + public void close() throws IOException { + socket.close(); + } + }; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/FrameCodec.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/FrameCodec.java new file mode 100644 index 000000000..fe1b3f89d --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/FrameCodec.java @@ -0,0 +1,53 @@ +package fr.enimaloc.catapult.bgs.core; + +import bgs.protocol.RpcProto.Header; +import com.google.protobuf.ByteString; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** BGS frame: {@code [uint16 big-endian header length][Header][body of Header.size bytes]}. */ +public final class FrameCodec { + + /** Upper bound on a body, to refuse a corrupt size before allocating it. */ + private static final int MAX_BODY = 16 * 1024 * 1024; + + public record Frame(Header header, ByteString body) { + } + + private FrameCodec() { + } + + /** Encodes a frame; {@code size} is always set from the body. */ + public static byte[] encode(Header header, ByteString body) { + Header h = header.toBuilder().setSize(body.size()).build(); + int headerLength = h.getSerializedSize(); + if (headerLength > 0xFFFF) { + throw new IllegalArgumentException("Header too large: " + headerLength); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(2 + headerLength + body.size()); + out.write(headerLength >>> 8); + out.write(headerLength); + out.writeBytes(h.toByteArray()); + out.writeBytes(body.toByteArray()); + return out.toByteArray(); + } + + /** Reads one frame; throws {@link java.io.EOFException} when the stream ends. */ + public static Frame read(InputStream in) throws IOException { + DataInputStream data = new DataInputStream(in); + int headerLength = data.readUnsignedShort(); + byte[] headerBytes = new byte[headerLength]; + data.readFully(headerBytes); + Header header = Header.parseFrom(headerBytes); + int size = header.getSize(); + if (size < 0 || size > MAX_BODY) { + throw new IOException("Invalid BGS body size: " + Integer.toUnsignedString(size)); + } + byte[] body = new byte[size]; + data.readFully(body); + return new Frame(header, ByteString.copyFrom(body)); + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/BgsServices.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/BgsServices.java new file mode 100644 index 000000000..0d08bd8a4 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/BgsServices.java @@ -0,0 +1,193 @@ +package fr.enimaloc.catapult.bgs.proto; + +import com.google.protobuf.Message; +import bgs.protocol.RpcProto; +import bgs.protocol.account.v1.AccountServiceOuterClass; +import bgs.protocol.authentication.v2.client.AuthenticationListenerOuterClass; +import bgs.protocol.authentication.v2.client.AuthenticationServiceOuterClass; +import bgs.protocol.connection.v1.ConnectionServiceProto; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass; +import bgs.protocol.presence.v2.PresenceListenerOuterClass; +import bgs.protocol.presence.v2.client.PresenceServiceOuterClass; + +/** + * The BGS methods the client uses, with the service hash, method id and body parsers of each. Hashes + * and ids are checked against the descriptors in bgs.desc by ServiceTableTest. + * + *

    Methods without a response parser are NO_RESPONSE: server notifications and fire-and-forget calls. + */ +public final class BgsServices { + + /** "Select game account" (service absent from bgs.desc): body {@code 1: GameAccountHandle{id, title_id, region}}. */ + public static final int SELECT_GAME_ACCOUNT_HASH = 0x7B37D770; + + /** Pushed by the server once the own presence is registered; no BatchSubscribe before it. Method 2 precedes ForceDisconnect. */ + public static final int SELF_PRESENCE_HASH = 0xBCA72135; + + public static final ServiceMethod + ACCOUNT_SUBSCRIBE = + new ServiceMethod<>("account.v1.AccountService", 0x62DA0891, 25, + AccountServiceOuterClass.SubscriptionUpdateRequest.parser(), AccountServiceOuterClass.SubscriptionUpdateResponse.parser()); + + public static final ServiceMethod + ACCOUNT_LISTENER_ON_ACCOUNT_STATE_UPDATED = + new ServiceMethod<>("account.v1.AccountListener", 0x54DFDA17, 1, + AccountServiceOuterClass.AccountStateNotification.parser(), null); + + public static final ServiceMethod + ACCOUNT_LISTENER_ON_GAME_ACCOUNT_STATE_UPDATED = + new ServiceMethod<>("account.v1.AccountListener", 0x54DFDA17, 2, + AccountServiceOuterClass.GameAccountStateNotification.parser(), null); + + public static final ServiceMethod + AUTHENTICATION_LISTENER_ON_LOGON_COMPLETE = + new ServiceMethod<>("authentication.v2.client.AuthenticationListener", 0x9DA8116B, 1, + AuthenticationListenerOuterClass.LogonCompleteNotification.parser(), null); + + public static final ServiceMethod + AUTHENTICATION_LISTENER_ON_LOGON_QUEUE_UPDATE = + new ServiceMethod<>("authentication.v2.client.AuthenticationListener", 0x9DA8116B, 2, + AuthenticationListenerOuterClass.LogonQueueUpdateNotification.parser(), null); + + public static final ServiceMethod AUTHENTICATION_LISTENER_ON_LOGON_QUEUE_END = + new ServiceMethod<>("authentication.v2.client.AuthenticationListener", 0x9DA8116B, 3, + RpcProto.NoData.parser(), null); + + public static final ServiceMethod + AUTHENTICATION_LISTENER_ON_EXTERNAL_CHALLENGE = + new ServiceMethod<>("authentication.v2.client.AuthenticationListener", 0x9DA8116B, 4, + AuthenticationListenerOuterClass.ExternalChallengeNotification.parser(), null); + + public static final ServiceMethod + AUTHENTICATION_LOGON = + new ServiceMethod<>("authentication.v2.client.AuthenticationService", 0xC02F8216, 1, + AuthenticationServiceOuterClass.LogonRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + AUTHENTICATION_VERIFY_AUTH_TOKEN = + new ServiceMethod<>("authentication.v2.client.AuthenticationService", 0xC02F8216, 2, + AuthenticationServiceOuterClass.VerifyAuthTokenRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + FRIENDS_LISTENER_ON_FRIEND_ADDED = + new ServiceMethod<>("friends.v2.client.FriendsListener", 0xDBC01A89, 1, + FriendsListenerOuterClass.FriendAddedNotification.parser(), null); + + public static final ServiceMethod + FRIENDS_LISTENER_ON_FRIEND_REMOVED = + new ServiceMethod<>("friends.v2.client.FriendsListener", 0xDBC01A89, 2, + FriendsListenerOuterClass.FriendRemovedNotification.parser(), null); + + public static final ServiceMethod + FRIENDS_LISTENER_ON_RECEIVED_INVITATION_ADDED = + new ServiceMethod<>("friends.v2.client.FriendsListener", 0xDBC01A89, 3, + FriendsListenerOuterClass.ReceivedInvitationAddedNotification.parser(), null); + + public static final ServiceMethod + FRIENDS_LISTENER_ON_RECEIVED_INVITATION_REMOVED = + new ServiceMethod<>("friends.v2.client.FriendsListener", 0xDBC01A89, 4, + FriendsListenerOuterClass.ReceivedInvitationRemovedNotification.parser(), null); + + public static final ServiceMethod + FRIENDS_LISTENER_ON_SENT_INVITATION_ADDED = + new ServiceMethod<>("friends.v2.client.FriendsListener", 0xDBC01A89, 5, + FriendsListenerOuterClass.SentInvitationAddedNotification.parser(), null); + + public static final ServiceMethod + FRIENDS_LISTENER_ON_SENT_INVITATION_REMOVED = + new ServiceMethod<>("friends.v2.client.FriendsListener", 0xDBC01A89, 6, + FriendsListenerOuterClass.SentInvitationRemovedNotification.parser(), null); + + public static final ServiceMethod + FRIENDS_LISTENER_ON_UPDATE_FRIEND_STATE = + new ServiceMethod<>("friends.v2.client.FriendsListener", 0xDBC01A89, 7, + FriendsListenerOuterClass.UpdateFriendStateNotification.parser(), null); + + public static final ServiceMethod + FRIENDS_SUBSCRIBE = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 1, + FriendsServiceOuterClass.SubscribeRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + FRIENDS_GET_SENT_INVITATIONS = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 3, + FriendsServiceOuterClass.GetSentInvitationsRequest.parser(), FriendsServiceOuterClass.GetSentInvitationsResponse.parser()); + + public static final ServiceMethod + FRIENDS_GET_RECEIVED_INVITATIONS = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 4, + FriendsServiceOuterClass.GetReceivedInvitationsRequest.parser(), FriendsServiceOuterClass.GetReceivedInvitationsResponse.parser()); + + public static final ServiceMethod + FRIENDS_GET_FRIENDS = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 5, + FriendsServiceOuterClass.GetFriendsRequest.parser(), FriendsServiceOuterClass.GetFriendsResponse.parser()); + + public static final ServiceMethod + FRIENDS_SEND_INVITATION = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 8, + FriendsServiceOuterClass.SendInvitationRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + FRIENDS_ACCEPT_INVITATION = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 9, + FriendsServiceOuterClass.AcceptInvitationRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + FRIENDS_REVOKE_INVITATION = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 10, + FriendsServiceOuterClass.RevokeInvitationRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + FRIENDS_IGNORE_INVITATION = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 12, + FriendsServiceOuterClass.IgnoreInvitationRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + FRIENDS_REMOVE_FRIEND = + new ServiceMethod<>("friends.v2.client.FriendsService", 0x5869BE8C, 13, + FriendsServiceOuterClass.RemoveFriendRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + PRESENCE_LISTENER_ON_PRESENCE_STATE_UPDATED = + new ServiceMethod<>("presence.v2.PresenceListener", 0xF4729709, 1, + PresenceListenerOuterClass.PresenceStateUpdatedNotification.parser(), null); + + public static final ServiceMethod + PRESENCE_LISTENER_ON_SUBSCRIBE_FAILURE = + new ServiceMethod<>("presence.v2.PresenceListener", 0xF4729709, 2, + PresenceListenerOuterClass.SubscribeFailureNotification.parser(), null); + + public static final ServiceMethod + PRESENCE_BATCH_SUBSCRIBE = + new ServiceMethod<>("presence.v2.client.PresenceService", 0x138D200C, 3, + PresenceServiceOuterClass.BatchSubscribeRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + PRESENCE_BATCH_UNSUBSCRIBE = + new ServiceMethod<>("presence.v2.client.PresenceService", 0x138D200C, 4, + PresenceServiceOuterClass.BatchUnsubscribeRequest.parser(), RpcProto.NoData.parser()); + + public static final ServiceMethod + CONNECTION_CONNECT = + new ServiceMethod<>("connection.v1.ConnectionService", 0x65446991, 1, + ConnectionServiceProto.ConnectRequest.parser(), ConnectionServiceProto.ConnectResponse.parser()); + + public static final ServiceMethod + CONNECTION_FORCE_DISCONNECT = + new ServiceMethod<>("connection.v1.ConnectionService", 0x65446991, 4, + ConnectionServiceProto.DisconnectNotification.parser(), null); + + public static final ServiceMethod CONNECTION_KEEP_ALIVE = + new ServiceMethod<>("connection.v1.ConnectionService", 0x65446991, 5, + RpcProto.NoData.parser(), null); + + public static final ServiceMethod + CONNECTION_REQUEST_DISCONNECT = + new ServiceMethod<>("connection.v1.ConnectionService", 0x65446991, 7, + ConnectionServiceProto.DisconnectRequest.parser(), null); + + private BgsServices() { + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fnv1a.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fnv1a.java new file mode 100644 index 000000000..84a85f6ab --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fnv1a.java @@ -0,0 +1,22 @@ +package fr.enimaloc.catapult.bgs.proto; + +import java.nio.charset.StandardCharsets; + +/** 32-bit FNV-1a, the hash BGS uses to identify a service on the wire. */ +public final class Fnv1a { + + private static final int OFFSET_BASIS = 0x811C9DC5; + private static final int PRIME = 0x01000193; + + private Fnv1a() { + } + + public static int hash(String s) { + int h = OFFSET_BASIS; + for (byte b : s.getBytes(StandardCharsets.UTF_8)) { + h ^= (b & 0xFF); + h *= PRIME; + } + return h; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fourcc.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fourcc.java new file mode 100644 index 000000000..9902d61eb --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fourcc.java @@ -0,0 +1,34 @@ +package fr.enimaloc.catapult.bgs.proto; + +import java.nio.charset.StandardCharsets; + +/** BGS title ids and programs are big-endian fourcc codes (4288624 = "App", 1465140039 = "WTCG"). */ +public final class Fourcc { + + private Fourcc() { + } + + /** The code of a title id, without its leading zero bytes; "0" for an id of 0. */ + public static String of(int titleId) { + StringBuilder sb = new StringBuilder(4); + for (int shift = 24; shift >= 0; shift -= 8) { + int b = (titleId >>> shift) & 0xFF; + if (b != 0 || sb.length() > 0) { + sb.append((char) b); + } + } + return sb.length() == 0 ? "0" : sb.toString(); + } + + public static int toId(String fourcc) { + byte[] bytes = fourcc.getBytes(StandardCharsets.ISO_8859_1); + if (bytes.length == 0 || bytes.length > 4) { + throw new IllegalArgumentException("A fourcc is 1 to 4 characters: " + fourcc); + } + int id = 0; + for (byte b : bytes) { + id = (id << 8) | (b & 0xFF); + } + return id; + } +} diff --git a/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/ServiceMethod.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/ServiceMethod.java new file mode 100644 index 000000000..7202df489 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/ServiceMethod.java @@ -0,0 +1,16 @@ +package fr.enimaloc.catapult.bgs.proto; + +import com.google.protobuf.Message; +import com.google.protobuf.Parser; + +/** + * One BGS RPC method: the service hash and method id that go in the frame header, and the parsers of + * its request and response bodies. {@code responseParser} is null for a NO_RESPONSE method. + */ +public record ServiceMethod( + String service, int serviceHash, int methodId, Parser requestParser, Parser responseParser) { + + public boolean hasResponse() { + return responseParser != null; + } +} diff --git a/catapult-bgs/src/main/resources/bgs.desc b/catapult-bgs/src/main/resources/bgs.desc new file mode 100644 index 000000000..a86ae4c8f Binary files /dev/null and b/catapult-bgs/src/main/resources/bgs.desc differ diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/BgsConnectionTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/BgsConnectionTest.java new file mode 100644 index 000000000..f6d872caf --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/BgsConnectionTest.java @@ -0,0 +1,211 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.connection.v1.ConnectionServiceProto.DisconnectNotification; +import fr.enimaloc.catapult.bgs.client.BgsConnection.State; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.RejectedExecutionException; + +import static fr.enimaloc.catapult.bgs.proto.BgsServices.ACCOUNT_SUBSCRIBE; +import static fr.enimaloc.catapult.bgs.proto.BgsServices.CONNECTION_FORCE_DISCONNECT; +import static fr.enimaloc.catapult.bgs.proto.BgsServices.CONNECTION_KEEP_ALIVE; +import static org.assertj.core.api.Assertions.assertThat; + +class BgsConnectionTest { + + private final ClientHarness h = new ClientHarness(); + private final ManualScheduler scheduler = new ManualScheduler(); + private final List sessions = new ArrayList<>(); + private final BgsConnection connection = new BgsConnection(h.client, () -> { + h.session = new FakeSession(); + sessions.add(h.session); + return h.session; + }, scheduler, Duration.ofSeconds(5), Duration.ofSeconds(120)); + + private void readySession() { + h.connect().logon().accountState().select(); + } + + private long mints() { + return sessions.stream().flatMap(s -> s.timeline.stream()).filter("mint"::equals).count(); + } + + @Test + void ioErrorReconnectsWithExponentialBackoffCappedAt120s() { + connection.start(); + assertThat(connection.state()).isEqualTo(State.CONNECTING); + long[] delays = {5, 10, 20, 40, 80, 120, 120}; + int expected = 1; + for (long delay : delays) { + h.session.drop(new IOException("reset")); + assertThat(connection.state()).isEqualTo(State.BACKOFF); + scheduler.advance(delay * 1000 - 1); + assertThat(sessions).hasSize(expected); + scheduler.advance(1); + assertThat(sessions).hasSize(++expected); + assertThat(connection.state()).isEqualTo(State.CONNECTING); + } + // reaching READY resets the backoff, and every reconnection mints again + readySession(); + assertThat(connection.state()).isEqualTo(State.READY); + assertThat(mints()).isEqualTo(1); + h.session.drop(new IOException("reset")); + scheduler.advance(4_999); + assertThat(sessions).hasSize(expected); + scheduler.advance(1); + assertThat(sessions).hasSize(expected + 1); + readySession(); + assertThat(mints()).isEqualTo(2); + } + + @Test + void sessionReplacedStopsWithoutReconnecting() { + connection.start(); + readySession(); + h.session.push(CONNECTION_FORCE_DISCONNECT, DisconnectNotification.newBuilder().setErrorCode(60).build()); + + assertThat(connection.state()).isEqualTo(State.STOPPED_REPLACED); + scheduler.advance(10 * 60_000); + assertThat(sessions).hasSize(1); + assertThat(scheduler.pending()).isZero(); + } + + @Test + void otherForceDisconnectBacksOff() { + connection.start(); + readySession(); + h.session.push(CONNECTION_FORCE_DISCONNECT, DisconnectNotification.newBuilder().setErrorCode(3).build()); + + assertThat(connection.state()).isEqualTo(State.BACKOFF); + scheduler.advance(5_000); + assertThat(sessions).hasSize(2); + } + + @Test + void codeRequiredStopsWithoutReconnecting() { + h.mintFailure = new TicketException("2FA code required", true); + connection.start(); + h.connect(); + + assertThat(connection.state()).isEqualTo(State.STOPPED_CODE_REQUIRED); + scheduler.advance(10 * 60_000); + assertThat(sessions).hasSize(1); + assertThat(scheduler.pending()).isZero(); + } + + @Test + void otherMintFailureBacksOff() { + h.mintFailure = new TicketException("login failed", false); + connection.start(); + h.connect(); + + assertThat(connection.state()).isEqualTo(State.BACKOFF); + scheduler.advance(5_000); + assertThat(sessions).hasSize(2); + } + + @Test + void keepAliveEvery30sWhileReady() { + connection.start(); + scheduler.advance(60_000); + assertThat(h.session.sentTo(CONNECTION_KEEP_ALIVE.serviceHash(), CONNECTION_KEEP_ALIVE.methodId())).isEmpty(); + + readySession(); + FakeSession live = h.session; + scheduler.advance(29_999); + assertThat(live.sentTo(CONNECTION_KEEP_ALIVE.serviceHash(), CONNECTION_KEEP_ALIVE.methodId())).isEmpty(); + scheduler.advance(1); + assertThat(live.sentTo(CONNECTION_KEEP_ALIVE.serviceHash(), CONNECTION_KEEP_ALIVE.methodId())).hasSize(1); + scheduler.advance(30_000); + assertThat(live.sentTo(CONNECTION_KEEP_ALIVE.serviceHash(), CONNECTION_KEEP_ALIVE.methodId())).hasSize(2); + + live.drop(new IOException("reset")); + scheduler.advance(4_000); + assertThat(live.sentTo(CONNECTION_KEEP_ALIVE.serviceHash(), CONNECTION_KEEP_ALIVE.methodId())).hasSize(2); + } + + @Test + void stopClosesTheSessionAndCancelsRetries() { + connection.start(); + readySession(); + connection.stop(); + + assertThat(h.session.closed).isTrue(); + assertThat(connection.state()).isEqualTo(State.STOPPED); + assertThat(scheduler.pending()).isZero(); + scheduler.advance(10 * 60_000); + assertThat(sessions).hasSize(1); + } + + @Test + void stopWhileInBackoffCancelsTheRetry() { + connection.start(); + h.session.drop(new IOException("reset")); + assertThat(connection.state()).isEqualTo(State.BACKOFF); + connection.stop(); + + assertThat(connection.state()).isEqualTo(State.STOPPED); + assertThat(scheduler.pending()).isZero(); + scheduler.advance(10 * 60_000); + assertThat(sessions).hasSize(1); + } + + @Test + void startThatNeverGetsTheAccountStateIsAbortedAndRetried() { + connection.start(); + h.connect().logon(); + scheduler.advance(120_000 - 1); + assertThat(connection.state()).isEqualTo(State.CONNECTING); + FakeSession stuck = h.session; + scheduler.advance(1); + + assertThat(stuck.closed).isTrue(); + assertThat(connection.state()).isEqualTo(State.BACKOFF); + scheduler.advance(5_000); + assertThat(sessions).hasSize(2); + assertThat(connection.state()).isEqualTo(State.CONNECTING); + } + + @Test + void accountSubscribeFailureClosesTheSessionAndBacksOff() { + connection.start(); + h.connect().logon(); + h.session.fail(ACCOUNT_SUBSCRIBE, 5); + + assertThat(h.session.closed).isTrue(); + assertThat(h.client.closed()).isCompletedWithValue(BgsListener.CloseReason.IO); + assertThat(connection.state()).isEqualTo(State.BACKOFF); + } + + @Test + void rejectedMintExecutorClosesTheSessionAndBacksOff() { + BgsClient rejecting = new BgsClient(ClientHarness.CONFIG, () -> "unused", h, r -> { + throw new RejectedExecutionException("full"); + }); + BgsConnection c = new BgsConnection(rejecting, () -> { + h.session = new FakeSession(); + return h.session; + }, scheduler, Duration.ofSeconds(5), Duration.ofSeconds(120)); + c.start(); + h.connect(); + + assertThat(h.session.closed).isTrue(); + assertThat(rejecting.closed()).isCompletedWithValue(BgsListener.CloseReason.IO); + assertThat(c.state()).isEqualTo(State.BACKOFF); + } + + @Test + void watchdogDoesNotFireAfterReady() { + connection.start(); + readySession(); + scheduler.advance(10 * 60_000); + + assertThat(connection.state()).isEqualTo(State.READY); + assertThat(h.session.closed).isFalse(); + assertThat(sessions).hasSize(1); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ClientHarness.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ClientHarness.java new file mode 100644 index 000000000..ac86d6121 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ClientHarness.java @@ -0,0 +1,141 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.RpcProto.NoData; +import bgs.protocol.account.v1.AccountServiceOuterClass.AccountStateNotification; +import bgs.protocol.account.v1.AccountTypes.AccountState; +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.authentication.v2.client.AuthenticationListenerOuterClass.LogonCompleteNotification; +import bgs.protocol.authentication.v2.client.AuthenticationTypes.LogonRecord; +import bgs.protocol.connection.v1.ConnectionServiceProto.ConnectResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetFriendsResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetReceivedInvitationsResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetSentInvitationsResponse; +import bgs.protocol.friends.v2.client.FriendsTypes.Friend; +import com.google.protobuf.ByteString; +import fr.enimaloc.catapult.bgs.client.BgsClient.BgsClientConfig; +import fr.enimaloc.catapult.bgs.proto.BgsServices; +import fr.enimaloc.catapult.bgs.proto.Fourcc; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static fr.enimaloc.catapult.bgs.proto.BgsServices.*; + +/** A BgsClient on a FakeSession, with a recording listener and helpers walking the session sequence. */ +final class ClientHarness implements BgsListener { + + static final long SELF = 1000L; + static final String SELF_TAG = "Bot#1111"; + static final String TICKET = "EU-test-ticket"; + static final String CIID = "ciid-42"; + static final GameAccountHandle APP_EU = handle(7, "App", 2); + static final BgsClientConfig CONFIG = new BgsClientConfig("bgs.test", 1119, "bot@example.test", "frFR"); + + FakeSession session = new FakeSession(); + final List friendEvents = new ArrayList<>(); + final List presenceEvents = new ArrayList<>(); + final List closes = new ArrayList<>(); + final List readies = new ArrayList<>(); + TicketException mintFailure; + final BgsClient client = new BgsClient(CONFIG, this::mint, this, Runnable::run); + CompletableFuture started; + + static GameAccountHandle handle(long id, String title, int region) { + return GameAccountHandle.newBuilder().setId(id).setTitleId(Fourcc.toId(title)).setRegion(region).build(); + } + + private String mint() throws TicketException { + session.timeline.add("mint"); + if (mintFailure != null) { + throw mintFailure; + } + return TICKET; + } + + /** Starts the client on a new FakeSession. */ + ClientHarness restart() { + session = new FakeSession(); + return start(); + } + + ClientHarness start() { + started = client.start(session); + return this; + } + + ClientHarness connect() { + // server_id is a required field nobody reads: a partial message is enough here + session.reply(CONNECTION_CONNECT, + ConnectResponse.newBuilder().setCiid(CIID).setConnectedRegion(2).buildPartial()); + return this; + } + + ClientHarness logon() { + session.reply(AUTHENTICATION_LOGON, NoData.getDefaultInstance()); + session.push(AUTHENTICATION_LISTENER_ON_LOGON_COMPLETE, LogonCompleteNotification.newBuilder() + .setRecord(LogonRecord.newBuilder().setAccountId(SELF).setBattleTag(SELF_TAG).addGameAccount(APP_EU)) + .build()); + return this; + } + + ClientHarness accountState() { + session.push(ACCOUNT_LISTENER_ON_ACCOUNT_STATE_UPDATED, + AccountStateNotification.newBuilder().setAccountState(AccountState.getDefaultInstance()).build()); + return this; + } + + ClientHarness select() { + session.replyRaw(BgsServices.SELECT_GAME_ACCOUNT_HASH, 1, ByteString.EMPTY); + return this; + } + + /** Start to ready: connect, logon, account state, game account selected. */ + ClientHarness ready() { + return start().connect().logon().accountState().select(); + } + + ClientHarness selfPresence() { + session.pushRaw(BgsServices.SELF_PRESENCE_HASH, 1, ByteString.EMPTY); + return this; + } + + /** Answers the three sync calls in one page each. */ + ClientHarness sync(GetFriendsResponse friends) { + session.reply(FRIENDS_GET_FRIENDS, friends); + session.reply(FRIENDS_GET_RECEIVED_INVITATIONS, GetReceivedInvitationsResponse.getDefaultInstance()); + session.reply(FRIENDS_GET_SENT_INVITATIONS, GetSentInvitationsResponse.getDefaultInstance()); + return this; + } + + static GetFriendsResponse friends(long continuation, Object... idTagPairs) { + GetFriendsResponse.Builder b = GetFriendsResponse.newBuilder().setContinuation(continuation); + for (int i = 0; i < idTagPairs.length; i += 2) { + b.addFriends(Friend.newBuilder().setId(((Number) idTagPairs[i]).longValue()) + .setBattleTag((String) idTagPairs[i + 1])); + } + return b.build(); + } + + // --- BgsListener --- + + @Override + public void onReady(long accountId, String battleTag) { + readies.add(accountId + " " + battleTag); + } + + @Override + public void onPresence(PresenceEvent event) { + presenceEvents.add(event); + } + + @Override + public void onFriend(FriendEvent event) { + friendEvents.add(event); + } + + @Override + public void onClosed(CloseReason reason) { + closes.add(reason); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FakeSession.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FakeSession.java new file mode 100644 index 000000000..12dbb1cc3 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FakeSession.java @@ -0,0 +1,179 @@ +package fr.enimaloc.catapult.bgs.client; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import fr.enimaloc.catapult.bgs.core.BgsChannel; +import fr.enimaloc.catapult.bgs.core.BgsRpcException; +import fr.enimaloc.catapult.bgs.proto.ServiceMethod; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** + * An in-memory {@link BgsChannel}: records every call with its header-level data, lets the test answer them, fail + * them and push server notifications. The loop runs tasks on the calling thread, so tests are deterministic. + */ +final class FakeSession implements BgsChannel { + + /** + * One outgoing call. {@code request} is null for a raw call; {@code reply} is null for a NO_RESPONSE call. + */ + record Sent(int serviceHash, int methodId, boolean ciid, Message request, ByteString body, + CompletableFuture reply) { + } + + /** Every call and mint, in order: "call 0x...:id", "mint". */ + final List timeline = new ArrayList<>(); + final List sent = new ArrayList<>(); + private final Map> handlers = new HashMap<>(); + private final List> closeListeners = new ArrayList<>(); + String ciid = ""; + boolean closed; + + // --- BgsChannel --- + + @Override + @SuppressWarnings("unchecked") + public CompletableFuture call(ServiceMethod method, Message request) { + CompletableFuture reply = new CompletableFuture<>(); + record(method.serviceHash(), method.methodId(), needsCiid(method), request, request.toByteString(), + method.hasResponse() ? reply : null); + if (!method.hasResponse()) { + reply.complete(null); + } + return reply; + } + + @Override + public void callNoReply(ServiceMethod method, Message request) { + record(method.serviceHash(), method.methodId(), needsCiid(method), request, request.toByteString(), null); + } + + @Override + public CompletableFuture callRaw(int serviceHash, int methodId, ByteString body, boolean withCiid) { + CompletableFuture reply = new CompletableFuture<>(); + record(serviceHash, methodId, withCiid, null, body, reply); + return reply; + } + + @Override + public void on(ServiceMethod method, Consumer handler) { + handlers.put(key(method.serviceHash(), method.methodId()), body -> { + try { + handler.accept(method.requestParser().parseFrom(body)); + } catch (InvalidProtocolBufferException e) { + throw new IllegalStateException(e); + } + }); + } + + @Override + public void onRaw(int serviceHash, int methodId, Consumer handler) { + handlers.put(key(serviceHash, methodId), handler); + } + + @Override + public void setCiid(String ciid) { + this.ciid = ciid; + } + + @Override + public void onClose(Consumer listener) { + closeListeners.add(listener); + } + + @Override + public Executor loop() { + return Runnable::run; + } + + @Override + public void close() { + drop(null); + } + + // --- test side --- + + /** Answers the oldest unanswered call of this method. */ + @SuppressWarnings("unchecked") + void reply(ServiceMethod method, R response) { + ((CompletableFuture) pending(method.serviceHash(), method.methodId()).reply()).complete(response); + } + + /** Fails the oldest unanswered call of this method with a BGS status. */ + void fail(ServiceMethod method, int status) { + pending(method.serviceHash(), method.methodId()).reply().completeExceptionally(new BgsRpcException(status)); + } + + @SuppressWarnings("unchecked") + void replyRaw(int serviceHash, int methodId, ByteString body) { + ((CompletableFuture) pending(serviceHash, methodId).reply()).complete(body); + } + + void failRaw(int serviceHash, int methodId, int status) { + pending(serviceHash, methodId).reply().completeExceptionally(new BgsRpcException(status)); + } + + void push(ServiceMethod method, Message notification) { + pushRaw(method.serviceHash(), method.methodId(), notification.toByteString()); + } + + void pushRaw(int serviceHash, int methodId, ByteString body) { + Consumer handler = handlers.get(key(serviceHash, methodId)); + if (handler != null) { + handler.accept(body); + } + } + + /** The session ends as on a transport error. */ + void drop(Throwable cause) { + if (closed) { + return; + } + closed = true; + List.copyOf(closeListeners).forEach(l -> l.accept(cause)); + } + + /** The typed requests sent for this method, in order. */ + @SuppressWarnings("unchecked") + List requests(ServiceMethod method) { + return sent.stream().filter(s -> s.serviceHash() == method.serviceHash() && s.methodId() == method.methodId()) + .map(s -> (Q) s.request()).toList(); + } + + List sentTo(int serviceHash, int methodId) { + return sent.stream().filter(s -> s.serviceHash() == serviceHash && s.methodId() == methodId).toList(); + } + + boolean hasPending(ServiceMethod method) { + return sentTo(method.serviceHash(), method.methodId()).stream() + .anyMatch(s -> s.reply() != null && !s.reply().isDone()); + } + + private Sent pending(int serviceHash, int methodId) { + return sentTo(serviceHash, methodId).stream().filter(s -> s.reply() != null && !s.reply().isDone()) + .findFirst().orElseThrow(() -> new AssertionError("No pending call 0x" + + Integer.toHexString(serviceHash).toUpperCase() + "." + methodId)); + } + + private void record(int serviceHash, int methodId, boolean ciid, Message request, ByteString body, + CompletableFuture reply) { + timeline.add("call 0x" + Integer.toHexString(serviceHash).toUpperCase() + "." + methodId); + sent.add(new Sent(serviceHash, methodId, ciid && !this.ciid.isEmpty(), request, body, reply)); + } + + /** Same rule as BgsSession: the ciid goes on friends and presence calls. */ + private static boolean needsCiid(ServiceMethod method) { + return method.service().contains("friends") || method.service().contains("presence"); + } + + private static long key(int serviceHash, int methodId) { + return ((long) serviceHash << 32) | (methodId & 0xFFFFFFFFL); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FriendsSyncTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FriendsSyncTest.java new file mode 100644 index 000000000..0cfc7389d --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FriendsSyncTest.java @@ -0,0 +1,295 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.RpcProto.NoData; +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.FriendAddedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.FriendRemovedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.ReceivedInvitationAddedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.ReceivedInvitationRemovedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.SentInvitationAddedNotification; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass.SentInvitationRemovedNotification; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.AcceptInvitationRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetFriendsRequest; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetReceivedInvitationsResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.GetSentInvitationsResponse; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass.SendInvitationRequest; +import bgs.protocol.friends.v2.client.FriendsTypes.Friend; +import bgs.protocol.friends.v2.client.FriendsTypes.ReceivedInvitation; +import bgs.protocol.friends.v2.client.FriendsTypes.RemovedFriendAssignment; +import bgs.protocol.friends.v2.client.FriendsTypes.RemovedInvitationAssignment; +import bgs.protocol.friends.v2.client.FriendsTypes.SentInvitation; +import bgs.protocol.friends.v2.client.FriendsTypes.UserDescription; +import bgs.protocol.presence.v2.PresenceListenerOuterClass.PresenceStateUpdatedNotification; +import bgs.protocol.presence.v2.PresenceTypes.PresenceField; +import bgs.protocol.presence.v2.PresenceTypes.PresenceFieldKey; +import bgs.protocol.presence.v2.PresenceTypes.PresenceFieldState; +import bgs.protocol.presence.v2.client.PresenceServiceOuterClass.BatchSubscribeRequest; +import bgs.protocol.presence.v2.client.PresenceServiceOuterClass.BatchUnsubscribeRequest; +import bgs.protocol.v2.AttributeTypes.Variant; +import fr.enimaloc.catapult.bgs.client.FriendEvent.FriendAdded; +import fr.enimaloc.catapult.bgs.client.FriendEvent.FriendRemoved; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationReceived; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationRemoved; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationSent; +import fr.enimaloc.catapult.bgs.client.FriendEvent.InvitationSentRemoved; +import fr.enimaloc.catapult.bgs.core.BgsRpcException; +import fr.enimaloc.catapult.bgs.proto.Fourcc; +import fr.enimaloc.catapult.bgs.proto.ServiceMethod; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import static fr.enimaloc.catapult.bgs.client.ClientHarness.friends; +import static fr.enimaloc.catapult.bgs.proto.BgsServices.*; +import static org.assertj.core.api.Assertions.assertThat; + +class FriendsSyncTest { + + @Test + void syncFollowsTheContinuationUntilZero() { + ClientHarness h = new ClientHarness().ready(); + h.session.reply(FRIENDS_GET_FRIENDS, friends(7, 1L, "a#1")); + assertThat(h.friendEvents).isEmpty(); + + h.session.reply(FRIENDS_GET_FRIENDS, friends(0, 2L, "b#2")); + + List requests = h.session.requests(FRIENDS_GET_FRIENDS); + assertThat(requests).hasSize(2); + assertThat(requests.get(1).getContinuation()).isEqualTo(7); + assertThat(requests.get(1).getOptions().getFetchNames()).isTrue(); + assertThat(h.client.friends()).isEqualTo(Map.of(1L, "a#1", 2L, "b#2")); + assertThat(h.friendEvents).containsExactly(new FriendAdded(1, "a#1"), new FriendAdded(2, "b#2")); + } + + @Test + void resyncAfterReconnectionEmitsTheDiffRemovalsFirst() { + ClientHarness h = new ClientHarness().ready().sync(friends(0, 1L, "a#1", 2L, "b#2")).selfPresence(); + h.session.drop(new IOException("reset")); + h.friendEvents.clear(); + + h.restart().connect().logon().accountState().select().selfPresence(); + h.sync(friends(0, 2L, "b#2", 3L, "c#3")); + + assertThat(h.friendEvents).containsExactly(new FriendRemoved(1, "a#1"), new FriendAdded(3, "c#3")); + assertThat(h.client.friends()).isEqualTo(Map.of(2L, "b#2", 3L, "c#3")); + assertThat(h.session.requests(PRESENCE_BATCH_UNSUBSCRIBE)).map(BatchUnsubscribeRequest::getAccountIdsList) + .containsExactly(List.of(1L)); + assertThat(h.session.requests(PRESENCE_BATCH_SUBSCRIBE)).map(BatchSubscribeRequest::getAccountIdsList) + .containsExactly(List.of(ClientHarness.SELF), List.of(1L, 2L), List.of(3L)); + } + + @Test + void resyncReportsInvitationsChangedOffline() { + ClientHarness h = new ClientHarness().ready(); + h.session.reply(FRIENDS_GET_FRIENDS, friends(0)); + h.session.reply(FRIENDS_GET_RECEIVED_INVITATIONS, received(70, 5, "e#5")); + h.session.reply(FRIENDS_GET_SENT_INVITATIONS, GetSentInvitationsResponse.newBuilder() + .addInvitations(SentInvitation.newBuilder().setId(80).setTargetName("t#8")).build()); + h.session.drop(new IOException("reset")); + h.friendEvents.clear(); + + h.restart().connect().logon().accountState().select(); + h.session.reply(FRIENDS_GET_FRIENDS, friends(0)); + h.session.reply(FRIENDS_GET_RECEIVED_INVITATIONS, GetReceivedInvitationsResponse.getDefaultInstance()); + h.session.reply(FRIENDS_GET_SENT_INVITATIONS, GetSentInvitationsResponse.getDefaultInstance()); + + assertThat(h.friendEvents).containsExactly(new InvitationRemoved(70, 5, "e#5", 0), + new InvitationSentRemoved(80, "t#8", FriendEvent.REASON_GONE_WHILE_OFFLINE)); + } + + @Test + void invitationAcceptedOfflineIsGoneWithAFriendAddedInTheSameResync() { + ClientHarness h = new ClientHarness().ready(); + h.session.reply(FRIENDS_GET_FRIENDS, friends(0)); + h.session.reply(FRIENDS_GET_RECEIVED_INVITATIONS, GetReceivedInvitationsResponse.getDefaultInstance()); + h.session.reply(FRIENDS_GET_SENT_INVITATIONS, GetSentInvitationsResponse.newBuilder() + .addInvitations(SentInvitation.newBuilder().setId(80).setTargetName("t#8")).build()); + h.session.drop(new IOException("reset")); + h.friendEvents.clear(); + + h.restart().connect().logon().accountState().select(); + h.sync(friends(0, 8L, "t#8")); + + assertThat(h.friendEvents).containsExactly(new FriendAdded(8, "t#8"), + new InvitationSentRemoved(80, "t#8", FriendEvent.REASON_GONE_WHILE_OFFLINE)); + } + + @Test + void livePushKeepsTheServerReason() { + ClientHarness h = new ClientHarness().ready().sync(friends(0)); + h.session.push(FRIENDS_LISTENER_ON_SENT_INVITATION_ADDED, SentInvitationAddedNotification.newBuilder() + .addInvitations(SentInvitation.newBuilder().setId(60).setTargetName("t#6")).build()); + h.session.push(FRIENDS_LISTENER_ON_SENT_INVITATION_REMOVED, SentInvitationRemovedNotification.newBuilder() + .addAssignments(RemovedInvitationAssignment.newBuilder().setInvitationId(60).setReason(3)).build()); + + assertThat(h.friendEvents).containsExactly(new InvitationSent(60, "t#6"), + new InvitationSentRemoved(60, "t#6", 3)); + } + + @Test + void pushesAreIdempotent() { + ClientHarness h = new ClientHarness().ready().sync(friends(0)); + ReceivedInvitationAddedNotification invited = ReceivedInvitationAddedNotification.newBuilder() + .addInvitations(ReceivedInvitation.newBuilder().setId(50) + .setInviter(UserDescription.newBuilder().setAccountId(9).setBattleTag("z#9"))).build(); + ReceivedInvitationRemovedNotification uninvited = ReceivedInvitationRemovedNotification.newBuilder() + .addAssignments(RemovedInvitationAssignment.newBuilder().setInvitationId(50).setReason(3)).build(); + FriendAddedNotification added = FriendAddedNotification.newBuilder() + .addFriends(Friend.newBuilder().setId(9).setBattleTag("z#9")).build(); + FriendRemovedNotification removed = FriendRemovedNotification.newBuilder() + .addAssignments(RemovedFriendAssignment.newBuilder().setId(9)).build(); + SentInvitationAddedNotification sent = SentInvitationAddedNotification.newBuilder() + .addInvitations(SentInvitation.newBuilder().setId(60).setTargetName("t#6")).build(); + SentInvitationRemovedNotification unsent = SentInvitationRemovedNotification.newBuilder() + .addAssignments(RemovedInvitationAssignment.newBuilder().setInvitationId(60)).build(); + + for (int i = 0; i < 2; i++) { + h.session.push(FRIENDS_LISTENER_ON_RECEIVED_INVITATION_ADDED, invited); + } + for (int i = 0; i < 2; i++) { + h.session.push(FRIENDS_LISTENER_ON_RECEIVED_INVITATION_REMOVED, uninvited); + } + for (int i = 0; i < 2; i++) { + h.session.push(FRIENDS_LISTENER_ON_FRIEND_ADDED, added); + } + for (int i = 0; i < 2; i++) { + h.session.push(FRIENDS_LISTENER_ON_FRIEND_REMOVED, removed); + } + for (int i = 0; i < 2; i++) { + h.session.push(FRIENDS_LISTENER_ON_SENT_INVITATION_ADDED, sent); + } + for (int i = 0; i < 2; i++) { + h.session.push(FRIENDS_LISTENER_ON_SENT_INVITATION_REMOVED, unsent); + } + + assertThat(h.friendEvents).containsExactly( + new InvitationReceived(50, 9, "z#9"), + new InvitationRemoved(50, 9, "z#9", 3), + new FriendAdded(9, "z#9"), + new FriendRemoved(9, "z#9"), + new InvitationSent(60, "t#6"), + new InvitationSentRemoved(60, "t#6", 0)); + } + + @Test + void removingAFriendInGameEndsTheGameAndUnsubscribes() { + ClientHarness h = new ClientHarness().ready().selfPresence().sync(friends(0, 4L, "d#4")); + h.session.push(PRESENCE_LISTENER_ON_PRESENCE_STATE_UPDATED, playing(4, "WTCG")); + assertThat(h.presenceEvents).singleElement().satisfies(e -> assertThat(e.game()).isEqualTo("WTCG")); + + h.session.push(FRIENDS_LISTENER_ON_FRIEND_REMOVED, FriendRemovedNotification.newBuilder() + .addAssignments(RemovedFriendAssignment.newBuilder().setId(4)).build()); + + assertThat(h.presenceEvents).hasSize(2); + assertThat(h.presenceEvents.get(1).accountId()).isEqualTo(4); + assertThat(h.presenceEvents.get(1).game()).isNull(); + assertThat(h.session.requests(PRESENCE_BATCH_UNSUBSCRIBE)).map(BatchUnsubscribeRequest::getAccountIdsList) + .containsExactly(List.of(4L)); + } + + @Test + void receivedInvitationsAtSyncAreReported() { + ClientHarness h = new ClientHarness().ready(); + h.session.reply(FRIENDS_GET_RECEIVED_INVITATIONS, received(70, 5, "e#5")); + h.session.reply(FRIENDS_GET_SENT_INVITATIONS, GetSentInvitationsResponse.newBuilder() + .addInvitations(SentInvitation.newBuilder().setId(80).setTargetName("t#8")).build()); + + assertThat(h.friendEvents).containsExactly(new InvitationReceived(70, 5, "e#5"), new InvitationSent(80, "t#8")); + } + + @Test + void actionsSendTheExactRequests() { + ClientHarness h = new ClientHarness().ready(); + + CompletableFuture invite = h.client.sendInvitation("Streamer#1234"); + CompletableFuture accept = h.client.acceptInvitation(70); + CompletableFuture decline = h.client.declineInvitation(71); + CompletableFuture revoke = h.client.revokeInvitation(72); + CompletableFuture remove = h.client.removeFriend(4); + + SendInvitationRequest inv = h.session.requests(FRIENDS_SEND_INVITATION).getFirst(); + assertThat(inv.getTarget().getBattleTag()).isEqualTo("Streamer#1234"); + assertThat(inv.getOptions().getLevel()).isEqualTo(1); + assertThat(inv.getOptions().getAttributesList()).singleElement().satisfies(a -> { + assertThat(a.getName()).isEqualTo("App-FriendInvitationReason"); + assertThat(a.getValue().getStringValue()).isEqualTo("enter_battletag"); + }); + AcceptInvitationRequest acc = h.session.requests(FRIENDS_ACCEPT_INVITATION).getFirst(); + assertThat(acc.getInvitationId()).isEqualTo(70); + assertThat(acc.getOptions().getLevel()).isEqualTo(1); + assertThat(h.session.requests(FRIENDS_IGNORE_INVITATION).getFirst().getInvitationId()).isEqualTo(71); + assertThat(h.session.requests(FRIENDS_REVOKE_INVITATION).getFirst().getInvitationId()).isEqualTo(72); + assertThat(h.session.requests(FRIENDS_REMOVE_FRIEND).getFirst().getTargetAccountId()).isEqualTo(4); + for (ServiceMethod m : List.of(FRIENDS_SEND_INVITATION, FRIENDS_ACCEPT_INVITATION, + FRIENDS_IGNORE_INVITATION, FRIENDS_REVOKE_INVITATION, FRIENDS_REMOVE_FRIEND)) { + assertThat(h.session.sentTo(m.serviceHash(), m.methodId())).singleElement() + .satisfies(s -> assertThat(s.ciid()).isTrue()); + } + + h.session.reply(FRIENDS_SEND_INVITATION, NoData.getDefaultInstance()); + h.session.reply(FRIENDS_ACCEPT_INVITATION, NoData.getDefaultInstance()); + h.session.reply(FRIENDS_IGNORE_INVITATION, NoData.getDefaultInstance()); + h.session.reply(FRIENDS_REVOKE_INVITATION, NoData.getDefaultInstance()); + h.session.fail(FRIENDS_REMOVE_FRIEND, 0x2A); + + assertThat(List.of(invite, accept, decline, revoke)).allSatisfy(f -> assertThat(f).isCompleted()); + assertThat(remove.exceptionNow()).isInstanceOf(BgsRpcException.class) + .satisfies(e -> assertThat(((BgsRpcException) e).status()).isEqualTo(0x2A)); + } + + @Test + void actionsOutsideAReadySessionFailAtOnce() { + ClientHarness idle = new ClientHarness(); + ClientHarness logging = new ClientHarness().start().connect().logon(); + ClientHarness closed = new ClientHarness().ready(); + closed.session.drop(new IOException("reset")); + + for (ClientHarness h : List.of(idle, logging, closed)) { + int before = h.session.sent.size(); + List>> actions = List.of( + c -> c.sendInvitation("x#1"), c -> c.acceptInvitation(1), c -> c.declineInvitation(1), + c -> c.revokeInvitation(1), c -> c.removeFriend(1)); + for (Function> action : actions) { + CompletableFuture f = action.apply(h.client); + assertThat(f).isCompletedExceptionally(); + assertThat(f.exceptionNow()).isInstanceOf(IllegalStateException.class); + } + assertThat(h.session.sent).hasSize(before); + } + } + + @Test + void localStateChangesOnPushesOnlyNotOnReplies() { + ClientHarness h = new ClientHarness().ready().sync(friends(0, 4L, "d#4")); + h.friendEvents.clear(); + + h.client.removeFriend(4); + h.session.reply(FRIENDS_REMOVE_FRIEND, NoData.getDefaultInstance()); + h.client.acceptInvitation(70); + h.session.reply(FRIENDS_ACCEPT_INVITATION, NoData.getDefaultInstance()); + assertThat(h.client.friends()).containsOnlyKeys(4L); + assertThat(h.friendEvents).isEmpty(); + + h.session.push(FRIENDS_LISTENER_ON_FRIEND_REMOVED, FriendRemovedNotification.newBuilder() + .addAssignments(RemovedFriendAssignment.newBuilder().setId(4)).build()); + assertThat(h.client.friends()).isEmpty(); + assertThat(h.friendEvents).containsExactly(new FriendRemoved(4, "d#4")); + } + + private static GetReceivedInvitationsResponse received(long id, long accountId, String tag) { + return GetReceivedInvitationsResponse.newBuilder().addInvitations(ReceivedInvitation.newBuilder().setId(id) + .setInviter(UserDescription.newBuilder().setAccountId(accountId).setBattleTag(tag))).build(); + } + + private static PresenceStateUpdatedNotification playing(long accountId, String fourcc) { + GameAccountHandle active = GameAccountHandle.newBuilder().setTitleId(Fourcc.toId(fourcc)).build(); + return PresenceStateUpdatedNotification.newBuilder().addStates(PresenceFieldState.newBuilder() + .setAccountId(accountId).addFields(PresenceField.newBuilder() + .setKey(PresenceFieldKey.newBuilder().setGroup(1).setField(21)) + .setValue(Variant.newBuilder().setBlobValue(active.toByteString())))).build(); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/GameAccountSelectorTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/GameAccountSelectorTest.java new file mode 100644 index 000000000..5b1ebfbd8 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/GameAccountSelectorTest.java @@ -0,0 +1,43 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static fr.enimaloc.catapult.bgs.client.ClientHarness.handle; +import static org.assertj.core.api.Assertions.assertThat; + +class GameAccountSelectorTest { + + private static final GameAccountHandle APP_US = handle(1, "App", 1); + private static final GameAccountHandle WTCG_US = handle(2, "WTCG", 1); + private static final GameAccountHandle WTCG_EU = handle(3, "WTCG", 2); + private static final GameAccountHandle APP_EU = handle(4, "App", 2); + + @Test + void launcherInTheConnectedRegionComesFirst() { + assertThat(GameAccountSelector.select(List.of(APP_US, WTCG_EU, APP_EU), 2)).isEqualTo(APP_EU); + } + + @Test + void thenAnyTitleInTheConnectedRegion() { + assertThat(GameAccountSelector.select(List.of(APP_US, WTCG_EU), 2)).isEqualTo(WTCG_EU); + } + + @Test + void thenTheLauncherInAnotherRegion() { + assertThat(GameAccountSelector.select(List.of(WTCG_US, APP_US), 2)).isEqualTo(APP_US); + } + + @Test + void thenTheFirstCandidate() { + assertThat(GameAccountSelector.select(List.of(WTCG_US, handle(5, "Pro", 1)), 2)).isEqualTo(WTCG_US); + } + + @Test + void unknownRegionCountsAsEuropeAndNoCandidateGivesNull() { + assertThat(GameAccountSelector.select(List.of(APP_US, APP_EU), 0)).isEqualTo(APP_EU); + assertThat(GameAccountSelector.select(List.of(), 2)).isNull(); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ManualScheduler.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ManualScheduler.java new file mode 100644 index 000000000..bb9171861 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ManualScheduler.java @@ -0,0 +1,149 @@ +package fr.enimaloc.catapult.bgs.client; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.Delayed; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.Callable; + +/** A scheduler driven by {@link #advance}: no thread, no sleep, a virtual clock in milliseconds. */ +final class ManualScheduler extends AbstractExecutorService implements ScheduledExecutorService { + + private final class Task implements ScheduledFuture { + final Runnable body; + final long period; + long due; + boolean cancelled; + boolean done; + + Task(Runnable body, long due, long period) { + this.body = body; + this.due = due; + this.period = period; + } + + @Override + public long getDelay(TimeUnit unit) { + return unit.convert(due - now, TimeUnit.MILLISECONDS); + } + + @Override + public int compareTo(Delayed o) { + return Long.compare(getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS)); + } + + @Override + public boolean cancel(boolean mayInterrupt) { + cancelled = true; + tasks.remove(this); + return true; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public boolean isDone() { + return done || cancelled; + } + + @Override + public Object get() { + return null; + } + + @Override + public Object get(long timeout, TimeUnit unit) { + return null; + } + } + + private final List tasks = new ArrayList<>(); + private long now; + + /** Moves the clock forward, running every task that falls due, in order. */ + void advance(long millis) { + long target = now + millis; + while (true) { + Task next = tasks.stream().filter(t -> t.due <= target).min(Comparator.comparingLong(t -> t.due)) + .orElse(null); + if (next == null) { + break; + } + now = Math.max(now, next.due); + if (next.period > 0) { + next.due = now + next.period; + } else { + tasks.remove(next); + next.done = true; + } + next.body.run(); + } + now = target; + } + + int pending() { + return tasks.size(); + } + + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + Task t = new Task(command, now + unit.toMillis(delay), 0); + tasks.add(t); + return t; + } + + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) { + Task t = new Task(command, now + unit.toMillis(initialDelay), unit.toMillis(delay)); + tasks.add(t); + return t; + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { + return scheduleWithFixedDelay(command, initialDelay, period, unit); + } + + @Override + public ScheduledFuture schedule(Callable callable, long delay, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override + public void execute(Runnable command) { + schedule(command, 0, TimeUnit.MILLISECONDS); + } + + @Override + public void shutdown() { + tasks.clear(); + } + + @Override + public List shutdownNow() { + tasks.clear(); + return List.of(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceReplayTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceReplayTest.java new file mode 100644 index 000000000..67304f24e --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceReplayTest.java @@ -0,0 +1,102 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.presence.v2.PresenceListenerOuterClass.PresenceStateUpdatedNotification; +import bgs.protocol.presence.v2.PresenceTypes.PresenceField; +import bgs.protocol.presence.v2.PresenceTypes.PresenceFieldKey; +import bgs.protocol.presence.v2.PresenceTypes.PresenceFieldState; +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.v2.AttributeTypes.Variant; +import com.google.protobuf.ByteString; +import fr.enimaloc.catapult.bgs.proto.Fourcc; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Replays a real (redacted) capture of a friend launching Hearthstone, playing two games, then quitting. */ +class PresenceReplayTest { + + private static final Pattern HEAD = Pattern.compile( + "^\\[bgs] presence (\\d+) title=(\\S+) fields=\\d+$|^\\[JEU] (\\d+) (?:joue a (\\S+).*|a quitte le jeu)$"); + private static final Pattern FIELD = Pattern.compile( + "^(?:\\[bgs])?\\s+\\('(\\w+)', (\\d+), (\\d+), (.*), (True|False)\\)$"); + + @Test + void replayEmitsHearthstoneThenNothing() throws IOException { + PresenceTracker tracker = new PresenceTracker(); + List events = new ArrayList<>(); + for (PresenceStateUpdatedNotification n : deltas()) { + tracker.apply(n).forEach(e -> events.add(e.accountId() + ":" + e.game())); + } + assertThat(events).containsExactly("100000002:WTCG", "100000002:null"); + } + + private static List deltas() throws IOException { + List lines; + try (InputStream in = PresenceReplayTest.class.getResourceAsStream("/presence.txt")) { + lines = new String(in.readAllBytes(), StandardCharsets.UTF_8).lines().toList(); + } + List out = new ArrayList<>(); + PresenceFieldState.Builder cur = null; + for (String line : lines) { + Matcher h = HEAD.matcher(line); + if (h.matches()) { + if (cur != null) { + out.add(wrap(cur)); + } + cur = PresenceFieldState.newBuilder().setAccountId(Long.parseLong( + h.group(1) != null ? h.group(1) : h.group(3))); + String title = h.group(1) != null ? h.group(2) : h.group(4); + if (title != null && !title.equals("None")) { + cur.setGameAccount(GameAccountHandle.newBuilder().setTitleId(Fourcc.toId(title))); + } + continue; + } + Matcher f = FIELD.matcher(line); + if (f.matches() && cur != null) { + int group = Integer.parseInt(f.group(2)); + int field = Integer.parseInt(f.group(3)); + cur.addFields(PresenceField.newBuilder() + .setKey(PresenceFieldKey.newBuilder().setTitleId(Fourcc.toId(f.group(1))) + .setGroup(group).setField(field)) + .setValue(variant(group, field, f.group(4))) + .setDeleted(f.group(5).equals("True"))); + } + } + if (cur != null) { + out.add(wrap(cur)); + } + return out; + } + + private static PresenceStateUpdatedNotification wrap(PresenceFieldState.Builder st) { + return PresenceStateUpdatedNotification.newBuilder().addStates(st).build(); + } + + /** Python literals: None, True/False, int, 'string'; account-level hex strings at (1, 20|21) are blobs. */ + private static Variant variant(int group, int field, String literal) { + Variant.Builder v = Variant.newBuilder(); + if (literal.equals("None")) { + return v.build(); + } + if (literal.equals("True") || literal.equals("False")) { + return v.setBoolValue(literal.equals("True")).build(); + } + if (literal.startsWith("'")) { + String s = literal.substring(1, literal.length() - 1); + if (group == 1 && (field == 20 || field == 21) && !s.isEmpty()) { + return v.setBlobValue(ByteString.copyFrom(HexFormat.of().parseHex(s))).build(); + } + return v.setStringValue(s).build(); + } + return v.setIntValue(Long.parseLong(literal)).build(); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceTrackerTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceTrackerTest.java new file mode 100644 index 000000000..224d4b6f0 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceTrackerTest.java @@ -0,0 +1,84 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.presence.v2.PresenceListenerOuterClass.PresenceStateUpdatedNotification; +import bgs.protocol.presence.v2.PresenceTypes.PresenceField; +import bgs.protocol.presence.v2.PresenceTypes.PresenceFieldKey; +import bgs.protocol.presence.v2.PresenceTypes.PresenceFieldState; +import bgs.protocol.v2.AttributeTypes.Variant; +import fr.enimaloc.catapult.bgs.proto.Fourcc; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PresenceTrackerTest { + + private static final long FRIEND = 42L; + + private static PresenceStateUpdatedNotification activeGame(String fourcc) { + return push(PresenceField.newBuilder() + .setKey(PresenceFieldKey.newBuilder().setGroup(1).setField(21)) + .setValue(Variant.newBuilder().setBlobValue( + GameAccountHandle.newBuilder().setTitleId(Fourcc.toId(fourcc)).build().toByteString()))); + } + + private static PresenceStateUpdatedNotification noActiveGame() { + return push(PresenceField.newBuilder() + .setKey(PresenceFieldKey.newBuilder().setGroup(1).setField(21)).setDeleted(true)); + } + + private static PresenceStateUpdatedNotification push(PresenceField.Builder field) { + return PresenceStateUpdatedNotification.newBuilder() + .addStates(PresenceFieldState.newBuilder().setAccountId(FRIEND).addFields(field)).build(); + } + + @Test + void repeatedDeltasOfTheSameGameEmitOnce() { + PresenceTracker tracker = new PresenceTracker(); + assertThat(tracker.apply(activeGame("WTCG"))).singleElement() + .satisfies(e -> { + assertThat(e.game()).isEqualTo("WTCG"); + assertThat(e.titleId()).isEqualTo(Fourcc.toId("WTCG")); + }); + assertThat(tracker.apply(activeGame("WTCG"))).isEmpty(); + } + + @Test + void launcherAloneIsNoGame() { + PresenceTracker tracker = new PresenceTracker(); + assertThat(tracker.apply(activeGame("App"))).isEmpty(); + tracker.apply(activeGame("WTCG")); + assertThat(tracker.apply(activeGame("App"))).singleElement().satisfies(e -> assertThat(e.game()).isNull()); + } + + @Test + void deletedAccountKeyEndsTheGame() { + PresenceTracker tracker = new PresenceTracker(); + tracker.apply(activeGame("WTCG")); + assertThat(tracker.apply(noActiveGame())).singleElement().satisfies(e -> assertThat(e.game()).isNull()); + } + + @Test + void forgetWhileInGameEmitsGameEnd() { + PresenceTracker tracker = new PresenceTracker(); + tracker.apply(activeGame("WTCG")); + assertThat(tracker.forget(FRIEND)).hasValueSatisfying(e -> { + assertThat(e.accountId()).isEqualTo(FRIEND); + assertThat(e.game()).isNull(); + }); + } + + @Test + void forgetWhileIdleEmitsNothing() { + assertThat(new PresenceTracker().forget(FRIEND)).isEmpty(); + } + + @Test + void clearDropsStateWithoutEmitting() { + PresenceTracker tracker = new PresenceTracker(); + tracker.apply(activeGame("WTCG")); + tracker.clear(); + assertThat(tracker.forget(FRIEND)).isEmpty(); + assertThat(tracker.apply(activeGame("WTCG"))).hasSize(1); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/SessionOrderTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/SessionOrderTest.java new file mode 100644 index 000000000..62011c1e1 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/SessionOrderTest.java @@ -0,0 +1,310 @@ +package fr.enimaloc.catapult.bgs.client; + +import bgs.protocol.account.v1.AccountServiceOuterClass.AccountStateNotification; +import bgs.protocol.account.v1.AccountServiceOuterClass.SubscriptionUpdateRequest; +import bgs.protocol.account.v1.AccountTypes.AccountState; +import bgs.protocol.account.v1.AccountTypes.GameAccountList; +import bgs.protocol.account.v1.AccountTypes.SubscriberReference; +import bgs.protocol.account.v2.GameAccountHandleOuterClass.GameAccountHandle; +import bgs.protocol.authentication.v2.client.AuthenticationListenerOuterClass.LogonCompleteNotification; +import bgs.protocol.authentication.v2.client.AuthenticationServiceOuterClass.LogonRequest; +import bgs.protocol.connection.v1.ConnectionServiceProto.ConnectResponse; +import bgs.protocol.connection.v1.ConnectionServiceProto.DisconnectNotification; +import bgs.protocol.presence.v2.client.PresenceServiceOuterClass.BatchSubscribeRequest; +import com.google.protobuf.CodedInputStream; +import fr.enimaloc.catapult.bgs.client.BgsListener.CloseReason; +import fr.enimaloc.catapult.bgs.core.BgsRpcException; +import fr.enimaloc.catapult.bgs.proto.BgsServices; +import fr.enimaloc.catapult.bgs.proto.Fourcc; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static fr.enimaloc.catapult.bgs.client.ClientHarness.*; +import static fr.enimaloc.catapult.bgs.proto.BgsServices.*; +import static org.assertj.core.api.Assertions.assertThat; + +class SessionOrderTest { + + private static final String CONNECT = "call 0x65446991.1"; + private static final String LOGON = "call 0xC02F8216.1"; + + @Test + void mintHappensBetweenConnectAndLogon() { + ClientHarness h = new ClientHarness().start(); + assertThat(h.session.timeline).containsExactly(CONNECT); + + h.connect(); + + assertThat(h.session.timeline).containsExactly(CONNECT, "mint", LOGON); + assertThat(h.session.ciid).isEqualTo(CIID); + LogonRequest logon = h.session.requests(AUTHENTICATION_LOGON).getFirst(); + assertThat(logon.getTitleId()).isEqualTo(Fourcc.toId("App")); + assertThat(logon.getPlatform()).isEqualTo("Win"); + assertThat(logon.getLocale()).isEqualTo("frFR"); + assertThat(logon.getApplicationVersion()).isEqualTo(25308); + assertThat(logon.getLogonOptions().getAuthToken().toStringUtf8()).isEqualTo(TICKET); + assertThat(logon.getLogonOptions().getEmail()).isEqualTo("bot@example.test"); + assertThat(logon.getLogonOptions().getAuthPlatform()).isEqualTo("BNET"); + } + + @Test + void noBatchSubscribeBeforeSelfPresence() { + ClientHarness h = new ClientHarness().ready().sync(friends(0, 30L, "c#3", 20L, "b#2")); + assertThat(h.session.requests(PRESENCE_BATCH_SUBSCRIBE)).isEmpty(); + + h.selfPresence(); + + assertThat(h.session.requests(PRESENCE_BATCH_SUBSCRIBE)).map(BatchSubscribeRequest::getAccountIdsList) + .containsExactly(List.of(SELF), List.of(20L, 30L)); + + h.selfPresence(); // a duplicate push subscribes nothing more + assertThat(h.session.requests(PRESENCE_BATCH_SUBSCRIBE)).hasSize(2); + } + + @Test + void friendAddedAfterSelfPresenceIsWatchedAtOnce() { + ClientHarness h = new ClientHarness().ready().selfPresence().sync(friends(0, 20L, "b#2")); + + assertThat(h.session.requests(PRESENCE_BATCH_SUBSCRIBE)).map(BatchSubscribeRequest::getAccountIdsList) + .containsExactly(List.of(SELF), List.of(20L)); + } + + @Test + void ciidOnFriendsPresenceAndSelectOnly() { + ClientHarness h = new ClientHarness().ready().sync(friends(0, 20L, "b#2")).selfPresence(); + + Set withCiid = Set.of(FRIENDS_SUBSCRIBE.serviceHash(), PRESENCE_BATCH_SUBSCRIBE.serviceHash(), + BgsServices.SELECT_GAME_ACCOUNT_HASH); + assertThat(h.session.sent).isNotEmpty().allSatisfy(s -> + assertThat(s.ciid()).as("ciid on 0x%X.%d", s.serviceHash(), s.methodId()) + .isEqualTo(withCiid.contains(s.serviceHash()))); + assertThat(h.session.sentTo(BgsServices.SELECT_GAME_ACCOUNT_HASH, 1)).singleElement() + .satisfies(s -> assertThat(s.ciid()).isTrue()); + } + + @Test + void accountSubscribeIsMinimal() { + ClientHarness h = new ClientHarness().start().connect().logon(); + + SubscriptionUpdateRequest req = h.session.requests(ACCOUNT_SUBSCRIBE).getFirst(); + assertThat(req.getRefCount()).isEqualTo(1); + SubscriberReference ref = req.getRef(0); + assertThat(ref.getObjectId()).isEqualTo(1); + assertThat(ref.getEntityId().getHigh()).isEqualTo(0x0100000000000000L); + assertThat(ref.getEntityId().getLow()).isEqualTo(SELF); + assertThat(ref.getAccountOptions().getAllFields_()).isTrue(); + assertThat(ref.hasAccountTags()).isFalse(); + assertThat(h.session.sentTo(ACCOUNT_SUBSCRIBE.serviceHash(), ACCOUNT_SUBSCRIBE.methodId())) + .singleElement().satisfies(s -> assertThat(s.ciid()).isFalse()); + } + + @Test + void selectBodyWrapsTheChosenHandleInField1() throws IOException { + ClientHarness h = new ClientHarness().start().connect().logon(); + h.session.push(ACCOUNT_LISTENER_ON_ACCOUNT_STATE_UPDATED, AccountStateNotification.newBuilder() + .setAccountState(AccountState.newBuilder().addGameAccounts(GameAccountList.newBuilder() + .addHandle(bgs.protocol.account.v1.AccountTypes.GameAccountHandle.newBuilder() + .setId(9).setProgram(Fourcc.toId("WTCG")).setRegion(1)))) + .build()); + + CodedInputStream in = h.session.sentTo(BgsServices.SELECT_GAME_ACCOUNT_HASH, 1).getFirst().body() + .newCodedInput(); + assertThat(in.readTag()).isEqualTo(10); // field 1, length-delimited + assertThat(GameAccountHandle.parseFrom(in.readBytes())).isEqualTo(APP_EU); + } + + @Test + void selectOnlyOncePerSession() { + ClientHarness h = new ClientHarness().start().connect().logon().accountState().accountState(); + + assertThat(h.session.sentTo(BgsServices.SELECT_GAME_ACCOUNT_HASH, 1)).hasSize(1); + h.select().accountState(); + assertThat(h.session.sentTo(BgsServices.SELECT_GAME_ACCOUNT_HASH, 1)).hasSize(1); + } + + @Test + void readyOnceTheGameAccountIsSelected() { + ClientHarness h = new ClientHarness().start().connect().logon().accountState(); + assertThat(h.client.ready()).isFalse(); + assertThat(h.started).isNotDone(); + assertThat(h.session.requests(FRIENDS_GET_FRIENDS)).isEmpty(); + + h.select(); + + assertThat(h.client.ready()).isTrue(); + assertThat(h.started).isCompleted(); + assertThat(h.readies).containsExactly(SELF + " " + SELF_TAG); + assertThat(h.session.requests(FRIENDS_SUBSCRIBE)).hasSize(1); + assertThat(h.session.requests(FRIENDS_GET_FRIENDS)).singleElement() + .satisfies(r -> assertThat(r.getOptions().getFetchNames()).isTrue()); + assertThat(h.session.requests(FRIENDS_GET_RECEIVED_INVITATIONS)).hasSize(1); + assertThat(h.session.requests(FRIENDS_GET_SENT_INVITATIONS)).hasSize(1); + } + + @Test + void logonErrorClosesWithLogonFailed() { + ClientHarness h = new ClientHarness().start().connect(); + h.session.reply(AUTHENTICATION_LOGON, bgs.protocol.RpcProto.NoData.getDefaultInstance()); + h.session.push(AUTHENTICATION_LISTENER_ON_LOGON_COMPLETE, + LogonCompleteNotification.newBuilder().setErrorCode(3).build()); + + assertThat(h.started).isCompletedExceptionally(); + assertThat(h.started.exceptionNow()).isInstanceOf(BgsRpcException.class) + .satisfies(e -> assertThat(((BgsRpcException) e).status()).isEqualTo(3)); + assertThat(h.session.closed).isTrue(); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.LOGON_FAILED); + assertThat(h.closes).containsExactly(CloseReason.LOGON_FAILED); + assertThat(h.session.requests(ACCOUNT_SUBSCRIBE)).isEmpty(); + } + + @Test + void refusedLogonCallClosesWithLogonFailed() { + ClientHarness h = new ClientHarness().start().connect(); + h.session.fail(AUTHENTICATION_LOGON, 7); + + assertThat(h.started.exceptionNow()).isInstanceOf(BgsRpcException.class); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.LOGON_FAILED); + } + + @Test + void mintNeedingACodeClosesWithCodeRequired() { + ClientHarness h = new ClientHarness(); + h.mintFailure = new TicketException("2FA code required", true); + h.start().connect(); + + assertThat(h.started.exceptionNow()).isSameAs(h.mintFailure); + assertThat(h.session.requests(AUTHENTICATION_LOGON)).isEmpty(); + assertThat(h.session.closed).isTrue(); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.CODE_REQUIRED); + assertThat(h.closes).containsExactly(CloseReason.CODE_REQUIRED); + } + + @Test + void otherMintFailureClosesWithIo() { + ClientHarness h = new ClientHarness(); + h.mintFailure = new TicketException("Login page timed out", false); + h.start().connect(); + + assertThat(h.started.exceptionNow()).isSameAs(h.mintFailure); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.IO); + } + + @Test + void anAbortedSessionInterruptsItsPendingMint() throws Exception { + CountDownLatch minting = new CountDownLatch(1); + CompletableFuture interrupted = new CompletableFuture<>(); + ClientHarness listener = new ClientHarness(); + BgsClient client = new BgsClient(CONFIG, () -> { + minting.countDown(); + try { + Thread.sleep(Duration.ofSeconds(30)); + interrupted.complete(false); + } catch (InterruptedException e) { + interrupted.complete(true); + } + throw new TicketException("Nobody waits for this ticket", false); + }, listener, Executors.newVirtualThreadPerTaskExecutor()); + FakeSession session = new FakeSession(); + client.start(session); + session.reply(CONNECTION_CONNECT, + ConnectResponse.newBuilder().setCiid(CIID).setConnectedRegion(2).buildPartial()); + assertThat(minting.await(5, TimeUnit.SECONDS)).isTrue(); + + client.abort(); + + assertThat(interrupted.get(5, TimeUnit.SECONDS)).isTrue(); + assertThat(client.closed()).isCompletedWithValue(CloseReason.IO); + } + + @Test + void forceDisconnect60MeansSessionReplaced() { + ClientHarness h = new ClientHarness().ready(); + h.session.push(CONNECTION_FORCE_DISCONNECT, DisconnectNotification.newBuilder().setErrorCode(60).build()); + + assertThat(h.session.closed).isTrue(); + assertThat(h.client.ready()).isFalse(); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.SESSION_REPLACED); + assertThat(h.closes).containsExactly(CloseReason.SESSION_REPLACED); + } + + @Test + void otherForceDisconnectIsReported() { + ClientHarness h = new ClientHarness().ready(); + h.session.push(CONNECTION_FORCE_DISCONNECT, DisconnectNotification.newBuilder().setErrorCode(3).build()); + + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.FORCE_DISCONNECT); + } + + @Test + void transportErrorClosesWithIoAndFailsAPendingStart() { + ClientHarness h = new ClientHarness().start().connect(); + h.session.drop(new IOException("reset")); + + assertThat(h.started).isCompletedExceptionally(); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.IO); + assertThat(h.closes).containsExactly(CloseReason.IO); + } + + @Test + void externalChallengeRemintsAndVerifiesOnTheSameSession() { + ClientHarness h = new ClientHarness().start().connect(); + h.session.push(AUTHENTICATION_LISTENER_ON_EXTERNAL_CHALLENGE, + bgs.protocol.authentication.v2.client.AuthenticationListenerOuterClass.ExternalChallengeNotification + .newBuilder().setPayloadType("web_auth_url").build()); + + assertThat(h.session.timeline).containsExactly(CONNECT, "mint", LOGON, "mint", "call 0xC02F8216.2"); + assertThat(h.session.requests(AUTHENTICATION_VERIFY_AUTH_TOKEN)).singleElement() + .satisfies(r -> assertThat(r.getAuthToken().toStringUtf8()).isEqualTo(TICKET)); + } + + @Test + void newSessionResetsTheSessionState() { + ClientHarness h = new ClientHarness().ready().sync(friends(0, 20L, "b#2")).selfPresence(); + h.session.drop(new IOException("reset")); + assertThat(h.client.ready()).isFalse(); + + h.restart(); + assertThat(h.client.closed()).isNotDone(); + h.connect().logon().accountState(); + + assertThat(h.session.sentTo(BgsServices.SELECT_GAME_ACCOUNT_HASH, 1)).hasSize(1); + h.select(); + assertThat(h.session.requests(PRESENCE_BATCH_SUBSCRIBE)).isEmpty(); + h.selfPresence(); + assertThat(h.session.requests(PRESENCE_BATCH_SUBSCRIBE)).map(BatchSubscribeRequest::getAccountIdsList) + .containsExactly(List.of(SELF), List.of(20L)); + } + + @Test + void localCloseReportsLocal() { + ClientHarness h = new ClientHarness().ready(); + h.client.close(); + + assertThat(h.session.closed).isTrue(); + assertThat(h.client.ready()).isFalse(); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.LOCAL); + assertThat(h.closes).containsExactly(CloseReason.LOCAL); + } + + @Test + void localCloseBeforeReadyFailsTheStart() { + ClientHarness h = new ClientHarness().start().connect(); + h.client.close(); + + assertThat(h.started).isCompletedExceptionally(); + assertThat(h.client.closed()).isCompletedWithValue(CloseReason.LOCAL); + } + + @Test + void configToStringMasksTheEmail() { + assertThat(CONFIG.toString()).doesNotContain("bot@example.test").contains("bgs.test"); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsSessionTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsSessionTest.java new file mode 100644 index 000000000..fcde621b3 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsSessionTest.java @@ -0,0 +1,258 @@ +package fr.enimaloc.catapult.bgs.core; + +import bgs.protocol.RpcProto; +import bgs.protocol.RpcProto.Header; +import bgs.protocol.connection.v1.ConnectionServiceProto; +import bgs.protocol.friends.v2.client.FriendsListenerOuterClass; +import bgs.protocol.friends.v2.client.FriendsServiceOuterClass; +import com.google.protobuf.ByteString; +import fr.enimaloc.catapult.bgs.proto.BgsServices; +import fr.enimaloc.catapult.bgs.proto.ServiceMethod; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BgsSessionTest { + + private static final int TIMEOUT_SECONDS = 5; + + /** The server side of an in-memory transport. */ + private PipedOutputStream serverOut; + private InputStream serverIn; + private BgsSession session; + + @BeforeEach + void setUp() throws IOException { + serverOut = new PipedOutputStream(); + PipedInputStream clientIn = new PipedInputStream(serverOut, 1 << 16); + PipedOutputStream clientOut = new PipedOutputStream(); + serverIn = new PipedInputStream(clientOut, 1 << 16); + session = BgsSession.open(new BgsTransport() { + @Override + public InputStream in() { + return clientIn; + } + + @Override + public OutputStream out() { + return clientOut; + } + + @Override + public void close() throws IOException { + clientIn.close(); + clientOut.close(); + } + }); + } + + @AfterEach + void tearDown() { + session.close(); + } + + private FrameCodec.Frame readFromClient() throws IOException { + return FrameCodec.read(serverIn); + } + + private void sendToClient(Header header, ByteString body) throws IOException { + serverOut.write(FrameCodec.encode(header, body)); + serverOut.flush(); + } + + private static T await(CompletableFuture f) throws Exception { + return f.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + @Test + void replyCompletesThePendingCallWithTheSameToken() throws Exception { + var future = session.call(BgsServices.CONNECTION_CONNECT, ConnectionServiceProto.ConnectRequest.getDefaultInstance()); + FrameCodec.Frame sent = readFromClient(); + var response = ConnectionServiceProto.ConnectResponse.newBuilder().setServerId( + RpcProto.ProcessId.newBuilder().setLabel(1).setEpoch(2)).build(); + + sendToClient(Header.newBuilder().setServiceId(0xFE).setToken(sent.header().getToken()).build(), + response.toByteString()); + + assertThat(await(future)).isEqualTo(response); + assertThat(sent.header().getServiceHash()).isEqualTo(BgsServices.CONNECTION_CONNECT.serviceHash()); + assertThat(sent.header().getMethodId()).isEqualTo(BgsServices.CONNECTION_CONNECT.methodId()); + } + + @Test + void nonZeroStatusFailsTheFutureWithBgsRpcException() throws Exception { + var future = session.callRaw(0x1234, 1, ByteString.EMPTY, false); + FrameCodec.Frame sent = readFromClient(); + + sendToClient(Header.newBuilder().setServiceId(0xFE).setToken(sent.header().getToken()).setStatus(7).build(), + ByteString.EMPTY); + + assertThatThrownBy(() -> await(future)).isInstanceOf(ExecutionException.class) + .cause().isInstanceOfSatisfying(BgsRpcException.class, e -> assertThat(e.status()).isEqualTo(7)); + } + + @Test + void tokensIncrementPerCall() throws Exception { + session.callRaw(0x1234, 1, ByteString.EMPTY, false); + session.callNoReply(BgsServices.CONNECTION_KEEP_ALIVE, RpcProto.NoData.getDefaultInstance()); + + int first = readFromClient().header().getToken(); + int second = readFromClient().header().getToken(); + + assertThat(second).isEqualTo(first + 1); + } + + @Test + void ciidOnlyOnCallsThatAskForIt() throws Exception { + session.setCiid("abc"); + session.callRaw(0x1234, 1, ByteString.EMPTY, true); + session.callRaw(0x1234, 2, ByteString.EMPTY, false); + session.call(BgsServices.CONNECTION_CONNECT, ConnectionServiceProto.ConnectRequest.getDefaultInstance()); + session.call(BgsServices.FRIENDS_GET_FRIENDS, FriendsServiceOuterClass.GetFriendsRequest.getDefaultInstance()); + + assertThat(readFromClient().header().getCiid()).isEqualTo("abc"); + assertThat(readFromClient().header().hasCiid()).isFalse(); + assertThat(readFromClient().header().hasCiid()).isFalse(); + assertThat(readFromClient().header().getCiid()).isEqualTo("abc"); + } + + @Test + void incomingCallIsAcknowledgedThenDispatched() throws Exception { + // A server call that expects a response: ack first, then the handler on the loop thread + var method = new ServiceMethod<>("test.Service", 0xAAAA, 3, + FriendsListenerOuterClass.FriendAddedNotification.parser(), RpcProto.NoData.parser()); + var received = new CompletableFuture(); + var thread = new AtomicReference(); + var loopThread = new CompletableFuture(); + session.loop().execute(() -> loopThread.complete(Thread.currentThread())); + session.on(method, n -> { + thread.set(Thread.currentThread()); + received.complete(n); + }); + var notification = FriendsListenerOuterClass.FriendAddedNotification.getDefaultInstance(); + + sendToClient(Header.newBuilder().setServiceId(0).setServiceHash(0xAAAA).setMethodId(3).setToken(42).build(), + notification.toByteString()); + + FrameCodec.Frame ack = readFromClient(); + assertThat(ack.header().getServiceId()).isEqualTo(0xFE); + assertThat(ack.header().getToken()).isEqualTo(42); + assertThat(ack.header().getSize()).isZero(); + assertThat(await(received)).isEqualTo(notification); + assertThat(thread.get()).isSameAs(await(loopThread)); + } + + @Test + void noResponseNotificationIsDispatchedWithoutAck() throws Exception { + var received = new CompletableFuture(); + session.on(BgsServices.FRIENDS_LISTENER_ON_FRIEND_ADDED, received::complete); + var notification = FriendsListenerOuterClass.FriendAddedNotification.getDefaultInstance(); + var method = BgsServices.FRIENDS_LISTENER_ON_FRIEND_ADDED; + + sendToClient(Header.newBuilder().setServiceId(0).setServiceHash(method.serviceHash()) + .setMethodId(method.methodId()).setToken(1).build(), notification.toByteString()); + assertThat(await(received)).isEqualTo(notification); + // the next frame written by the client is this call, not an ack + session.callRaw(0x1234, 1, ByteString.EMPTY, false); + + assertThat(readFromClient().header().getServiceHash()).isEqualTo(0x1234); + } + + @Test + void unknownIncomingServiceIsAcknowledged() throws Exception { + sendToClient(Header.newBuilder().setServiceId(0).setServiceHash(0xDEAD).setMethodId(9).setToken(7).build(), + ByteString.copyFromUtf8("x")); + + FrameCodec.Frame ack = readFromClient(); + assertThat(ack.header().getServiceId()).isEqualTo(0xFE); + assertThat(ack.header().getToken()).isEqualTo(7); + // the session is still alive afterwards + var future = session.callRaw(0x1234, 1, ByteString.EMPTY, false); + sendToClient(Header.newBuilder().setServiceId(0xFE).setToken(readFromClient().header().getToken()).build(), + ByteString.copyFromUtf8("ok")); + assertThat(await(future).toStringUtf8()).isEqualTo("ok"); + } + + @Test + void aThrowingHandlerDoesNotKillTheSession() throws Exception { + session.onRaw(0xBEEF, 1, body -> { + throw new IllegalStateException("boom"); + }); + sendToClient(Header.newBuilder().setServiceId(0).setServiceHash(0xBEEF).setMethodId(1).setToken(1).build(), + ByteString.EMPTY); + readFromClient(); // ack + + var future = session.callRaw(0x1234, 1, ByteString.EMPTY, false); + sendToClient(Header.newBuilder().setServiceId(0xFE).setToken(readFromClient().header().getToken()).build(), + ByteString.copyFromUtf8("ok")); + + assertThat(await(future).toStringUtf8()).isEqualTo("ok"); + } + + @Test + void eofClosesTheSessionAndFailsPendingCalls() throws Exception { + var closed = new CompletableFuture(); + session.onClose(cause -> closed.complete(cause)); + var future = session.callRaw(0x1234, 1, ByteString.EMPTY, false); + readFromClient(); + + serverOut.close(); + + assertThat(await(closed)).isNotNull(); + assertThatThrownBy(() -> await(future)).isInstanceOf(ExecutionException.class); + assertThatThrownBy(() -> await(session.callRaw(0x1234, 1, ByteString.EMPTY, false))) + .isInstanceOf(ExecutionException.class); + } + + @Test + void localCloseFailsPendingCallsAndNotifiesWithNoCause() throws Exception { + var closed = new CompletableFuture(); + session.onClose(closed::complete); + var future = session.callRaw(0x1234, 1, ByteString.EMPTY, false); + + session.close(); + + assertThat(await(closed)).isNull(); + assertThatThrownBy(() -> await(future)).isInstanceOf(ExecutionException.class); + } + + @Test + void closeListenerRegisteredAfterCloseIsStillNotified() throws Exception { + session.close(); + var first = new CompletableFuture(); + session.onClose(first::complete); + // the loop is shut down by then: a second late registration must also be notified + var second = new CompletableFuture(); + session.onClose(second::complete); + + assertThat(await(first)).isNull(); + assertThat(await(second)).isNull(); + } + + @Test + void closeListenerRegisteredAfterEofGetsTheCause() throws Exception { + var closed = new CompletableFuture(); + session.onClose(closed::complete); + serverOut.close(); + await(closed); + + var late = new CompletableFuture(); + session.onClose(late::complete); + + assertThat(await(late)).isNotNull(); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsTransportTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsTransportTest.java new file mode 100644 index 000000000..3528b7136 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsTransportTest.java @@ -0,0 +1,41 @@ +package fr.enimaloc.catapult.bgs.core; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BgsTransportTest { + + @Test + @Timeout(value = 20, unit = TimeUnit.SECONDS) + void aServerThatNeverAnswersTheHandshakeTimesOut() throws Exception { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + Thread acceptor = Thread.ofVirtual().start(() -> { + // accepts and keeps the connection open without a byte back + try (Socket ignored = server.accept()) { + Thread.sleep(Duration.ofSeconds(20)); + } catch (IOException | InterruptedException e) { + // the test is over + } + }); + long start = System.nanoTime(); + + assertThatThrownBy(() -> BgsTransport.tls(InetAddress.getLoopbackAddress().getHostAddress(), + server.getLocalPort(), Duration.ofMillis(500))) + .isInstanceOf(SocketTimeoutException.class); + + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(10)); + acceptor.interrupt(); + } + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/FrameCodecTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/FrameCodecTest.java new file mode 100644 index 000000000..7d49fca2b --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/FrameCodecTest.java @@ -0,0 +1,61 @@ +package fr.enimaloc.catapult.bgs.core; + +import bgs.protocol.RpcProto.Header; +import com.google.protobuf.ByteString; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.nio.ByteBuffer; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FrameCodecTest { + + @Test + void encodesHeaderLengthThenHeaderThenBody() throws Exception { + ByteString body = ByteString.copyFromUtf8("hello"); + Header header = Header.newBuilder().setServiceId(0).setServiceHash(0x5869BE8C).setMethodId(9).setToken(3).build(); + + byte[] frame = FrameCodec.encode(header, body); + + int headerLength = ByteBuffer.wrap(frame, 0, 2).getShort() & 0xFFFF; + Header decoded = Header.parseFrom(Arrays.copyOfRange(frame, 2, 2 + headerLength)); + assertThat(decoded.getSize()).isEqualTo(body.size()); + assertThat(decoded.getToken()).isEqualTo(3); + assertThat(Arrays.copyOfRange(frame, 2 + headerLength, frame.length)).isEqualTo(body.toByteArray()); + assertThat(frame).hasSize(2 + headerLength + body.size()); + } + + @Test + void readReturnsWhatEncodeProduced() throws Exception { + Header header = Header.newBuilder().setServiceId(0xFE).setToken(5).setStatus(7).build(); + byte[] two = concat(FrameCodec.encode(header, ByteString.copyFromUtf8("a")), + FrameCodec.encode(header, ByteString.EMPTY)); + var in = new ByteArrayInputStream(two); + + FrameCodec.Frame first = FrameCodec.read(in); + FrameCodec.Frame second = FrameCodec.read(in); + + assertThat(first.header().getStatus()).isEqualTo(7); + assertThat(first.body().toStringUtf8()).isEqualTo("a"); + assertThat(second.body()).isEmpty(); + assertThatThrownBy(() -> FrameCodec.read(in)).isInstanceOf(EOFException.class); + } + + @Test + void truncatedBodyIsAnEof() { + byte[] frame = FrameCodec.encode(Header.newBuilder().setServiceId(0).setToken(0).build(), ByteString.copyFromUtf8("abcdef")); + byte[] cut = Arrays.copyOf(frame, frame.length - 2); + + assertThatThrownBy(() -> FrameCodec.read(new ByteArrayInputStream(cut))).isInstanceOf(EOFException.class); + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] r = Arrays.copyOf(a, a.length + b.length); + System.arraycopy(b, 0, r, a.length, b.length); + return r; + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/DescriptorHashes.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/DescriptorHashes.java new file mode 100644 index 000000000..6d18ae9eb --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/DescriptorHashes.java @@ -0,0 +1,59 @@ +package fr.enimaloc.catapult.bgs.proto; + +import bgs.protocol.MethodOptionsProto; +import bgs.protocol.ServiceOptionsProto; +import com.google.protobuf.DescriptorProtos.FileDescriptorProto; +import com.google.protobuf.DescriptorProtos.FileDescriptorSet; +import com.google.protobuf.DescriptorProtos.MethodDescriptorProto; +import com.google.protobuf.DescriptorProtos.ServiceDescriptorProto; +import com.google.protobuf.ExtensionRegistry; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.HashMap; +import java.util.Map; + +/** Reads bgs.desc and its BGS options: the service hashes and method ids as the descriptors define them. */ +final class DescriptorHashes { + + static final String NO_RESPONSE = "bgs.protocol.NO_RESPONSE"; + + record Method(String name, int id, String inputType, String outputType) { + } + + record Service(String fullName, int hash, Map methods) { + } + + private DescriptorHashes() { + } + + /** Service by full name (bgs.protocol.friends.v2.client.FriendsService). */ + static Map load() { + ExtensionRegistry registry = ExtensionRegistry.newInstance(); + registry.add(ServiceOptionsProto.serviceOptions); + registry.add(MethodOptionsProto.methodOptions); + FileDescriptorSet set; + try (InputStream in = DescriptorHashes.class.getResourceAsStream("/bgs.desc")) { + set = FileDescriptorSet.parseFrom(in, registry); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + Map services = new HashMap<>(); + for (FileDescriptorProto file : set.getFileList()) { + for (ServiceDescriptorProto service : file.getServiceList()) { + String fullName = file.getPackage() + "." + service.getName(); + // The client hashes the legacy name (bnet.protocol...) when the descriptor carries one + String legacy = service.getOptions().getExtension(ServiceOptionsProto.serviceOptions).getDescriptorName(); + Map methods = new HashMap<>(); + for (MethodDescriptorProto m : service.getMethodList()) { + int id = m.getOptions().getExtension(MethodOptionsProto.methodOptions).getId(); + methods.put(m.getName(), new Method(m.getName(), id, + m.getInputType().substring(1), m.getOutputType().substring(1))); + } + services.put(fullName, new Service(fullName, Fnv1a.hash(legacy.isEmpty() ? fullName : legacy), methods)); + } + } + return services; + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/FourccTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/FourccTest.java new file mode 100644 index 000000000..3f6905849 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/FourccTest.java @@ -0,0 +1,27 @@ +package fr.enimaloc.catapult.bgs.proto; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FourccTest { + + @Test + void fourccRoundTrips() { + assertThat(Fourcc.of(4288624)).isEqualTo("App"); + assertThat(Fourcc.of(1465140039)).isEqualTo("WTCG"); + assertThat(Fourcc.toId("WTCG")).isEqualTo(1465140039); + assertThat(Fourcc.toId("App")).isEqualTo(4288624); + } + + @Test + void zeroIsNotEmpty() { + assertThat(Fourcc.of(0)).isEqualTo("0"); + } + + @Test + void rejectsCodesLongerThanFourCharacters() { + assertThatThrownBy(() -> Fourcc.toId("TOOLONG")).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/ServiceTableTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/ServiceTableTest.java new file mode 100644 index 000000000..b16ddb9c2 --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/ServiceTableTest.java @@ -0,0 +1,89 @@ +package fr.enimaloc.catapult.bgs.proto; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.AccessFlag; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ServiceTableTest { + + private static final Map EXPECTED_HASHES = new LinkedHashMap<>(); + + static { + EXPECTED_HASHES.put("bgs.protocol.connection.v1.ConnectionService", 0x65446991); + EXPECTED_HASHES.put("bgs.protocol.authentication.v2.client.AuthenticationService", 0xC02F8216); + EXPECTED_HASHES.put("bgs.protocol.authentication.v2.client.AuthenticationListener", 0x9DA8116B); + EXPECTED_HASHES.put("bgs.protocol.account.v1.AccountService", 0x62DA0891); + EXPECTED_HASHES.put("bgs.protocol.account.v1.AccountListener", 0x54DFDA17); + EXPECTED_HASHES.put("bgs.protocol.friends.v2.client.FriendsService", 0x5869BE8C); + EXPECTED_HASHES.put("bgs.protocol.friends.v2.client.FriendsListener", 0xDBC01A89); + EXPECTED_HASHES.put("bgs.protocol.presence.v2.client.PresenceService", 0x138D200C); + EXPECTED_HASHES.put("bgs.protocol.presence.v2.PresenceListener", 0xF4729709); + } + + @Test + void hashesComputedFromDescriptorsMatchTheKnownTable() { + Map services = DescriptorHashes.load(); + + EXPECTED_HASHES.forEach((name, hash) -> + assertThat(services.get(name)).as(name).isNotNull().extracting(DescriptorHashes.Service::hash).isEqualTo(hash)); + assertThat(BgsServices.FRIENDS_ACCEPT_INVITATION.serviceHash()).isEqualTo(0x5869BE8C); + assertThat(BgsServices.FRIENDS_ACCEPT_INVITATION.methodId()).isEqualTo(9); + } + + @Test + void everyConstantMatchesItsDescriptorMethod() throws Exception { + Map services = DescriptorHashes.load(); + List> methods = constants(); + assertThat(methods).hasSize(33); + + for (ServiceMethod m : methods) { + DescriptorHashes.Service service = services.get("bgs.protocol." + m.service()); + assertThat(service).as(m.service()).isNotNull(); + DescriptorHashes.Method method = service.methods().values().stream() + .filter(x -> x.id() == m.methodId()).findFirst().orElseThrow(); + String label = m.service() + "." + method.name(); + + assertThat(m.serviceHash()).as(label).isEqualTo(service.hash()); + assertThat(m.requestParser().parsePartialFrom(new byte[0]).getDescriptorForType().getFullName()) + .as(label + " request").isEqualTo(method.inputType()); + if (DescriptorHashes.NO_RESPONSE.equals(method.outputType())) { + assertThat(m.responseParser()).as(label + " response").isNull(); + } else { + assertThat(m.responseParser().parsePartialFrom(new byte[0]).getDescriptorForType().getFullName()) + .as(label + " response").isEqualTo(method.outputType()); + } + } + } + + @Test + void rawHashesOfServicesAbsentFromTheDescriptors() { + assertThat(BgsServices.SELECT_GAME_ACCOUNT_HASH).isEqualTo(0x7B37D770); + assertThat(BgsServices.SELF_PRESENCE_HASH).isEqualTo(0xBCA72135); + assertThat(DescriptorHashes.load().values()).extracting(DescriptorHashes.Service::hash) + .doesNotContain(BgsServices.SELECT_GAME_ACCOUNT_HASH, BgsServices.SELF_PRESENCE_HASH); + } + + @Test + void fnv1aMatchesTheReferenceVectors() { + assertThat(Fnv1a.hash("")).isEqualTo(0x811C9DC5); + assertThat(Fnv1a.hash("a")).isEqualTo(0xE40C292C); + assertThat(Fnv1a.hash("foobar")).isEqualTo(0xBF9CF968); + } + + private static List> constants() throws IllegalAccessException { + List> result = new ArrayList<>(); + for (Field f : BgsServices.class.getDeclaredFields()) { + if (f.accessFlags().contains(AccessFlag.STATIC) && ServiceMethod.class.isAssignableFrom(f.getType())) { + result.add((ServiceMethod) f.get(null)); + } + } + return result; + } +} diff --git a/catapult-bgs/src/test/resources/presence.txt b/catapult-bgs/src/test/resources/presence.txt new file mode 100644 index 000000000..46854be50 --- /dev/null +++ b/catapult-bgs/src/test/resources/presence.txt @@ -0,0 +1,76 @@ +[bgs] presence 100000002 title=App fields=16 +[bgs] ('BN', 2, 4, 1794557474990106, False) +[bgs] ('BN', 2, 5, 'Streamer#2000', False) +[bgs] ('App', 2, 3, None, True) +[bgs] ('BN', 2, 23, 1112425812, False) +[bgs] ('App', 2, 1, None, True) +[bgs] ('BN', 2, 17, 2, False) +[bgs] ('BN', 2, 14, 1, False) +[bgs] ('App', 2, 2, None, True) +[bgs] ('BN', 2, 20, 100000002, False) +[bgs] ('BN', 2, 10, True, False) +[bgs] ('BN', 2, 21, 'App', False) +[bgs] ('BN', 2, 1, False, False) +[bgs] ('BN', 2, 15, 5728622, False) +[bgs] ('BN', 2, 16, 1, False) +[bgs] ('BN', 2, 13, 2, False) +[bgs] ('BN', 2, 22, None, True) +[JEU] 100000002 joue a WTCG (title_id=1465140039) + ('BN', 2, 4, 1794557480188755, False) + ('BN', 2, 5, 'Streamer#2000', False) + ('BN', 2, 14, 1, False) + ('BN', 2, 17, 2, False) + ('BN', 2, 20, 100000002, False) + ('BN', 2, 10, False, False) + ('BN', 2, 21, 'WTCG', False) + ('BN', 2, 1, True, False) + ('BN', 2, 15, 5728622, False) + ('BN', 2, 16, 0, False) + ('BN', 2, 13, 2, False) + ('BN', 2, 22, 'Tente de battre Rexxar', False) +[JEU] 100000002 a quitte le jeu + ('BN', 1, 13, 1, False) + ('BN', 1, 6, 1794557480188755, False) + ('BN', 1, 4, 'Streamer#2000', False) + ('BN', 1, 20, '0a180a0d0883c2d72f10c786d1ba05180210d3f68ac9bf849803', False) + ('BN', 1, 21, '0883c2d72f10c786d1ba051802', False) + ('BN', 1, 22, 1, False) + ('BN', 1, 23, 1794557480188755, False) + ('BN', 1, 25, 'Tente de battre Rexxar', False) +[bgs] presence 100000001 title=App fields=15 +[bgs] ('BN', 2, 5, 'TestBot#1000', False) +[bgs] ('BN', 2, 12, False, False) +[bgs] ('BN', 2, 4, 1794557701533019, False) +[bgs] ('BN', 2, 20, 100000001, False) +[bgs] ('BN', 2, 10, False, False) +[bgs] ('BN', 2, 21, 'App', False) +[bgs] ('BN', 2, 6, '100000001#1', False) +[bgs] ('BN', 2, 1, True, False) +[bgs] ('BN', 2, 15, 5728622, False) +[bgs] ('BN', 2, 23, 1112425812, False) +[bgs] ('BN', 2, 16, 1, False) +[bgs] ('BN', 2, 13, 2, False) +[bgs] ('BN', 2, 17, 2, False) +[bgs] ('BN', 2, 14, 1, False) +[bgs] ('BN', 2, 22, None, True) +[bgs] presence 100000001 title=None fields=9 +[bgs] ('BN', 1, 6, 1794557701533019, False) +[bgs] ('BN', 1, 4, 'TestBot#1000', False) +[bgs] ('BN', 1, 12, False, False) +[bgs] ('BN', 1, 1, 'Test Bot', False) +[bgs] ('BN', 1, 20, '0a170a0c0884c2d72f10f0e08502180210dbdad0b2c0849803', False) +[bgs] ('BN', 1, 21, '0884c2d72f10f0e085021802', False) +[bgs] ('BN', 1, 22, 1, False) +[bgs] ('BN', 1, 23, 1794557701533019, False) +[bgs] ('BN', 1, 25, None, True) +[JEU] 100000002 joue a WTCG (title_id=1465140039) + ('BN', 2, 22, 'Tente de battre Garrosh Hurlenfer', False) +[JEU] 100000002 a quitte le jeu + ('BN', 1, 25, 'Tente de battre Garrosh Hurlenfer', False) +[bgs] presence 100000002 title=None fields=6 +[bgs] ('BN', 1, 6, 1794558036923705, False) +[bgs] ('BN', 1, 20, '', False) +[bgs] ('BN', 1, 21, None, True) +[bgs] ('BN', 1, 22, 0, False) +[bgs] ('BN', 1, 23, 1794558036923705, False) +[bgs] ('BN', 1, 25, None, True) diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/AccountDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/AccountDto.java index 779055427..f0ba2f3df 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/AccountDto.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/AccountDto.java @@ -2,5 +2,6 @@ import java.util.UUID; -public record AccountDto(UUID id, String label, String minecraftUsername, int fillOrder, +/** A Minecraft service account; {@code displayName} is its Minecraft username. */ +public record AccountDto(UUID id, String displayName, String minecraftUsername, int fillOrder, boolean friendLimitReached, boolean enabled, int linkCount) {} diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/BattleNetAccountDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/BattleNetAccountDto.java new file mode 100644 index 000000000..8f0755370 --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/BattleNetAccountDto.java @@ -0,0 +1,13 @@ +package fr.enimaloc.catapult.common.dto.admin; + +import java.util.UUID; + +/** + * A Battle.net bot account as the admin API shows it: never the e-mail, the password or the browser state. + * {@code waitingForCode}: an admin login waits for the 2FA code now (a {@code CODE_REQUIRED} left by an automatic + * login has none: only a re-authentication can take a code then). {@code displayName}: the BattleTag, else (before + * the first login) the masked e-mail, e.g. {@code b***@example.com}. + */ +public record BattleNetAccountDto(UUID id, String displayName, String battleTag, String authState, String sessionState, + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount, + boolean waitingForCode) {} diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingDto.java index fae0ee5ef..17337e6bc 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingDto.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingDto.java @@ -11,21 +11,23 @@ * {@code bindingCount} the number of bindings that follow it (MAPPED); {@code updatedBy} the admin's Twitch login. * {@code ignoredByDefault}: the game's bindings are ignored, but those whose streamer chose; its category * ({@code twitchGameId}, {@code twitchGameName}) is then null when it sets none. + * {@code igdbId}: the IGDB game the source is pinned to, null for none; {@code igdbGameName} its name when known. */ public record GameMappingDto(UUID id, String sourceType, String sourceId, String sourceName, String twitchGameId, String twitchGameName, Set ccls, Set tws, long bindingCount, - Instant updatedAt, String updatedBy, Boolean ignoredByDefault) { + Instant updatedAt, String updatedBy, Boolean ignoredByDefault, + String igdbId, String igdbGameName) { /** Boxed so a catapult-api that predates it still reads (Jackson refuses a missing primitive): absent, false. */ public GameMappingDto { ignoredByDefault = Boolean.TRUE.equals(ignoredByDefault); } - /** A mapping that doesn't ignore its game. */ + /** A mapping that doesn't ignore its game and pins no IGDB game. */ public GameMappingDto(UUID id, String sourceType, String sourceId, String sourceName, String twitchGameId, String twitchGameName, Set ccls, Set tws, long bindingCount, Instant updatedAt, String updatedBy) { this(id, sourceType, sourceId, sourceName, twitchGameId, twitchGameName, ccls, tws, bindingCount, updatedAt, - updatedBy, false); + updatedBy, false, null, null); } } diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingRequest.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingRequest.java index e09faec32..ea1a957c9 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingRequest.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/GameMappingRequest.java @@ -6,19 +6,21 @@ * Creates or changes an admin game mapping. {@code ccls} / {@code tws}: null keeps IGDB's, a set (even empty) * replaces them. {@code applyLive}: also update now the channels live on this game, else their bindings only serve * the next detection. {@code ignoredByDefault}: the game's bindings are ignored, but those whose streamer chose; the - * category is then optional (none: the bindings keep IGDB's). + * category is then optional (none: the bindings keep IGDB's). {@code igdbId}: the IGDB game the + * source is pinned to, null for none. */ public record GameMappingRequest(String sourceType, String sourceId, String twitchGameId, String twitchGameName, - Set ccls, Set tws, boolean applyLive, Boolean ignoredByDefault) { + Set ccls, Set tws, boolean applyLive, Boolean ignoredByDefault, + String igdbId) { /** Boxed so a body without it still reads (Jackson refuses a missing primitive): absent, the game isn't ignored. */ public GameMappingRequest { ignoredByDefault = Boolean.TRUE.equals(ignoredByDefault); } - /** A request that doesn't ignore the game. */ + /** A request that doesn't ignore the game and pins no IGDB game. */ public GameMappingRequest(String sourceType, String sourceId, String twitchGameId, String twitchGameName, Set ccls, Set tws, boolean applyLive) { - this(sourceType, sourceId, twitchGameId, twitchGameName, ccls, tws, applyLive, false); + this(sourceType, sourceId, twitchGameId, twitchGameName, ccls, tws, applyLive, false, null); } } diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/IgdbGameOption.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/IgdbGameOption.java new file mode 100644 index 000000000..38caeed38 --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/IgdbGameOption.java @@ -0,0 +1,7 @@ +package fr.enimaloc.catapult.common.dto.admin; + +/** + * An IGDB game offered by the game mapping form's search: its ID and name, its release year and cover image + * when IGDB has them (null otherwise). + */ +public record IgdbGameOption(String id, String name, Integer releaseYear, String coverUrl) {} diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/BattleNetData.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/BattleNetData.java new file mode 100644 index 000000000..6fe22e9bb --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/BattleNetData.java @@ -0,0 +1,11 @@ +package fr.enimaloc.catapult.common.dto.channel; + +public record BattleNetData( + String status, + String battleTag, + String serviceAccountBattleTag +) { + public boolean connected() { + return "ACCEPTED".equalsIgnoreCase(status); + } +} diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/ChannelPageData.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/ChannelPageData.java index 77cceb710..e6096adba 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/ChannelPageData.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/ChannelPageData.java @@ -23,6 +23,8 @@ public record ChannelPageData( SteamData steam, XboxData xbox, MinecraftData minecraft, + /** The owner's Battle.net link; null for anyone else, or without Battle.net for the channel. */ + BattleNetData battleNet, ObsData obs, TwitchatData twitchat, /** The owner's custom bindings; null for anyone else, or while the experiment is closed to the channel. */ @@ -33,7 +35,19 @@ public record ChannelPageData( */ Map drops ) { - /** A page without the drops. */ + /** A page without the Battle.net link. */ + public ChannelPageData(ChannelUserDto channelUser, String channelUsername, boolean isOwner, boolean isLive, + boolean botEnabled, GameDto currentGame, PagedBindings bindings, List availableCcls, + Set blockedCcls, List availableTws, Set blockedTws, + String filterStatus, String filterSource, SteamData steam, XboxData xbox, + MinecraftData minecraft, ObsData obs, TwitchatData twitchat, + CustomBindingsStateDto customBindings, Map drops) { + this(channelUser, channelUsername, isOwner, isLive, botEnabled, currentGame, bindings, availableCcls, + blockedCcls, availableTws, blockedTws, filterStatus, filterSource, steam, xbox, minecraft, null, obs, + twitchat, customBindings, drops); + } + + /** A page without the drops nor the Battle.net link. */ public ChannelPageData(ChannelUserDto channelUser, String channelUsername, boolean isOwner, boolean isLive, boolean botEnabled, GameDto currentGame, PagedBindings bindings, List availableCcls, Set blockedCcls, List availableTws, Set blockedTws, @@ -41,7 +55,7 @@ public ChannelPageData(ChannelUserDto channelUser, String channelUsername, boole MinecraftData minecraft, ObsData obs, TwitchatData twitchat, CustomBindingsStateDto customBindings) { this(channelUser, channelUsername, isOwner, isLive, botEnabled, currentGame, bindings, availableCcls, - blockedCcls, availableTws, blockedTws, filterStatus, filterSource, steam, xbox, minecraft, obs, twitchat, - customBindings, null); + blockedCcls, availableTws, blockedTws, filterStatus, filterSource, steam, xbox, minecraft, null, obs, + twitchat, customBindings, null); } } diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/connect/BattleNetLinkStateResponse.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/connect/BattleNetLinkStateResponse.java new file mode 100644 index 000000000..1a92d1080 --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/connect/BattleNetLinkStateResponse.java @@ -0,0 +1,4 @@ +package fr.enimaloc.catapult.common.dto.connect; + +public record BattleNetLinkStateResponse(String status, String battleTag, String serviceAccountBattleTag) { +} diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/event/ChannelEvents.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/event/ChannelEvents.java index 68eb8ebee..27161feb2 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/event/ChannelEvents.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/event/ChannelEvents.java @@ -35,6 +35,7 @@ public final class ChannelEvents { public static final String CONNECTION_CHANGED = "connection.changed"; public static final String DTDD_MAPPING_CHANGED = "dtdd.mapping.changed"; public static final String MINECRAFT_LINK_CHANGED = "minecraft.link.changed"; + public static final String BATTLENET_LINK_CHANGED = "battlenet.link.changed"; public static final String CUSTOM_BINDING_CHANGED = "custom.binding.changed"; public static final String CUSTOM_BINDINGS_UPDATED = "custom.bindings.updated"; @@ -91,6 +92,13 @@ public record DtddMappingChanged(String channelId, String channelLogin, String i /** The channel's Minecraft link changed: its status ({@code NONE} once gone) and player name. */ public record MinecraftLinkChanged(String channelId, String channelLogin, String status, String minecraftName) {} + /** + * The channel's Battle.net link changed: its status ({@code NONE} once gone), the streamer's BattleTag and the + * BattleTag of the bot it is linked to (both null once gone). + */ + public record BattleNetLinkChanged(String channelId, String channelLogin, String status, String battleTag, + String serviceAccountBattleTag) {} + /** * New notifications wait to be sent to these members' Twitchat: whichever catapult-web instance holds one * of their Twitchat pages or OBS widgets open asks catapult-api to flush them. Published on diff --git a/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java b/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java index ca27e8c4d..949e6f0c1 100644 --- a/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java +++ b/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java @@ -51,7 +51,7 @@ void dtddTopics_withoutThresholds_haveNone() { void aGameMapping_withoutItsIgnore_doesNotIgnoreTheGame() { GameMappingRequest request = new GameMappingRequest("STEAM", "730", "1", "CS", null, null, false); assertThat(request.ignoredByDefault()).isFalse(); - assertThat(new GameMappingRequest("STEAM", "730", null, null, null, null, false, null).ignoredByDefault()) + assertThat(new GameMappingRequest("STEAM", "730", null, null, null, null, false, null, null).ignoredByDefault()) .isFalse(); assertThat(json.readValue("{\"sourceType\":\"STEAM\",\"sourceId\":\"730\",\"applyLive\":true}", GameMappingRequest.class).ignoredByDefault()).isFalse(); @@ -63,6 +63,16 @@ void aGameMapping_withoutItsIgnore_doesNotIgnoreTheGame() { GameMappingDto.class).ignoredByDefault()).isFalse(); } + @Test + void aGameMapping_withoutItsIgdbGame_pinsNone() { + assertThat(json.readValue("{\"sourceType\":\"STEAM\",\"sourceId\":\"730\",\"applyLive\":true}", + GameMappingRequest.class).igdbId()).isNull(); + GameMappingDto dto = json.readValue("{\"sourceType\":\"STEAM\",\"sourceId\":\"730\",\"bindingCount\":0}", + GameMappingDto.class); + assertThat(dto.igdbId()).isNull(); + assertThat(dto.igdbGameName()).isNull(); + } + @Test void aRecalculation_countsWhatItWentThrough() { TwRebuildStatusDto status = new TwRebuildStatusDto("RUNNING", "NON_OVERRIDDEN", 40, 10, 3, 1, Instant.EPOCH, diff --git a/catapult-web/Dockerfile b/catapult-web/Dockerfile index a372cf97e..5a1403b7b 100644 --- a/catapult-web/Dockerfile +++ b/catapult-web/Dockerfile @@ -7,6 +7,7 @@ RUN apk add --no-cache git COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./ COPY gradle ./gradle COPY catapult-common/build.gradle.kts ./catapult-common/ +COPY catapult-bgs/build.gradle.kts ./catapult-bgs/ COPY catapult-api/build.gradle.kts ./catapult-api/ COPY catapult-web/build.gradle.kts ./catapult-web/ RUN ./gradlew :catapult-web:dependencies --no-daemon -q diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java new file mode 100644 index 000000000..420f12508 --- /dev/null +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java @@ -0,0 +1,110 @@ +package fr.enimaloc.catapult.controller; + +import fr.enimaloc.catapult.service.ApiService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.UUID; + +/** + * The admin Keys tab's Battle.net bot pool: add a bot, submit its 2FA code, re-authenticate, enable / + * disable, reorder, lift "full", change its password and delete, each answered with catapult-api's status + * (409 while a login is running or none waits for a code, 404 unknown, 502 when unreachable). The + * credentials and the code only travel in the JSON bodies, and are never logged nor sent back. + */ +@RestController +@RequestMapping("/admin/battlenet-accounts") +@RequiredArgsConstructor +public class AdminBattleNetController { + private final ApiService apiService; + private final ModelFiller filler; + + public record CreateBody(String email, String password) { + /** Never print the credentials. */ + @Override + public String toString() { + return "CreateBody[]"; + } + } + + public record CodeBody(String code) { + @Override + public String toString() { + return "CodeBody[]"; + } + } + + public record EnabledBody(Boolean enabled) {} + + public record DetailsBody(Integer fillOrder) {} + + public record PasswordBody(String password) { + @Override + public String toString() { + return "PasswordBody[]"; + } + } + + @PostMapping + public ResponseEntity create(@RequestBody CreateBody body) { + AdminAccess.require(filler); + if (blank(body.email()) || blank(body.password())) return ResponseEntity.badRequest().build(); + return ResponseEntity.status(apiService.adminCreateBattleNetAccount(body.email().trim(), body.password())).build(); + } + + @PostMapping("/{id}/code") + public ResponseEntity code(@PathVariable UUID id, @RequestBody CodeBody body) { + AdminAccess.require(filler); + if (blank(body.code())) return ResponseEntity.badRequest().build(); + return ResponseEntity.status(apiService.adminSubmitBattleNetCode(id, body.code().trim())).build(); + } + + @PostMapping("/{id}/reauth") + public ResponseEntity reauth(@PathVariable UUID id) { + AdminAccess.require(filler); + return ResponseEntity.status(apiService.adminReauthBattleNetAccount(id)).build(); + } + + @PostMapping("/{id}/enabled") + public ResponseEntity enabled(@PathVariable UUID id, @RequestBody EnabledBody body) { + AdminAccess.require(filler); + if (body.enabled() == null) return ResponseEntity.badRequest().build(); + return ResponseEntity.status(apiService.adminPatchBattleNetAccount(id, Map.of("enabled", body.enabled()))).build(); + } + + @PostMapping("/{id}/reset-limit") + public ResponseEntity resetLimit(@PathVariable UUID id) { + AdminAccess.require(filler); + return ResponseEntity.status(apiService.adminPatchBattleNetAccount(id, Map.of("friendLimitReached", false))).build(); + } + + @PostMapping("/{id}/details") + public ResponseEntity details(@PathVariable UUID id, @RequestBody DetailsBody body) { + AdminAccess.require(filler); + if (body.fillOrder() == null || body.fillOrder() < 0) return ResponseEntity.badRequest().build(); + return ResponseEntity.status(apiService.adminPatchBattleNetAccount(id, Map.of("fillOrder", body.fillOrder()))).build(); + } + + @PostMapping("/{id}/password") + public ResponseEntity password(@PathVariable UUID id, @RequestBody PasswordBody body) { + AdminAccess.require(filler); + if (blank(body.password())) return ResponseEntity.badRequest().build(); + return ResponseEntity.status(apiService.adminPatchBattleNetAccount(id, Map.of("password", body.password()))).build(); + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable UUID id) { + AdminAccess.require(filler); + return ResponseEntity.status(apiService.adminDeleteBattleNetAccount(id)).build(); + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminGameMappingsController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminGameMappingsController.java index 03f2b9277..e7949ed64 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminGameMappingsController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminGameMappingsController.java @@ -3,6 +3,7 @@ import fr.enimaloc.catapult.common.dto.admin.DetectedGameDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.service.ApiService; import fr.enimaloc.catapult.service.http.ApiClient; import fr.enimaloc.catapult.common.dto.admin.TwitchCategoryDto; @@ -68,6 +69,13 @@ public ResponseEntity> categories(@RequestParam(defaultV return answer(apiService.adminSearchCategories(q)); } + /** The IGDB games matching {@code q}, as {@code [{id, name, releaseYear, coverUrl}]}. */ + @GetMapping("/igdb-games") + public ResponseEntity> igdbGames(@RequestParam(defaultValue = "") String q) { + AdminAccess.require(filler); + return answer(apiService.adminSearchIgdbGames(q)); + } + private static ResponseEntity answer(ApiClient.Result result) { return ResponseEntity.status(result.status()).body(result.body()); } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminMinecraftController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminMinecraftController.java index ba4c9efbc..01c1efc6f 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminMinecraftController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminMinecraftController.java @@ -6,7 +6,6 @@ import fr.enimaloc.catapult.service.http.ApiError; import fr.enimaloc.catapult.service.http.ApiErrorText; import lombok.RequiredArgsConstructor; -import org.springframework.context.MessageSource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; @@ -26,7 +25,7 @@ * delete (409 while users are linked to it), each answered with catapult-api's status (502 when * unreachable); and the Microsoft device-code flow catapult-api runs, which this only relays: the * device code (else catapult-api's status and the refusal's {@code message}, 502 when unreachable), then each create / reauth poll with catapult-api's status - * (202 not confirmed yet, 200 done with the account's {@code minecraftUsername} and {@code label}, else the status alone). + * (202 not confirmed yet, 200 done with the account's {@code minecraftUsername} and {@code displayName}, else the status alone). */ @RestController @RequestMapping("/admin/minecraft-accounts") @@ -35,10 +34,9 @@ public class AdminMinecraftController { private final ApiService apiService; private final ModelFiller filler; private final ApiErrorText errorText; - private final MessageSource messageSource; public record EnabledBody(Boolean enabled) {} - public record CreateBody(String deviceCode, String label) {} + public record CreateBody(String deviceCode) {} public record ReauthBody(String deviceCode) {} @PostMapping("/{id}/enabled") @@ -74,13 +72,10 @@ public ResponseEntity deviceCode(Locale locale) { } @PostMapping("/create") - public ResponseEntity> create(@RequestBody CreateBody body, Locale locale) { + public ResponseEntity> create(@RequestBody CreateBody body) { AdminAccess.require(filler); if (blank(body.deviceCode())) return ResponseEntity.badRequest().build(); - String label = blank(body.label()) - ? messageSource.getMessage("admin.minecraft.default-label", null, locale) - : body.label().trim(); - return poll(() -> apiService.adminCreateMinecraftAccount(body.deviceCode(), label)); + return poll(() -> apiService.adminCreateMinecraftAccount(body.deviceCode())); } @PostMapping("/{id}/reauth") @@ -101,7 +96,7 @@ private static ResponseEntity> poll(Supplier shown = new HashMap<>(); if (result.status() == 200) { shown.put("minecraftUsername", answer.get("minecraftUsername")); - shown.put("label", answer.get("label")); + shown.put("displayName", answer.get("displayName")); } return ResponseEntity.status(result.status()).body(shown); } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java index 9dcf8d06f..28b3884c5 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java @@ -468,13 +468,14 @@ String twDtddTopics(Model model, HttpServletResponse response, @PathVariable Str }); } - /** Steam and DTDD keys cards, then the Minecraft service accounts card. */ + /** Steam and DTDD keys cards, then the Minecraft and Battle.net service accounts cards. */ @GetMapping("/keys") String keys(Model model, HttpServletResponse response) { return guarded(model, response, () -> { model.addAttribute("steam", apiService.adminApiKeys(ApiKeyProvider.STEAM)); model.addAttribute("dtdd", apiService.adminApiKeys(ApiKeyProvider.DTDD)); model.addAttribute("minecraft", apiService.adminMinecraftAccounts()); + model.addAttribute("battlenet", apiService.adminBattleNetAccounts()); // The rate limits learned from the platforms' 429s: each host, then its routes model.addAttribute("limits", apiService.adminRateLimits()); return "pages/admin-tab/keys :: content"; diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelActionsController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelActionsController.java index d79e2de51..630225548 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelActionsController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelActionsController.java @@ -22,6 +22,7 @@ import fr.enimaloc.catapult.common.dto.channel.TwSettingsRequest; import fr.enimaloc.catapult.common.dto.channel.UpdateBindingRequest; import fr.enimaloc.catapult.common.dto.channel.ValidateRequest; +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; import fr.enimaloc.catapult.common.dto.connect.LinkStateResponse; import fr.enimaloc.catapult.common.dto.twitchat.TwitchatSettingsResponse; import fr.enimaloc.catapult.service.ApiService; @@ -183,6 +184,20 @@ public ResponseEntity minecraftDisconnect(@PathVariable String username) { return ApiStatus.answer(apiService.minecraftDisconnect(username)); } + // Like Minecraft's, the member's own link. Linking is catapult-api's OAuth flow, not a call from here. + + @GetMapping("/channel/{username}/battlenet") + public ResponseEntity battleNetStatus(@PathVariable String username) { + if (!ownChannel(username)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(apiService.battleNetStatus(username)); + } + + @PostMapping("/channel/{username}/battlenet/disconnect") + public ResponseEntity battleNetDisconnect(@PathVariable String username) { + if (!ownChannel(username)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ApiStatus.answer(apiService.battleNetDisconnect(username)); + } + @PostMapping("/channel/{username}/xbox/disconnect") public ResponseEntity xboxDisconnect(@PathVariable String username) { if (!ownChannel(username)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridge.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridge.java index 2c2caf10d..817fd42a7 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridge.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridge.java @@ -16,6 +16,7 @@ import fr.enimaloc.catapult.event.binding.CustomBindingChangedEvent; import fr.enimaloc.catapult.event.binding.CustomBindingsUpdatedEvent; import fr.enimaloc.catapult.event.binding.GameChangedEvent; +import fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftDisconnectedEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftSyncEvent; import fr.enimaloc.catapult.event.steam.SteamConnectionStateEvent; @@ -143,6 +144,11 @@ private static List events(String name, String login, JsonN yield "NONE".equals(link.status()) ? List.of(new MinecraftDisconnectedEvent(login)) : List.of(new MinecraftSyncEvent(login, link.status(), link.minecraftName())); } + case ChannelEvents.BATTLENET_LINK_CHANGED -> { + var link = read(data, ChannelEvents.BattleNetLinkChanged.class); + yield List.of(new BattleNetLinkEvent(login, link.status(), link.battleTag(), + link.serviceAccountBattleTag())); + } default -> List.of(); }; } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventStreamController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventStreamController.java index 3aeffec28..39d37d811 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventStreamController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelEventStreamController.java @@ -1,6 +1,7 @@ package fr.enimaloc.catapult.controller; import fr.enimaloc.catapult.event.ChannelUpdatedEvent; +import fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent; import fr.enimaloc.catapult.event.binding.CustomBindingsUpdatedEvent; import fr.enimaloc.catapult.service.ApiService; import fr.enimaloc.catapult.service.http.ApiClient; @@ -89,15 +90,18 @@ public void onChannelUpdated(ChannelUpdatedEvent event) { String name = event.getClass().getSimpleName(); ChannelUpdatedEvent forModerators = forModerators(event); for (SseEmitter emitter : emittersByUsername.getOrDefault(key(event.username()), List.of())) { - sse.send(emitter, name, ownerEmitters.contains(emitter) ? event : forModerators); + ChannelUpdatedEvent payload = ownerEmitters.contains(emitter) ? event : forModerators; + if (payload != null) sse.send(emitter, name, payload); } } /** * {@code event} as a moderator's stream gets it: without the custom bindings, which catapult-api shows the - * owner alone (their page has no list to update). + * owner alone (their page has no list to update). Null when they get nothing at all: the Battle.net link, whose + * tags and card are the owner's alone. */ static ChannelUpdatedEvent forModerators(ChannelUpdatedEvent event) { + if (event instanceof BattleNetLinkEvent) return null; return event instanceof CustomBindingsUpdatedEvent updated ? new CustomBindingsUpdatedEvent(updated.username()) : event; } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelState.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelState.java index bea803f72..60a32bfd5 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelState.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/ChannelState.java @@ -5,6 +5,7 @@ import fr.enimaloc.catapult.common.dto.channel.ChannelPageData; import fr.enimaloc.catapult.common.dto.channel.DtddMappingStatusDto; import fr.enimaloc.catapult.common.dto.channel.GameDto; +import fr.enimaloc.catapult.common.dto.channel.BattleNetData; import fr.enimaloc.catapult.common.dto.channel.MinecraftData; import fr.enimaloc.catapult.common.dto.channel.SteamData; import fr.enimaloc.catapult.common.dto.channel.UserSettingsDto; @@ -17,6 +18,7 @@ import fr.enimaloc.catapult.event.DtddMappingChangedEvent; import fr.enimaloc.catapult.event.binding.CustomBindingsUpdatedEvent; import fr.enimaloc.catapult.event.binding.GameChangedEvent; +import fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftDisconnectedEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftSyncEvent; import fr.enimaloc.catapult.event.steam.SteamConnectionStateEvent; @@ -71,6 +73,11 @@ public static List of(ChannelPageData ch, UserSettingsDto settings, DtddM if (ch.minecraft() != null) { events.add(minecraft(login, ch.minecraft())); } + if (ch.battleNet() != null) { + BattleNetData link = ch.battleNet(); + events.add(new BattleNetLinkEvent(login, link.status() == null ? "NONE" : link.status(), link.battleTag(), + link.serviceAccountBattleTag())); + } CustomBindingsStateDto custom = ch.customBindings(); if (custom != null) { events.add(new CustomBindingsUpdatedEvent(login, custom.bindings(), diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/IntegrationCards.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/IntegrationCards.java index f97bfe0dd..63e5fdd80 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/IntegrationCards.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/IntegrationCards.java @@ -1,6 +1,7 @@ package fr.enimaloc.catapult.controller; import fr.enimaloc.catapult.common.dto.channel.ChannelPageData; +import fr.enimaloc.catapult.common.dto.channel.BattleNetData; import fr.enimaloc.catapult.common.dto.channel.MinecraftData; import fr.enimaloc.catapult.common.dto.channel.ObsData; import fr.enimaloc.catapult.common.dto.channel.SteamData; @@ -57,6 +58,9 @@ public static List of(ChannelPageData ch, Map (showsNone ? "" : "!") + "MinecraftDisconnectedEvent"); } + // Battle.net: one sub-view and button per status, driven by the status field of BattleNetLinkEvent, + // which carries NONE too. Linking goes through Blizzard's login (catapult-api's + // /oauth2/authorization/battlenet), so a rejected or removed link offers the same button as none at all. + // The tags are sentence parts of their own so that the event can replace them in place. + + private static IntegrationDto battleNet(BattleNetData battleNet) { + String status = battleNet.status() == null ? "NONE" : battleNet.status(); + return card("battlenet", + new Container(Attrs.id("battlenet-status-body"), Container.Kind.DIV, List.of( + battleNetCase("caseNone", status, List.of("NONE"), + new Text(Attrs.NONE, false, key(PREFIX + "battlenet.none"))), + battleNetCase("casePending", status, List.of("PENDING"), + new Container(Attrs.cls("battlenet-sentence"), Container.Kind.DIV, List.of( + new Text(Attrs.NONE, false, key(PREFIX + "battlenet.pending.before")), + battleNetTag("battlenet-bot-tag", "serviceAccountBattleTag", + battleNet.serviceAccountBattleTag()), + new Text(Attrs.NONE, false, key(PREFIX + "battlenet.pending.after"))))), + battleNetCase("caseRejected", status, List.of("INVITE_REJECTED"), + new Text(Attrs.NONE, false, key(PREFIX + "battlenet.rejected"))), + battleNetCase("caseAccepted", status, List.of("ACCEPTED"), + new Container(Attrs.cls("battlenet-sentence"), Container.Kind.DIV, List.of( + new Text(Attrs.NONE, false, key(PREFIX + "battlenet.accepted")), + battleNetTag("battlenet-tag", "battleTag", battleNet.battleTag())))), + battleNetCase("caseRemoved", status, List.of("REMOVED"), + new Text(Attrs.NONE, false, key(PREFIX + "battlenet.removed"))), + // No service account with room for one: nothing to do but wait. + battleNetCase("caseFull", status, List.of("FULL"), + new Text(Attrs.NONE, false, key(PREFIX + "battlenet.full"))))), + + // An OAuth login, so it looks like the Xbox card's actions: filled connect, text disconnect. + // Each button needs its own flag name, as the Minecraft card's do. + new Container(Attrs.id("battlenet-status-actions").withClass("card-actions"), + Container.Kind.DIV, List.of( + new Button(Attrs.id("battlenet-connect-btn") + .withState(battleNetState("showConnect", status, + List.of("NONE", "INVITE_REJECTED", "REMOVED"))), + Button.Kind.FILLED, key(PREFIX + "battlenet.connect")), + new Button(Attrs.id("battlenet-disconnect-btn") + .withState(battleNetState("showDisconnect", status, List.of("ACCEPTED"))), + Button.Kind.TEXT, key(PREFIX + "battlenet.disconnect"))))); + } + + private static Text battleNetTag(String className, String field, String tag) { + return new Text(Attrs.cls(className).withState(SpaState.sync("textContent", "BattleNetLinkEvent." + field)), + false, Message.literal(tag)); + } + + private static Container battleNetCase(String flag, String status, List statuses, IntegrationNode content) { + return new Container(Attrs.state(battleNetState(flag, status, statuses)), Container.Kind.DIV, List.of(content)); + } + + private static SpaState battleNetState(String flag, String status, List statuses) { + return SpaState.flag(flag, statuses.contains(status), "BattleNetLinkEvent.status=" + String.join("/", statuses)); + } + // OBS: no SSE event either, channel-connections.js refreshes the page after saving. private static IntegrationDto obs(ObsData obs) { diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/event/battlenet/BattleNetLinkEvent.java b/catapult-web/src/main/java/fr/enimaloc/catapult/event/battlenet/BattleNetLinkEvent.java new file mode 100644 index 000000000..91af9c5c7 --- /dev/null +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/event/battlenet/BattleNetLinkEvent.java @@ -0,0 +1,8 @@ +package fr.enimaloc.catapult.event.battlenet; + +import fr.enimaloc.catapult.event.ChannelUpdatedEvent; + +/** The channel's Battle.net link as it now stands; {@code NONE} once gone, the tags then being null. */ +public record BattleNetLinkEvent(String username, String status, String battleTag, String serviceAccountBattleTag) + implements ChannelUpdatedEvent { +} diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java index 867aaa2e5..8e8bca5a9 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java @@ -32,7 +32,9 @@ import fr.enimaloc.catapult.common.dto.admin.TwKeywordTestDto; import fr.enimaloc.catapult.common.dto.admin.TwRebuild; import fr.enimaloc.catapult.common.dto.admin.AccountDto; +import fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto; import fr.enimaloc.catapult.common.dto.admin.MinecraftDeviceCodeDto; +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; import fr.enimaloc.catapult.common.dto.admin.WhitelistPageDto; import fr.enimaloc.catapult.common.dto.admin.MemberTargetingDto; import fr.enimaloc.catapult.common.dto.admin.AdminPage; @@ -46,6 +48,7 @@ import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.common.dto.admin.TwitchCategoryDto; import fr.enimaloc.catapult.common.dto.notification.AdminNotificationDto; import fr.enimaloc.catapult.common.dto.notification.BroadcastRequestDto; @@ -103,6 +106,10 @@ public interface ApiService { int minecraftEnroll(String username, String name); int minecraftSync(String username); int minecraftDisconnect(String username); + /** The logged-in member's own Battle.net link; null when logged out or catapult-api fails. */ + BattleNetLinkStateResponse battleNetStatus(String username); + /** Unlinks the member's Battle.net account; catapult-api's status (204), 401 when logged out, 502 when unreachable. */ + int battleNetDisconnect(String username); /** Unlinks the channel's Xbox account; catapult-api's status (204; 403 not the owner), 502 when unreachable. */ int xboxDisconnect(String username); /** The logged-in user's OBS connection; null when logged out or OBS isn't enabled. */ @@ -348,6 +355,8 @@ default int adminCreateNotification(AdminNotificationCreateBody body) { List adminDetectedGames(String sourceType, String q); /** The Twitch categories matching {@code q}, with the box art as catapult-api returns it; null when catapult-api fails. */ List adminSearchCategories(String q); + /** The IGDB games matching {@code q} (name, release year, cover), for the mapping form's IGDB picker; null when catapult-api fails. */ + List adminSearchIgdbGames(String q); /** catapult-api's status (201; 400 MANUAL platform, missing ID or category; 409 already mapped) and result, 502 when unreachable. */ ApiClient.Result adminCreateGameMapping(GameMappingRequest request); /** catapult-api's status (200; 400 as for a creation; 404 unknown) and result, 502 when unreachable. */ @@ -509,13 +518,29 @@ default int adminCreateNotification(AdminNotificationCreateBody body) { /** A device-code start: the code when {@code status} is 200, else null and catapult-api's problem (may be null). */ record MinecraftDeviceCodeStart(int status, MinecraftDeviceCodeDto code, ApiError error) {} /** - * Polls the device code to enrol a new account ({@code label} null: catapult-api's default): + * Polls the device code to enrol a new account: * 202 while it isn't confirmed yet, 200 with the account once it is, else the error; 502 when unreachable. */ - ApiClient.Result> adminCreateMinecraftAccount(String deviceCode, String label); + ApiClient.Result> adminCreateMinecraftAccount(String deviceCode); /** Polls the device code to re-authenticate the account, answered like {@link #adminCreateMinecraftAccount}. */ ApiClient.Result> adminReauthMinecraftAccount(UUID id, String deviceCode); + /** The Battle.net bot accounts; null when catapult-api fails (Battle.net disabled included). */ + List adminBattleNetAccounts(); + /** + * Stores a bot account and starts its first login: catapult-api's status (202; 400 refused), 502 when unreachable. + * The credentials travel in the request body only. + */ + int adminCreateBattleNetAccount(String email, String password); + /** Submits the 2FA code of a login waiting for one: 202; 409 when none is waiting; 404 unknown; 502 when unreachable. */ + int adminSubmitBattleNetCode(UUID id, String code); + /** Re-runs the account's login: 202; 409 while a login is running; 404 unknown; 502 when unreachable. */ + int adminReauthBattleNetAccount(UUID id); + /** PATCHes a bot ({@code fillOrder}, {@code enabled}, {@code password}, {@code friendLimitReached}): 200; 409; 404; 502. */ + int adminPatchBattleNetAccount(UUID id, Map patch); + /** Deletes a bot account: 204; 404 unknown; 502 when unreachable. */ + int adminDeleteBattleNetAccount(UUID id); + /** The trigger warning definitions, by sort order; null when catapult-api fails. */ List adminTws(); /** One definition; null when unknown or when catapult-api fails. */ diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java index db51486fe..e8109f82a 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java @@ -18,6 +18,7 @@ import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.common.dto.admin.TwitchCategoryDto; import fr.enimaloc.catapult.common.dto.admin.AdminMemberDto; import fr.enimaloc.catapult.common.dto.admin.AdminMemberSummaryDto; @@ -35,6 +36,7 @@ import fr.enimaloc.catapult.common.dto.admin.TwKeywordTestDto; import fr.enimaloc.catapult.common.dto.admin.TwRebuild; import fr.enimaloc.catapult.common.dto.admin.AccountDto; +import fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto; import fr.enimaloc.catapult.common.dto.admin.WhitelistPageDto; import fr.enimaloc.catapult.common.dto.admin.MemberTargetingDto; import fr.enimaloc.catapult.common.dto.admin.AdminPage; @@ -65,6 +67,7 @@ import fr.enimaloc.catapult.common.dto.channel.GameDto; import fr.enimaloc.catapult.common.dto.channel.SearchResponse; import fr.enimaloc.catapult.common.dto.channel.UserSettingsDto; +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; import fr.enimaloc.catapult.common.dto.connect.LinkStateResponse; import fr.enimaloc.catapult.common.dto.twitchat.TwitchatDispatch; import fr.enimaloc.catapult.common.dto.twitchat.TwitchatEventActionDto; @@ -85,6 +88,7 @@ import fr.enimaloc.catapult.event.binding.BindingUpdatedEvent; import fr.enimaloc.catapult.event.binding.CclStateEvent; import fr.enimaloc.catapult.event.binding.GameChangedEvent; +import fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftDisconnectedEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftEnrollEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftSyncEvent; @@ -190,6 +194,7 @@ public class MockApiService implements ApiService { private final MockTwStore tws = new MockTwStore(); /** Application-wide: the Minecraft service accounts are the same for every mock session. */ private final MockMinecraftAccountsStore minecraftAccounts = new MockMinecraftAccountsStore(); + private final MockBattleNetAccountsStore battleNetAccounts = new MockBattleNetAccountsStore(); /** Application-wide, as catapult-api's: every mock account that logged in is a member any admin session lists. */ private final MockMembers members = new MockMembers(); /** Application-wide too: a member's custom flags are the same for every mock session. */ @@ -513,6 +518,22 @@ public int minecraftDisconnect(String username) { return NO_CONTENT; } + @Override + public BattleNetLinkStateResponse battleNetStatus(String username) { + log.trace("battleNetStatus({})", username); + return currentData().getBattleNetLink(); + } + + @Override + public int battleNetDisconnect(String username) { + log.trace("battleNetDisconnect({})", username); + MockData data = currentData(); + data.battleNetDisconnect(); + BattleNetLinkStateResponse link = data.getBattleNetLink(); + eventPublisher.publishEvent(new BattleNetLinkEvent(username, link.status(), null, null)); + return NO_CONTENT; + } + @Override public int xboxDisconnect(String username) { log.trace("xboxDisconnect({})", username); @@ -801,6 +822,12 @@ public List adminSearchCategories(String q) { .toList(); } + @Override + public List adminSearchIgdbGames(String q) { + log.trace("adminSearchIgdbGames({})", q); + return gameMappings.igdbGames(q); + } + @Override public ApiClient.Result adminCreateGameMapping(GameMappingRequest request) { log.trace("adminCreateGameMapping({})", request); @@ -1730,6 +1757,42 @@ public int adminRefreshApiKeys(ApiKeyProvider provider) { return apiKeys.refresh(); } + @Override + public List adminBattleNetAccounts() { + log.trace("adminBattleNetAccounts()"); + return battleNetAccounts.list(); + } + + @Override + public int adminCreateBattleNetAccount(String email, String password) { + log.trace("adminCreateBattleNetAccount()"); + return battleNetAccounts.create(email); + } + + @Override + public int adminSubmitBattleNetCode(UUID id, String code) { + log.trace("adminSubmitBattleNetCode({})", id); + return battleNetAccounts.submitCode(id, code); + } + + @Override + public int adminReauthBattleNetAccount(UUID id) { + log.trace("adminReauthBattleNetAccount({})", id); + return battleNetAccounts.reauth(id); + } + + @Override + public int adminPatchBattleNetAccount(UUID id, Map patch) { + log.trace("adminPatchBattleNetAccount({}, {})", id, patch.keySet()); // never the password + return battleNetAccounts.patch(id, patch); + } + + @Override + public int adminDeleteBattleNetAccount(UUID id) { + log.trace("adminDeleteBattleNetAccount({})", id); + return battleNetAccounts.delete(id); + } + @Override public List adminMinecraftAccounts() { log.trace("adminMinecraftAccounts()"); @@ -1755,9 +1818,9 @@ public MinecraftDeviceCodeStart adminMinecraftDeviceCode() { } @Override - public ApiClient.Result> adminCreateMinecraftAccount(String deviceCode, String label) { - log.trace("adminCreateMinecraftAccount({})", label); - return minecraftAccounts.create(deviceCode, label); + public ApiClient.Result> adminCreateMinecraftAccount(String deviceCode) { + log.trace("adminCreateMinecraftAccount()"); + return minecraftAccounts.create(deviceCode); } @Override diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStore.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStore.java new file mode 100644 index 000000000..39e788ba9 --- /dev/null +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStore.java @@ -0,0 +1,101 @@ +package fr.enimaloc.catapult.service.mock; + +import fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * The mock's Battle.net bot pool: one store for the whole application, answering like catapult-api. + * A new account waits for a 2FA code ({@code CODE_REQUIRED}); {@link #CODE} moves it to {@code READY} + * and any other code leaves it waiting. A code for an account that isn't waiting is a 409, an unknown + * account a 404. Passwords are accepted and dropped; a bot is shown by its BattleTag, else its masked e-mail. + */ +class MockBattleNetAccountsStore { + static final String CODE = "123456"; + + private record Account(UUID id, String email, String battleTag, String authState, String sessionState, + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount) { + BattleNetAccountDto dto() { + // the mock's CODE_REQUIRED bots always have a login waiting for the code + return new BattleNetAccountDto(id, battleTag != null ? battleTag : masked(email), battleTag, authState, sessionState, fillOrder, + friendLimitReached, enabled, friendCount, "CODE_REQUIRED".equals(authState)); + } + + Account with(String authState, String sessionState, boolean friendLimitReached, boolean enabled) { + return new Account(id, email, battleTag, authState, sessionState, fillOrder, friendLimitReached, enabled, friendCount); + } + } + + private final List accounts = new ArrayList<>(List.of( + new Account(UUID.fromString("7b3a1d2f-0000-4000-8000-000000000001"), "testbot@example.com", "TestBot#1000", + "READY", "READY", 0, false, true, 42), + new Account(UUID.fromString("7b3a1d2f-0000-4000-8000-000000000002"), "testbot2@example.com", null, + "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", 1, false, true, 0))); + + synchronized List list() { + return accounts.stream().map(Account::dto).toList(); + } + + synchronized int create(String email) { + if (email == null || email.isBlank()) return 400; + accounts.add(new Account(UUID.randomUUID(), email.trim(), null, "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", + accounts.size(), false, true, 0)); + return 202; + } + + synchronized int submitCode(UUID id, String code) { + int index = indexOf(id); + if (index < 0) return 404; + Account a = accounts.get(index); + if (!"CODE_REQUIRED".equals(a.authState())) return 409; + if (CODE.equals(code)) { + String tag = a.battleTag() == null ? a.email().substring(0, Math.max(1, a.email().indexOf('@'))) + "#0001" : a.battleTag(); + accounts.set(index, new Account(a.id(), a.email(), tag, + "READY", "READY", a.fillOrder(), a.friendLimitReached(), a.enabled(), a.friendCount())); + } + return 202; + } + + synchronized int reauth(UUID id) { + int index = indexOf(id); + if (index < 0) return 404; + Account a = accounts.get(index); + if ("NEW".equals(a.authState())) return 409; + accounts.set(index, a.with("CODE_REQUIRED", "STOPPED_CODE_REQUIRED", a.friendLimitReached(), a.enabled())); + return 202; + } + + synchronized int patch(UUID id, Map patch) { + int index = indexOf(id); + if (index < 0) return 404; + Account a = accounts.get(index); + boolean enabled = patch.get("enabled") instanceof Boolean b ? b : a.enabled(); + boolean full = patch.get("friendLimitReached") instanceof Boolean b ? b : a.friendLimitReached(); + String session = enabled == a.enabled() ? a.sessionState() : enabled ? "CONNECTING" : "DISABLED"; + accounts.set(index, a.with(a.authState(), session, full, enabled)); + return 200; + } + + synchronized int delete(UUID id) { + int index = indexOf(id); + if (index < 0) return 404; + accounts.remove(index); + return 204; + } + + private int indexOf(UUID id) { + for (int i = 0; i < accounts.size(); i++) { + if (accounts.get(i).id().equals(id)) return i; + } + return -1; + } + + /** Like catapult-api: {@code bot@example.com} → {@code b***@example.com}. */ + private static String masked(String email) { + int at = email.lastIndexOf('@'); + return email.charAt(0) + "***" + (at <= 0 ? "" : email.substring(at)); + } +} diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java index 5a5ea2f8a..e81bc6fa5 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java @@ -20,6 +20,8 @@ import fr.enimaloc.catapult.common.dto.channel.SteamData; import fr.enimaloc.catapult.common.dto.channel.UserSettingsDto; import fr.enimaloc.catapult.common.dto.channel.XboxData; +import fr.enimaloc.catapult.common.dto.channel.BattleNetData; +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; import fr.enimaloc.catapult.common.dto.connect.LinkStateResponse; import fr.enimaloc.catapult.common.dto.twitchat.TwitchatEventActionDto; import fr.enimaloc.catapult.common.dto.twitchat.TwitchatEvents; @@ -60,6 +62,9 @@ public class MockData { }; private static final ObsData DEFAULT_OBS = new ObsData(false, "127.0.0.1", 4455, true); private static final LinkStateResponse MINECRAFT_UNLINKED = new LinkStateResponse("NONE", null, null); + private static final BattleNetLinkStateResponse BATTLENET_UNLINKED = new BattleNetLinkStateResponse("NONE", null, null); + private static final String MOCK_BATTLE_TAG = "MockStreamer#1234"; + private static final String MOCK_BOT_BATTLE_TAG = "CatapultBot#4242"; /** A session's initial Steam state. */ record SteamState(boolean connected, boolean personalToken, boolean tokenShared, @@ -101,6 +106,8 @@ record SteamState(boolean connected, boolean personalToken, boolean tokenShared, @Getter private boolean hasMinecraftProvider; private LinkStateResponse minecraftLink; + /** Battle.net is offered to every mock session; the quick-launch presets differ by their link. */ + private BattleNetLinkStateResponse battleNetLink = BATTLENET_UNLINKED; private ObsData obsData; private String obsPassword; private String twitchatBranch = TwitchatData.AUTO; @@ -193,13 +200,20 @@ public static MockData fromJwt(String jwt) { int id = Integer.parseInt(jwt); List channels = MockPresets.CHANNELS; List bindings = MockPresets.BINDINGS; - return new MockData( + MockData data = new MockData( channels.get(Math.min(id, channels.size() - 1)), List.of(bindings.get(Math.min(id, bindings.size() - 1))), MockPresets.DEFAULT_SETTINGS, id == 0 ? channels : List.of(), new SteamState(true, true, false, false, false, false), false, false, MINECRAFT_UNLINKED, DEFAULT_OBS); + // The first preset is linked, the second one waits for its friend invitation, the others have no link. + data.battleNetLink = switch (id) { + case 0 -> new BattleNetLinkStateResponse("ACCEPTED", MOCK_BATTLE_TAG, MOCK_BOT_BATTLE_TAG); + case 1 -> new BattleNetLinkStateResponse("PENDING", MOCK_BATTLE_TAG, MOCK_BOT_BATTLE_TAG); + default -> BATTLENET_UNLINKED; + }; + return data; } private static MockData fromConfig(MockConfig config) { @@ -325,7 +339,7 @@ public ChannelPageData getPage(String username, String status, String source) { shown.live(), botEnabled, gameDto, new PagedBindings(0, bindings.isEmpty() ? 0 : 1, bindings.size(), bindings), AVAILABLE_CCLS, userSettingsDto.blockedCcls(), AVAILABLE_TWS, userSettingsDto.blockedTws(), - status, source, steamData(), hasXboxProvider ? new XboxData(hasXbox) : null, minecraftData(), obsData, + status, source, steamData(), hasXboxProvider ? new XboxData(hasXbox) : null, minecraftData(), battleNetData(), obsData, obsData == null ? null : TwitchatData.of(twitchatBranch), username.equals(channelDto.twitchUsername()) ? customBindings.state() : null, // drops: the mock has no experiments, they are offered @@ -351,6 +365,11 @@ private MinecraftData minecraftData() { minecraftLink.serviceAccountUsername()); } + private BattleNetData battleNetData() { + return new BattleNetData(battleNetLink.status(), battleNetLink.battleTag(), + battleNetLink.serviceAccountBattleTag()); + } + public ChannelUserDto getChannelUser() { return userOf(channelDto); } @@ -368,6 +387,14 @@ public LinkStateResponse getMinecraftLink() { return minecraftLink; } + public BattleNetLinkStateResponse getBattleNetLink() { + return battleNetLink; + } + + public void battleNetDisconnect() { + battleNetLink = BATTLENET_UNLINKED; + } + // --- binding mutations --------------------------------------------------------------------- public void setBindingCclEnabled(String bindingId, boolean enabled) { diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStore.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStore.java index 686322d81..0a4921100 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStore.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStore.java @@ -5,6 +5,7 @@ import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.service.http.ApiClient; import java.time.Clock; @@ -19,13 +20,36 @@ /** * The mock's admin game mappings: one store for the whole application. Answers like catapult-api: 201 for a * created mapping (400 for the MANUAL platform, a missing ID or a missing category; 409 for a game already - * mapped), 404 for an unknown mapping. Lists the mappings last changed first, and the detected games without a + * mapped), 404 for an unknown mapping. A mapping pinned to an IGDB game without a category takes the game's + * Twitch category when the mock's IGDB list knows one (400 otherwise), as catapult-api deduces it. Lists the mappings last changed first, and the detected games without a * mapping most streamers first, in pages of {@code pageSize} (small by default, so the mock shows paging). */ class MockGameMappingsStore { static final int DEFAULT_PAGE_SIZE = 2; private static final int DETECTED_MAX = 20; + /** An IGDB game of the mock's fixed list, with the Twitch category catapult-api would deduce for it (none: null). */ + private record IgdbGame(String id, String name, int year, String twitchId, String twitchName) { + IgdbGameOption option() { + return new IgdbGameOption(id, name, year, null); + } + } + + private static final List IGDB_GAMES = List.of( + new IgdbGame("123", "World of Warcraft", 2004, "18122", "World of Warcraft"), + new IgdbGame("417650", "World of Warcraft: Forever", 2030, null, null), + new IgdbGame("1279", "Hearthstone", 2014, "138585", "Hearthstone"), + new IgdbGame("125174", "Overwatch", 2016, "515025", "Overwatch"), + new IgdbGame("125165", "Diablo IV", 2023, "515024", "Diablo IV"), + new IgdbGame("120", "Diablo III", 2012, "313558", "Diablo III"), + new IgdbGame("142803", "Diablo II: Resurrected", 2021, "1788326126", "Diablo II: Resurrected"), + new IgdbGame("239", "StarCraft II: Wings of Liberty", 2010, "490422", "StarCraft II"), + new IgdbGame("7313", "Heroes of the Storm", 2015, "32959", "Heroes of the Storm"), + new IgdbGame("111650", "Warcraft III: Reforged", 2020, "12924", "Warcraft III"), + new IgdbGame("1942", "Counter-Strike 2", 2023, "32399", "Counter-Strike"), + new IgdbGame("1020", "Elden Ring", 2022, "512953", "Elden Ring"), + new IgdbGame("9999", "Obscure Prototype", 2019, null, null)); + private final Clock clock; private final int pageSize; private final List mappings = new ArrayList<>(); @@ -49,8 +73,36 @@ class MockGameMappingsStore { mappings.add(new GameMappingDto(UUID.randomUUID(), "XBOX", "1942", "Halo Infinite", "506416", "Halo Infinite", java.util.Set.of("violent-graphic"), java.util.Set.of("violence"), 6, now.minus(1, ChronoUnit.DAYS), "enimaloc")); + // The Blizzard titles the migration seeds: each pinned to its IGDB game + String[][] blizzard = { + {"WoW", "18122", "World of Warcraft", "123"}, {"WTCG", "138585", "Hearthstone", "1279"}, + {"Pro", "515025", "Overwatch", "125174"}, {"Fen", "515024", "Diablo IV", "125165"}, + {"D3", "313558", "Diablo III", "120"}, {"OSI", "1788326126", "Diablo II: Resurrected", "142803"}, + {"S2", "490422", "StarCraft II", "239"}, {"Hero", "32959", "Heroes of the Storm", "7313"}, + {"W3", "12924", "Warcraft III", "111650"}}; + for (String[] b : blizzard) { + mappings.add(new GameMappingDto(UUID.randomUUID(), "BATTLENET", b[0], null, b[1], b[2], null, null, 0, + now.minus(10, ChronoUnit.DAYS), "enimaloc", false, b[3], igdbName(b[3]))); + } mappings.add(new GameMappingDto(UUID.randomUUID(), "STEAM", "431960", "Wallpaper Engine", null, null, - null, null, 0, now.minus(3, ChronoUnit.DAYS), "enimaloc", true)); + null, null, 0, now.minus(3, ChronoUnit.DAYS), "enimaloc", true, null, null)); + } + + /** The mock's IGDB games whose name contains {@code q}; none for a blank {@code q}. */ + List igdbGames(String q) { + String needle = q == null ? "" : q.trim().toLowerCase(Locale.ROOT); + if (needle.isEmpty()) return List.of(); + return IGDB_GAMES.stream().filter(g -> g.name().toLowerCase(Locale.ROOT).contains(needle)) + .map(IgdbGame::option).limit(DETECTED_MAX).toList(); + } + + private static IgdbGame igdbGame(String id) { + return blank(id) ? null : IGDB_GAMES.stream().filter(g -> g.id().equals(id)).findFirst().orElse(null); + } + + private static String igdbName(String id) { + IgdbGame game = igdbGame(id); + return game == null ? null : game.name(); } synchronized AdminPage list(int page) { @@ -76,7 +128,7 @@ synchronized List detected(String sourceType, String q) { synchronized ApiClient.Result create(GameMappingRequest request, String updatedBy) { if (request == null || invalid(request.sourceType(), request.sourceId(), request.twitchGameId(), - request.ignoredByDefault())) { + request.ignoredByDefault(), request.igdbId())) { return new ApiClient.Result<>(400, null); } String sourceId = request.sourceId().trim(); @@ -84,10 +136,11 @@ synchronized ApiClient.Result create(GameMappingRequest reque DetectedGameDto game = detected.stream() .filter(d -> d.sourceType().equals(request.sourceType()) && d.sourceId().equals(sourceId)) .findFirst().orElse(null); + String[] category = category(request); GameMappingDto mapping = new GameMappingDto(UUID.randomUUID(), request.sourceType(), sourceId, - game == null ? null : game.sourceName(), request.twitchGameId(), request.twitchGameName(), + game == null ? null : game.sourceName(), category[0], category[1], request.ccls(), request.tws(), game == null ? 0 : game.streamerCount(), clock.instant(), updatedBy, - request.ignoredByDefault()); + request.ignoredByDefault(), request.igdbId(), igdbName(request.igdbId())); mappings.add(mapping); return new ApiClient.Result<>(201, result(mapping, request.applyLive())); } @@ -97,12 +150,14 @@ synchronized ApiClient.Result update(UUID id, GameMappingRequ int index = indexOf(id); if (index < 0) return new ApiClient.Result<>(404, null); GameMappingDto current = mappings.get(index); - if (request == null || blank(request.twitchGameId()) && !request.ignoredByDefault()) { + if (request == null || unresolved(request.twitchGameId(), request.ignoredByDefault(), request.igdbId())) { return new ApiClient.Result<>(400, null); } + String[] category = category(request); GameMappingDto changed = new GameMappingDto(id, current.sourceType(), current.sourceId(), current.sourceName(), - request.twitchGameId(), request.twitchGameName(), request.ccls(), request.tws(), - current.bindingCount(), clock.instant(), updatedBy, request.ignoredByDefault()); + category[0], category[1], request.ccls(), request.tws(), + current.bindingCount(), clock.instant(), updatedBy, request.ignoredByDefault(), + request.igdbId(), igdbName(request.igdbId())); mappings.set(index, changed); return new ApiClient.Result<>(200, result(changed, request.applyLive())); } @@ -120,10 +175,27 @@ private AdminPage page(List all, int page) { return new AdminPage<>(all.subList(from, to), Math.max(page, 0), (all.size() + pageSize - 1) / pageSize, all.size()); } - /** No category is only valid for a game ignored by default, as catapult-api checks it. */ - private static boolean invalid(String sourceType, String sourceId, String twitchGameId, boolean ignoredByDefault) { + /** + * No category is only valid for a game ignored by default, or one pinned to an IGDB game whose Twitch category + * can be deduced, as catapult-api checks it. + */ + private static boolean invalid(String sourceType, String sourceId, String twitchGameId, boolean ignoredByDefault, + String igdbId) { return blank(sourceType) || "MANUAL".equalsIgnoreCase(sourceType) - || blank(sourceId) || blank(twitchGameId) && !ignoredByDefault; + || blank(sourceId) || unresolved(twitchGameId, ignoredByDefault, igdbId); + } + + private static boolean unresolved(String twitchGameId, boolean ignoredByDefault, String igdbId) { + if (!blank(twitchGameId) || ignoredByDefault) return false; + IgdbGame game = igdbGame(igdbId); + return game == null || game.twitchId() == null; + } + + /** The request's category (id, name), else the one deduced from its IGDB game, else none. */ + private static String[] category(GameMappingRequest request) { + if (!blank(request.twitchGameId())) return new String[]{request.twitchGameId(), request.twitchGameName()}; + IgdbGame game = request.ignoredByDefault() ? null : igdbGame(request.igdbId()); + return game == null ? new String[]{null, null} : new String[]{game.twitchId(), game.twitchName()}; } private static boolean blank(String s) { diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStore.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStore.java index 986a30f2a..0b66612ce 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStore.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStore.java @@ -17,17 +17,17 @@ * Deleting an account users are linked to is a 409; an unknown account is a 404. */ class MockMinecraftAccountsStore { - private record Account(UUID id, String label, String minecraftUsername, int fillOrder, int linkCount, + private record Account(UUID id, String minecraftUsername, int fillOrder, int linkCount, boolean enabled, boolean friendLimitReached) { AccountDto dto() { - return new AccountDto(id, label, minecraftUsername, fillOrder, friendLimitReached, enabled, linkCount); + return new AccountDto(id, minecraftUsername, minecraftUsername, fillOrder, friendLimitReached, enabled, linkCount); } } private final List accounts = new ArrayList<>(List.of( - new Account(UUID.fromString("6d2f0c1e-0000-4000-8000-000000000001"), "Bot1", "CatapultBot1", 0, 12, true, false), - new Account(UUID.fromString("6d2f0c1e-0000-4000-8000-000000000002"), "Bot2", "CatapultBot2", 1, 0, false, false), - new Account(UUID.fromString("6d2f0c1e-0000-4000-8000-000000000003"), "Bot3", "CatapultBot3", 2, 30, true, true))); + new Account(UUID.fromString("6d2f0c1e-0000-4000-8000-000000000001"), "CatapultBot1", 0, 12, true, false), + new Account(UUID.fromString("6d2f0c1e-0000-4000-8000-000000000002"), "CatapultBot2", 1, 0, false, false), + new Account(UUID.fromString("6d2f0c1e-0000-4000-8000-000000000003"), "CatapultBot3", 2, 30, true, true))); /** Issued device codes → how many times each was polled. */ private final Map polls = new HashMap<>(); @@ -41,11 +41,10 @@ synchronized MinecraftDeviceCodeDto deviceCode() { return new MinecraftDeviceCodeDto(code, "MOCK-" + code.substring(0, 4).toUpperCase(), "https://www.microsoft.com/link", 5, 900); } - synchronized ApiClient.Result> create(String deviceCode, String label) { + synchronized ApiClient.Result> create(String deviceCode) { ApiClient.Result> pending = poll(deviceCode); if (pending != null) return pending; - String name = label == null || label.isBlank() ? "Minecraft account" : label; - Account account = new Account(UUID.randomUUID(), name, "CatapultBot" + (accounts.size() + 1), accounts.size(), 0, true, false); + Account account = new Account(UUID.randomUUID(), "CatapultBot" + (accounts.size() + 1), accounts.size(), 0, true, false); accounts.add(account); return done(account); } @@ -56,7 +55,7 @@ synchronized ApiClient.Result> reauth(UUID id, String device ApiClient.Result> pending = poll(deviceCode); if (pending != null) return pending; Account a = accounts.get(index); - Account renewed = new Account(a.id(), a.label(), a.minecraftUsername(), a.fillOrder(), a.linkCount(), true, a.friendLimitReached()); + Account renewed = new Account(a.id(), a.minecraftUsername(), a.fillOrder(), a.linkCount(), true, a.friendLimitReached()); accounts.set(index, renewed); return done(renewed); } @@ -67,7 +66,7 @@ synchronized int patch(UUID id, Map patch) { Account a = accounts.get(index); boolean enabled = patch.get("enabled") instanceof Boolean b ? b : a.enabled(); boolean full = patch.get("friendLimitReached") instanceof Boolean b ? b : a.friendLimitReached(); - accounts.set(index, new Account(a.id(), a.label(), a.minecraftUsername(), a.fillOrder(), a.linkCount(), enabled, full)); + accounts.set(index, new Account(a.id(), a.minecraftUsername(), a.fillOrder(), a.linkCount(), enabled, full)); return 200; } @@ -92,7 +91,7 @@ private ApiClient.Result> poll(String deviceCode) { } private static ApiClient.Result> done(Account account) { - return new ApiClient.Result<>(200, Map.of("id", account.id().toString(), "label", account.label(), + return new ApiClient.Result<>(200, Map.of("id", account.id().toString(), "displayName", account.minecraftUsername(), "minecraftUsername", account.minecraftUsername())); } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java index 162e4ac90..4d3fcc21b 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java @@ -39,12 +39,14 @@ import fr.enimaloc.catapult.common.dto.admin.TwKeywordTestDto; import fr.enimaloc.catapult.common.dto.admin.TwRebuild; import fr.enimaloc.catapult.common.dto.admin.AccountDto; +import fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto; import fr.enimaloc.catapult.common.dto.admin.MetaTagDto; import fr.enimaloc.catapult.common.dto.admin.MetaTagRequest; import fr.enimaloc.catapult.common.dto.admin.SocialLinkDto; import fr.enimaloc.catapult.common.dto.admin.SocialLinkOrderRequest; import fr.enimaloc.catapult.common.dto.admin.SocialLinkRequest; import fr.enimaloc.catapult.common.dto.admin.MinecraftDeviceCodeDto; +import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; import fr.enimaloc.catapult.common.dto.admin.DtddKeysPageData; import fr.enimaloc.catapult.common.dto.admin.SteamKeysPageData; import fr.enimaloc.catapult.common.dto.admin.WhitelistPageDto; @@ -60,6 +62,7 @@ import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.common.dto.admin.TwitchCategoryDto; import fr.enimaloc.catapult.common.dto.notification.AdminNotificationDto; import fr.enimaloc.catapult.common.dto.notification.BroadcastRequestDto; @@ -171,6 +174,7 @@ public class RealApiService implements ApiService { private static final ParameterizedTypeReference> MISSING_GAMES = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> DETECTED_GAMES = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> CATEGORIES = new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference> IGDB_GAMES = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> DTDD_PROPOSALS = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> TWS = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> ENABLED_COMMANDS = new ParameterizedTypeReference<>() {}; @@ -353,6 +357,20 @@ public int minecraftDisconnect(String username) { return client.delete("/api/internal/users/{userId}/links/minecraft", me); } + @Override + public BattleNetLinkStateResponse battleNetStatus(String username) { + UUID me = me(); + if (me == null) return null; + return client.get("/api/internal/users/{userId}/links/battlenet", BattleNetLinkStateResponse.class, me); + } + + @Override + public int battleNetDisconnect(String username) { + UUID me = me(); + if (me == null) return HttpStatus.UNAUTHORIZED.value(); + return client.delete("/api/internal/users/{userId}/links/battlenet", me); + } + @Override public int xboxDisconnect(String username) { return onChannel(username, id -> client.delete("/api/internal/users/{userId}/oauth-tokens/{provider}", id, "xbox"), @@ -993,6 +1011,11 @@ public List adminSearchCategories(String q) { return client.get("/api/internal/admin/game-mappings/categories?q={q}", CATEGORIES, q == null ? "" : q); } + @Override + public List adminSearchIgdbGames(String q) { + return client.get("/api/internal/admin/game-mappings/igdb-games?q={q}", IGDB_GAMES, q == null ? "" : q); + } + @Override public ApiClient.Result adminCreateGameMapping(GameMappingRequest request) { return client.call(HttpMethod.POST, "/api/internal/admin/game-mappings", request, GameMappingResult.class); @@ -1344,6 +1367,39 @@ public int adminRefreshApiKeys(ApiKeyProvider provider) { return result.status(); } + @Override + public List adminBattleNetAccounts() { + // a 404 (Battle.net disabled in catapult-api) is null, like any failed read + BattleNetAccountDto[] accounts = client.get("/api/internal/admin/battlenet-accounts", BattleNetAccountDto[].class); + return accounts == null ? null : List.of(accounts); + } + + @Override + public int adminCreateBattleNetAccount(String email, String password) { + return client.call(HttpMethod.POST, "/api/internal/admin/battlenet-accounts", + Map.of("email", email, "password", password), Void.class).status(); + } + + @Override + public int adminSubmitBattleNetCode(UUID id, String code) { + return client.call(HttpMethod.POST, "/api/internal/admin/battlenet-accounts/{id}/code", Map.of("code", code), Void.class, id).status(); + } + + @Override + public int adminReauthBattleNetAccount(UUID id) { + return client.call(HttpMethod.POST, "/api/internal/admin/battlenet-accounts/{id}/reauth", null, Void.class, id).status(); + } + + @Override + public int adminPatchBattleNetAccount(UUID id, Map patch) { + return client.call(HttpMethod.PATCH, "/api/internal/admin/battlenet-accounts/{id}", patch, Void.class, id).status(); + } + + @Override + public int adminDeleteBattleNetAccount(UUID id) { + return client.call(HttpMethod.DELETE, "/api/internal/admin/battlenet-accounts/{id}", null, Void.class, id).status(); + } + @Override public List adminMinecraftAccounts() { // a 404 (Minecraft disabled in catapult-api) is null, like any failed read @@ -1383,9 +1439,8 @@ private static String text(Object value) { } @Override - public ApiClient.Result> adminCreateMinecraftAccount(String deviceCode, String label) { - Map body = label == null ? Map.of("deviceCode", deviceCode) : Map.of("deviceCode", deviceCode, "label", label); - return mapResult(client.call(HttpMethod.POST, "/api/internal/admin/minecraft-accounts", body, Map.class)); + public ApiClient.Result> adminCreateMinecraftAccount(String deviceCode) { + return mapResult(client.call(HttpMethod.POST, "/api/internal/admin/minecraft-accounts", Map.of("deviceCode", deviceCode), Map.class)); } @Override diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index cd0465355..b5b675aad 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -260,6 +260,7 @@ channel.connections.steam.disconnect=Disconnect Steam channel.connections.xbox.connect=Connect Xbox channel.connections.xbox.disconnect=Disconnect Xbox channel.connections.minecraft=Minecraft +channel.connections.battlenet=Battle.net channel.connections.minecraft.name_placeholder=Minecraft username channel.connections.minecraft.enroll=Link channel.connections.minecraft.pending=Waiting for in-game acceptance… @@ -268,6 +269,15 @@ channel.connections.minecraft.rejected=Invite rejected, manual add required. channel.connections.minecraft.disconnect=Unlink channel.connections.minecraft.removed=Link removed server-side. channel.connections.minecraft.full=Every Catapult Minecraft account is full for now: linking will be possible again soon. +channel.connections.battlenet.connect=Connect Battle.net +channel.connections.battlenet.none=Not linked: link your Battle.net account to have the games you play detected. +channel.connections.battlenet.pending.before=Add +channel.connections.battlenet.pending.after=as a friend on Battle.net, or accept its invitation. +channel.connections.battlenet.rejected=Invitation declined: link your account again to get a new one. +channel.connections.battlenet.accepted=Connected as +channel.connections.battlenet.removed=Link removed on Battle.net: link your account again to reconnect. +channel.connections.battlenet.full=Every Catapult Battle.net account is full for now: linking will be possible again soon. +channel.connections.battlenet.disconnect=Unlink channel.connections.obs=OBS channel.connections.obs.host=IP address channel.connections.obs.port=Port @@ -773,6 +783,7 @@ admin.game-mappings.column.platform=Platform admin.game-mappings.column.id=ID admin.game-mappings.column.name=Detected name admin.game-mappings.column.category=Twitch category +admin.game-mappings.column.igdb-game=IGDB game admin.game-mappings.column.ccls=CCL admin.game-mappings.column.tws=TW admin.game-mappings.column.bindings=Bindings @@ -802,6 +813,9 @@ admin.game-mappings.form.format.battlenet=The Battle.net game ID, as detected admin.game-mappings.form.format.minecraft=Always « minecraft » admin.game-mappings.form.suggestion={0} — {1} · Streamers: {2} admin.game-mappings.form.category=Twitch category +admin.game-mappings.form.category-helper=Deduced from IGDB when empty +admin.game-mappings.form.igdb-game=IGDB game +admin.game-mappings.form.igdb-clear=Clear the IGDB game admin.game-mappings.form.ccls=Content classification labels admin.game-mappings.form.tws=Trigger warnings admin.game-mappings.form.override=Replace IGDB's @@ -814,11 +828,11 @@ admin.game-mappings.form.save=Save admin.game-mappings.form.close=Close admin.game-mappings.result=Bindings updated: {0} · Live channels updated: {1} · Failures: {2} admin.game-mappings.deleted=Mapping deleted. -admin.game-mappings.error.400=Invalid mapping: check the platform, the ID and the category. +admin.game-mappings.error.400=Invalid mapping: check the platform, the ID, the IGDB game and the category (an IGDB game without a Twitch category needs one entered). admin.game-mappings.error.404=This mapping no longer exists. admin.game-mappings.error.409=This game already has a mapping. admin.game-mappings.error.id=Enter the game's ID. -admin.game-mappings.error.category=Pick a Twitch category from the search, or ignore the game by default. +admin.game-mappings.error.category=Pick a Twitch category, or pick an IGDB game (its category is deduced; if it has none, enter one), or ignore the game by default. admin.game-mappings.unavailable=The game mappings are unavailable right now. admin.game-mappings.delete.title=Delete the mapping of {0}? admin.game-mappings.delete.description=The bindings that follow it go back to IGDB's category. @@ -994,7 +1008,6 @@ admin.keys.dtdd.invalid=Invalid API key format. admin.minecraft.title=Minecraft service accounts admin.minecraft.unavailable=The Minecraft service accounts are unavailable (Minecraft integration disabled or catapult-api unreachable). admin.minecraft.empty=No service account yet. -admin.minecraft.col.label=Label admin.minecraft.col.username=Username admin.minecraft.col.order=Fill order admin.minecraft.col.links=Links @@ -1008,8 +1021,6 @@ admin.minecraft.action.reset_limit=Reset limit admin.minecraft.action.reauth=Re-authenticate admin.minecraft.action.delete=Delete admin.minecraft.action.delete-confirm=Delete the account {0}? -admin.minecraft.default-label=Minecraft account -admin.minecraft.enroll.label=Label (e.g. CatapultBot1) admin.minecraft.enroll.start=Add an account admin.minecraft.error.links=Users are linked to this account — unlink them first. admin.minecraft.unknown=This account no longer exists. @@ -1526,3 +1537,54 @@ admin.members.actions.premium_revoke.confirm=Remove premium from "{0}"? admin.members.premium.title=Premium for {0} admin.members.premium.until=End date admin.members.premium.until.hint=Last premium day (Paris time). Empty: no end. +admin.battlenet.title=Battle.net bots +admin.battlenet.unavailable=The Battle.net bots are unavailable (Battle.net integration disabled or catapult-api unreachable). +admin.battlenet.empty=No bot yet. +admin.battlenet.col.account=Account +admin.battlenet.col.auth=Login +admin.battlenet.col.session=Session +admin.battlenet.col.friends=Friends +admin.battlenet.col.state=State +admin.battlenet.auth.NEW=Logging in… +admin.battlenet.auth.CODE_REQUIRED=2FA code required +admin.battlenet.auth.READY=Ready +admin.battlenet.auth.AUTH_FAILED=Login failed +admin.battlenet.session.CONNECTING=Connecting +admin.battlenet.session.READY=Connected +admin.battlenet.session.BACKOFF=Reconnecting +admin.battlenet.session.STOPPED_REPLACED=Stopped (another session took over) +admin.battlenet.session.STOPPED_CODE_REQUIRED=Admin action needed +admin.battlenet.session.STOPPED=Stopped +admin.battlenet.session.DISABLED=Disabled +admin.battlenet.state.enabled=Enabled +admin.battlenet.state.disabled=Disabled +admin.battlenet.state.full=Full +admin.battlenet.action.enable=Enable +admin.battlenet.action.disable=Disable +admin.battlenet.action.lift_full=Lift "full" +admin.battlenet.action.reauth=Re-authenticate +admin.battlenet.action.delete=Delete +admin.battlenet.action.delete-confirm=Delete the bot {0}? +admin.battlenet.code.label=2FA code +admin.battlenet.code.submit=Send the code +admin.battlenet.code.reauth-hint=No login waits for a code any more: use "Re-authenticate" to log the bot in again. +admin.battlenet.password.label=New password +admin.battlenet.password.submit=Change password +admin.battlenet.enroll.email=Bot e-mail +admin.battlenet.enroll.password=Bot password +admin.battlenet.enroll.start=Add a bot +admin.battlenet.error.conflict=A login is already running for this bot (or none is waiting for a code). +admin.battlenet.error.invalid=Fill in every field. +admin.battlenet.unknown=This bot no longer exists. +admin.battlenet.unavailable-action=The Battle.net bots are unavailable right now. +admin.battlenet.updated=Bot updated. +admin.battlenet.deleted=Bot deleted. +admin.battlenet.created=Bot added: its login is running. +admin.battlenet.code-sent=Code sent. +admin.battlenet.reauthed=Login restarted. +admin.battlenet.prereq.title=Before adding a bot, on account.battle.net: +admin.battlenet.prereq.name=The first name and last name of the account must be filled in. +admin.battlenet.prereq.secret=A secret question must be set. +admin.battlenet.prereq.delay=Without these, invitations are accepted but never delivered; a change can take up to 3 hours to apply. +admin.battlenet.details.order=Fill order +admin.battlenet.details.submit=Save diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index a8f300f09..d5d0bb1f2 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -260,6 +260,7 @@ channel.connections.steam.disconnect=Disconnect Steam channel.connections.xbox.connect=Connect Xbox channel.connections.xbox.disconnect=Disconnect Xbox channel.connections.minecraft=Minecraft +channel.connections.battlenet=Battle.net channel.connections.minecraft.name_placeholder=Minecraft username channel.connections.minecraft.enroll=Link channel.connections.minecraft.pending=Waiting for in-game acceptance… @@ -268,6 +269,15 @@ channel.connections.minecraft.rejected=Invite rejected, manual add required. channel.connections.minecraft.disconnect=Unlink channel.connections.minecraft.removed=Link removed server-side. channel.connections.minecraft.full=Every Catapult Minecraft account is full for now: linking will be possible again soon. +channel.connections.battlenet.connect=Connect Battle.net +channel.connections.battlenet.none=Not linked: link your Battle.net account to have the games you play detected. +channel.connections.battlenet.pending.before=Add +channel.connections.battlenet.pending.after=as a friend on Battle.net, or accept its invitation. +channel.connections.battlenet.rejected=Invitation declined: link your account again to get a new one. +channel.connections.battlenet.accepted=Connected as +channel.connections.battlenet.removed=Link removed on Battle.net: link your account again to reconnect. +channel.connections.battlenet.full=Every Catapult Battle.net account is full for now: linking will be possible again soon. +channel.connections.battlenet.disconnect=Unlink channel.connections.obs=OBS channel.connections.obs.host=IP address channel.connections.obs.port=Port @@ -773,6 +783,7 @@ admin.game-mappings.column.platform=Platform admin.game-mappings.column.id=ID admin.game-mappings.column.name=Detected name admin.game-mappings.column.category=Twitch category +admin.game-mappings.column.igdb-game=IGDB game admin.game-mappings.column.ccls=CCL admin.game-mappings.column.tws=TW admin.game-mappings.column.bindings=Bindings @@ -802,6 +813,9 @@ admin.game-mappings.form.format.battlenet=The Battle.net game ID, as detected admin.game-mappings.form.format.minecraft=Always « minecraft » admin.game-mappings.form.suggestion={0} — {1} · Streamers: {2} admin.game-mappings.form.category=Twitch category +admin.game-mappings.form.category-helper=Deduced from IGDB when empty +admin.game-mappings.form.igdb-game=IGDB game +admin.game-mappings.form.igdb-clear=Clear the IGDB game admin.game-mappings.form.ccls=Content classification labels admin.game-mappings.form.tws=Trigger warnings admin.game-mappings.form.override=Replace IGDB's @@ -814,11 +828,11 @@ admin.game-mappings.form.save=Save admin.game-mappings.form.close=Close admin.game-mappings.result=Bindings updated: {0} · Live channels updated: {1} · Failures: {2} admin.game-mappings.deleted=Mapping deleted. -admin.game-mappings.error.400=Invalid mapping: check the platform, the ID and the category. +admin.game-mappings.error.400=Invalid mapping: check the platform, the ID, the IGDB game and the category (an IGDB game without a Twitch category needs one entered). admin.game-mappings.error.404=This mapping no longer exists. admin.game-mappings.error.409=This game already has a mapping. admin.game-mappings.error.id=Enter the game's ID. -admin.game-mappings.error.category=Pick a Twitch category from the search, or ignore the game by default. +admin.game-mappings.error.category=Pick a Twitch category, or pick an IGDB game (its category is deduced; if it has none, enter one), or ignore the game by default. admin.game-mappings.unavailable=The game mappings are unavailable right now. admin.game-mappings.delete.title=Delete the mapping of {0}? admin.game-mappings.delete.description=The bindings that follow it go back to IGDB's category. @@ -994,7 +1008,6 @@ admin.keys.dtdd.invalid=Invalid API key format. admin.minecraft.title=Minecraft service accounts admin.minecraft.unavailable=The Minecraft service accounts are unavailable (Minecraft integration disabled or catapult-api unreachable). admin.minecraft.empty=No service account yet. -admin.minecraft.col.label=Label admin.minecraft.col.username=Username admin.minecraft.col.order=Fill order admin.minecraft.col.links=Links @@ -1008,8 +1021,6 @@ admin.minecraft.action.reset_limit=Reset limit admin.minecraft.action.reauth=Re-authenticate admin.minecraft.action.delete=Delete admin.minecraft.action.delete-confirm=Delete the account {0}? -admin.minecraft.default-label=Minecraft account -admin.minecraft.enroll.label=Label (e.g. CatapultBot1) admin.minecraft.enroll.start=Add an account admin.minecraft.error.links=Users are linked to this account — unlink them first. admin.minecraft.unknown=This account no longer exists. @@ -1472,3 +1483,54 @@ admin.members.actions.premium_revoke.confirm=Remove premium from "{0}"? admin.members.premium.title=Premium for {0} admin.members.premium.until=End date admin.members.premium.until.hint=Last premium day (Paris time). Empty: no end. +admin.battlenet.title=Battle.net bots +admin.battlenet.unavailable=The Battle.net bots are unavailable (Battle.net integration disabled or catapult-api unreachable). +admin.battlenet.empty=No bot yet. +admin.battlenet.col.account=Account +admin.battlenet.col.auth=Login +admin.battlenet.col.session=Session +admin.battlenet.col.friends=Friends +admin.battlenet.col.state=State +admin.battlenet.auth.NEW=Logging in… +admin.battlenet.auth.CODE_REQUIRED=2FA code required +admin.battlenet.auth.READY=Ready +admin.battlenet.auth.AUTH_FAILED=Login failed +admin.battlenet.session.CONNECTING=Connecting +admin.battlenet.session.READY=Connected +admin.battlenet.session.BACKOFF=Reconnecting +admin.battlenet.session.STOPPED_REPLACED=Stopped (another session took over) +admin.battlenet.session.STOPPED_CODE_REQUIRED=Admin action needed +admin.battlenet.session.STOPPED=Stopped +admin.battlenet.session.DISABLED=Disabled +admin.battlenet.state.enabled=Enabled +admin.battlenet.state.disabled=Disabled +admin.battlenet.state.full=Full +admin.battlenet.action.enable=Enable +admin.battlenet.action.disable=Disable +admin.battlenet.action.lift_full=Lift "full" +admin.battlenet.action.reauth=Re-authenticate +admin.battlenet.action.delete=Delete +admin.battlenet.action.delete-confirm=Delete the bot {0}? +admin.battlenet.code.label=2FA code +admin.battlenet.code.submit=Send the code +admin.battlenet.code.reauth-hint=No login waits for a code any more: use "Re-authenticate" to log the bot in again. +admin.battlenet.password.label=New password +admin.battlenet.password.submit=Change password +admin.battlenet.enroll.email=Bot e-mail +admin.battlenet.enroll.password=Bot password +admin.battlenet.enroll.start=Add a bot +admin.battlenet.error.conflict=A login is already running for this bot (or none is waiting for a code). +admin.battlenet.error.invalid=Fill in every field. +admin.battlenet.unknown=This bot no longer exists. +admin.battlenet.unavailable-action=The Battle.net bots are unavailable right now. +admin.battlenet.updated=Bot updated. +admin.battlenet.deleted=Bot deleted. +admin.battlenet.created=Bot added: its login is running. +admin.battlenet.code-sent=Code sent. +admin.battlenet.reauthed=Login restarted. +admin.battlenet.prereq.title=Before adding a bot, on account.battle.net: +admin.battlenet.prereq.name=The first name and last name of the account must be filled in. +admin.battlenet.prereq.secret=A secret question must be set. +admin.battlenet.prereq.delay=Without these, invitations are accepted but never delivered; a change can take up to 3 hours to apply. +admin.battlenet.details.order=Fill order +admin.battlenet.details.submit=Save diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 8c791df0b..c7d9a4dc0 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -260,6 +260,7 @@ channel.connections.steam.disconnect=Déconnecter Steam channel.connections.xbox.connect=Connecter Xbox channel.connections.xbox.disconnect=Déconnecter Xbox channel.connections.minecraft=Minecraft +channel.connections.battlenet=Battle.net channel.connections.minecraft.name_placeholder=Pseudo Minecraft channel.connections.minecraft.enroll=Lier channel.connections.minecraft.pending=En attente d'acceptation en jeu… @@ -268,6 +269,15 @@ channel.connections.minecraft.rejected=Invitation refusée, ajout manuel requis. channel.connections.minecraft.disconnect=Délier channel.connections.minecraft.removed=Lien supprimé côté serveur. channel.connections.minecraft.full=Tous les comptes Minecraft de Catapult sont pleins pour le moment : la liaison sera de nouveau possible bientôt. +channel.connections.battlenet.connect=Connecter Battle.net +channel.connections.battlenet.none=Non lié : lie ton compte Battle.net pour détecter les jeux auxquels tu joues. +channel.connections.battlenet.pending.before=Ajoute +channel.connections.battlenet.pending.after=en ami sur Battle.net ou accepte son invitation. +channel.connections.battlenet.rejected=Invitation refusée : lie à nouveau ton compte pour en recevoir une nouvelle. +channel.connections.battlenet.accepted=Connecté en tant que +channel.connections.battlenet.removed=Lien supprimé côté Battle.net : lie à nouveau ton compte pour te reconnecter. +channel.connections.battlenet.full=Tous les comptes Catapult Battle.net sont pleins pour le moment : le lien sera de nouveau possible bientôt. +channel.connections.battlenet.disconnect=Délier channel.connections.obs=OBS channel.connections.obs.host=Adresse IP channel.connections.obs.port=Port @@ -773,6 +783,7 @@ admin.game-mappings.column.platform=Plateforme admin.game-mappings.column.id=ID admin.game-mappings.column.name=Nom détecté admin.game-mappings.column.category=Catégorie Twitch +admin.game-mappings.column.igdb-game=Jeu IGDB admin.game-mappings.column.ccls=CCL admin.game-mappings.column.tws=TW admin.game-mappings.column.bindings=Associations @@ -802,6 +813,9 @@ admin.game-mappings.form.format.battlenet=L'ID du jeu Battle.net, tel que détec admin.game-mappings.form.format.minecraft=Toujours « minecraft » admin.game-mappings.form.suggestion={0} — {1} · Streamers : {2} admin.game-mappings.form.category=Catégorie Twitch +admin.game-mappings.form.category-helper=Déduite d'IGDB si vide +admin.game-mappings.form.igdb-game=Jeu IGDB +admin.game-mappings.form.igdb-clear=Retirer le jeu IGDB admin.game-mappings.form.ccls=Classifications de contenu (CCL) admin.game-mappings.form.tws=Trigger warnings admin.game-mappings.form.override=Remplacer ceux d'IGDB @@ -814,11 +828,11 @@ admin.game-mappings.form.save=Enregistrer admin.game-mappings.form.close=Fermer admin.game-mappings.result=Associations mises à jour : {0} · Chaînes en live mises à jour : {1} · Échecs : {2} admin.game-mappings.deleted=Mapping supprimé. -admin.game-mappings.error.400=Mapping invalide : vérifiez la plateforme, l'ID et la catégorie. +admin.game-mappings.error.400=Mapping invalide : vérifiez la plateforme, l'ID, le jeu IGDB et la catégorie (un jeu IGDB sans catégorie Twitch en demande une saisie). admin.game-mappings.error.404=Ce mapping n'existe plus. admin.game-mappings.error.409=Ce jeu a déjà un mapping. admin.game-mappings.error.id=Saisissez l'ID du jeu. -admin.game-mappings.error.category=Choisissez une catégorie Twitch dans la recherche, ou ignorez le jeu par défaut. +admin.game-mappings.error.category=Choisissez une catégorie Twitch, ou un jeu IGDB (sa catégorie en est déduite ; s'il n'en a pas, saisissez-en une), ou ignorez le jeu par défaut. admin.game-mappings.unavailable=Les mappings de jeu sont indisponibles pour l'instant. admin.game-mappings.delete.title=Supprimer le mapping de {0} ? admin.game-mappings.delete.description=Les associations qui le suivent reviennent à la catégorie d'IGDB. @@ -994,7 +1008,6 @@ admin.keys.dtdd.invalid=Format de clé API invalide. admin.minecraft.title=Comptes de service Minecraft admin.minecraft.unavailable=Les comptes de service Minecraft sont indisponibles (intégration Minecraft désactivée ou catapult-api injoignable). admin.minecraft.empty=Aucun compte de service. -admin.minecraft.col.label=Label admin.minecraft.col.username=Pseudo admin.minecraft.col.order=Ordre de remplissage admin.minecraft.col.links=Liens @@ -1008,8 +1021,6 @@ admin.minecraft.action.reset_limit=Réinitialiser la limite admin.minecraft.action.reauth=Ré-authentifier admin.minecraft.action.delete=Supprimer admin.minecraft.action.delete-confirm=Supprimer le compte {0} ? -admin.minecraft.default-label=Compte Minecraft -admin.minecraft.enroll.label=Label (ex. CatapultBot1) admin.minecraft.enroll.start=Ajouter un compte admin.minecraft.error.links=Des utilisateurs sont liés à ce compte — délie-les d'abord. admin.minecraft.unknown=Ce compte n'existe plus. @@ -1526,3 +1537,54 @@ admin.members.actions.premium_revoke.confirm=Retirer le premium de « {0} » ? admin.members.premium.title=Premium de {0} admin.members.premium.until=Date de fin admin.members.premium.until.hint=Dernier jour premium (heure de Paris). Vide : sans fin. +admin.battlenet.title=Bots Battle.net +admin.battlenet.unavailable=Les bots Battle.net sont indisponibles (intégration Battle.net désactivée ou catapult-api injoignable). +admin.battlenet.empty=Aucun bot pour l'instant. +admin.battlenet.col.account=Compte +admin.battlenet.col.auth=Connexion +admin.battlenet.col.session=Session +admin.battlenet.col.friends=Amis +admin.battlenet.col.state=État +admin.battlenet.auth.NEW=Connexion en cours… +admin.battlenet.auth.CODE_REQUIRED=Code 2FA requis +admin.battlenet.auth.READY=Prêt +admin.battlenet.auth.AUTH_FAILED=Connexion échouée +admin.battlenet.session.CONNECTING=Connexion +admin.battlenet.session.READY=Connecté +admin.battlenet.session.BACKOFF=Reconnexion +admin.battlenet.session.STOPPED_REPLACED=Arrêté (une autre session a pris la place) +admin.battlenet.session.STOPPED_CODE_REQUIRED=Action admin requise +admin.battlenet.session.STOPPED=Arrêté +admin.battlenet.session.DISABLED=Désactivé +admin.battlenet.state.enabled=Actif +admin.battlenet.state.disabled=Désactivé +admin.battlenet.state.full=Plein +admin.battlenet.action.enable=Activer +admin.battlenet.action.disable=Désactiver +admin.battlenet.action.lift_full=Lever « plein » +admin.battlenet.action.reauth=Réauthentifier +admin.battlenet.action.delete=Supprimer +admin.battlenet.action.delete-confirm=Supprimer le bot {0} ? +admin.battlenet.code.label=Code 2FA +admin.battlenet.code.submit=Envoyer le code +admin.battlenet.code.reauth-hint=Plus aucune connexion n'attend de code : utilisez « Réauthentifier » pour reconnecter le bot. +admin.battlenet.password.label=Nouveau mot de passe +admin.battlenet.password.submit=Changer le mot de passe +admin.battlenet.enroll.email=E-mail du bot +admin.battlenet.enroll.password=Mot de passe du bot +admin.battlenet.enroll.start=Ajouter un bot +admin.battlenet.error.conflict=Une connexion est déjà en cours pour ce bot (ou aucune n'attend de code). +admin.battlenet.error.invalid=Remplis tous les champs. +admin.battlenet.unknown=Ce bot n'existe plus. +admin.battlenet.unavailable-action=Les bots Battle.net sont indisponibles pour le moment. +admin.battlenet.updated=Bot mis à jour. +admin.battlenet.deleted=Bot supprimé. +admin.battlenet.created=Bot ajouté : sa connexion est en cours. +admin.battlenet.code-sent=Code envoyé. +admin.battlenet.reauthed=Connexion relancée. +admin.battlenet.prereq.title=Avant d'ajouter un bot, sur account.battle.net : +admin.battlenet.prereq.name=Le prénom et le nom du compte doivent être renseignés. +admin.battlenet.prereq.secret=Une question secrète doit être définie. +admin.battlenet.prereq.delay=Sans cela, les invitations sont acceptées mais jamais délivrées ; une modification peut prendre jusqu'à 3 h. +admin.battlenet.details.order=Ordre de remplissage +admin.battlenet.details.submit=Enregistrer diff --git a/catapult-web/src/main/resources/static/css/catapult.css b/catapult-web/src/main/resources/static/css/catapult.css index da308c081..3a09d23d3 100644 --- a/catapult-web/src/main/resources/static/css/catapult.css +++ b/catapult-web/src/main/resources/static/css/catapult.css @@ -1825,6 +1825,18 @@ mdui-select::part(menu) { max-width: none; } +/* The Battle.net card's sentences: static text around BattleTags the live event replaces in place. */ +.battlenet-sentence { + display: flex; + flex-wrap: wrap; + gap: 0.25em; +} + +.battlenet-sentence .battlenet-bot-tag, +.battlenet-sentence .battlenet-tag { + font-weight: 600; +} + /* Actions sit right-aligned under the details. A segmented group lays out its own * segments; a plain div (OBS) holds separate buttons. Either way, no separator: * outranks the `.connection-card > div` rules above. */ @@ -2097,10 +2109,10 @@ span.box-art { * The tables below are one grid each, every row (header included) a subgrid of it: as separate grids, * the rows sized their action column to their own buttons, so no cell sat under its header. */ -.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row)) { display: grid; column-gap: 12px; } -.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row)) > * { grid-column: 1 / -1; } -.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) { grid-template-columns: subgrid; } -.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) > .admin-row-actions { justify-content: flex-end; } +.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-battlenet-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row)) { display: grid; column-gap: 12px; } +.admin-table:has(> :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-battlenet-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row)) > * { grid-column: 1 / -1; } +.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-battlenet-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) { grid-template-columns: subgrid; } +.admin-table > :is(.admin-dtdd-row, .admin-groups-row, .admin-whitelist-invite-row, .admin-whitelist-row, .admin-keys-row, .admin-minecraft-row, .admin-battlenet-row, .admin-tw-row, .admin-meta-tag-row, .admin-social-link-row, .invite-row, .admin-gm-row, .admin-gm-missing-row, .admin-dc-topics-row, .admin-dc-games-row, .admin-dc-searches-row, .admin-dc-quota-row, .admin-rl-row) > .admin-row-actions { justify-content: flex-end; } .admin-pagination { display: flex; @@ -2354,16 +2366,18 @@ mdui-snackbar.broadcast-warning { --mdui-color-inverse-surface: var(--mdui-color /* The admin game mappings tab. */ .admin-gm-hint { margin: 0 0 16px; } .admin-gm-missing-header { margin-top: 24px; } -.admin-gm-row, .admin-table:has(> .admin-gm-row) { grid-template-columns: 7.5rem minmax(0, 1fr) minmax(0, 1.5fr) minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr) 5.5rem 9rem auto; } +.admin-gm-row, .admin-table:has(> .admin-gm-row) { grid-template-columns: 7.5rem minmax(0, 1fr) minmax(0, 1.5fr) minmax(0, 1.5fr) minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr) 5.5rem 9rem auto; } .admin-gm-missing-row, .admin-table:has(> .admin-gm-missing-row) { grid-template-columns: 7.5rem minmax(0, 1fr) minmax(0, 2fr) 6rem auto; } .admin-gm-labels { min-width: 0; overflow-wrap: anywhere; } +.admin-gm-igdb-field { display: flex; align-items: center; gap: 4px; } +.admin-gm-igdb-field > mdui-text-field { flex: 1; } .admin-gm-category { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; } .admin-gm-ignored-helper { margin: -8px 0 0; } .admin-gm-row > .admin-member-id { display: flex; flex-direction: column; } .admin-gm-form { display: flex; flex-direction: column; gap: 12px; min-width: min(560px, 100%); } .admin-gm-form [hidden] { display: none; } .admin-gm-search { display: flex; flex-direction: column; gap: 4px; } -.admin-gm-suggestions, .admin-gm-category-results { max-height: 220px; overflow-y: auto; padding: 0; } +.admin-gm-suggestions, .admin-gm-category-results, .admin-gm-igdb-results { max-height: 220px; overflow-y: auto; padding: 0; } .admin-gm-override { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 8px 12px 12px; border: 1px solid var(--catapult-border); border-radius: 12px; } .admin-gm-override legend { padding: 0 4px; color: var(--catapult-text-muted); } .admin-gm-switch { display: flex; align-items: center; gap: 12px; } @@ -2495,7 +2509,7 @@ mdui-snackbar.broadcast-warning { --mdui-color-inverse-surface: var(--mdui-color .admin-keys-row > .admin-row-actions { grid-column: 1 / -1; } .admin-keys-row > .admin-cell { white-space: normal; } } -.admin-minecraft-row, .admin-table:has(> .admin-minecraft-row) { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 6rem 4rem 12rem auto; } +.admin-minecraft-row, .admin-table:has(> .admin-minecraft-row) { grid-template-columns: minmax(0, 1fr) 6rem 4rem 12rem auto; } .admin-minecraft-states { display: flex; flex-wrap: wrap; gap: 4px; } .admin-minecraft-full { color: rgb(var(--mdui-color-error)); } .admin-minecraft-row > .admin-minecraft-flow { grid-column: 1 / -1; } @@ -2504,6 +2518,19 @@ mdui-snackbar.broadcast-warning { --mdui-color-inverse-surface: var(--mdui-color .admin-minecraft-code { margin: 4px 0; } .admin-minecraft-user-code { font-family: monospace; font-size: 1.15em; letter-spacing: .05em; } .admin-minecraft-error { color: rgb(var(--mdui-color-error)); } +.admin-battlenet-row, .admin-table:has(> .admin-battlenet-row) { grid-template-columns: minmax(0, 1fr) 7rem 10rem 4rem 12rem auto; } +.admin-battlenet-states { display: flex; flex-wrap: wrap; gap: 4px; } +.admin-battlenet-full { color: rgb(var(--mdui-color-error)); } +.admin-battlenet-row > .admin-battlenet-code, .admin-battlenet-row > .admin-battlenet-password, .admin-battlenet-row > .admin-battlenet-details { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.admin-battlenet-message { overflow-wrap: anywhere; } +.admin-battlenet-message.admin-battlenet-error { color: rgb(var(--mdui-color-error)); } +.admin-battlenet-prereq { margin-top: 12px; color: var(--catapult-text-muted); } +@media (max-width: 800px) { + .admin-battlenet-row, .admin-table:has(> .admin-battlenet-row) { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .admin-battlenet-row > [data-label]::before { content: attr(data-label) " : "; color: var(--catapult-text-muted); } + .admin-battlenet-row > .admin-row-actions { grid-column: 1 / -1; flex-wrap: wrap; } + .admin-battlenet-row > .admin-cell { white-space: normal; } +} @media (max-width: 600px) { .admin-minecraft-row, .admin-table:has(> .admin-minecraft-row) { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .admin-minecraft-row > [data-label]::before { content: attr(data-label) " : "; color: var(--catapult-text-muted); } diff --git a/catapult-web/src/main/resources/static/js/admin-battlenet.js b/catapult-web/src/main/resources/static/js/admin-battlenet.js new file mode 100644 index 000000000..6bd782e43 --- /dev/null +++ b/catapult-web/src/main/resources/static/js/admin-battlenet.js @@ -0,0 +1,181 @@ +/** + * The admin Keys tab's Battle.net bot pool card. Add a bot (e-mail, password), send the 2FA code of + * a bot waiting for one, re-authenticate, enable / disable, lift "full", change the password and delete + * (after a confirmation) all post to AdminBattleNetController, the button disabled until the list is + * patched (admin-tabs.js). A refusal is told inline under the card's form by its status: 409 (a login is + * already running, or none waits for a code), 404 (gone: patched away), 400 (invalid), else unavailable. + * + * Passwords are typed in password fields, only sent in the POST body and emptied once sent; nothing + * ever puts them in a URL or the page. Display names (BattleTag, else masked e-mail) come from the + * server-rendered rows, never from innerHTML. + * + * While a bot is NEW, or CODE_REQUIRED with a login waiting for its code (its row's data-auth-state and + * data-waiting-code), the tab is patched every 3 s, so its login progress shows without a reload; the + * polling stops once none is, once the card has left the page and when another admin tab is shown. Listeners are delegated to the panel: its content comes and goes. + */ +window.CatapultAdminBattleNet = (function () { + const BASE = "/admin/battlenet-accounts"; + const POLL_MS = 3000; + const PENDING = '.admin-battlenet-row[data-auth-state="NEW"], ' + + '.admin-battlenet-row[data-auth-state="CODE_REQUIRED"][data-waiting-code="true"]'; + const ok = status => status >= 200 && status < 300; + + let timer = null; + + /** The status of a POST (0 when it couldn't be sent). */ + async function post(url, body) { + try { + return (await CatapultCsrf.postJson(url, body)).status; + } catch { + return 0; + } + } + + const panelOf = () => document.querySelector('mdui-tab-panel[value="keys"]'); + + function refusal(card, status) { + if (status === 409) return card.dataset.labelConflict; + if (status === 404) return card.dataset.labelUnknown; + if (status === 400) return card.dataset.labelInvalid; + return card.dataset.labelUnavailable; + } + + function show(card, text, error) { + const message = card.querySelector(".admin-battlenet-message"); + if (!message) return; + message.textContent = text ?? ""; + message.classList.toggle("admin-battlenet-error", !!error); + message.hidden = !text; + } + + /** Posts an action, holding the button disabled until the list is patched; the fields are emptied once sent. */ + async function act(panel, card, button, url, body, doneLabel, fields) { + button.disabled = true; + try { + const status = await post(url, body); + if (ok(status)) { + (fields ?? []).forEach(field => { field.value = ""; }); + show(card, "", false); + window.mdui?.snackbar?.({ message: doneLabel }); + await CatapultAdminTabs.patch(panel); + return; + } + show(card, refusal(card, status), true); + if (status === 404 || status === 409) await CatapultAdminTabs.patch(panel); + } finally { + button.disabled = false; + } + } + + async function remove(panel, card, button) { + button.disabled = true; // before the confirmation: a second click must not open another + try { + const root = panel.querySelector(".admin-keys") ?? card; + if (!await CatapultAdminTabs.confirmed(root, button.dataset.confirm)) return; + await act(panel, card, button, `${BASE}/${encodeURIComponent(button.dataset.id)}/delete`, {}, card.dataset.labelDeleted); + } finally { + button.disabled = false; + } + } + + function add(panel, card, button) { + const enroll = card.querySelector(".admin-battlenet-enroll"); + const fields = ["email", "password"].map(name => enroll.querySelector(`[name="${name}"]`)); + const email = (fields[0]?.value ?? "").trim(); + const password = fields[1]?.value ?? ""; + if (!email || !password) return show(card, card.dataset.labelInvalid, true); + // the password is sent as typed (not trimmed) + return act(panel, card, button, BASE, { email, password }, card.dataset.labelCreated, fields); + } + + function sendCode(panel, card, button) { + const field = button.closest(".admin-battlenet-code")?.querySelector('[name="code"]'); + const code = (field?.value ?? "").trim(); + if (!code) return show(card, card.dataset.labelInvalid, true); + return act(panel, card, button, `${BASE}/${encodeURIComponent(button.dataset.id)}/code`, { code }, + card.dataset.labelCodeSent, [field]); + } + + function changePassword(panel, card, button) { + const field = button.closest(".admin-battlenet-password")?.querySelector('[name="password"]'); + const password = field?.value ?? ""; + if (!password) return show(card, card.dataset.labelInvalid, true); + return act(panel, card, button, `${BASE}/${encodeURIComponent(button.dataset.id)}/password`, { password }, + card.dataset.labelUpdated, [field]); + } + + /** Whether the Keys tab is the one shown (an unmounted tabs element counts as shown: nothing to compare). */ + function keysShown() { + const tabs = document.getElementById("admin-tabs"); + return !tabs || (tabs.value ?? tabs.getAttribute("value")) === "keys"; + } + + function saveDetails(panel, card, button) { + const box = button.closest(".admin-battlenet-details"); + const order = Number(box?.querySelector('[name="fillOrder"]')?.value); + if (!Number.isInteger(order) || order < 0) return show(card, card.dataset.labelInvalid, true); + return act(panel, card, button, `${BASE}/${encodeURIComponent(button.dataset.id)}/details`, + { fillOrder: order }, card.dataset.labelUpdated); + } + + function stopPolling() { + clearTimeout(timer); + timer = null; + } + + /** Patches the tab again in 3 s while a bot is NEW or waits for its code; no second timer, none otherwise. */ + function schedule() { + const panel = panelOf(); + if (timer !== null || !panel || !keysShown() || !panel.querySelector(PENDING)) return; + timer = setTimeout(async () => { + timer = null; + const current = panelOf(); + if (!current || !keysShown() || !current.querySelector(PENDING)) return; + try { + await CatapultAdminTabs.patch(current); + } finally { + schedule(); // a no-op once the tab was left meanwhile + } + }, POLL_MS); + } + + function attach() { + const panel = panelOf(); + if (!panel) return; + if (!panel.dataset.battlenetBound) { + panel.dataset.battlenetBound = "true"; + panel.addEventListener("click", event => { + const target = event.target.closest?.( + ".admin-battlenet-add, .admin-battlenet-code-submit, .admin-battlenet-password-submit, .admin-battlenet-details-submit, " + + ".admin-battlenet-reauth, .admin-battlenet-toggle, .admin-battlenet-reset, .admin-battlenet-delete"); + if (!target || !panel.contains(target) || target.disabled) return; + const card = target.closest(".admin-battlenet"); + if (!card) return; + const id = encodeURIComponent(target.dataset.id ?? ""); + if (target.matches(".admin-battlenet-add")) add(panel, card, target); + else if (target.matches(".admin-battlenet-code-submit")) sendCode(panel, card, target); + else if (target.matches(".admin-battlenet-password-submit")) changePassword(panel, card, target); + else if (target.matches(".admin-battlenet-details-submit")) saveDetails(panel, card, target); + else if (target.matches(".admin-battlenet-reauth")) act(panel, card, target, `${BASE}/${id}/reauth`, {}, card.dataset.labelReauthed); + else if (target.matches(".admin-battlenet-toggle")) act(panel, card, target, `${BASE}/${id}/enabled`, + { enabled: target.dataset.enabled === "true" }, card.dataset.labelUpdated); + else if (target.matches(".admin-battlenet-reset")) act(panel, card, target, `${BASE}/${id}/reset-limit`, {}, card.dataset.labelUpdated); + else remove(panel, card, target); + }); + + // Leaving the Keys tab stops the polling (mdui's inner change events bubble too: only the tabs' own counts). + const tabs = document.getElementById("admin-tabs"); + tabs?.addEventListener("change", event => { + if (event.target === tabs && tabs.value !== "keys") stopPolling(); + else if (event.target === tabs) schedule(); + }); + } + schedule(); + } + + document.addEventListener("catapult:render", attach); + // each patch of the tab (and its first render) may bring or clear a pending bot + document.addEventListener("catapult:admin-panel", () => schedule()); + + return { attach }; +})(); diff --git a/catapult-web/src/main/resources/static/js/admin-game-mappings.js b/catapult-web/src/main/resources/static/js/admin-game-mappings.js index bac3db70f..4ddf295f5 100644 --- a/catapult-web/src/main/resources/static/js/admin-game-mappings.js +++ b/catapult-web/src/main/resources/static/js/admin-game-mappings.js @@ -7,7 +7,9 @@ * The form (a dialog) creates a mapping (new, or from a missing game's row: its platform and ID filled in) or * changes one (from its row). Its game field suggests the platform's detected games as the ID or name is typed * ("name — ID · Streamers: n"; picking one fills the ID, any ID typed is kept as is); its category field searches - * Twitch through GameSearch. The CCLs and TWs stay IGDB's (null) unless their switch replaces them with the boxes + * Twitch through GameSearch. Its IGDB game field searches IGDB the same way ("name (year)"; a clear button + * empties it): picking, changing or clearing the IGDB game empties the category, which catapult-api then deduces + * from the game, so a category is only required when neither it nor an IGDB game is set. The CCLs and TWs stay IGDB's (null) unless their switch replaces them with the boxes * checked (even none). Its "ignored by default" switch makes the category optional. After a save the form shows what changed and turns into the saved mapping's edit form. * A deletion asks first, in its own dialog. A refusal is told by its status, never by catapult-api's message * (catapult-web relays none): 400 invalid, 404 gone, 409 already mapped, anything else unavailable. @@ -140,6 +142,14 @@ window.CatapultAdminGameMappings = (function () { }; form.search = GameSearch.attach(form.category, el.querySelector(".admin-gm-category-results"), `${BASE}/categories`, () => hideMessages(form), { initial: null }); + form.igdbInput = el.querySelector('[name="igdbGame"]'); + // The category follows the IGDB game: whatever was picked for the old one no longer applies + form.igdb = GameSearch.attach(form.igdbInput, el.querySelector(".admin-gm-igdb-results"), + `${BASE}/igdb-games`, () => { form.search.select(null); hideMessages(form); }, + { initial: null, label: game => game.releaseYear == null ? game.name : `${game.name} (${game.releaseYear})` }); + form.igdbInput.addEventListener("input", () => { + if (form.igdbInput.value.trim() === "") form.search.select(null); + }); wireSuggestions(form); form.platform.addEventListener("change", () => { showFormat(form); @@ -242,7 +252,7 @@ window.CatapultAdminGameMappings = (function () { } /** - * Fills the form with `mapping` (`{ id?, sourceType, sourceId, sourceName, twitchGameId, twitchGameName, ccls, tws, + * Fills the form with `mapping` (`{ id?, sourceType, sourceId, sourceName, twitchGameId, twitchGameName, igdbId, igdbGameName, ccls, tws, * ignoredByDefault? }`) and opens it. */ function open(form, mapping) { @@ -254,6 +264,7 @@ window.CatapultAdminGameMappings = (function () { form.gameName.textContent = mapping.sourceName ?? ""; form.dropSuggestions(); form.search.select(mapping.twitchGameId ? { id: mapping.twitchGameId, name: mapping.twitchGameName } : null); + form.igdb.select(mapping.igdbId ? { id: mapping.igdbId, name: mapping.igdbGameName || mapping.igdbId } : null); setOverride(form, "ccls", mapping.ccls ?? null); setOverride(form, "tws", mapping.tws ?? null); form.ignored.checked = mapping.ignoredByDefault === true; @@ -273,6 +284,8 @@ window.CatapultAdminGameMappings = (function () { sourceName: row.dataset.sourceName, twitchGameId: row.dataset.twitchGameId, twitchGameName: row.dataset.twitchGameName, + igdbId: row.dataset.igdbId, + igdbGameName: row.dataset.igdbGameName, ccls: list("ccls"), tws: list("tws"), ignoredByDefault: row.hasAttribute("data-ignored-by-default") @@ -283,15 +296,17 @@ window.CatapultAdminGameMappings = (function () { hideMessages(form); const sourceId = form.sourceId.value.trim(); const category = form.search.selected(); + const igdb = form.igdb.selected(); const ignoredByDefault = form.ignored.checked; if (sourceId === "") return tell(form.error, form.root.dataset.labelErrorId); - // A game ignored by default needs no category: its bindings keep IGDB's - if (!category && !ignoredByDefault) return tell(form.error, form.root.dataset.labelErrorCategory); + // A game ignored by default needs no category: its bindings keep IGDB's; one pinned to an IGDB game has its deduced + if (!category && !igdb && !ignoredByDefault) return tell(form.error, form.root.dataset.labelErrorCategory); const body = { sourceType: form.platform.value, sourceId, twitchGameId: category?.id ?? null, twitchGameName: category?.name ?? null, + igdbId: igdb?.id ?? null, ccls: overrideOf(form, "ccls"), tws: overrideOf(form, "tws"), applyLive: form.applyLive.checked, @@ -357,7 +372,7 @@ window.CatapultAdminGameMappings = (function () { panel.addEventListener("click", event => { const target = event.target.closest?.( "[data-gm-page], [data-gm-missing-page], [data-gm-edit], [data-gm-delete], [data-gm-create], #admin-gm-new, " - + ".admin-gm-save, .admin-gm-confirm-delete, .admin-gm-close"); + + ".admin-gm-igdb-clear, .admin-gm-save, .admin-gm-confirm-delete, .admin-gm-close"); if (!target || !panel.contains(target) || target.disabled) return; if (target.matches("[data-gm-page]")) { reload(panel, Number(target.dataset.gmPage), undefined); @@ -374,6 +389,10 @@ window.CatapultAdminGameMappings = (function () { }); } else if (target.matches("#admin-gm-new")) { open(formOf(panel), { sourceType: "STEAM", ccls: null, tws: null }); + } else if (target.matches(".admin-gm-igdb-clear")) { + const form = formOf(panel); + form.igdb.select(null); + form.search.select(null); } else if (target.matches(".admin-gm-save")) { save(panel, formOf(panel)); } else if (target.matches(".admin-gm-confirm-delete")) { diff --git a/catapult-web/src/main/resources/static/js/admin-minecraft.js b/catapult-web/src/main/resources/static/js/admin-minecraft.js index 7acc4c4da..45b64aa4c 100644 --- a/catapult-web/src/main/resources/static/js/admin-minecraft.js +++ b/catapult-web/src/main/resources/static/js/admin-minecraft.js @@ -7,7 +7,7 @@ * "Add an account" and "Re-authenticate" run catapult-api's Microsoft device-code flow: get a device * code, show its user code, its verification link and the time left before it expires (counted down * by time.js), then poll the create / reauth route every max(3, interval) s: 202 → poll again, 200 → - * done (the list patched, the enroll form emptied), anything else → the error, stop. The countdown only shows the time left: + * done (the list patched), anything else → the error, stop. The countdown only shows the time left: * once the code has expired, catapult-api refuses the next poll (400) and the flow ends with the * expired label, so a poll answered as the code runs out still counts. * A refusal (of the device code or of a poll) shows its localized label then catapult-api's message. @@ -182,8 +182,6 @@ window.CatapultAdminMinecraft = (function () { button.disabled = true; // until the list is patched window.mdui?.snackbar?.({ message: doneLabel }); flowEl.replaceChildren(); // done: the code shown is of no more use - const enroll = flowEl.closest(".admin-minecraft-enroll"); - if (enroll) CatapultAdminTabs.reset(enroll); try { await CatapultAdminTabs.patch(panel); } finally { @@ -198,11 +196,9 @@ window.CatapultAdminMinecraft = (function () { } function add(panel, card, button) { - const field = card.querySelector('.admin-minecraft-enroll [name="label"]'); const flowEl = card.querySelector(".admin-minecraft-enroll .admin-minecraft-flow"); if (!flowEl) return; - const label = (field?.value ?? "").trim(); - return runFlow(panel, card, button, flowEl, `${BASE}/create`, label ? { label } : {}, card.dataset.labelCreated); + return runFlow(panel, card, button, flowEl, `${BASE}/create`, {}, card.dataset.labelCreated); } function reauth(panel, card, button) { diff --git a/catapult-web/src/main/resources/static/js/channel-connections.js b/catapult-web/src/main/resources/static/js/channel-connections.js index 0d6d4c51a..05551c1c8 100644 --- a/catapult-web/src/main/resources/static/js/channel-connections.js +++ b/catapult-web/src/main/resources/static/js/channel-connections.js @@ -1,5 +1,5 @@ /** - * Handlers for the Steam/Minecraft/Xbox/OBS connection cards. The Steam/Minecraft cards' elements are all + * Handlers for the Steam/Minecraft/Battle.net/Xbox/OBS connection cards. The Steam/Minecraft cards' elements are all * pre-rendered and only toggled via the `hidden` class (spa:if/spa:switch, see * channel.html) instead of being created/destroyed per SSE event, so handlers only * need attaching once, at render — no refresh() either: the SSE echo updates the cards. @@ -78,6 +78,10 @@ window.CatapultConnections = (function () { on("minecraft-check-btn", "click", () => postJson(`${baseUrl}/minecraft/sync`, {})); on("minecraft-disconnect-btn", "click", () => postJson(`${baseUrl}/minecraft/disconnect`, {})); + // Battle.net. Linking leaves for Blizzard's login, which sends the member back to this tab. + on("battlenet-connect-btn", "click", () => location.assign("/oauth2/authorization/battlenet")); + on("battlenet-disconnect-btn", "click", () => postJson(`${baseUrl}/battlenet/disconnect`, {})); + // OBS: connecting and disconnecting both save the whole form, only `enabled` differs. // An empty password field means "keep the stored one" (it's never sent back to the page), // and blank host/port fall back to catapult-api's defaults. No SSE event covers OBS, so the diff --git a/catapult-web/src/main/resources/static/js/channel-page.js b/catapult-web/src/main/resources/static/js/channel-page.js index 1f2e40ac1..e551bfb0a 100644 --- a/catapult-web/src/main/resources/static/js/channel-page.js +++ b/catapult-web/src/main/resources/static/js/channel-page.js @@ -137,7 +137,8 @@ window.CatapultChannel = (function () { // owner, and the Minecraft card every status sub-view (NONE/PENDING/INVITE_REJECTED/ // ACCEPTED/REMOVED), all toggled generically by each element's own spa:on/spa: // switch/spa:value (see channel.html) instead of being created/destroyed per event. - // No handler is needed here for any Steam or Minecraft event. + // The Battle.net card is the same, switching on BattleNetLinkEvent's status. + // No handler is needed here for any Steam, Minecraft or Battle.net event. return { username, baseUrl, refresh, postJson, postSwitch, showError, actionFailed, unreachable, diff --git a/catapult-web/src/main/resources/static/js/game-search.js b/catapult-web/src/main/resources/static/js/game-search.js index 7a5a13a9f..88c6bd7a2 100644 --- a/catapult-web/src/main/resources/static/js/game-search.js +++ b/catapult-web/src/main/resources/static/js/game-search.js @@ -15,6 +15,8 @@ * field is left it shows the selection back, and a save never pairs a typed name with another * game's id. select() sets the selection from elsewhere (a save made in another tab). * + * `label` (default: the result's name) words a result's list item; the field shows the name picked either way. + * * A result with a Twitch `boxArtUrl` shows its box art, through catapult-web's proxy (the browser * never asks Twitch's CDN); one without (DTDD's, the mock's) shows its name alone. */ @@ -39,7 +41,7 @@ window.GameSearch = (function () { channel.showError(status ? channel.actionFailed(status) : channel.unreachable()); } - function attach(inputEl, resultsEl, searchUrl, onSelect, { pick = data => data, initial } = {}) { + function attach(inputEl, resultsEl, searchUrl, onSelect, { pick = data => data, initial, label = game => game.name } = {}) { let debounceTimer; let latest = 0; let selected = initial?.id ? { id: initial.id, name: initial.name ?? "" } : null; @@ -66,7 +68,7 @@ window.GameSearch = (function () { if (query !== latest) return; resultsEl.replaceChildren(...(Array.isArray(games) ? games : []).map(game => { const item = document.createElement("mdui-list-item"); - item.textContent = game.name; + item.textContent = label(game); const art = boxArt(game.boxArtUrl); if (art) { const img = document.createElement("img"); diff --git a/catapult-web/src/main/resources/templates/index.html b/catapult-web/src/main/resources/templates/index.html index 18e0e84b6..4ee236582 100644 --- a/catapult-web/src/main/resources/templates/index.html +++ b/catapult-web/src/main/resources/templates/index.html @@ -237,6 +237,7 @@ + diff --git a/catapult-web/src/main/resources/templates/pages/admin-tab/game-mappings.html b/catapult-web/src/main/resources/templates/pages/admin-tab/game-mappings.html index c08a29530..fb0f75d65 100644 --- a/catapult-web/src/main/resources/templates/pages/admin-tab/game-mappings.html +++ b/catapult-web/src/main/resources/templates/pages/admin-tab/game-mappings.html @@ -40,6 +40,7 @@

    Game mappings

    ID Detected name Twitch category + IGDB game CCL TW Bindings @@ -49,7 +50,8 @@

    Game mappings

    Game mappings Ignored by default + igdb IGDB None @@ -143,9 +147,18 @@

    Ignore this game by default

    helper

    + diff --git a/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html b/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html index 5a474e780..65031af37 100644 --- a/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html +++ b/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html @@ -111,7 +111,6 @@

    Minecraft service accounts

    th:text="#{admin.minecraft.empty}">None.

    - Bot1 - CatapultBot1 + CatapultBot1 0 0 @@ -139,7 +137,7 @@

    Minecraft service accounts

    th:attr="data-id=${a.id()}" th:text="#{admin.minecraft.action.reset_limit}">Reset limit + data-confirm=#{admin.minecraft.action.delete-confirm(${a.displayName()})}">
    @@ -147,8 +145,6 @@

    Minecraft service accounts

    - Add an account
    @@ -158,5 +154,112 @@

    Minecraft service accounts

    + +
    + +
    +

    Battle.net bots

    +
    + +

    Unavailable.

    + + +

    None.

    +
    + +
    + Bot#1234 + READY + READY + 0 + + Enabled + Disabled + Full + + + Re-authenticate + Disable + Lift full + + +

    Re-authenticate to enter a code.

    +
    + + Send the code +
    +
    + + Save +
    +
    + + Change password +
    +
    +
    + +
    +
    + + + Add a bot +
    +
    + + +
    + Before adding a bot +
      +
    • First and last name set on account.battle.net
    • +
    • A secret question set on account.battle.net
    • +
    • Changes can take up to 3 hours to propagate
    • +
    +
    +
    +
    +
    +
    diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminBattleNetControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminBattleNetControllerTest.java new file mode 100644 index 000000000..b45ee7db8 --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminBattleNetControllerTest.java @@ -0,0 +1,128 @@ +package fr.enimaloc.catapult.controller; + +import fr.enimaloc.catapult.security.WebSecurityConfig; +import fr.enimaloc.catapult.service.ApiService; +import fr.enimaloc.catapult.service.http.ApiClient; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.Map; +import java.util.UUID; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AdminBattleNetController.class) +@Import({WebSecurityConfig.class, ModelFiller.class}) +class AdminBattleNetControllerTest { + static final UUID ID = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + static final String BASE = "/admin/battlenet-accounts"; + + @Autowired MockMvc mvc; + @MockitoBean ApiService apiService; + @MockitoBean fr.enimaloc.catapult.controller.twitchat.TwitchatEventForms twitchatEventForms; + + private MockHttpServletRequestBuilder admin(MockHttpServletRequestBuilder b, String body) { + return b.with(csrf()).sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt") + .contentType(MediaType.APPLICATION_JSON).content(body); + } + + @Test + void createForwardsCredentialsAndReturns202() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminCreateBattleNetAccount("bot@example.com", " s3cret ")).thenReturn(202); + + mvc.perform(admin(post(BASE), "{\"email\":\"bot@example.com \",\"password\":\" s3cret \"}")) + .andExpect(status().isAccepted()) + .andExpect(content().string("")); + // a legacy label is ignored, like any unknown field + mvc.perform(admin(post(BASE), "{\"label\":\"Bot1\",\"email\":\"bot@example.com\",\"password\":\" s3cret \"}")) + .andExpect(status().isAccepted()); + verify(apiService, org.mockito.Mockito.times(2)).adminCreateBattleNetAccount("bot@example.com", " s3cret "); + } + + @Test + void createWithAMissingFieldIs400AndNeverCallsTheApi() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + + mvc.perform(admin(post(BASE), "{\"email\":\"\",\"password\":\"x\"}")).andExpect(status().isBadRequest()); + mvc.perform(admin(post(BASE), "{\"email\":\"a@b.c\"}")).andExpect(status().isBadRequest()); + verify(apiService, never()).adminCreateBattleNetAccount(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void codeIsForwarded() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminSubmitBattleNetCode(ID, "123456")).thenReturn(202); + + mvc.perform(admin(post(BASE + "/" + ID + "/code"), "{\"code\":\" 123456\"}")).andExpect(status().isAccepted()); + verify(apiService).adminSubmitBattleNetCode(ID, "123456"); + mvc.perform(admin(post(BASE + "/" + ID + "/code"), "{\"code\":\"\"}")).andExpect(status().isBadRequest()); + } + + @Test + void codeWithNoWaitingLoginRelays409() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminSubmitBattleNetCode(ID, "1")).thenReturn(409); + when(apiService.adminReauthBattleNetAccount(ID)).thenReturn(409); + + mvc.perform(admin(post(BASE + "/" + ID + "/code"), "{\"code\":\"1\"}")).andExpect(status().isConflict()); + mvc.perform(admin(post(BASE + "/" + ID + "/reauth"), "{}")).andExpect(status().isConflict()); + } + + @Test + void enabledResetLimitAndPasswordPatchTheAccount() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminPatchBattleNetAccount(ID, Map.of("enabled", false))).thenReturn(200); + when(apiService.adminPatchBattleNetAccount(ID, Map.of("friendLimitReached", false))).thenReturn(404); + when(apiService.adminPatchBattleNetAccount(ID, Map.of("password", "n3w"))).thenReturn(200); + + mvc.perform(admin(post(BASE + "/" + ID + "/enabled"), "{\"enabled\":false}")).andExpect(status().isOk()); + mvc.perform(admin(post(BASE + "/" + ID + "/enabled"), "{}")).andExpect(status().isBadRequest()); + mvc.perform(admin(post(BASE + "/" + ID + "/reset-limit"), "{}")).andExpect(status().isNotFound()); + mvc.perform(admin(post(BASE + "/" + ID + "/password"), "{\"password\":\"n3w\"}")).andExpect(status().isOk()); + mvc.perform(admin(post(BASE + "/" + ID + "/password"), "{\"password\":\" \"}")).andExpect(status().isBadRequest()); + } + + @Test + void detailsForwardsTheFillOrderAlone_andValidates() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminPatchBattleNetAccount(ID, Map.of("fillOrder", 3))).thenReturn(200); + + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{\"fillOrder\":3}")).andExpect(status().isOk()); + // a legacy label is ignored, never forwarded + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{\"label\":\"Bot2\",\"fillOrder\":3}")).andExpect(status().isOk()); + verify(apiService, org.mockito.Mockito.times(2)).adminPatchBattleNetAccount(ID, Map.of("fillOrder", 3)); + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{\"fillOrder\":-1}")).andExpect(status().isBadRequest()); + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{}")).andExpect(status().isBadRequest()); + } + + @Test + void deleteRelaysTheStatus() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminDeleteBattleNetAccount(ID)).thenReturn(204); + + mvc.perform(admin(post(BASE + "/" + ID + "/delete"), "{}")).andExpect(status().isNoContent()); + } + + @Test + void nonAdminIsRefusedBeforeTheApiIsCalled() throws Exception { + when(apiService.isAdmin()).thenReturn(false); + + mvc.perform(admin(post(BASE), "{\"email\":\"b\",\"password\":\"c\"}")).andExpect(status().is4xxClientError()); + verify(apiService, never()).adminCreateBattleNetAccount(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } +} diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsControllerTest.java index b2b11d516..2dfdb1bd0 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsControllerTest.java @@ -4,6 +4,7 @@ import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; import fr.enimaloc.catapult.common.dto.admin.GameMappingResult; +import fr.enimaloc.catapult.common.dto.admin.IgdbGameOption; import fr.enimaloc.catapult.security.WebSecurityConfig; import fr.enimaloc.catapult.service.ApiService; import fr.enimaloc.catapult.service.http.ApiClient; @@ -122,16 +123,32 @@ void lookups_answerJson_andDetectedNeedsAPlatform() throws Exception { .andExpect(status().isBadRequest()); } + @Test + void igdbGames_areProxied_andAnUnreachableApiIs502() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminSearchIgdbGames("warc")).thenReturn(List.of( + new IgdbGameOption("123", "World of Warcraft", 2004, "https://images.igdb.com/igdb/image/upload/t_cover_big/x.jpg"))); + when(apiService.adminSearchIgdbGames("down")).thenReturn(null); + + mvc.perform(get(BASE + "/igdb-games?q=warc").sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt")) + .andExpect(status().isOk()).andExpect(jsonPath("$[0].id").value("123")) + .andExpect(jsonPath("$[0].name").value("World of Warcraft")) + .andExpect(jsonPath("$[0].releaseYear").value(2004)); + mvc.perform(get(BASE + "/igdb-games?q=down").sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt")) + .andExpect(status().isBadGateway()); + } + @Test void everyRoute_refusesVisitorsAndMembers_andActionsNeedCsrf() throws Exception { List routes = List.of(post(BASE), put(BASE + "/" + ID), - delete(BASE + "/" + ID), get(BASE + "/detected?sourceType=STEAM"), get(BASE + "/categories?q=a")); + delete(BASE + "/" + ID), get(BASE + "/detected?sourceType=STEAM"), get(BASE + "/categories?q=a"), + get(BASE + "/igdb-games?q=a")); for (MockHttpServletRequestBuilder route : routes) { mvc.perform(route.with(csrf()).contentType(MediaType.APPLICATION_JSON).content(BODY)) .andExpect(status().isUnauthorized()); } for (MockHttpServletRequestBuilder route : List.of(post(BASE), put(BASE + "/" + ID), delete(BASE + "/" + ID), - get(BASE + "/detected?sourceType=STEAM"), get(BASE + "/categories?q=a"))) { + get(BASE + "/detected?sourceType=STEAM"), get(BASE + "/categories?q=a"), get(BASE + "/igdb-games?q=a"))) { mvc.perform(route.with(csrf()).sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt") .contentType(MediaType.APPLICATION_JSON).content(BODY)).andExpect(status().isForbidden()); } @@ -143,5 +160,6 @@ void everyRoute_refusesVisitorsAndMembers_andActionsNeedCsrf() throws Exception verify(apiService, never()).adminDeleteGameMapping(any(), anyBoolean()); verify(apiService, never()).adminDetectedGames(anyString(), anyString()); verify(apiService, never()).adminSearchCategories(anyString()); + verify(apiService, never()).adminSearchIgdbGames(anyString()); } } diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsRenderingTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsRenderingTest.java index 20dea3adb..08782c5ed 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsRenderingTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminGameMappingsRenderingTest.java @@ -85,6 +85,42 @@ void listsMappingsWithIgdbOrOverrides() throws Exception { assertThat(igdbRow).contains(">14<"); } + @Test + void igdbColumn_showsNameAndId_theIdAlone_orNothing_andEscapesIgdbText() throws Exception { + UUID named = UUID.fromString("00000000-0000-0000-0000-000000000011"); + UUID idOnly = UUID.fromString("00000000-0000-0000-0000-000000000012"); + UUID none = UUID.fromString("00000000-0000-0000-0000-000000000013"); + UUID hostile = UUID.fromString("00000000-0000-0000-0000-000000000014"); + Instant at = Instant.parse("2026-10-22T10:00:00Z"); + when(apiService.adminGameMappings(0)).thenReturn(new AdminPage<>(List.of( + new GameMappingDto(named, "BATTLENET", "WoW", null, "18122", "World of Warcraft", null, null, 0, at, + "a", false, "123", "World of Warcraft"), + new GameMappingDto(idOnly, "BATTLENET", "Hero", null, "32959", "Heroes of the Storm", null, null, 0, at, + "a", false, "7313", null), + new GameMappingDto(none, "STEAM", "730", null, "32399", "Counter-Strike", null, null, 0, at, "a"), + new GameMappingDto(hostile, "STEAM", "9", null, "1", "One", null, null, 0, at, + "a", false, "5", "x")), 0, 1, 4)); + + String html = render(Locale.ENGLISH); + + assertThat(html).contains("IGDB game", ">World of Warcraft (123)<", ">7313<", "data-igdb-id=\"123\"", + "data-igdb-game-name=\"World of Warcraft\""); + String noneRow = html.substring(html.indexOf("data-id=\"" + none + "\""), html.indexOf("data-id=\"" + hostile + "\"")); + assertThat(noneRow).doesNotContain("data-igdb-id").contains("admin-gm-igdb\" data-label=\"IGDB game\">
    "); + // IGDB's text only ever reaches the page escaped + assertThat(html).doesNotContain("x").contains("<b>x</b> (5)"); + } + + @Test + void form_hasTheIgdbPicker_andTheCategoryHint() throws Exception { + String en = render(Locale.ENGLISH); + String fr = render(Locale.FRENCH); + + assertThat(en).contains("name=\"igdbGame\"", "admin-gm-igdb-results", "admin-gm-igdb-clear", + "Deduced from IGDB when empty", "Clear the IGDB game"); + assertThat(fr).doesNotContain("??").contains("Jeu IGDB", "Déduite d'IGDB si vide", "Retirer le jeu IGDB"); + } + @Test void missingList_isPagedWithCreateButtons() throws Exception { String html = render(Locale.ENGLISH); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminMinecraftControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminMinecraftControllerTest.java index 67168d04c..7ea6720eb 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminMinecraftControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminMinecraftControllerTest.java @@ -42,8 +42,6 @@ static class Texts { @Bean StaticMessageSource messageSource() { StaticMessageSource messages = new StaticMessageSource(); - messages.addMessage("admin.minecraft.default-label", Locale.FRENCH, "Compte Minecraft"); - messages.addMessage("admin.minecraft.default-label", Locale.ENGLISH, "Minecraft account"); messages.addMessage("api.error.minecraft_device_code_refused", Locale.FRENCH, "Device-code Microsoft refusé: {0}"); messages.addMessage("api.error.minecraft_device_code_refused", Locale.ENGLISH, "Microsoft refused the device code: {0}"); return messages; @@ -134,56 +132,44 @@ void deviceCodeOtherError_hasNoMessage() throws Exception { } @Test - void create_relays202Then200() throws Exception { + void create_relays202Then200_withTheAccountsDisplayName() throws Exception { when(apiService.isAdmin()).thenReturn(true); - when(apiService.adminCreateMinecraftAccount("dc-1", "Bot1")) + when(apiService.adminCreateMinecraftAccount("dc-1")) .thenReturn(new ApiClient.Result<>(202, Map.of("status", "PENDING"))) - .thenReturn(new ApiClient.Result<>(200, Map.of("id", ID.toString(), "label", "Bot1", + .thenReturn(new ApiClient.Result<>(200, Map.of("id", ID.toString(), "displayName", "CatapultBot1", "minecraftUsername", "CatapultBot1", "linkCount", 0))); - mvc.perform(admin(post(BASE + "/create"), "{\"deviceCode\":\"dc-1\",\"label\":\" Bot1 \"}")) + mvc.perform(admin(post(BASE + "/create"), "{\"deviceCode\":\"dc-1\"}")) .andExpect(status().isAccepted()); + // a legacy label is ignored, like any unknown field mvc.perform(admin(post(BASE + "/create"), "{\"deviceCode\":\"dc-1\",\"label\":\"Bot1\"}")) .andExpect(status().isOk()) - .andExpect(content().json("{\"minecraftUsername\":\"CatapultBot1\",\"label\":\"Bot1\"}", + .andExpect(content().json("{\"minecraftUsername\":\"CatapultBot1\",\"displayName\":\"CatapultBot1\"}", org.springframework.test.json.JsonCompareMode.STRICT)); } @Test - void create_anErrorIsRelayedAsItsStatusAlone_andABlankLabelIsFilledWithTheBundlesDefault() throws Exception { + void create_anErrorIsRelayedAsItsStatusAlone() throws Exception { when(apiService.isAdmin()).thenReturn(true); - when(apiService.adminCreateMinecraftAccount("dc-1", "Minecraft account")) + when(apiService.adminCreateMinecraftAccount("dc-1")) .thenReturn(new ApiClient.Result<>(502, Map.of("message", "Xbox chain failed", "trace", "x"))); - mvc.perform(admin(post(BASE + "/create"), "{\"deviceCode\":\"dc-1\",\"label\":\" \"}")) + mvc.perform(admin(post(BASE + "/create"), "{\"deviceCode\":\"dc-1\"}")) .andExpect(status().isBadGateway()) .andExpect(content().json("{}", org.springframework.test.json.JsonCompareMode.STRICT)); } - @Test - void create_withoutALabel_sendsTheDefaultInTheAdminsLanguage() throws Exception { - when(apiService.isAdmin()).thenReturn(true); - when(apiService.adminCreateMinecraftAccount(any(), any())).thenReturn(new ApiClient.Result<>(202, null)); - - mvc.perform(admin(post(BASE + "/create"), "{\"deviceCode\":\"c\",\"label\":\" \"}").locale(Locale.FRENCH)) - .andExpect(status().isAccepted()); - mvc.perform(admin(post(BASE + "/create"), "{\"deviceCode\":\"c\",\"label\":\" \"}").locale(Locale.ENGLISH)) - .andExpect(status().isAccepted()); - verify(apiService).adminCreateMinecraftAccount("c", "Compte Minecraft"); - verify(apiService).adminCreateMinecraftAccount("c", "Minecraft account"); - } - @Test void reauth_relaysItsStatus() throws Exception { when(apiService.isAdmin()).thenReturn(true); when(apiService.adminReauthMinecraftAccount(ID, "dc-2")) .thenReturn(new ApiClient.Result<>(202, null)) - .thenReturn(new ApiClient.Result<>(200, Map.of("label", "Bot1", "minecraftUsername", "CatapultBot1"))) + .thenReturn(new ApiClient.Result<>(200, Map.of("displayName", "CatapultBot1", "minecraftUsername", "CatapultBot1"))) .thenReturn(new ApiClient.Result<>(404, null)); mvc.perform(admin(post(BASE + "/" + ID + "/reauth"), "{\"deviceCode\":\"dc-2\"}")).andExpect(status().isAccepted()); mvc.perform(admin(post(BASE + "/" + ID + "/reauth"), "{\"deviceCode\":\"dc-2\"}")).andExpect(status().isOk()) - .andExpect(content().json("{\"minecraftUsername\":\"CatapultBot1\",\"label\":\"Bot1\"}")); + .andExpect(content().json("{\"minecraftUsername\":\"CatapultBot1\",\"displayName\":\"CatapultBot1\"}")); mvc.perform(admin(post(BASE + "/" + ID + "/reauth"), "{\"deviceCode\":\"dc-2\"}")).andExpect(status().isNotFound()) .andExpect(content().json("{}", org.springframework.test.json.JsonCompareMode.STRICT)); } @@ -192,9 +178,9 @@ void reauth_relaysItsStatus() throws Exception { void aBlankDeviceCode_is400_withoutCallingTheApi() throws Exception { when(apiService.isAdmin()).thenReturn(true); - mvc.perform(admin(post(BASE + "/create"), "{\"label\":\"Bot1\"}")).andExpect(status().isBadRequest()); + mvc.perform(admin(post(BASE + "/create"), "{}")).andExpect(status().isBadRequest()); mvc.perform(admin(post(BASE + "/" + ID + "/reauth"), "{\"deviceCode\":\" \"}")).andExpect(status().isBadRequest()); - verify(apiService, never()).adminCreateMinecraftAccount(any(), any()); + verify(apiService, never()).adminCreateMinecraftAccount(any()); verify(apiService, never()).adminReauthMinecraftAccount(any(), any()); } @@ -208,7 +194,7 @@ void visitorIs401_memberIs403_andTheApiIsNeverCalled() throws Exception { mvc.perform(admin(post(BASE + "/" + ID + "/delete"), "{}")).andExpect(status().isForbidden()); mvc.perform(admin(post(BASE + "/" + ID + "/enabled"), "{\"enabled\":true}")).andExpect(status().isForbidden()); verify(apiService, never()).adminMinecraftDeviceCode(); - verify(apiService, never()).adminCreateMinecraftAccount(any(), any()); + verify(apiService, never()).adminCreateMinecraftAccount(any()); verify(apiService, never()).adminDeleteMinecraftAccount(any()); verify(apiService, never()).adminPatchMinecraftAccount(any(), any()); } diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java index 98a112e94..8fc5cfe0d 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java @@ -959,10 +959,10 @@ void unreachableApi_showsThePlaceholder() throws Exception { assertThat(asAdmin("/spa/admin/keys")).contains("admin-placeholder").doesNotContain("admin-keys-add-save"); } - private static fr.enimaloc.catapult.common.dto.admin.AccountDto account(String id, String label, long links, + private static fr.enimaloc.catapult.common.dto.admin.AccountDto account(String id, String name, long links, boolean enabled, boolean full) { - return new fr.enimaloc.catapult.common.dto.admin.AccountDto(java.util.UUID.fromString(id), label, - label + "_mc", 0, full, enabled, (int) links); + return new fr.enimaloc.catapult.common.dto.admin.AccountDto(java.util.UUID.fromString(id), name + "_mc", + name + "_mc", 0, full, enabled, (int) links); } @Test @@ -975,8 +975,11 @@ void accountsCard_statesAndActions() throws Exception { String html = asAdmin("/spa/admin/keys").replaceAll("\\s+", " "); - assertThat(html).contains("id=\"admin-minecraft\"", "Act<ive>", "Off_mc", "admin-minecraft-add", + assertThat(html).contains("id=\"admin-minecraft\"", "Act<ive>_mc", "Off_mc", "admin-minecraft-add", "Minecraft service accounts"); + // shown by its Minecraft username alone: no label column nor label field + String card = html.substring(html.indexOf("id=\"admin-minecraft\""), html.indexOf("id=\"admin-battlenet\"")); + assertThat(card).doesNotContain("name=\"label\"", ">Label<"); // the state chips themselves: enabled ×2, disabled ×1, full ×1 (not the seeded "Full" label) assertThat(html.split("Enabled", -1)).hasSize(3); assertThat(html.split("Disabled", -1)).hasSize(2); @@ -991,6 +994,79 @@ void accountsCard_statesAndActions() throws Exception { assertThat(html.split("admin-minecraft-delete\"", -1)).hasSize(4); } + private static fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto bot(String id, String displayName, String tag, + String auth, String session, boolean enabled, boolean full) { + return bot(id, displayName, tag, auth, session, enabled, full, "CODE_REQUIRED".equals(auth)); + } + + private static fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto bot(String id, String displayName, String tag, + String auth, String session, boolean enabled, boolean full, boolean waitingForCode) { + return new fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto(java.util.UUID.fromString(id), displayName, tag, auth, + session, 0, full, enabled, 4, waitingForCode); + } + + @Test + void keysTabRendersTheBattleNetSection() throws Exception { + when(apiService.adminApiKeys(org.mockito.ArgumentMatchers.any())).thenReturn(keys(true)); + when(apiService.adminBattleNetAccounts()).thenReturn(List.of( + bot("00000000-0000-0000-0000-00000000000a", "Ready", "Tag#1", "READY", "READY", true, true), + bot("00000000-0000-0000-0000-00000000000b", "w***@example.com", null, "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", false, false))); + + String html = asAdmin("/spa/admin/keys").replaceAll("\\s+", " "); + + // each bot shown by its display name (its BattleTag, else its masked e-mail): no label to type or show + assertThat(html.substring(html.indexOf("id=\"admin-battlenet\""))).doesNotContain("name=\"label\"", ">Label<"); + assertThat(html).contains("id=\"admin-battlenet\"", "Battle.net bots", "Ready<b>", "w***@example.com", "Add a bot", + "data-auth-state=\"CODE_REQUIRED\"", "2FA code required", "Admin action needed", "account.battle.net"); + // the code field only for the bot waiting for one; the full flag lifts only the full one + assertThat(html.split("admin-battlenet-code-submit\"", -1)).hasSize(2); + assertThat(html).contains("admin-battlenet-code-submit\" data-id=\"00000000-0000-0000-0000-00000000000b\""); + assertThat(html.split("admin-battlenet-reset\"", -1)).hasSize(2); + assertThat(html.split("admin-battlenet-toggle\"", -1)).hasSize(3); + assertThat(html.split("admin-battlenet-delete\"", -1)).hasSize(3); + assertThat(html).doesNotContain("Ready"); + } + + @Test + void aBotNoLoginWaitsForGetsTheReauthHintInsteadOfTheCodeField() throws Exception { + when(apiService.adminApiKeys(org.mockito.ArgumentMatchers.any())).thenReturn(keys(true)); + when(apiService.adminBattleNetAccounts()).thenReturn(List.of( + bot("00000000-0000-0000-0000-00000000000b", "Stuck", null, "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", + true, false, false))); + + String html = asAdmin("/spa/admin/keys").replaceAll("\\s+", " "); + + assertThat(html).contains("data-waiting-code=\"false\"", "admin-battlenet-code-hint", "No login waits for a code any more") + .doesNotContain("admin-battlenet-code-submit"); + } + + @Test + void passwordNeverRenderedBack() throws Exception { + when(apiService.adminApiKeys(org.mockito.ArgumentMatchers.any())).thenReturn(keys(true)); + when(apiService.adminBattleNetAccounts()).thenReturn(List.of( + bot("00000000-0000-0000-0000-00000000000a", "Bot", "Tag#1", "READY", "READY", true, false))); + + String html = asAdmin("/spa/admin/keys").replaceAll("\\s+", " "); + + // every password field is a password input, autofill-proof, with no value of any kind + java.util.regex.Matcher fields = java.util.regex.Pattern + .compile("]*name=\"password\"[^>]*>").matcher(html); + int count = 0; + while (fields.find()) { + count++; + assertThat(fields.group()).contains("type=\"password\"", "autocomplete=\"new-password\"").doesNotContain("value="); + } + assertThat(count).isEqualTo(2); // the bot's "change password" and the add form + } + + @Test + void battleNetSection_unavailableWhenTheApiFails() throws Exception { + when(apiService.adminApiKeys(org.mockito.ArgumentMatchers.any())).thenReturn(keys(true)); + when(apiService.adminBattleNetAccounts()).thenReturn(null); + + assertThat(asAdmin("/spa/admin/keys")).contains("admin-battlenet-unavailable").doesNotContain("admin-battlenet-add"); + } + @Test void accountsCard_emptyAndUnavailable() throws Exception { when(apiService.adminApiKeys(org.mockito.ArgumentMatchers.any())).thenReturn(keys(true)); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelActionsControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelActionsControllerTest.java index 12e3f18fb..41997758a 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelActionsControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelActionsControllerTest.java @@ -303,6 +303,17 @@ void minecraftSync_callsApiService() throws Exception { verify(apiService).minecraftSync("enimaloc"); } + @Test + void disconnectCallsTheApi() throws Exception { + mvc.perform(post("/channel/enimaloc/battlenet/disconnect").with(csrf()).session(as("enimaloc"))) + .andExpect(status().isNoContent()); + mvc.perform(post("/channel/someone/battlenet/disconnect").with(csrf()).session(as("enimaloc"))) + .andExpect(status().isForbidden()); + + verify(apiService).battleNetDisconnect("enimaloc"); + org.mockito.Mockito.verify(apiService, org.mockito.Mockito.never()).battleNetDisconnect("someone"); + } + @Test void minecraftDisconnect_callsApiService() throws Exception { mvc.perform(post("/channel/enimaloc/minecraft/disconnect").with(csrf()).session(as("enimaloc"))) diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridgeTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridgeTest.java index 4fe11f395..acb6ba006 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridgeTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventRedisBridgeTest.java @@ -17,6 +17,7 @@ import fr.enimaloc.catapult.event.binding.CustomBindingChangedEvent; import fr.enimaloc.catapult.event.binding.CustomBindingsUpdatedEvent; import fr.enimaloc.catapult.event.binding.GameChangedEvent; +import fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftDisconnectedEvent; import fr.enimaloc.catapult.event.minecraft.MinecraftSyncEvent; import fr.enimaloc.catapult.event.steam.SteamConnectionStateEvent; @@ -232,6 +233,16 @@ void minecraftLink() { .containsExactly(new MinecraftDisconnectedEvent("streamer")); } + @Test + void bridgeRelaysBattleNetLinkChanged() { + assertThat(ChannelEventRedisBridge.events(channel, envelope(ChannelEvents.BATTLENET_LINK_CHANGED, + new ChannelEvents.BattleNetLinkChanged(owner.toString(), "streamer", "ACCEPTED", "Me#1", "Bot#2")))) + .containsExactly(new BattleNetLinkEvent("streamer", "ACCEPTED", "Me#1", "Bot#2")); + assertThat(ChannelEventRedisBridge.events(channel, envelope(ChannelEvents.BATTLENET_LINK_CHANGED, + new ChannelEvents.BattleNetLinkChanged(owner.toString(), "streamer", "NONE", null, null)))) + .containsExactly(new BattleNetLinkEvent("streamer", "NONE", null, null)); + } + @Test void ignoresUnknownEvents_otherChannels_andGarbage() { ApplicationEventPublisher publisher = mock(ApplicationEventPublisher.class); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventStreamControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventStreamControllerTest.java index 838a1daeb..35fa4cea2 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventStreamControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelEventStreamControllerTest.java @@ -9,6 +9,7 @@ import fr.enimaloc.catapult.monitoring.InteractionMetrics; import fr.enimaloc.catapult.security.WebSecurityConfig; import fr.enimaloc.catapult.service.ApiService; +import fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent; import fr.enimaloc.catapult.service.http.ApiClient; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -110,6 +111,20 @@ void theCustomBindingsGoToTheOwnersStreams_aModeratorsGetTheEventWithoutThem() t .doesNotContain("Art"); } + @Test + void theBattleNetLinkGoesToTheOwnersStreams_aModeratorsGetNothing() throws Exception { + MvcResult owner = subscribe("enimaloc", member("EnimaLoc")); + MvcResult moderator = subscribe("enimaloc", member("a-moderator")); + BattleNetLinkEvent link = new BattleNetLinkEvent("enimaloc", "ACCEPTED", "Me#1", "Bot#2"); + + controller.onChannelUpdated(link); + + assertThat(owner.getResponse().getContentAsString()).contains("event:BattleNetLinkEvent", "Me#1", "Bot#2"); + assertThat(moderator.getResponse().getContentAsString()).doesNotContain("BattleNetLinkEvent") + .doesNotContain("Me#1").doesNotContain("Bot#2"); + assertThat(ChannelEventStreamController.forModerators(link)).isNull(); + } + @Test void aChannelIsFollowedByItsLogin_whateverItsCase() throws Exception { MvcResult stream = subscribe("EnimaLoc"); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelStateTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelStateTest.java index 2652135cd..deb26ba93 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelStateTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelStateTest.java @@ -63,6 +63,17 @@ private static List events(List entries return entries.stream().map(ChannelState.Entry::data).toList(); } + @Test + void battleNetLink_asItsEvent() { + ChannelPageData ch = new ChannelPageData(new ChannelUserDto("1", "2", "enimaloc", "img"), "enimaloc", true, true, + false, null, new PagedBindings(0, 0, 0, List.of()), List.of(), Set.of(), List.of(), Set.of(), null, + null, null, null, null, new fr.enimaloc.catapult.common.dto.channel.BattleNetData("PENDING", "Me#1", + "Bot#2"), null, null, null, null); + + assertThat(events(ChannelState.of(ch, SETTINGS, null))) + .contains(new fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent("enimaloc", "PENDING", "Me#1", "Bot#2")); + } + @Test void everythingThePageShows_asItsEvents() { UUID active = UUID.randomUUID(); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelVisibilityRenderingTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelVisibilityRenderingTest.java index ac4beb2f7..0fb90a421 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelVisibilityRenderingTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelVisibilityRenderingTest.java @@ -8,6 +8,7 @@ import fr.enimaloc.catapult.common.dto.channel.DtddMappingStatusDto; import fr.enimaloc.catapult.common.dto.channel.ExtensionSlotDto; import fr.enimaloc.catapult.common.dto.channel.GameDto; +import fr.enimaloc.catapult.common.dto.channel.BattleNetData; import fr.enimaloc.catapult.common.dto.channel.MinecraftData; import fr.enimaloc.catapult.common.dto.channel.ObsData; import fr.enimaloc.catapult.common.dto.channel.PagedBindings; @@ -555,6 +556,22 @@ private ChannelPageData channelPageData(boolean isLive, MinecraftData minecraft) null, null, null, null, minecraft, null, null, null); } + private ChannelPageData channelPageData(BattleNetData battleNet) { + return new ChannelPageData( + new ChannelUserDto("id-1", "twitch-1", "enimaloc", "https://example.test/avatar.png"), + "enimaloc", true, true, true, null, + new PagedBindings(0, 1, 0, List.of()), + List.of(), Set.of(), List.of(), Set.of(), + null, null, null, null, null, battleNet, null, null, null, null); + } + + private String battleNetPage(BattleNetData battleNet) throws Exception { + when(apiService.channelPage(any(), anyInt(), any(), any())).thenReturn(channelPageData(battleNet)); + return mvc.perform(get("/spa/channel/enimaloc").locale(java.util.Locale.ENGLISH)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + } + /** Extracts the opening tag text for the first element with the given id. */ private static String openingTagForId(String html, String id) { java.util.regex.Matcher m = java.util.regex.Pattern @@ -688,6 +705,81 @@ void minecraftStatusFull_saysEveryAccountIsFull_withoutAnyButton() throws Except assertThat(openingTagForId(html, "minecraft-disconnect-btn")).contains("hidden"); } + @Test + void battleNetCardRendersOneCasePerStatus() throws Exception { + String html = battleNetPage(new BattleNetData("NONE", null, null)); + + for (String flag : List.of("caseNone", "casePending", "caseRejected", "caseAccepted", "caseRemoved", "caseFull")) { + assertThat(countOccurrences(html, "data-if=\"" + flag + "\"")).as(flag).isEqualTo(1); + } + assertThat(html).contains("data-conn=\"battlenet\""); + assertThat(html).contains("data-on=\"caseNone:BattleNetLinkEvent.status=NONE\""); + assertThat(html).contains("data-on=\"caseAccepted:BattleNetLinkEvent.status=ACCEPTED\""); + String noneCase = html.substring(html.lastIndexOf('<', html.indexOf("data-if=\"caseNone\""))); + assertThat(noneCase.substring(0, noneCase.indexOf('>'))).doesNotContain("hidden"); + String acceptedCase = html.substring(html.lastIndexOf('<', html.indexOf("data-if=\"caseAccepted\""))); + assertThat(acceptedCase.substring(0, acceptedCase.indexOf('>'))).contains("hidden"); + assertThat(openingTagForId(html, "battlenet-connect-btn")).doesNotContain("hidden"); + assertThat(openingTagForId(html, "battlenet-disconnect-btn")).contains("hidden"); + // Same look as the Xbox account link: a plain button, not a segmented one. + assertThat(openingTagForId(html, "battlenet-connect-btn")).startsWith("'))).doesNotContain("hidden"); + assertThat(html).contains("CatapultBot#4242") + .contains("data-value=\"textContent:BattleNetLinkEvent.serviceAccountBattleTag\""); + assertThat(openingTagForId(html, "battlenet-connect-btn")).contains("hidden"); + assertThat(openingTagForId(html, "battlenet-disconnect-btn")).contains("hidden"); + } + + @Test + void acceptedCaseShowsTheStreamerTagAndTheDisconnectButton() throws Exception { + String html = battleNetPage(new BattleNetData("ACCEPTED", "Me#1", "CatapultBot#4242")); + + assertThat(html).contains("Connected as").contains("Me#1"); + assertThat(openingTagForId(html, "battlenet-disconnect-btn")).doesNotContain("hidden") + .contains("data-on=\"showDisconnect:BattleNetLinkEvent.status=ACCEPTED\""); + assertThat(openingTagForId(html, "battlenet-connect-btn")).contains("hidden"); + } + + @Test + void rejectedAndRemovedOfferTheConnectButtonAgain() throws Exception { + for (String status : List.of("INVITE_REJECTED", "REMOVED")) { + String html = battleNetPage(new BattleNetData(status, "Me#1", "CatapultBot#4242")); + assertThat(openingTagForId(html, "battlenet-connect-btn")).as(status).doesNotContain("hidden"); + assertThat(openingTagForId(html, "battlenet-disconnect-btn")).as(status).contains("hidden"); + } + } + + @Test + void aHostileBattleTag_isRenderedEscaped() throws Exception { + String html = battleNetPage(new BattleNetData("ACCEPTED", "x", "y")); + + assertThat(html).contains("<b>x</b>").doesNotContain("x").doesNotContain("y"); + } + + @Test + void noVerifyButton() throws Exception { + String html = battleNetPage(new BattleNetData("PENDING", "Me#1", "CatapultBot#4242")); + + assertThat(html).doesNotContain("battlenet-check-btn").doesNotContain("battlenet-sync-btn"); + } + + @Test + void battleNetUnavailable_hasNoCard() throws Exception { + String html = battleNetPage(null); + + assertThat(html).doesNotContain("data-conn=\"battlenet\"").doesNotContain("battlenet-status-body"); + } + /** Minecraft unavailable to the channel (catapult-api's null): no card at all. */ @Test void minecraftUnavailable_hasNoCard() throws Exception { diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java index 648a9e478..ac78691ef 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java @@ -433,6 +433,18 @@ void steamToken() { new SteamTokenSharedStateEvent(username, false), new SteamTokenDeletedEvent(username)); } + @Test + void battleNetDisconnect_unlinksAndTellsThePage() { + data.battleNetDisconnect(); + events.clear(); + + service.battleNetDisconnect(username); + + assertThat(data.getBattleNetLink().status()).isEqualTo("NONE"); + assertThat(events).containsExactly( + new fr.enimaloc.catapult.event.battlenet.BattleNetLinkEvent(username, "NONE", null, null)); + } + @Test void minecraftLink() { service.minecraftEnroll(username, "Steve"); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStoreTest.java new file mode 100644 index 000000000..3106e0db6 --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStoreTest.java @@ -0,0 +1,65 @@ +package fr.enimaloc.catapult.service.mock; + +import fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class MockBattleNetAccountsStoreTest { + private final MockBattleNetAccountsStore store = new MockBattleNetAccountsStore(); + + private BattleNetAccountDto find(String authState) { + return store.list().stream().filter(a -> a.authState().equals(authState)).findFirst().orElseThrow(); + } + + @Test + void seedHasAReadyAndACodeRequiredAccount() { + assertThat(find("READY").sessionState()).isEqualTo("READY"); + assertThat(find("CODE_REQUIRED").battleTag()).isNull(); + } + + @Test + void theRightCodeMovesTheAccountToReady_aWrongOneLeavesItWaiting() { + UUID id = find("CODE_REQUIRED").id(); + + assertThat(store.submitCode(id, "000000")).isEqualTo(202); + assertThat(find("CODE_REQUIRED").id()).isEqualTo(id); + + assertThat(store.submitCode(id, MockBattleNetAccountsStore.CODE)).isEqualTo(202); + assertThat(store.list()).filteredOn(a -> a.id().equals(id)).singleElement().satisfies(a -> { + assertThat(a.authState()).isEqualTo("READY"); + assertThat(a.battleTag()).isNotNull(); + assertThat(a.displayName()).isEqualTo(a.battleTag()); + }); + assertThat(store.submitCode(id, MockBattleNetAccountsStore.CODE)).isEqualTo(409); // nothing waits any more + assertThat(store.submitCode(UUID.randomUUID(), "1")).isEqualTo(404); + } + + @Test + void createWaitsForACode_reauthPatchAndDelete() { + assertThat(store.create("bot9@example.com")).isEqualTo(202); + // no BattleTag before the first login: the masked e-mail stands in + BattleNetAccountDto created = store.list().stream().filter(a -> a.displayName().equals("b***@example.com")) + .findFirst().orElseThrow(); + assertThat(created.authState()).isEqualTo("CODE_REQUIRED"); + assertThat(created.battleTag()).isNull(); + + UUID ready = find("READY").id(); + assertThat(store.reauth(ready)).isEqualTo(202); + assertThat(store.list()).filteredOn(a -> a.id().equals(ready)).singleElement() + .satisfies(a -> assertThat(a.authState()).isEqualTo("CODE_REQUIRED")); + + assertThat(store.patch(created.id(), Map.of("enabled", false, "friendLimitReached", true))).isEqualTo(200); + assertThat(store.list()).filteredOn(a -> a.id().equals(created.id())).singleElement().satisfies(a -> { + assertThat(a.enabled()).isFalse(); + assertThat(a.friendLimitReached()).isTrue(); + assertThat(a.sessionState()).isEqualTo("DISABLED"); + }); + assertThat(store.delete(created.id())).isEqualTo(204); + assertThat(store.delete(created.id())).isEqualTo(404); + assertThat(store.patch(created.id(), Map.of())).isEqualTo(404); + } +} diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStoreTest.java index 95097fdf5..a5aecf2b3 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStoreTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStoreTest.java @@ -23,15 +23,22 @@ private static GameMappingRequest request(String type, String id, String categor } @Test - void seeded_hasThreeMappings_newestFirst_oneIgnoredByDefault() { + void seeded_hasTheBlizzardTitles_newestFirst_oneIgnoredByDefault() { assertThat(store.list(0).content()).hasSize(2); - assertThat(store.list(0).totalElements()).isEqualTo(3); + assertThat(store.list(0).totalElements()).isEqualTo(12); assertThat(store.list(0).content()).extracting(GameMappingDto::updatedAt).isSortedAccordingTo(java.util.Comparator.reverseOrder()); - // The oldest, on the second page: a wallpaper app ignored without a category - assertThat(store.list(1).content()).singleElement().satisfies(m -> { - assertThat(m.ignoredByDefault()).isTrue(); - assertThat(m.twitchGameId()).isNull(); - }); + var all = new java.util.ArrayList(); + for (int page = 0; page < 6; page++) all.addAll(store.list(page).content()); + // A wallpaper app ignored without a category + assertThat(all).filteredOn(GameMappingDto::ignoredByDefault).singleElement() + .satisfies(m -> assertThat(m.twitchGameId()).isNull()); + // The nine Blizzard titles, each pinned to its IGDB game (the IGDB name differs from the title's for two) + assertThat(all).filteredOn(m -> m.sourceType().equals("BATTLENET")).hasSize(9) + .allSatisfy(m -> assertThat(m.twitchGameId()).isNotNull()) + .extracting(GameMappingDto::sourceId, GameMappingDto::igdbId, GameMappingDto::igdbGameName) + .contains(org.assertj.core.groups.Tuple.tuple("WoW", "123", "World of Warcraft"), + org.assertj.core.groups.Tuple.tuple("S2", "239", "StarCraft II: Wings of Liberty"), + org.assertj.core.groups.Tuple.tuple("W3", "111650", "Warcraft III: Reforged")); } @Test @@ -104,7 +111,7 @@ void delete_removesTheMapping_andAnUnknownIdIs404() { assertThat(deleted.status()).isEqualTo(200); assertThat(deleted.body().mapping()).isNull(); - assertThat(store.list(0).totalElements()).isEqualTo(2); + assertThat(store.list(0).totalElements()).isEqualTo(11); assertThat(store.delete(existing.id(), false).status()).isEqualTo(404); } @@ -117,12 +124,42 @@ void detected_filtersByPlatformAndNameOrId() { assertThat(store.detected(null, "")).isEmpty(); } + @Test + void igdbGames_searchesTheFixedListByName_andIsEmptyWithoutAQuery() { + assertThat(store.igdbGames("warcraft")).extracting(o -> o.name()) + .contains("World of Warcraft", "Warcraft III: Reforged"); + assertThat(store.igdbGames("WORLD OF").getFirst().releaseYear()).isEqualTo(2004); + assertThat(store.igdbGames("zzzz-none")).isEmpty(); + assertThat(store.igdbGames(" ")).isEmpty(); + assertThat(store.igdbGames(null)).isEmpty(); + } + + @Test + void create_withAnIgdbGame_deducesTheCategory_orRefusesWhenThereIsNone() { + var deduced = store.create(new GameMappingRequest("STEAM", "413150", null, null, null, null, false, false, "125165"), "me"); + + assertThat(deduced.status()).isEqualTo(201); + assertThat(deduced.body().mapping()).satisfies(m -> { + assertThat(m.igdbId()).isEqualTo("125165"); + assertThat(m.igdbGameName()).isEqualTo("Diablo IV"); + assertThat(m.twitchGameId()).isEqualTo("515024"); + }); + // An explicit category wins over the deduced one + var explicit = store.create(new GameMappingRequest("STEAM", "99", "1", "One", null, null, false, false, "125165"), "me"); + assertThat(explicit.body().mapping().twitchGameId()).isEqualTo("1"); + // An IGDB game with no Twitch category, or an unknown one: nothing to deduce + assertThat(store.create(new GameMappingRequest("STEAM", "98", null, null, null, null, false, false, "9999"), "me") + .status()).isEqualTo(400); + assertThat(store.create(new GameMappingRequest("STEAM", "97", null, null, null, null, false, false, "nope"), "me") + .status()).isEqualTo(400); + } + @Test void create_withoutCategory_onlyWhenIgnoredByDefault() { assertThat(store.create(new GameMappingRequest("STEAM", "413150", null, null, null, null, false), "me") .status()).isEqualTo(400); - var created = store.create(new GameMappingRequest("STEAM", "413150", null, null, null, null, false, true), "me"); + var created = store.create(new GameMappingRequest("STEAM", "413150", null, null, null, null, false, true, null), "me"); assertThat(created.status()).isEqualTo(201); assertThat(created.body().mapping().ignoredByDefault()).isTrue(); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStoreTest.java index bed499816..6f5f5f948 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStoreTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockMinecraftAccountsStoreTest.java @@ -30,14 +30,15 @@ void create_answers202ForTheFirstPoll_then200_andListsTheAccount() { assertThat(code.verificationUri()).startsWith("https://"); int before = store.list().size(); - assertThat(store.create(code.deviceCode(), "Bot9").status()).isEqualTo(202); - var done = store.create(code.deviceCode(), "Bot9"); + assertThat(store.create(code.deviceCode()).status()).isEqualTo(202); + var done = store.create(code.deviceCode()); assertThat(done.status()).isEqualTo(200); - assertThat(done.body()).containsEntry("label", "Bot9").containsKey("minecraftUsername"); - assertThat(store.list()).hasSize(before + 1).anyMatch(a -> a.label().equals("Bot9") && a.enabled()); - assertThat(store.create(code.deviceCode(), "Bot9").status()).isEqualTo(400); // a used code is gone - assertThat(store.create("never-issued", "x").status()).isEqualTo(400); + String name = (String) done.body().get("minecraftUsername"); + assertThat(done.body()).containsEntry("displayName", name); + assertThat(store.list()).hasSize(before + 1).anyMatch(a -> a.displayName().equals(name) && a.enabled()); + assertThat(store.create(code.deviceCode()).status()).isEqualTo(400); // a used code is gone + assertThat(store.create("never-issued").status()).isEqualTo(400); } @Test diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java index fddaf193c..7afd1e316 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java @@ -745,6 +745,17 @@ void minecraftSync() { service.minecraftSync("enimaloc"); } + @Test + void battleNetStatusAndDisconnect() { + respond(HttpMethod.GET, OWN + "/links/battlenet", + "{\"status\":\"PENDING\",\"battleTag\":\"Me#1\",\"serviceAccountBattleTag\":\"Bot#2\"}"); + expect(HttpMethod.DELETE, OWN + "/links/battlenet").andRespond(withNoContent()); + + assertThat(service.battleNetStatus("enimaloc")).isEqualTo( + new fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse("PENDING", "Me#1", "Bot#2")); + assertThat(service.battleNetDisconnect("enimaloc")).isEqualTo(204); + } + @Test void minecraftDisconnect() { expect(HttpMethod.DELETE, OWN + "/links/minecraft").andRespond(withNoContent()); @@ -1832,12 +1843,12 @@ void adminExperimentAction_reachesEachActionsRoute_andNoneForAnUnknownOne() { @Test void adminMinecraftAccounts_readCatapultApisAccountDto() { respond(HttpMethod.GET, "/api/internal/admin/minecraft-accounts", "[{\"id\":\"00000000-0000-0000-0000-00000000000a\"," - + "\"label\":\"Bot1\",\"minecraftUsername\":\"CatapultBot1\",\"fillOrder\":2,\"friendLimitReached\":true," + + "\"displayName\":\"CatapultBot1\",\"minecraftUsername\":\"CatapultBot1\",\"fillOrder\":2,\"friendLimitReached\":true," + "\"enabled\":false,\"linkCount\":7}]"); assertThat(service.adminMinecraftAccounts()).singleElement().satisfies(a -> { assertThat(a.id()).isEqualTo(UUID.fromString("00000000-0000-0000-0000-00000000000a")); - assertThat(a.label()).isEqualTo("Bot1"); + assertThat(a.displayName()).isEqualTo("CatapultBot1"); assertThat(a.minecraftUsername()).isEqualTo("CatapultBot1"); assertThat(a.fillOrder()).isEqualTo(2); assertThat(a.linkCount()).isEqualTo(7); @@ -1866,6 +1877,53 @@ void minecraftAccountActions_sendTheirShapes_andReturnTheStatus() { assertThat(service.adminDeleteMinecraftAccount(id)).isEqualTo(409); } + @Test + void adminBattleNetAccounts_readCatapultApisDto() { + respond(HttpMethod.GET, "/api/internal/admin/battlenet-accounts", "[{\"id\":\"00000000-0000-0000-0000-00000000000a\"," + + "\"displayName\":\"Bot#1\",\"battleTag\":\"Bot#1\",\"authState\":\"CODE_REQUIRED\",\"sessionState\":\"STOPPED_CODE_REQUIRED\"," + + "\"fillOrder\":2,\"friendLimitReached\":true,\"enabled\":false,\"friendCount\":7,\"waitingForCode\":true}]"); + assertThat(service.adminBattleNetAccounts()).singleElement().satisfies(a -> { + assertThat(a.displayName()).isEqualTo("Bot#1"); + assertThat(a.battleTag()).isEqualTo("Bot#1"); + assertThat(a.authState()).isEqualTo("CODE_REQUIRED"); + assertThat(a.sessionState()).isEqualTo("STOPPED_CODE_REQUIRED"); + assertThat(a.friendCount()).isEqualTo(7); + assertThat(a.friendLimitReached()).isTrue(); + assertThat(a.waitingForCode()).isTrue(); + }); + } + + @Test + void adminBattleNetAccounts_isNullWhenTheApiFails() { + expect(HttpMethod.GET, "/api/internal/admin/battlenet-accounts").andRespond(withStatus(HttpStatus.NOT_FOUND)); + + assertThat(service.adminBattleNetAccounts()).isNull(); + } + + @Test + void battleNetAccountActions_sendTheirShapes_andReturnTheStatus() { + UUID id = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + expect(HttpMethod.POST, "/api/internal/admin/battlenet-accounts") + .andExpect(content().json("{\"email\":\"b@x.y\",\"password\":\"pw\"}", + org.springframework.test.json.JsonCompareMode.STRICT)) + .andRespond(withStatus(HttpStatus.ACCEPTED).contentType(MediaType.APPLICATION_JSON).body("{}")); + expect(HttpMethod.POST, "/api/internal/admin/battlenet-accounts/" + id + "/code") + .andExpect(content().json("{\"code\":\"123456\"}", org.springframework.test.json.JsonCompareMode.STRICT)) + .andRespond(withStatus(HttpStatus.ACCEPTED)); + expect(HttpMethod.POST, "/api/internal/admin/battlenet-accounts/" + id + "/reauth") + .andRespond(withStatus(HttpStatus.CONFLICT)); + expect(HttpMethod.PATCH, "/api/internal/admin/battlenet-accounts/" + id) + .andExpect(content().json("{\"friendLimitReached\":false}", org.springframework.test.json.JsonCompareMode.STRICT)) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + expect(HttpMethod.DELETE, "/api/internal/admin/battlenet-accounts/" + id).andRespond(withNoContent()); + + assertThat(service.adminCreateBattleNetAccount("b@x.y", "pw")).isEqualTo(202); + assertThat(service.adminSubmitBattleNetCode(id, "123456")).isEqualTo(202); + assertThat(service.adminReauthBattleNetAccount(id)).isEqualTo(409); + assertThat(service.adminPatchBattleNetAccount(id, Map.of("friendLimitReached", false))).isEqualTo(200); + assertThat(service.adminDeleteBattleNetAccount(id)).isEqualTo(204); + } + @Test void adminMinecraftDeviceCode_readsTheSnakeCaseAnswer_andKeepsARefusalsStatusAndProblem() { expect(HttpMethod.POST, "/api/internal/admin/minecraft-accounts/device-code").andRespond(withSuccess( @@ -1893,17 +1951,17 @@ void adminMinecraftDeviceCode_readsTheSnakeCaseAnswer_andKeepsARefusalsStatusAnd void createAndReauth_postTheDeviceCode_andKeepTheStatusAndBody() { UUID id = UUID.fromString("00000000-0000-0000-0000-00000000000a"); expect(HttpMethod.POST, "/api/internal/admin/minecraft-accounts") - .andExpect(content().json("{\"deviceCode\":\"dc-1\",\"label\":\"Bot1\"}", org.springframework.test.json.JsonCompareMode.STRICT)) + .andExpect(content().json("{\"deviceCode\":\"dc-1\"}", org.springframework.test.json.JsonCompareMode.STRICT)) .andRespond(withStatus(HttpStatus.ACCEPTED).contentType(MediaType.APPLICATION_JSON).body("{\"status\":\"PENDING\"}")); expect(HttpMethod.POST, "/api/internal/admin/minecraft-accounts") .andExpect(content().json("{\"deviceCode\":\"dc-1\"}", org.springframework.test.json.JsonCompareMode.STRICT)) - .andRespond(withSuccess("{\"label\":\"Compte Minecraft\",\"minecraftUsername\":\"CatapultBot1\"}", MediaType.APPLICATION_JSON)); + .andRespond(withSuccess("{\"displayName\":\"CatapultBot1\",\"minecraftUsername\":\"CatapultBot1\"}", MediaType.APPLICATION_JSON)); expect(HttpMethod.POST, "/api/internal/admin/minecraft-accounts/" + id + "/reauth") .andExpect(content().json("{\"deviceCode\":\"dc-2\"}", org.springframework.test.json.JsonCompareMode.STRICT)) .andRespond(withStatus(HttpStatus.BAD_GATEWAY).contentType(MediaType.APPLICATION_JSON).body("{\"message\":\"chain failed\"}")); - assertThat(service.adminCreateMinecraftAccount("dc-1", "Bot1").status()).isEqualTo(202); - var created = service.adminCreateMinecraftAccount("dc-1", null); + assertThat(service.adminCreateMinecraftAccount("dc-1").status()).isEqualTo(202); + var created = service.adminCreateMinecraftAccount("dc-1"); assertThat(created.status()).isEqualTo(200); assertThat(created.body()).containsEntry("minecraftUsername", "CatapultBot1"); var failed = service.adminReauthMinecraftAccount(id, "dc-2"); diff --git a/catapult-web/src/test/js/admin-battlenet.test.js b/catapult-web/src/test/js/admin-battlenet.test.js new file mode 100644 index 000000000..65bfe3061 --- /dev/null +++ b/catapult-web/src/test/js/admin-battlenet.test.js @@ -0,0 +1,226 @@ +/* + * The admin Keys tab's Battle.net bot pool card: the actions and their inline refusals, the 2FA code and + * password fields (sent once, then emptied), and the 3 s refresh while a bot is NEW or waits for its code. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { load, render, response } from "./helpers.js"; + +const ID = "00000000-0000-0000-0000-00000000000b"; +let routes; +let posted; + +function row(state, waitingForCode) { + return ` +
    + + + + +
    +
    +
    +
    `; +} + +function page(state = "READY", waitingForCode = state === "CODE_REQUIRED") { + document.body.innerHTML = ` + + +
    +
    +
    + ${row(state, waitingForCode)} +
    + + +
    + +
    +
    +
    +
    + +
    `; + render(); +} + +const message = () => document.querySelector(".admin-battlenet-message"); +const settle = () => vi.advanceTimersByTimeAsync(0); + +async function click(selector) { + document.querySelector(selector).click(); + await settle(); +} + +beforeEach(async () => { + vi.useFakeTimers(); + routes = {}; + posted = vi.fn(async url => routes[url] ?? response(200, {})); + window.CatapultCsrf = { postJson: posted }; + window.mdui = { confirm: vi.fn(() => Promise.resolve()), snackbar: vi.fn() }; + document.documentElement.lang = "en"; + await load("admin-tabs", "admin-battlenet"); + window.CatapultChannel = { showError: vi.fn() }; + window.CatapultAdminTabs.patch = vi.fn(async () => {}); + page(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("admin-battlenet actions", () => { + it("adds a bot with the typed credentials in the body, then empties the fields and patches", async () => { + routes["/admin/battlenet-accounts"] = response(202, {}); + document.querySelector('.admin-battlenet-enroll [name="email"]').value = " bot@example.com "; + document.querySelector('.admin-battlenet-enroll [name="password"]').value = "s3cret"; + + await click(".admin-battlenet-add"); + + expect(posted).toHaveBeenCalledWith("/admin/battlenet-accounts", { email: "bot@example.com", password: "s3cret" }); + expect(window.mdui.snackbar).toHaveBeenCalledWith({ message: "Created" }); + expect(document.querySelector('.admin-battlenet-enroll [name="password"]').value).toBe(""); + expect(CatapultAdminTabs.patch).toHaveBeenCalledWith(document.querySelector('mdui-tab-panel[value="keys"]')); + }); + + it("refuses an incomplete add form without calling the server", async () => { + document.querySelector('.admin-battlenet-enroll [name="email"]').value = "bot@example.com"; + + await click(".admin-battlenet-add"); + + expect(posted).not.toHaveBeenCalled(); + expect(message().textContent).toBe("Invalid"); + expect(message().hidden).toBe(false); + }); + + it("sends the 2FA code and the new password once, emptying the fields", async () => { + document.querySelector('.admin-battlenet-code [name="code"]').value = " 123456 "; + await click(".admin-battlenet-code-submit"); + expect(posted).toHaveBeenLastCalledWith(`/admin/battlenet-accounts/${ID}/code`, { code: "123456" }); + expect(document.querySelector('.admin-battlenet-code [name="code"]').value).toBe(""); + + document.querySelector('.admin-battlenet-password [name="password"]').value = "n3w"; + await click(".admin-battlenet-password-submit"); + expect(posted).toHaveBeenLastCalledWith(`/admin/battlenet-accounts/${ID}/password`, { password: "n3w" }); + expect(document.querySelector('.admin-battlenet-password [name="password"]').value).toBe(""); + }); + + it("posts reauth, toggle and lift-full to their routes", async () => { + await click(".admin-battlenet-reauth"); + expect(posted).toHaveBeenLastCalledWith(`/admin/battlenet-accounts/${ID}/reauth`, {}); + await click(".admin-battlenet-toggle"); + expect(posted).toHaveBeenLastCalledWith(`/admin/battlenet-accounts/${ID}/enabled`, { enabled: false }); + await click(".admin-battlenet-reset"); + expect(posted).toHaveBeenLastCalledWith(`/admin/battlenet-accounts/${ID}/reset-limit`, {}); + }); + + it("tells a 409 and a 404 inline, keeping the typed code on a refusal", async () => { + routes[`/admin/battlenet-accounts/${ID}/code`] = response(409, {}); + document.querySelector('.admin-battlenet-code [name="code"]').value = "123456"; + + await click(".admin-battlenet-code-submit"); + expect(message().textContent).toBe("Busy"); + expect(message().classList.contains("admin-battlenet-error")).toBe(true); + expect(document.querySelector('.admin-battlenet-code [name="code"]').value).toBe("123456"); + + routes[`/admin/battlenet-accounts/${ID}/reauth`] = response(404, {}); + await click(".admin-battlenet-reauth"); + expect(message().textContent).toBe("Gone"); + + routes[`/admin/battlenet-accounts/${ID}/reset-limit`] = response(502, {}); + await click(".admin-battlenet-reset"); + expect(message().textContent).toBe("Down"); + }); + + it("deletes after the confirmation only", async () => { + window.mdui.confirm = vi.fn(() => Promise.reject()); + await click(".admin-battlenet-delete"); + expect(posted).not.toHaveBeenCalled(); + + window.mdui.confirm = vi.fn(() => Promise.resolve()); + await click(".admin-battlenet-delete"); + expect(posted).toHaveBeenCalledWith(`/admin/battlenet-accounts/${ID}/delete`, {}); + expect(window.mdui.snackbar).toHaveBeenLastCalledWith({ message: "Deleted" }); + }); +}); + +describe("admin-battlenet details", () => { + it("sends the fill order alone, refusing a negative or fractional one", async () => { + await click(".admin-battlenet-details-submit"); + expect(posted).toHaveBeenLastCalledWith(`/admin/battlenet-accounts/${ID}/details`, { fillOrder: 2 }); + + posted.mockClear(); + document.querySelector('.admin-battlenet-details [name="fillOrder"]').value = "-1"; + await click(".admin-battlenet-details-submit"); + document.querySelector('.admin-battlenet-details [name="fillOrder"]').value = "1.5"; + await click(".admin-battlenet-details-submit"); + expect(posted).not.toHaveBeenCalled(); + expect(message().textContent).toBe("Invalid"); + }); +}); + +describe("admin-battlenet polling", () => { + it("patches the tab every 3 s while a bot waits for its code, and stops once none does", async () => { + page("CODE_REQUIRED"); + await vi.advanceTimersByTimeAsync(2999); + expect(CatapultAdminTabs.patch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(3000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(2); + + document.querySelector(".admin-battlenet-row").dataset.authState = "READY"; + await vi.advanceTimersByTimeAsync(3000); + const calls = CatapultAdminTabs.patch.mock.calls.length; + await vi.advanceTimersByTimeAsync(30000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(calls); + }); + + it("does not poll a CODE_REQUIRED bot no login waits for", async () => { + page("CODE_REQUIRED", false); + await vi.advanceTimersByTimeAsync(30000); + expect(CatapultAdminTabs.patch).not.toHaveBeenCalled(); + }); + + it("does not poll when every bot is settled", async () => { + page("READY"); + await vi.advanceTimersByTimeAsync(30000); + expect(CatapultAdminTabs.patch).not.toHaveBeenCalled(); + }); + + it("polls a NEW bot too, and stops when another admin tab is shown", async () => { + page("NEW"); + await vi.advanceTimersByTimeAsync(3000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(1); + + const tabs = document.getElementById("admin-tabs"); + tabs.value = "broadcast"; + tabs.dispatchEvent(new Event("change")); + const calls = CatapultAdminTabs.patch.mock.calls.length; + await vi.advanceTimersByTimeAsync(30000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(calls); + }); + + it("does not restart when a not-yet-visited tab is opened, nor after a tick in flight at leave time", async () => { + page("CODE_REQUIRED"); + let finish; + CatapultAdminTabs.patch = vi.fn(() => new Promise(resolve => { finish = resolve; })); + await vi.advanceTimersByTimeAsync(3000); // a tick is in flight + const tabs = document.getElementById("admin-tabs"); + tabs.value = "broadcast"; + tabs.dispatchEvent(new Event("change")); + document.dispatchEvent(new CustomEvent("catapult:admin-panel", { detail: { tab: "broadcast" } })); + finish(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(30000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(1); + + tabs.value = "keys"; // back on Keys: it resumes + tabs.dispatchEvent(new Event("change")); + await vi.advanceTimersByTimeAsync(3000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/catapult-web/src/test/js/admin-game-mappings.test.js b/catapult-web/src/test/js/admin-game-mappings.test.js index 0ca0dc062..bcc9232e7 100644 --- a/catapult-web/src/test/js/admin-game-mappings.test.js +++ b/catapult-web/src/test/js/admin-game-mappings.test.js @@ -30,6 +30,8 @@ function page() {
    + +
    @@ -149,6 +151,8 @@ function fullPage(rows = ROW) {
    + +
    @@ -179,11 +183,24 @@ const LISTS = "/spa/admin/game-mappings?page=1&missingPage=0"; const panel = () => q('mdui-tab-panel[value="game-mappings"]'); const fragment = rows => `
    ${rows}
    `; let selected; +let selectedIgdb; +let igdbAttach; describe("admin-game-mappings form and lists", () => { beforeEach(() => { selected = null; - window.GameSearch = { attach: () => ({ select: s => { selected = s; }, selected: () => selected }) }; + selectedIgdb = null; + igdbAttach = null; + // One search per picker field: the category's and the IGDB game's + window.GameSearch = { + attach: (input, results, url, onSelect, options) => { + if (input.name === "igdbGame") { + igdbAttach = { url, onSelect, options }; + return { select: s => { selectedIgdb = s; }, selected: () => selectedIgdb }; + } + return { select: s => { selected = s; }, selected: () => selected }; + } + }; window.mdui.snackbar = vi.fn(); }); @@ -268,7 +285,7 @@ describe("admin-game-mappings form and lists", () => { expect([url, init.method]).toEqual(["/admin/game-mappings", "POST"]); expect(JSON.parse(init.body)).toEqual({ sourceType: "STEAM", sourceId: "s1", twitchGameId: "9", twitchGameName: "Portal TW", - ccls: ["a"], tws: null, applyLive: false, ignoredByDefault: false + ccls: ["a"], tws: null, applyLive: false, ignoredByDefault: false, igdbId: null }); expect(result().textContent).toBe("B 2 L 1 F 0"); expect(q(".admin-gm-form-title").textContent).toBe("Edit"); @@ -285,6 +302,76 @@ describe("admin-game-mappings form and lists", () => { expect(fetched.mock.calls[1][1].method).toBe("PUT"); }); + it("searches IGDB like the category, wording a result with its year when it has one", () => { + fullPage(); + click("#admin-gm-new"); + + expect(igdbAttach.url).toBe("/admin/game-mappings/igdb-games"); + const { label } = igdbAttach.options; + expect(label({ name: "World of Warcraft", releaseYear: 2004 })).toBe("World of Warcraft (2004)"); + expect(label({ name: "Unreleased", releaseYear: null })).toBe("Unreleased"); + }); + + it("saves with the IGDB game picked and no category, which catapult-api deduces", async () => { + fullPage(); + click("#admin-gm-new"); + sourceId().value = "WoW"; + selectedIgdb = { id: "123", name: "World of Warcraft" }; + fetched.mockResolvedValueOnce(response(200, { mapping: { id: "90" } })); + + click(".admin-gm-save"); + await vi.advanceTimersByTimeAsync(0); + + expect(error().hidden).toBe(true); + expect(JSON.parse(fetched.mock.calls[0][1].body)).toEqual({ + sourceType: "STEAM", sourceId: "WoW", twitchGameId: null, twitchGameName: null, + ccls: null, tws: null, applyLive: false, ignoredByDefault: false, igdbId: "123" + }); + }); + + it("empties the category when an IGDB game is picked, and when the field is emptied by hand", () => { + fullPage(); + click("#admin-gm-new"); + selected = { id: "9", name: "Portal TW" }; + + igdbAttach.onSelect({ id: "123", name: "World of Warcraft" }); + expect(selected).toBeNull(); + + selected = { id: "9", name: "Portal TW" }; + const input = q('[name="igdbGame"]'); + input.value = "wor"; + input.dispatchEvent(new Event("input")); + expect(selected).toEqual({ id: "9", name: "Portal TW" }); + input.value = " "; + input.dispatchEvent(new Event("input")); + expect(selected).toBeNull(); + }); + + it("clears the IGDB game and the category with the clear button", () => { + fullPage(); + click("#admin-gm-new"); + selectedIgdb = { id: "123", name: "World of Warcraft" }; + selected = { id: "9", name: "Portal TW" }; + + click(".admin-gm-igdb-clear"); + + expect(selectedIgdb).toBeNull(); + expect(selected).toBeNull(); + }); + + it("opens a row's form with its IGDB game, the id standing in for an unknown name", () => { + fullPage(ROW.replace('data-id="7"', 'data-id="7" data-igdb-id="123" data-igdb-game-name="World of Warcraft"')); + click("[data-gm-edit]"); + expect(selectedIgdb).toEqual({ id: "123", name: "World of Warcraft" }); + + fullPage(ROW.replace('data-id="7"', 'data-id="7" data-igdb-id="456"')); + click("[data-gm-edit]"); + expect(selectedIgdb).toEqual({ id: "456", name: "456" }); + + click("#admin-gm-new"); + expect(selectedIgdb).toBeNull(); + }); + it("saves a game ignored by default without a category", async () => { fullPage(); click("#admin-gm-new"); @@ -298,7 +385,7 @@ describe("admin-game-mappings form and lists", () => { expect(error().hidden).toBe(true); expect(JSON.parse(fetched.mock.calls[0][1].body)).toEqual({ sourceType: "STEAM", sourceId: "431960", twitchGameId: null, twitchGameName: null, - ccls: null, tws: null, applyLive: false, ignoredByDefault: true + ccls: null, tws: null, applyLive: false, ignoredByDefault: true, igdbId: null }); }); diff --git a/catapult-web/src/test/js/admin-minecraft.test.js b/catapult-web/src/test/js/admin-minecraft.test.js index 2227debff..8317c13b6 100644 --- a/catapult-web/src/test/js/admin-minecraft.test.js +++ b/catapult-web/src/test/js/admin-minecraft.test.js @@ -30,7 +30,6 @@ function page() {
    -
    @@ -81,8 +80,7 @@ afterEach(() => { describe("admin-minecraft device-code flow", () => { it("shows the user code and an https link, then polls every max(3, interval) s until 200 and patches", async () => { - routes["/admin/minecraft-accounts/create"] = [response(202, {}), response(200, { minecraftUsername: "Bot_mc", label: "Bot1" })]; - document.querySelector('[name="label"]').value = " Bot1 "; + routes["/admin/minecraft-accounts/create"] = [response(202, {}), response(200, { minecraftUsername: "Bot_mc", displayName: "Bot_mc" })]; await click(".admin-minecraft-add"); @@ -98,7 +96,7 @@ describe("admin-minecraft device-code flow", () => { expect(polls()).toHaveLength(0); await vi.advanceTimersByTimeAsync(1); expect(polls()).toHaveLength(1); - expect(posted).toHaveBeenLastCalledWith("/admin/minecraft-accounts/create", { deviceCode: "dc-1", label: "Bot1" }); + expect(posted).toHaveBeenLastCalledWith("/admin/minecraft-accounts/create", { deviceCode: "dc-1" }); expect(CatapultAdminTabs.patch).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(3000); @@ -106,7 +104,6 @@ describe("admin-minecraft device-code flow", () => { expect(window.mdui.snackbar).toHaveBeenLastCalledWith({ message: "Created" }); expect(CatapultAdminTabs.patch).toHaveBeenCalledWith(panel()); expect(addFlow().children).toHaveLength(0); // the code shown is of no more use - expect(document.querySelector('[name="label"]').value).toBe(""); await vi.advanceTimersByTimeAsync(30000); expect(polls()).toHaveLength(2); @@ -236,7 +233,7 @@ describe("admin-minecraft device-code flow", () => { expect(polls()).toHaveLength(1); panel().innerHTML = "

    another view

    "; - answer(response(200, { minecraftUsername: "Bot_mc", label: "Bot1" })); + answer(response(200, { minecraftUsername: "Bot_mc", displayName: "Bot_mc" })); await vi.advanceTimersByTimeAsync(30000); expect(polls()).toHaveLength(1); diff --git a/catapult-web/src/test/js/channel-actions.test.js b/catapult-web/src/test/js/channel-actions.test.js index 4690e7260..f48a69601 100644 --- a/catapult-web/src/test/js/channel-actions.test.js +++ b/catapult-web/src/test/js/channel-actions.test.js @@ -283,6 +283,8 @@ describe("channel-connections", () => { + + @@ -352,6 +354,12 @@ describe("channel-connections", () => { ]); }); + it("unlinks Battle.net with a post", async () => { + await click("#battlenet-disconnect-btn"); + + expect(posts()).toEqual([["/channel/enimaloc/battlenet/disconnect", {}]]); + }); + it("saves the OBS settings, keeping the stored password when the field is empty", async () => { await click("#obs-connect-btn"); document.getElementById("obs-password").value = "pw"; diff --git a/catapult-web/src/test/js/game-search.test.js b/catapult-web/src/test/js/game-search.test.js index a202ceb6d..298c6e195 100644 --- a/catapult-web/src/test/js/game-search.test.js +++ b/catapult-web/src/test/js/game-search.test.js @@ -21,6 +21,24 @@ beforeEach(async () => { }); describe("GameSearch.attach", () => { + it("words each result with `label` as text, never as markup, and picks the plain name", async () => { + fetchMock.mockResolvedValue(response(200, [{ id: "5", name: "x", releaseYear: 2004 }])); + const onSelect = vi.fn(); + const search = GameSearch.attach(input, results, "/search", onSelect, + { initial: null, label: game => `${game.name} (${game.releaseYear})` }); + + type("wo"); + await vi.advanceTimersByTimeAsync(300); + + expect(results.children).toHaveLength(1); + expect(results.children[0].textContent).toBe("x (2004)"); + expect(results.querySelector("b")).toBeNull(); + results.children[0].click(); + expect(input.value).toBe("x"); + expect(search.selected()).toEqual({ id: "5", name: "x" }); + expect(onSelect).toHaveBeenCalledOnce(); + }); + it("clears the results without searching below two characters", async () => { GameSearch.attach(input, results, "/search", vi.fn()); diff --git a/settings.gradle.kts b/settings.gradle.kts index af527be2a..51386d80b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,4 +9,4 @@ pluginManagement { } rootProject.name = "catapult" -include("catapult-common", "catapult-api", "catapult-web") +include("catapult-common", "catapult-bgs", "catapult-api", "catapult-web")