From b252f9fba73c45f5a07ef1af51db4e47e755aaed Mon Sep 17 00:00:00 2001 From: "catapult-counterweight[bot]" <349249181+catapult-counterweight[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:49:48 +0000 Subject: [PATCH 01/54] chore(release): v1.2.0 changelog --- CHANGELOG.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) 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 From 0118885f029dac9dd131cfc8964a136a73aeb8ab Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:18:46 +0100 Subject: [PATCH 02/54] feat(bgs): add the catapult-bgs module with the Battle.net service table Java schemas are generated at build time from the bgs.desc descriptor set, with the outer class names and Java packages patched where files would collide. --- catapult-bgs/build.gradle.kts | 148 ++++++++++++++ .../catapult/bgs/proto/BgsServices.java | 193 ++++++++++++++++++ .../fr/enimaloc/catapult/bgs/proto/Fnv1a.java | 22 ++ .../enimaloc/catapult/bgs/proto/Fourcc.java | 34 +++ .../catapult/bgs/proto/ServiceMethod.java | 16 ++ catapult-bgs/src/main/resources/bgs.desc | Bin 0 -> 151885 bytes .../catapult/bgs/proto/DescriptorHashes.java | 59 ++++++ .../catapult/bgs/proto/FourccTest.java | 27 +++ .../catapult/bgs/proto/ServiceTableTest.java | 89 ++++++++ settings.gradle.kts | 2 +- 10 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 catapult-bgs/build.gradle.kts create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/BgsServices.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fnv1a.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/Fourcc.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/proto/ServiceMethod.java create mode 100644 catapult-bgs/src/main/resources/bgs.desc create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/DescriptorHashes.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/FourccTest.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/ServiceTableTest.java 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/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 0000000000000000000000000000000000000000..a86ae4c8f4cccaefc892d4ca36c0bcf5baa4658a GIT binary patch literal 151885 zcmd44d32;#b|1z93P9DvR{iyc2D?e{ zg(^%{p~)UC8EHn*XrCF`(rB?_DN!QHS){R~D3)zSR%AQQv0~>qIgWFpW5tq2lB1(P za*{}ne3ak4_r33X-%?w%!J*C(r}5QW?z`{4yTAL?A04Q<{YGQIR=IT8Y_uD@M>j5& zE3Lg|^|0M&F7P*JT3X0oXyJZRow!o1RaU%)g-Y9bNhNRK<4j_1_~Q8DBMYHU3&9>+ zG+Ul~%ul2~`+xqEze;4jgZ}IO_J}$e)7EiRvR*o%@e>!*dH%OEqs9(Pd$&sam6;*_ zZ2oi}P^w%xtklbu`re(HVZfBmTVI?f)rrHS-CA|8Xf;!4Hj>ZC4=Y}G=W#XtZl!d~ zZaU6Qr|7fQ5TiB@+to(hZaU4AXXFQ~@v@pYsI*!DzSzEVSeZ$p-T2~M1aUr^1!?2D z*ETviS(UETj}Byn5gs9m`W#w>M@dmPJ;Ye6(!5>WtIUk@0FOsND9G>OF-$*a8Bn~} zuGCvt`pg&)@@Qm`E7eM^93H~&=e(fC1V^oz6gG2w@qEk>waO+VxRp1JomG`-HIABl zmEvBbTq#!THySf(9^k>q00nNijCQMNmYy`5QPRNfyM_lh)tFwT38 zodY#-yV9)QsO||+v=38@FGV`X;%ec#)wR$rwJVp^&c^jze)Y;~ZbiTh3_ttV5^CI9 z32{`&ncYfLlZyV!xnHGk9Mx(qMjup!3t4N|8{0o%Fs3VLgHd7@BJ;m z9b)yBGDMjEP5yo(nM!6x+~?GZ$WokXfSOxg%jUD&tD75{`&kx!fPWMnhqW>{FGE_vzc zd`zQ=P%*VQ-`@ztsx>-+e?qI^pKzAWr|1LYAGod%OD$K7UgiDmfa#PgH%dpfcJX$p zc2t45r_K}l<8}JxJgCNn<5n@odn}Dd^1_5y=?iWDr&>^xG~43o=>t#yqR%x%5x5Y) zyfCvMJ&iTiXZ4XT)n-lrx>SBjK1+vE&HdFOl_X>k32eW!l`F1n?kuh4G6^C9{%~b& zGrOG`^8Q}k*nZ);%&^yCM?NI&2G1^LM!*CoWPsJTb1UdF8vKGbW6q>XbDO2j&9zL* z8?&&TU){KxNqb{n&2R2(Wt2C(smd|bF zvfH^8;LG~<@txH?z74s1>QtPtjXgpZEfAL&UyRZMj}@i`_dgw0Cu8*9*hyYfBSOo< zv7d;L8x6#i9o7$|pRKxzoQtZiG(;Fk=Nlu6f#^;z#6l37e1V1x2*D2NA;R@{yDT|dELuaCI!|QQ$=j9Fr*^T@}sSGvr;;6zh_9Dj-4tN z>xV~eQS?RZruhqVLt@)UZSQx23|6yz;s!7IC??T0zW6|dPz9JU)Xqm#X0KMQ z)Z0Y?yjtHEF@AaEMyb}SSbESHDuC&{@U0ImhZp@XdE$T8?Z9g@4r*Sm5SE|po zD($&%Hd^7r#lzhN0_=$evddrwGhni&xsT4hlPi4lT%(S8%GKM|@=>War~L@6rvT5< z0saTT5l-O=Xf5PV&|vF&k%ZP345`z;*i8OzvQv{|MmJ80n) z)IUmhuJ<#H?eaWl%5M$^Hm*l&1LLMBo= zpWbg68hKuw+}kVin&B=o2%5>*z@FIK`EbbMB28*R8{?VFwkXt9)&7;cF4}yBX zH!=4oWAmR+gd29DUYHqo3=K2&f_9tDYPr&y!z6Rn{d%KWDKGG0AoLS4w&t3Z1GFoL z>Hh_b{_|6NdpSNaSq9dbhnsEpQ+q<&MJ((_^}U%#u!3}cnttHZy2YQJC$QMo%~JD_ zbb%AFIw+bZ?w&bk8`te{-#H}s4|B)4}%k5ZTc5(OV5t0~-wSXyk9O~P6#KNmN#6|3-nF@S zY{s#BSxwj{(Mc;gi%=Y)4=-=7$%Kuj3EXwIG=eE z|9vd;c%IM{IWF#B4y&o4N5*+ooz+Ya=eS*bx7uVja!?XahT-4&Q@RyQ>9^1ZOae`u zHT7V<0ciwwS8A5wO)`G+VrdU7vju)cBGnt^e7yminH&3hKvp+(f~S>at=G{$t+131 zN{2YD?dBb}wP5(-LyrD-uB*rVgI%mu_Dg$rifmZ1OA!vy`VIKF+bwqd2xoTv9&6m; z4cJ|5a4wtH0dE6&LdO^T^fnwD`NWd|OnZ>PNM;oO9nFm4zo|?b|4nC<`#Y0rf|o^9 z*QmGP2^-lX6PG;^Q;QErR2RGT!ZIm5mq*1W%S%u4W`Kd*0(a$&&g;LNt7`O9EgoC+ z=co@UF!mb-9z6X;4srjP32sBq=LXT`0iZK9d- ze_g{6{nFP3m82j>P=6r_&Yzu&3YPuu(S8xEyR_F1N z1vQ0Sz)y9DPCN8hq*H!ckD~u>1L(sT_MkDcNl?olv%fy1rh*H@VRFol!Ask0l#ljs z;7EDzHqQy&XVvRs59ISaKDqr2wBl<_m()o!060nU-BJ@C;eA9E*+{T{dP{x*t1;w) zbHnr1&jx`AXvht)Z)PULgj zIY{WujpEM6n;V;Nkqb5O*$D6iHb3LHE^O}P8Jh1OQkn3{bPBeZopMG6dup`(1r#`dS1|^6$XQ7g|Umm%qM>Wv3uT9J|wcZu{EiN+#V+#5tLh?k^{xK~a!O zP!{AsbUZ$kQri7rhtxRvT@2K4GL&ky#=FH*tqQ|K1gYXrS^Cni;ObxZVXpyOrGx4| z66pJI>ORnV+<%P&6!vL_@JECZt61750&POy)Du<-0;+33C5Vx9;x_t_O1hX6=>G2s zHKlEOL%}xY+(9rGRQVt3v@;*167Bx_YU@5z0@i6R_wI`qm~`_wqYNnB+6o@Z2n*ay>i<;*SlZ?v~1gS3`_H zW<3y&C)M=ECaheTy16S?a?9HVi8y(!5pjy6!5>E~BPIw9HA7OOBy17YwqyD%kQ-qO zp?jS{1@{Qh45b}bMN>!gM6a3lzjNrrc}h8ljTW+CZc~zt3IEZ7P9kSY{{r>PYh8zV z8EQ>R%y2$qe(_qvrBQAmAFHB4AeNm#1f0LgfaIMKk0I0_9e13_kIOe{g7{x)f4@o7 znt{ZTJLP@mhO=*|l)(dqBJ02jyeXW|Fa#wlMIQ)BLPx3|_-mshuEK0n5PudeIAzb1cszyHzLR};gCRp%!e0yCadvy;S#*H6Xe=lRf( zNFgg~=?Mm8lmQucMwiz%3%L~rBkd$Nw{jaun$T1F`D9;7sIyHZ&yg1rK4?M((9kFw zm3eCMnW*LEPoCFZ86`R0ap~y@O0pX(8Wupdm0f-le|gZHHUI=halRckG~CEUWK}ra zpv-H%JSKqdLqwiu(2ee)ks`YTG;BaaWRw65&Ww1+2V;t6^SfgHjPXBtD3J*Mlig6F zy;vSH9%pte|(+az%lb_iD;X^EZmL%=|Hqk@ew)nLl9!{_$Ae(QsPxIsYi3&S_6VvBhaq zMJvII_|4Q}uG?<VA~+RwjhmZQ=)JLiS=!(Q zfL!#({7m8j=bR=QNJ7K7ifgOutJ?*$u&w;&_NM-Z4Kn-jC;om|bIks90=Ye2;+h7= z{SGjp4rqmf^UzA}N_J;$yI6Q+&GEWj|zVP3*; zUfW#GuF=jQ-$5JV%iEdc8>v)6vW8~wD8~ZXz__QJD9(`6e9A_EQHKD~1=SgM0-xQH zFbC898`LE8Ku;LJWMxyA_(cHjneA{#kfgwPz>P={fOTD+j%D=ldpu~;M~VU`VBB0mVvGeRMiP!MP-0tH8erU2 zH5Fvnknd-Awy(TYyq;ZKUExfx;6TE&u|NPyygW1PCb4k|x1gp&jMq7@X0-nRR(=F3 z#!!G213b{jN{;E9b6>70<#SZh6yGSoR{_qsp53?(Fm95GuMJUGNt)W3%)z@x4znipO|h7{zyTWW8NE$oNM6z z)dOU`YoA$f=bG-I$K)YO?*)bsG2F#JcOV_~5VL=dw3SZ4-j#oJL!HtAQwsDKZ-~%q z@!ERW<|nO=8m5-BM0)6Zq=K1|Xo)>Wg5roSW(-795}6_VcPKOLr%fgy&pZ9vs{!T)r%xBPj^03st$;30VG?YO^g8ruO2&mTKIKpKO z`A&aRptn{jk(P`ZHbrXg5p9h_>63A(PH$%nx4U%j%pNw~SCM z(iO`71e9{0&Y#r{R`lvgcnZkK5&)@d*P7kBcCXFg534HmHld+aflRe{sY{CQ@jaRI>kG$ zQmx8C2^QoYyQrrYqseRCfL|!+-g$2v_o7OfuO3&v&lyw0h1@n+7Bk1yYyrmdKOa(K z=1_zE=2lii{^*m4UFeU(MKhltgDCSpvcz1>{9*Ltv&j5KlH`Qx4te@4_+9AJbD2L5 ze|jPFCu0zX8PjoRqp+Q&?9w0FAck)~KT|~N=5}#&W9=QR$@*c@`e6t{|E%fpGXC#H z>&L(G1Eb-eU$TDwsp!uyTR;Ct`1vGUvl=_=*|#%)h5s9y-%*)}stDaDpd-;n+(#Ob z^_A5iECqUi^;J}+%+S)eBaNnU5atR?89y}6DpKoO;uBBnyJP!}8ZLA!G@ASV?KFzA zwl4Xx_ru*khnYMye06K-7lvl9@?EwC9tk(q7V?!FwaOlyEcLe&>cW1lu}c9Jb6=!& ziLYbmdu|iE`z`tBUZdtdr7}CX(6nE#l;Nh3gvxO>3`xSVsH8)G;HD7S(@A$8EU{zT zVY}_H`xqPswMHpA=8)ZPXldx$kowb%dNc~PSk(;)Y{x-A+w)f6Y$7jISWhR|vd z*_LpxYxf}!Ep?Te!h0k+P}10Br5k1+xIGTE&f-^#$@8wQ*~xSS_<{K90QLK;Iny zyjl*<_&g-XoiyJ)F`g$ zd>YO|I;1I!Qiuejpz#5Y!GujIM4u7-o0((!dXysC*T9rQp3o?y9gv6>eqHwLEhJCJ zA`crjs+|fFp*$blvJlUl#y!jT_G(9E+Ogq_4sIIHbiyqXVuC~WqK44mNjI-f$DULq zy#z;|2?A{~@6eb-E@2{ea|R^c(7*qqgww_+{_mPKa#K; zGr?|>l4Hx)vKt$@HC)4bLY?ivZ^G+<_?<4SyvYa3h@W%ngvqslSa^1^xLa+DyqKSZ z7seAn@D5yCU0GqD0~T~G_jYFJ@{SUxxV{?Ha-tVW?8#=n_#1LyF9b0d5{Z{LIIE>v z?ls((^l%{}4Ks|(@2C^4@-6K)*8;xR*k6tZHH5yUhgCqqS;8HD_~tcoS!05}@$*CK z@uI#GXU@7W%)7TehYkh9Pex20jp3*Tx}d7 zmInzh{P|1}H)g7()c5|ylgZ1l!jacLy-)Ent1C&@@x@+Uhn6mc@QS<;T8Y8uKYUr8 zG^5%GJsaR*y2ZlnXJ%ut-!~#>``Z|>taMh8#0VeKm>Ls|I76BHJlsC5rgYOaT%oA9 zDo}f+{m{ip+@*tTJFFtc$6J$hZ^0V?MCvZrkSNS%cgze(3mrRbHujsP1MX{04g+u$ z0Z(!rkWMqXG>MH$gO88Eb@NvV54L}Zq<=jVY@$jN;U^!w#6>b#M_`CoYdk?(6H@|8 z^5{*5Ol!LHLHZ~OYOom#(Y4GfwOeYp;mm56_L))pY>giXM_;Bj1`?LcMGg$XS15YHn@ys#G)Bf8Eu2or7bk&V=jW8r>^=3&BrNv#VlL)3z>Zm|A@OoQd7i2=~>MJt*Hc&Gq{;iy5V7vsd|hu z&)n}EQYSSD+G3Y1W2C-`yeCIFnJIH><_3q&qdU&Pr3g4_VedJg&=dkF_v_!{c3s=o z7u0EK6heSpp9jA=Vf`%2l*1^3sUB#f2Lvo6iwpwG+EcL05oF|T8UQ-ih7(QfTk3P}Y7|cl!XJB-iQ4BDm z?tY}>K%F-3nun$+Z;c8hTN=b75yPqBL zxFsivt%CbOM@iw@3yENg0T35?ydx+Y$wp*@k?Ocr5e$Xor1+%9h`+P4gnKwFt%T>2 zFvZ;nP=}Dfjom3+SxNTaN<3dg_IM#8)5`*8aR^F96 z8Z@ohyTekojN6w|*Cpk^pp~R8utB+2sgEw*;h9=|wj&OVKn>T(IBNNN!DL_=0OBU1 zEw~km>z_i_TpB3_rE~($pSJgvFR2jFNTg~Vw2&5sjKMuxC0~Rj`9$?n>iezYZiC#0 zCb&mcz39s0B*LyiW#ME16d-%I+HBuMLQjQnp!&#ORP%HxiUc3D+~yq<3j;zu{~_Q% zJjB)H@(kVLNa0E_Rh9w|Z|Q3pA#*{mmQ)_0N%tQh!+A@DFH;{dfVCdAD7=LGk3L_< zS56h7@LmJicG%P6jb;^vHYCiKavo9V(dcHi3>_Um!77s9As|pUL)4^{^UeCmv82}` zKy*iJCBK^6SSdKJnr+l;caWn^nc5nLcX5sRu+k*G z0Elwe)HLw~YaB|MFeBmK9w$7SzxBv4AP_Y$ACb0_e}`i3LoVW}Cy{c;dI~P0k($ED z%%~J`_Rm09WJIPBDxvr<&IxDy7jHMf z9JHuAqo(L5B;J+vcU~PGzQbAx>I4P+j*y*1b)zVEpPPTGf&10_&BoE8KC1wVhB;~> zk+9jgfe4yDtsrk|N*2>0ij?7+bfu^-bC=~-p1Y*ZZegl|p2_m2MxmhiUVI)T{5p-3VI#HWtQY1AQ6rAJy6U2=>f-$;+1L119r6As!@l8L{;w(HFXE zNyH%*jT72RLKVpcI@^IJawdK$p-vk(aFeHmJcar`1B3|;apGQ&{J{F&B;1X2o`znA z33i8AAk(+sl6H9#>c&G}OCs;srfdgd(DP8j8elDeSkDK1w5zq^t?J$_CW%SL-4_ut zCYTGXx)cBA<>RP><&-t;AgF_{yNFcd6XA)K(AE*PqMh<^NaT~WZ6QJog-EGCas=q| zB+*cc4kZtHK-+{LDD6QjiubFreD;bt|DKjc>hXm8b3?*-3XnaRnOmJv3Zkwlq``cG z*M>4lUUmU5gl_$Hv-9+rRuaM#PAtwr9VvQyD2b`pZnOC6ep3?&6nAfXw5dhXk#eB* zDFXZO;RMs!V_YQ3myKaa=aI^VAvd8Vb5{48uwikOLB4HBkpt>PR`M8e1sa5u0?k=g zgc|j#b`D8IGQOTv$-Pq5glIxOPHVR1>}tWircN?_2);esKx3~S7E9$an1`4yXjY$@Q!iP{YD{$tY{!> z)mGcGt>qy29t^3iGk~G*{up2W5W`X*4>C1+q%{^UhufdQh9kp*l zxKSB4h<;rFM5_ka&DN(&-bdlqWbp_3G*N8>?jwEt+@DXX3B7IttpWqX(K~0k2((|M zr+whd4cyb$Dh1oIyn;vo$|4a zCZzD%V>n?;l#c2+YO!?Uue$RUa&3^k=YAfJE<$=}RB zS5t83RS^uH;t(PlA>6)*<^@FQwev^Inz@d+(9(_N@6V(3R2U6%$i(-EUVTBC)Q7 zysomT12AL474jKQt8q%-Y1U!YLC(=C#_)McC6A8qH|(KbGZ<`;$=PG*xQ$hSXNFQMGpD+jioygU=1BOh?;}2JLOz5$LHNmKTc#_umYw zaSa|z^Po?GK=MuC4u1WxNzHik91#Kk1<- zD(E^P207W?9YFk#+ySRZDGN&)ZE%TN84|-()QQ81*d|MSLzt3lBt>3L{1WkKkk2DU z$`*daRmWQ64k}_&1?ZHv5@I|ZSE&f4c;ZoGhKPoXW{jhRMGwvxBsCPwIboTCS}cH4 zwL&SB2lI`s7tS7q`N;<&>7vHd1BQULo@kB5MDtOkX(2VHMpmb0k)2PY)ma1bO#``B zgpCd`{yYlu&3181p`ayhO>S(XfJzZr{aH90yid4oK=%dUYVba7!qHHC<|SmeqadYp z+`;`Z{x(ec4f5?u7DWZ92!PkIu(Q03s+%YP;B}-=#r2J~%&6bYq}RjQk0APw52;6@ zL3DBoyw3>w6=WGwQiNaQ4%DtQW{~Jj{vu=#FfV5NDw;0AtmyJ})YjuH10NzMTC&ob z$BUhZ^LGN!EV-coFOu~9hJBc`uu;FQ0JDJZ5x8fOKZBL z2$$L1@*2EW+eO|(y2FFAsoQk*VP&;IiMJ~tjs-Q}snt?;dwUJnB~Wp~B)BACK0nf_ zeIVOq2t{Wf?$mOXGJolY%_>TvGG+X6LOmU`c=(QC^)#sv7-~v#bw-_u>I8B&=6@Eq z4g?hx{GvW`vzjr1n?w2B)z!i_=vsrs7h3K^^l&!(i}@SaNg(2M-u!KP?rfMZbZ^CZ zT0LR@j%$HqxQ&|7%Th^M*7|>qsK=sf)l1Y-hq(RjBX_kI0nHAEWcMEtNt zlIzI}-jm3=rp}sVRh>Oi)D+9Cb1EEP=uED}u(_69Q8Hq+2?*bkF5jgSWs&e%L#;Z} z{#lz$lNf@%kK!|uSt~IJGQ!Pm`nGmMJfQ7;p$_jF{Ey^i5f>*cOr2dL z!3**NHfpdr#K{M*+gZpK=yTOJexmS(w`dZ??1pUYq6;cGY2mw~PH8+*2c~JtS=WA;pPfa3DoYAQ%#PLCR;9%~EvT*CCQ^hk)g zIdb$gxfu5LeWP&+RJv;9`(*#Wg9{`8D^!jG&JjNyo=XV&$~ZQY@2!&#H%Y-}3tvJ& z*krx!sA`B%1V=tN9a~e=%evAy(G1lp4=6DaXmFi_3y3m9f^FwMBsZXN z1?}tmj2gH5AUdFv2c`Z`erp-*8~KLeui)-^lu!Wo!VS5=kBBNDZE=XWTJ(EyDZiOr zSICFz2%qHouJ0mXWV||92IP%=5mg?Cm^}RkVJ5@_VH}mX;?1Ek%u!+Y z>1AZFwFFm&Dx7ErTM#slR6`-D$Szzi!b=4KKkx)5O))e<1WC~pxgezGH2mjjHQhYi z>nzdwOv9S(OP`wUQ{MESBo>pF7s9DXFn3aoLleb~+O5rvLJrwJW5(tLIr+vWs!Nc? z%thbC)O=Q@fh;N?3AgT9r5^@Jpw7gkpJIkvq$8EX#@VT(TxTf^X?s$=E7oZAUvz4bjE`XKV2XUuz(;SuuobX zM-WKKUN#7W&2|&gj&k9^1~ij~$)4mTWl^n>uZTzyKWgG1FE(i@pUPY*nV{eOd<-%# zlJ1O37U(7*&Khp<;XVll=br9g4I?EO;p$40d7wUa{Br~x-WEc5yK$>RS#{JRfH*qQ zoc%GaZzIrwrX)20WmZoHFn@XsJID3gB$+C-H9odMCgiaKqtPJ&u{yz<521|czzBlV z(?&9y`iK%`3yo`Ud2j>B5*{NO+^b^!EHj;FkQ*)6b7WAMxa2CG4Q_N`dys)d185}% zxlvTCafY9|qL8BAI>gEv%`)4+6J#;NZ-gs_@Z3qsdlls2y`q<1$ub6H~){(5=!HcviTyrJjd_9|{|SR>Sa(FzNQ;PxVri z#18{`2>M5eckd?AKf5;&!8sG2O2`U!5#^gwt8STg#@SScKkvNdUc6E(?Mqb2z#dMn zr1LjWawU=G4GhbAH9fCk{2lBbL*0OLIy;NC^x)(#3*w|4Qs+RQ9>7lT!o7{zxN$X> z^uTgCcd&tfpS-)3w#FcP-wm#wv6IIHQJr$AIei;j3odbJB?3=IqFo>LEA&%Puv^SU zgT(%LWAnTSWsg!fiA(fBd%iFK3zXUyUZ%z#u0WAY7%;qGKp}oS5GbDniB{TjvnUSj zcqgKyGnNGDoij{z_tmmFTke9o&|z@BSWj?>4w@PDHMnnXwR z0*nW|bMn0*oNSmbItP&~SOc{2N`N*73>@HHsQY8_pwaeHkVXawZWyqHP6h<)PZ1ac zSUUNyWt~isXhwzTjbO4_ndzFX(Qy!6;gZvG`S*PaNk$qmcXMF5%OK@DFlq5 z-7D&x-d8Ih!{9NH9@~|}p-zoCCxLM^fFC0@lG8oxpmg_mtTm4N*Wq;3U{a(nkeXwF zy%arLT|ve(1or(Ip}!6coOB{TiG-+%HazXN)m&$=^xC)74ATq;H2qv24s8m6og)i$ zmLm&xQDGUgQYHo|`eyxuiz5s~=+i z=OAwp1!UlfM$Efdue9H7G;fL94Oc*kdc*VTWjybNHhbi;1tOv@k|1bIIVLuO7X$Rt zm9{*2vu$e+GMI)Ac^Za0SfmiTQm$KAv3nW=*J>a2K}6C3ClWzJM7AK`srMM+-GKZp z>akvf>m__#WnkZ!No{g@F{r{vmk|Rruk2sIL9S8Hf_9@pr7fj0&HK!EI2TS&rxVZV zhmlkWI^2WkEOfoc&L*S+Gk98vlBAj8yMK}h=-tt!-J?u_E^CZUB9Mg+CUko)_N&2b zB7rT8Wafyt>MjX1L2!B)7_r7Q^nas2CExcBx<7}AtED+<;S+%U{o6(>v0|`0``3{8 z$$yLg_oR1GQG4y2J8CLuAUd4~4K~zVdiGMs6B}+Xhu@VOsgusPq8^#K_2Ddb93{>= zV{>`7^RAiwJS}QH;d&>``nPvtU37I%&}g$F zu)GHxm}MlPZNlVS!Ol2~X>Gvhe77LBQvj3)okKN_W=7L?LV*N}!&|M)%$-f{K;yuS zbIy-K7HG&KI*{{HaMyzK$Er;Rn3wOOX8^K?obML1gQl^x)GpMomti$F(DdBNKpzc9 z3RlIGDR(QOcg1LM-FL;bGE;Zm6~A#{?GIB8T2w&`>0Hh;-H;mA&*a>9<`t3Q!GhR} zs+rEC&JR+aew>rQdA=JU9i0lZFAUl(0MJv!fC*|}Y3#y)gAL7Y^utwb1ybYUX$Sd- z5BNjIC{``!W8GG#fsX5FV0-PZ`v=gpF#8G!_mX;K7h1eHW>KkY!JJ(Uh?Z&d#>+#M z3o_8Q7r&fT)4C0*0`xc`p2t4DSvMVtHHw@pcOgi-wJqOg&jnX$$Ad%~B%C94ZnGZd z#%9Vcqz6WE_tOqJby}r0ZOcsAMY8x)_Pa>oD#PF^ZF=F?;%;WT&T72{oMu>q&6?aw z%sva^KsHWS;^0y5yp$lKNP#GpMy?HgHgTW&#bNabV%3)I;o1?U zFqnLthXTPbzY&>O(s?%G>A$5WmuR#~8HeN!sLs@dfc*gT)UJ|1Hie&Ti3G|$1Ue`m zMJ_!d!H=U)eAf*n)o;*SVC)#cFXReJx{J!;$z&?y*gYSyJ(GTT5znKa+Ntwe2I=)E zD(l+4vT)GsbH2L0K-kz*3=o|;QGi~@Gir z-^~T4hi8kP)9e#~H$nhh4sx*Ajp(rDi~ov}Q;5oGpXvXZzkVxmf&he!ASc_$Own%k zvif1SpGEK`G`&WUvpgsOF_`TQ!qDsrn9@t?xgb-DMQj9lhD*&PnBKoIp&si<@4;y0 z@sYcO^c-U?Y3YvF!A@A;L}G)s13{mVe%BoV%q84Y%CSr{Gua8Yr?j=hilDoVYb4O+ zNO~JZ?;cE#`r5oB!V&fK&XylX5r=O`Xm?&%fU^qkT^vd!+)ssgubC+yo+%_eV`mB+ zPR;wRxn5Q0xA4R@Jzhc=D0qEzRLz{otmk1NRIE!DQf?u=__;A;NtzRGJO?;pmi->G zMq**KXhjtHkX_lUlU4~u+d!dqryR85``M8wVa>*y@iGh5z4|tlGE^2e)M6nvjWuq= zZi*g#0*f4$MJ^HUg^a>xO6+J>djmwbMM6n-ZmP)u2{{83T|YC8U?!-f7JOG4O|AAF z$I3=A-+StGEa^KT+awl~u|4})=~yN)yABDERnHD00gMTwd}zdq(V_rP0X`5E1xW10 z)oi*F6`Qc4e}+C4)nLkaeOY=V0}a_1oUlvLiAlVaA!%j8>dpp5rk`UZ>MZ$!$E<_| zQdB2R-7c13|1^p8nl}9XyU6PaXw`u%_qg;K_xBQN?0N~+4C-yl;o5DWj1`IkdbM4V z4_1YaC7%eCVNRH)BE9+}!9&$2g3m~i(wycIQ5#yfq>$+Zh3rg;K^c`YU^!dqMgx@` z_H<)OYz0V1{`{04opcaTgxW_nsgNioLr6|WhajQ*8fwiM^w3UlS~I2erE$x_dp(sh z{7whALL%u{f?*rf|o3oui-)f%6jm-qVhMD73J43^q+Vwh0BO~e-_s_Q4YPZm0PBZ1h_9T z7fRWh@}|^@Tk7wPX`>B={Tp>M+N$;2Rp1eQ=J3p%p^KwP z&CBrY9958g=N!mL>VUb#;`g9TMcsqa4LkS1tvxdb(*}{WZ!LPi^1Dh>;KTnFVNeS@ znzA{^JIxt;ww=ir1h^80b-9epOvz20A4!$*6bLN4jE@Xl;VvCep}M$fh@GVA1}9|! zhR5VMnjUlhKO9nL{XwC&6j<; zwp+>JZp;Q>d%;6MoyW`Pu5aQ^F;HpOW~*26gg2TEcW;I=_()f$t7Tn*Tc+zmbYp2` z{?`x%D;(jTHm-0q>JdZ~cCKE<`vLsYZ3q!tc+>!h!Rfy7-_K2{hvLPU?QsdFURsi@hX|aFmo;(HWi~d8Q#=AacuQ2CzN*G_ zz1Scd6<IXawz8)pH<^{g@CT^9M$mR z37w_M%R|q+;K>h?g?usFBm@!DU^xwSvV_Y(l0igCyCpnsPvYvvQLVv8BY+< z6&y$uFbCpMp^OfXIcu>4IjgtIy8q$+5CR{1W#-)n5jV;-Mrw>Cv3C%X#6YM34>?p0 zDpWgEFB;`+wZ$nx>CkXEPFAR z)TjWa1!w3kh1GeT;iv5M2^%I-Txd_t@@N_iPl@@NuYZ+J;1t5iRO z7)B(A+uG>}=tB;&w@^fXGt3O-3^Hao;AR0}|q;w-*Sw@#CLsW*IA!%w^W=}^gv(P;qyZf;jXH@3kJI2(7korc1OYik)C&c}MebzLq z!({VcMZjPg9fGPYo>*L7+gXBEAJFc#If>UH<9=PwtuNuy`&#Y_-3Nuf-lfr-tMpW` zsP$c%Ezz45q}D&9p6=4jfVuQeQQtL%ivOf~Jg&d)2iFqwKsvMP(PBx#dVn8 zL-Q?+NaL?p_nHl^(=K8L*KpAwzqxe{);(U9%xpR;y zOxN-Em0b1;CjF$E+|qJe)`d6Gyf;c^X_^XHp6bU-<%gwg0f6%}YN0N83-=lZN9NpH zQodBihU|aq+A6#aL^XIkm$eWq0vHQ^$CXZgPZzY1nEyUx6bbt6NR-xiL7Qlr%dQ8C zA>a|yH7dxUK_`o>bE9=!T!ux5g}Ca0F74>8S$QAqtEbe1F=z0AHOoFu;cOqGXSN@n zKHHrYM9rk1!ktX5B+Tn)5{Qe2Zs8lA;Yp>SRz!lpcX1fah7@7ZYy#X;RNgM!l0+U? zc4b!whtug9O`}apSv@w4B0Sr`DJcNZcSQj;#5Q%%!2L!p{YMp^bpeLg)ugVetA{4- zcPd;$Ei`n1jLAuea>{&_K5bo=Tf{&q|RDT#_b>k@G*KjfejPde0ndui;!7Z4htA6 z6^loR$Z*tx$4XoHAmKm?8Y)N-9E@{=3XmW&;=$kzmiZ1OQKc@^ltQ6Lg}XtTF|)2Y zwHS)8hW0j=nfq5ONk2-tU%?Y07C^%B#kJ4wJ31XcXoP$lG}67opwYPR6Er%6Dfe%J zZkBWfb-bH0pPcEH8JSQ_h^#-m8(jV|?o*+{F}z`x0)M`?}wz z&dGjx>g@ZQVX!T9-^N>PCRd@!q*AiEY381^{)L{goPQ5sR* zyC03QzlXDCd#R;;X_cfgOd?}!sZ=*-7n~`4H@BD0IN*ljFlr5hHZtx05u6jm=>Y^B zZRUNh-r7$Wj*k0AW1QKEisEv_yQ?um+qP&4tf{wV>#vCqOf=%heh-e)e-*{h$U)dS zKu?cRCP`pe0nka$g-iasG${+7PZ3L`iaw#I6@FZHA7Qr0D zf*xD`Ap;dwX@&~HAyncogkHzuh47|*Z93K$Ls;tPOh-1{{iO+&A)+UbIbWSc1zZYD z&?^@Rkl=leJ8T4Sok@8?0~xL+dqJ0jqaYAJuF;mdWaO>qVyosWM)E=}Qi&BM;LVSv z+o(y@`XgXBoH$QWQ-fE3vT+$-L zEhx=egGrVjzcnI{(^$~xXc-l!;YV(~9#k)biqm+HgP>fJ_TJ81;O`W&!uUj_=7$IP z2R!#;hM_C_2V!3NSbUIMp(=pa3Pt3pOeC2N7PjZO9ZI=*?Jydfe!67zL)-wby4Vdu zcCE{8P>9fO6SPksj+;kA!*=K+kuhw5xyhs4gu{*UUJAoGBNpqU>H^I!pp&I+){3e} z^cc+yS+e}}1U1{QP&m|Ne9T&%r-}HAw6Q8ty!=pq^WOAF|zJY(!#r{p-(aFG2o-$~jq(Ka7)qGI~`Ir(~4 z&Lwlx3jgqV`C&FNw-|^s-!H%2FE}uh>mPqWew_2kRhyNEjaHSI+{_P3i--J^t6TU- z``_YiPLdfURXx>!Bs7YkwJ1HC!$^_9m@kB14=N44O`nK_0*fov-Ywlhk;x`f z15=i1btL%q0J|<~8tiG#HX+a;1 z7E&WE=wkzgRJnhrUPZz+LAj0Ab`+hqkBh?b;UP0m=K9L8_AYyqk>u%8pxAv$sT?&~ zJphGM9gd@BjrD{ymDGE;T5eMfL!t?&cQ-3|y|^uI6;$s_Awqf^(c-43j1gRE_Z3tn zq%p5;udm4qGj&`pp<;)ms`VK5pF_(Nh$y>*sxwvbBCYOgvR!$_lp5+4s3Wjh`$sS* z-M)!OYU}hkE)h^9(q}Cg;cjBvvVJ3br8mjvldY(p(VQgXFIRVsl*DMm>fkfd^R9^)oN zq9g`_gMSM((Ctw0Gnb%}QGzZ#de?cbclmlpj+z>S51d4bB2j&F-nb;E_Hs2Pe$mR0 zPE*i})N)Vu`(grMaIhhPWl_l9Jx5VZW= z#Mn7dz>A)L5tPqh({P}r440uTBIviQ>(TRjc=9#teU!UBd3A=t?tz}!zEG<7_0LCi zcAZB79@HIEWBZEq^ydJeT(9&xxGoq$A4G!!S)+3LgdZ%OgqwAGeo~LH$(17!(KzW{ zOfjoLG(wJLDACH?a`9kr{|{U}k9`F>kh-|-=ATp3VDG4^2gVOfv2#!)1I@rrhuA`!B>ZGBLj;v-@Qct@b4t}+g|RTRR0 ztm_`3(Vm_%>F+_xIt#5!=I+apHXD@|vthPB+kpqo#v#*v9B)yu#=WJrcKDR6_z)>f z@=j9Q2%2Io?Tcch`=`~e?VZ-=$HF(J}ilqXj9MUcNtr9?e7FjAh0 zuxcrD0g#u}xUKh#6SjLeRr+7|vNYW98B(M2k#!i%=12G>%Eod8FD+PCFUWBgDa30h z_;{)kvkw~CSUBS6^A}AT#`{?`X`oX*V#wh|N8OAR;ZC|wJa431QKy!5ypaW%p-AAD z9(Du>!v7(V#FxLp(N*G)*sRr3=XgIq81?u#ZJ{4M*7uCTn@Q z1ah#RMDBp-WJWvfu~V?*>d(6K@O zFv`w@HIsPN&n<>ZDw-`0TGQ?)loFaIE26KhxQKfKY;9=559N>g)`@$Lm~Qq647aEjaI5a;DpKs2IPP5=(Ab5EYy*S z|EcgzrSK%4JA+Ut&Wk@7)&cU6&RsGOl*RhkqB)L5rSOucQ63(YTK4d7(^4jvreS7| z&!^EM%T-r#A#@nk*8w%jrO=zBhV7vBDqj{w>7OQ!_GI8g!0URyzROLAVsdE`v!&<9 z&>RS;xHr_q8r}b*hdl8Oj?U3`Y2T=%{Y~#FpK|O%E-j|W`8Slcz>}~|vsZCdlkY9+ zlvub*tdGlUxojRb9-iB?z7}$L&}aTlWxyx@3jGWt7O$&qT+goIi8Xp0^n~}-n5Y;_ zPs@#$cM98^>)KBIuHjf`s+GYWyV3KgVHdng)ai!LeZ8*-C7cWDT+9ukZOT)pXN=;n zDGkrBsp;$0R&|%-nUZKq`Q9k%ytKMT&)`X7GY^dfY<@&3`s>cp8f8w;zpFwSZDfGI zu?dTuv(pQkJ9%W7>n+2}N|B$q*_Bm#RS_u1`+4y#@MhEII`W+HXd`7h>n!Gx*nPm` zAzhu?vy#Qq;gIT#{?Yp+neepZ|N( zS+tI2^q!Np7lPV_chH2(hYp%>`UP+o3789~@1Q1mAio^G&_~MH)MDr{1jBYH4FG|4 zA+y(1CK5>(&fg(G5~fpl+I7@;5ASTri#L;`;Me$(9w=VK0S8s^DW*(D8zP9JNz=7K z_D_>a!%=-oK;S%S9nGhlgS6qhlYR4D_Hh5+91tU1Ez6vwE0xwydyQ=4mw&bM zuSj^^17c(C!TtTD;!929JutwTBQGjUUj{|c4>YJA`1R%xV2CZe60XyJh&QBx@+j9J z18r)tPp*M-c{&tL2Rl+W(6B0f1;hAn$O1R}fDz;j=m%~>1@~e{5S`eJ@U*CC@?cWv zeRtCx>Xd2zoXWH+r6%5D@-r8N&4i5JfKLyhQO%#|t?4vH*gOtKZilq{{m9&2hN!Rv z&)o@@fVDBGw=qOz;#PWDrTqjgG4Mn0ei}UKjq*>aFT%Q7`kh6Xkkj3YFsj;)Za-0C+V!Q4x3Hy50W0|~cpND4F74(d?{QaclJl;D`MCgQw`nGJJ5K`WHv9dUM=bx2hSNCG5SI*}^)ElHAI;rs@VW3m! zlLno?AZduM_q-Y>yS5>h;q^;*$HchZf0;xe3#X0=$a3?La)Qssx=tR0a1XFuA&J4+ zuCVKYEeFy4`(e8=q$EDt_d!ZTF!7{BG|@Lh_en_z#}>MGX(q&S$k?|v1L7Y{gGhD{ z#!wUC8Dh36lnK@0Rxks+>Al~IbN=SksIJjNtZr&CR0=aBA4I%FZ!){6NHM`=Y^Hj} z)0-p^)z5Bua3XzK3qR!ldXMqwVnUpeNWi9oXQH<%g52rDqt?x0r$LFarriI8Ld$d_ z^-_#t_S*Yc5!W$$MWtGLOm;K&a;@4xkU3&r<|dIa>HY``727Q=-tl#XlnvegWm<{F z#_xkunY}$`W?trXKRFQnB@OlTy2RS0mf6~mY3V*I>Oui&pX6EL98Gq~!smq-yl<3H zO3P%9!tqSxnRNe4@>1j3+Es7ahL+%^9`r^M(n>k$R5X$f_Kz`Sh2G7xt;Du|Dz$HMt^|; zYP2*YK5jvr5|bPlr}hwZ)9h}B_z+T?`>(xhscpXKVF<;*Do&=mIT^jZOehe}sq@GI zhiX!(9N?uoa^SKQi>vKPXivrx3mxE2#PYi+vq}eF7HAO)>3QD8$-84)hU*ATbwE=m zt|L^`p;K3`(=%}sm;6qd!iE9dl2SfNzs6#oSW}GOfwbyajz#Q=gPdFUsV|MICt~ga zi<7N&vw9dv)P5{dXG333qLzNG(!7rNb~}sNumxd zL>KdcOxF>Lz9Y}Z$tz^pmy_4U>mkAE3#MR!In|%=RC-apUyYVKiQNXSd=g&P(u7y` z;l9i{_gmD3;OwSm{Rc7m=TJi-IJ+(8?*jkkU<>{`oKc$5n#aX81-1`-OtJ0mTn}E) z5Zs3zE!o3Z{ovl35A4e>!YK^5yER1+TPhr4;M(rcTG!=42%>P4OiE)6fiEKtC)rq( zG^B-OX9wBCVi$TI$&4ofx89IUqx4sBiH{?ga| z{D`OqdAS_AW7N(o({oq7YGH-yF)i7NW zrt9qEi=hW0W7dKa8m6oJ`^fx@!Sd5;M084F5WRL4%{eu{Pr7?mozl)IJE=|HPKzX$ zMF^e_3CgVvLU!&S=w*gzVZdbukEiIay-@Q!KXptuAO<>N=w?!cn|z{U0}Nr@;4OIe zV8u}DU`sUv#Zc>j11$@Eg(!BwL2gSjk43of!wf{BJyrL>TgBIxXE^H>)ALF9!y zI#PUH47Cq%MSyO1^(7F3H!*n>UnO_{KUkC-VE$Lln84C^Lpp$fDOyPL)u2oLKJw*@ZBwBk#M$csn(+1lo3n8haCA1dK=Y3>Z~`V z&9!%;fi!9#=$fJcLpI-BZRH85j36mus4O+k1y6)m@a^%*rPD~-JuyGcE!R;FCC`jx z{#7*@?2N=$DfG)pmtIL;WXW2esSYpQ!u*d>jyg0VULidrEvb=tQblKbE~mbECMqu|^O5JgDxE_Q~fyzJqeQTzxsVK>@$7EnlO$%c$5Z zHHgiP`RjP;0oo4b_{zxi%E>CSO^er`lhohv=jStWx%#p?ahvYp+1`xs<@iVgO;rES zNaUq0V!<{|b*WE8OK-HWdHnf{u}{i$@=JB%e{nZ&qxW`W$U9uwU&whY zu{E?cyp`M<*&5v%+e&Svx7617*2LE2*3{PYR%YwOma}zo>(th1^-B+^M`L`0cC@u0 z%_ICqPw4RU$Ka#<8MWh2d#Y|4PU}qarcv zKIu0m#d%700GSdM1_~hKwuZK4DOsW8Z4K>_7CYAc270eqn?1XgdyF*~QU$fqQRD~L zP2mR!K0$mBc4Y-`Pk5zsba9m)`I{iNP$rIx$L{Yp?yqA46jr_ksCYHHE2hf?s7@yE zeM|IUjjmM9ZzkX44Wk=Bnuer&WQPk!K^j6ng|6wVt7qS9s8&Ky#W)D9<0q7@L9tNx zx9~uw{C1oqc_p;dq(6rrM8eia;0+Vy-^L3t9ygP6!pB6LZ^S_SLRSVCg3H1&4*Ug; ze`KW|6TtA72M!aVxVyicGF~kxe;NmV)l1npmV*@Z>C1ud4kI0SA1L=1fl2?*V>(zc zQL7#vC=-zY9d(}firtd*ie$@0z!D3@CA9h;n&U#N<>c%atv*nUR{ziAL#q#E-)N11 zrO`?(UmS(Uf>N-2QMP&Wd#Mcqi2G^OI_Ko|W5VDGyGj9VbJG3$VBa$5akB5|#7>Z- zi$2$#qg!BFTn9NCdI7qA#j$K>%mS?92Lk;7HA7IEYRY(ibtx{+Juv2%l5t(Jq$G~^)N9qX~oNvFGy+ip78T*J63&MRG!DO1`V2Ag-z`F1s9QGdXKa$f5?w|8U$od%o#A*ZcQnst-; zVQ45OHOHbMjcLFAV6(bHx4wcdl+>MWL#+p!aZ%RkkD(Ppr~5i1?FXCnS$#ge8tVP5 z)7P&fidSzwcSY9ekI~;cBkc!U=T+efB1QY*3RcU(W-1F;kp5lEbMVNjTr4&V(k`y0P=o?#zhItj_9m zH`0~KzmjmiO`SE9hxA70V|~mX>ND8de=IP4NIC6m`cR+2rvLH4^dUX1ujxa52AlpT z60-{+fk%|Pi&A|O9ocKt@ZPQH4&?PQ3LElpp-?T2TpRjq;y(31pHxr91X~Wcd>|)c zhjhq(bFuG9_{II3xST@3M|kO7L|b;K03g+5pk?W#t@oc->;8{$oDhij#z*mto&7DT zC81<1v!^1&Q31$`BC!vHZrsB*L)s6=Dh7Y25(PXT&R6y-)!USDX&yPb7h4eYSIY7R z@>l+63H4|cw0nd4h?|n&v)z=0-M-zFs6qZvFiSYjPmQa-Ud&KJfNzJ~&J<%?iE_`* z+26rQEzb}j@;pPFd3`T-lud0OO`oX#yguB+E&F~P{t&m1!Lpq~GNBuER9Dh_BEZI| zxb!yFMI(y!vIC_oZB9!!n<<_uMVw?qA_Fe|cv=z;X(3hW*)XwD>@BeIMw1{7y@be=Q@<-MmJ!n8ktaNK4+dKP&V&YS&GY=;5tP3aBK zFyMjz`u;e0^a7pryNBqmaFhg0$nPVKC~)(^SZJ&L!V}wlF#L=F7F>F}odX+FikMS9>!CNPZODFa$|R1#`CU3Zl>`gW)JO zlbC%KNBbk{sm_{Ql<&MmkLofzFnZlDWz>`1^}1H2c^j!|I+f;LsdaIgTrDOXnaI7R zGM?unkMvf&gyy`Z#{ASw5_o;nB=J2T9p1qDLXKNhC%kWXOZ*V`Q7X)|El`X#R55<~ao@IG_Kd^(l|9;pY#f+R44 ztjH`aA70u(N_B8dvvIp>hL;6B_CWKAmYHuMJl};ND$$Wc0eUQYQAb|!Igs+>@<30@ zt*dh}b7&&5^qN5`;4^Qe=bWhCd$oSVdBvQYn{?OJ{lcvR+K9C*yFPjBg=kyRaUWF= zYgQ8&k21WdoyOMUPp8C!vM5&$zVp3!CC57*abD;6W8ihsGt9mmME@QLrDspUva7Wi zWbIbfxOI@?{Ce?YTAd6$r-JZoG*<9@jh<= zVPt)aIu&<-oaKH2jq7b-?S`XK;0UF{&v!d-{j0p)M(GEdu}ori4K(z+da@rK0hSTT z`T6M(ZC_)ZAp;Y_=nTKIt>$8MhNejAdy#Y7J@+}BIqZI8jIJ3y4SiPF)dRXQTh<7pFoV#4Gt~aEQb-P zKuPzLY6>^S>0#x&hqEIRcGYPdZ|f%dG0d^9&V=XCYc*H%Mc3=|YW`=GgW(93@JSm< zdKY_lH+30)6J-Gclk0_;v1B3r-IcP-c1mr6{>ygaK~(P&hj#6Be|E}4m$}#dDz2dE zhwF+|Rj!5Np_1#+pJ8u*U%VP;fK$_vm;96hX6!oXabD^k>1axD{UW6 z=-$Dj7nRcOW1vHN#AN(U)MGk^g(Di_*OwC_?-7k~zX(bJ|2j5G89YGZ;=x)AB2O@w zI|#y2`?`bBwnp*{w|l3NL<~o9U_(#w;K!^n3!vQeeijg!fE6P5U*Q6ca3a~Zfal^K zE^qL?b6)p(mt>W8ubpARZ}`H01;t$!=UJ|jadQmkWyOb9PD}UOHz9xw#6JdeOyV|_ z(sK-8z8nU#yIMd{7}&sNPwv!9d>!h;2OYwLp-P#@fW74~_Iji|##Qb_0j#rGJa%>1 z_5-ZP0BU?l6Sco)Rw}q%Rj+(KX2;U=mD{+q{`HvrJ#`Aa^y@M6+iVBfGiyd342p|1 z8Yn3KUTEYGLNak|)NQcc<*&(?PT@uMzCA_ugFjLSNo5YWo$)3ygnKBa?&5&^@EAtv z9dQ4r<2m3hFFld&0MWjTS_k~=F}s|xugA=C#_XB>Gv?z-zvtKcjQIn(a>o1*AM_au z%-ms7{hcv?qz-2c*0X*STh}krQM!A0!UnZS+1wLo%=y|h?<5-_HBw&m>WT>`eLU(M zwlbmFX~DH@@oLj4?F2I;-gCx+?2%MqkHO=z+sLL znduS^IUe9(>J92SKt|Cd2;oBm&$5~_;ArZ2MoJELb;0VQ1;kje5?o=`gM4t5{Rz%K zz~q=R3^>tr2j6iJ@D2ohTb=QYRtY;F17U-eAsB(kzpzPEcZK+!u_C=6IO-gNp~qHUAS^kII%^n zz`x_U52fjGxer5p0ed>Ad(eWnPU$j2{ zfnwxIx+TKvGbJ^~7taQfqTyLByF2Pk7)H%k-q+gy4NiKlTtyIt0r_A^VUUG87Tt(j z{x0YyuTBOQc`q>cE{d2?@QLxZRjs3D_y>YL7iFK$Vdl^swhv@(BqpS*rVazMlc~i} zN@NHuI$XhIrcl=4UF14+%KvcYyt>!baeH)UVIEoiJoK>Pxqca zBod!oIzN*@8s(Y!*lNEpQ0|WfRWA?)y7y3SvRDW{!R@zF2atlzQ-Y1dru zmt2s%erQgn%Cq}l06Ub(ai!Tf&`LKI5B^}V^Nj7V5f;!R)x(sZQNQ((*u2r;1(U9| zihHJ=F8<3)YMcm7XR|tQ+qvuA6R$jng+}CTe5$XhH1iF?O!V&g29{HzpV5bq=RPXR zveW(eOCvL8k5C0`-$w1$C3A+IZ8fHqQ>S}>-O5bX4LwQcI}+^CLZ7{6mEDg$O8(#w zS&hvk-4i3iedNsPm5n&>sd2O*a-jT7r~4LJEzRWJ^WOp;0yB@I*wM6=vPX%9PWP3B zS{n$u;dl%sh#ae*PQ~j{Jtx|spSe=bhDuV_oYOr8#7eB|dVC(hqYERP=;k~TG@ACX zTUOx3PKN6`;)4Vsam5}GS{ZO%$D4DSDvd3cOd+Q`>+sqcTy{^swwed%<{@0tRo;&+ zr_WK0Ct|iIfy-h%pO8K%F6z3R5W2APqI#6GinKRN??$o@qf*Xk>FIG>NwRWcZUCBY2VLG&(Vs)i9<(bt(m?X z(OxCdU?*(!Sf1UqpYgA0?L5Jl!j+TE?}s z?`Nip&w%tK-IX_CC&BT%GmT)605We_3`o!3eJss@g41_HG!O2B908?dr)Oy!L@>zLtjSHb3 zfys^-cb(&wgOn?}*(v>e=Vx3A0?R^*>0$61Q-YvZ#{hzBU^FLV9F9F8is@k>>PEQ- z&VOKTLu=P~5A8_Knde65q?Fn2+EJ*BUaeOh6Zf&rMP~y-_wn@c48hP{Jqq^;0P&*< z=Z+e;!k#^xS5`}dM%&>=AOR}#)X?$d{&qdgSvaB~Ah{6-6+QHt}Ha-zY|9uBN% zhkhnQ;muF0(}6IdiO=skE%Y>l2i@<2Fz@GkO z2oyur&vo^^d4q=Nx=&ENUI9@4Mq>7PL{pwn4|Og|VM0l7pSD>K0SF&fw9S@N%TrPV`?Oo$oy5}vW^{4 zL_~e}{kjytw+^#}Hk{SwIncgWFxxLu+%Ju&alOvf`i%y?7HeiUt92ey<2M`h*19Z4 zmN|iaOU;kw`5U6~Bb9?vwZMi~ubq+5c7`T*cbb2@mdmcJ zBZBe*8@N86jnE`7W1ns znGxp^HD~6(zFJsaTFVu$Y~oSV)!bXfEBV#j#!4YG>O86*GOezyuB_mp)hqeUb={zN zWpx7&p=QQ#rZT2kZhdQQ^POBSlX9O_nH&mXfi~#TV8TYrIjg3t_2P|Y>4>)DHtJQ$ z^TzIX;Uc@$nh3at_$O+%(UGJSvPmZsKBRa`SR-CS)}gS2J^|nj3)avA2nvH$-aEwm z%3SWVcp$lWN%x`}MQ+<_z2!WhhO1?H06DpI0>@->ew=@zxD)cfkg`6GxLdnfMQ#XQ zh{cFa|IcnvKdh3(4m3N_kL_ARabOFW+T++>*@KX>t&%iP2y!}CNfcCtUm4_&EEQ6I zk6O+Jb-Hxaz6s=4k#^H)7Ay7AZq0j98d2=lr%T>P;s+_L`b@A-6PFkG8EQgtic)u% zv#W)zTImi|8e?YjpgN0UA9rxV%13lm@a8zq2;L1Q?9s-d0ih4$e6`EG) z8r%v8jHTz{Q4?7TI6&oU%i4Dr`83m^zEnp#NE}Nl<;_Tl9A6CSo)%6*gL&(JHF!Eh z_G;$=cqQ&8lEi)Ky;iN?YB|WP6kR~hWh9Tr8q?}4lHYw6cO(1(JBrB(m1H?bt4`13 zKE4r-j+)BnuC8uw6n8e>+}M0;19}6vy7^WB=l1{1?SNbRf4-T|3|oJ0zXi_h|GK;Z zj_m(gxC+)grIa3dYdfFWMc&q={`>0M=2CVoQ=b31Bq|OQvl5O3&fsirZLe+=x8K>y z6<1e`ta(8#2EVVY7P7?gm$KX2Yq^cwc5!Q`a1G73A%d2(+gM{EGw(dC9*pm|oqKz` zxO^?Uoq5W+s2+)LB=2HVt73L-E%P*TaGvPdvaprQz6mLnS#Vxf&&M~X$HqyJ`Op_O zmsjy7_|^R8&Q>9F2``F09^X|YTXFM>C*z)hT6CdX<3i?H=SB5we3PyG>h&yMF6ULU zXoPt8#(uu@*!djbg&=+f&;CBy@7RRKSMh-ArC^7v+4Wp;CA*z17OrLUgc&@JUcfWy zMeOtPW_~48pZ|A5YC0@o&Z`+bwX~gIU8W5eqVXvkXo>u~ws{rQwUpi1U_AvoM29AY z9c(0&gB2Wt&I7G&F29K;!_d;)$Us`>Df*AICuv_J?UvVemWu1S^`%_CaBX#~s0ST^ zI{84Pu^Cj}q#s3o>iI~U)s5@fH99=lo-yZs6f6iMX?drxy}6!Ak$M#Q(M(ZT#mQR6 z@~ZQHI;6VEADkdOD8I42y8VvU0_6x63)|W4odS*}?=HAaua<;!Iy{Mb)M|;>ONA{vtp&% zeoJ>jUFh(;nBR~YBW+5e6!pSA%f%q{NF?C2OT%5`4AQ_4dIXe~tD+$f5V;A~ za;epDqZouPI$ASXW4N=Dvl~#G{TaDLaOgKZDQ=p#SjpCi3*~uQ(*<>MH^Uipq(z!} zKE_uz<%=HuNjwE_6x{J}KEE|Kb#m|LQpO=2%nbe(=iEkq)bpSg5Y;M%&Z?IYN(8f} z3Ju|&=YcJKiWM|}TFRe|{;KDN*@gl%w+vNuHrv7@AoZL!#rkYfInvK2_MzYzw~<~` zDA&?jlQoQ}h)Yv27I6K5+(L_ZD97CnuTrMd%XlGhb~@wEnuq%13gKLE5f({m3?9Q; zit3>*v+51Q?96`jE_=%=tVRiNXlOsRgma8Ez}i9`0ly3%$h<3t6;p0JIuF`)h(St0kZ6euyB{#{f3qqHGc2N>uE>PdHTYHHb0m z5QNLE_gcHv)c_ePw?5Da8ixYw|B#r?K?=UC7J{*y=!n?>ub&^&EyA&#FOR5;c4cvB z;=WUoe1+r-HKCD5gX(yoA*rESD-Tx-?^_3Ve<6Wltd0Ff9n2Gxw>VOQX^OrcJwe}& zU?z}u3qb8ce|8+I?`M1_K`yaqt2P_#Hky!a)&^ zz4nbp^FT)&rt!IqtG|E?QIKlc{Tf`%3@lzsYBbBv`Dj$fic`HFQh(%r{{ZR_+^}{q zhZkv9MKJGI8r8$13EI#-p|nFzWZbx=eY{ny$1Z?Y+O|L@0wPvk_1GW7CcpLE$bp+{}OmO956 zSZ)Awm3GB`JBSDpAuM?C_Xv^VR9~X7B#{L#e0(vK%@90f{@Gcu2+FOf^FG=oeI_t( zF8EwdSjXvbJY@=hD^bh;LhikGr3tde6IiucrM@3rABd|dMUt#@m?l~|EZwO!N{9(i zz(h_WeAW6ZyT~UNe?FPmayYeB0UFNOLBG{ z!YHdg8WKjGHZ7hEqEBS~?3kMGDwKj=qT@gQs39P7NelEqu+s~FdICdE!ju}-ac~hx z_p6ho!$ZVPh36p}MvR#w_LCrr$r=g1VnY`WlTcSEp{e6W@*&bf(|t;v;Xv9Q!uPg) zjh7M+@Xb2L6KIVGo$3{Y1}l;Fc1)L->0Tm5DbiYWlsIX<_&*IJ2`E4i1#NzQ0{78q z$>E$1|AfuHc1H!4-^k#WrludC@03c@cwcAO>Dub=sb9GKE5JQTu9_`S!DQ$~84 zNkb3)3^IUL8wDZ5`dOM+@9LoH4U=G3dnAE&Oer zeZ+R+K69z+y=!ZaGAsUK-__fOPjMGf>_DSO_iK$^XlE73vpSuw)}5 zxrCBvW#j~MSz=UEN?`G(x^~~vsN^QNzZ|KiZ4~A?IPWUAE8;SoG?@`scw5%!2h@{L zNC^_;ky=J&AVSZ!l1JGzaVX;2K;DgLl&?^sOgo`yd@C5_kn(C&IoN|Z#eBq}1cTf26c5jhfP zm*Q||NNKgH(T!syZcsQyQ6z2CM}VL$T(pSMAZXDC5EliSKF}5|TEIxrqCwjL{iEqC z{lopIzwdnK+7RnPbnH#EIWfh)7*!i5vZ^gU4Pou%-0^rHInM?-OjME_Web7+{-A@>;(X+snyUwJ zs5OMu%ao+x&$K>h@1xILxISav=2_;#5|g(_bYg?-(NC4t2+znPkB9Y>z`Nj_O8N=HuWsIm%q{WPHq`f8+haV0H)3B+@(Nb&9q2T@6Z2TH z;NaeA&H-D5hvP>QfdwWS<@(Y)OV>>_%lg^^x$jS?S?@<1E>fl#AMY1L4P0NE9wLQ(c=VC7MimKjY zsm=%sFZk{q^J6T~&GQ(95_XdRY1m4^0k5m^X2jd9gd(beCI`)6T_F+zo=v{LgKWT6 zHLf35{WRJ62zyT2d^TEb@Y0yu5>(Xl$*BysIk(a_M;PbRCZqX!JeQa=gddmG8OXcN z^E$}pTuR#zkvtJ=yA|52ZmObW~DJ8`W24NRq=L&ho|%$$kzZ)FXaa)kW{EC<9j zR)9MuZx1h36qHSlozc)ey8g2m3VybvPNmJl?t!QgUSu8z+K&oyW{heS1lA zoGbh?+PmqPGAZHc+jSY{W_rjCb4!e3!rTUX>k$SPWrG`YQaGDdRyVw$eV_huS*Q{+ z@_Pl-$@ozTA2C><_iCVtF$0P>fwh1E--N`#B&$}!&+C}G`d2y;n)uoS(<{&hm89)f5c#v74YOfct2!F`;u;Z=183>xy-JI!qf$5%St$Wd#K6W9T@XazdzkNwpe7b9d` zjE~Exk3v}v86BC4T~JeTPY-E8vm+<`_YCghcbeUHb7!ydt3#;bizB^bPt=Mne;f?l zS-gDhv(9$jX=CYCy!OSK8g_2uLw*giUDo=8EB^8_2gvHcPW0*%_v6jFT)z!5?TUKI zH%eqr&pn5^N&ma(S`J?;e_VP>{q+&`f**3V)7gu1@!cSc;8`yU*!|48lQUKU%yUQ- zx~RpYhtDM)`MEK<;TAlG?QMiUB62B4n4$YpLomjoh}dO^uThlUWX339B&9{!}KGXH%s?89@Tis4F@4_{Div- z>EUXL9a~ zaOM@dGKsMHf)_H}R%hK4u?md)H>lnixb9sSbY*V+p{)YQfeHkqBP4$}3A`6UXSK(N24QKqVA(>la8JLzIR>FGT_z#@SN{ zeS8GbY(A}qS^i`TXr}y0NI9D=O3>i7jnbm`cL_Jl02i6Ej+OB5AQR0Shx1y}BL9-9 zJ1O%w@($gts}Q_kxO8CckdUr7i$V_RffWhqnQJFFsm}H$tl%6i7tJ32N$3*JF0pp^ z|6qqfRlLLc;75V5bka^_We0iVEob$JY<$9;Z@mG!+h_yW!||bdHgi}_Hd;XneoW0G z#R;=w&?R$7_Vp7Mp|oR2tSNk3g?40{K4IajoPoXjphY8!jtAD0hKs@-p=FGPSJd(A zosH&p`=iz-Fe`y$ccZzxv;){ZKn&UR0d9WEy#r&56OiCXLj(lFZ2QHK@+y-ZK^L5~ z(0hczuybGEJ<%A*+?FxmmcXY6%s*CUg>>NVPjWClYo68|P3x)Yv*0X8Tt0=Y*S*IO ztZeA3Kan5N?i`H}xVPTJju=jZB!mae5BV?5u>KvW-|hEuYY(u91VA<1qsl*q!wAsr z{OJc5)$lIOPmh|Aq20#r(7dD%*k{nYfwB|zy+^&TL_%K47N z(ERV_y6UK#^)yd_XK-{^!w7yEH zLmGO>d;_zQ^E0rDykA$R%X63FF%9z~jtHQ-aaCQpa^?E+%_WSX)CfkSf0u$$Ratm< z;md%jsj8vNH-QzTMdR-etLHPGR3R2;zp4e^kAMgKd&zMVx&8Ofse4uvzz4T17yJbnQZk#GFNR#$-}ioYGh+V{>fu`#~vu3*3X6 zC!Kb9DT;LP&yfeG8Im10*S->5Rg;hiyN}oJ8vdmzep2Q)*exX4$?m_;_O7T31V<}9 z2@_0ObA34>$9nJ4CJD*CHp%vpAaz64%z}*3Gv)*wBrq5!SWz|RI(CD;psFl?+Pf=o z7BeT$-88;E0ysz`=vgIfOHKMzkauwmUHc7+C|^Us6x&wQl3=8x!CinmR+7eYJv9}K z_PZrDtPew$2T2*j9NGsIAyOBC9NC{6d+N_nbsXM0!&f&eupOT2+mcyHAH7b-pmiwF>); zT{-|B?gMO^uyK=m`~OsaWz)vFZfm#Gg`VW#RUGqnfIq;7)>DnsGe{uok@V*DP)xfo zsI*@KW~iRy@?Pr!@HX9Q_N)?TRkL>t$-f0Z3YkZ(@%Zy@aaisp^xY2TU7-3CMy^}l z1g<3;ZAlB6Y&_$)h&F0|x!2ootzk!l1_Y!IAZt0nY_cH+iJQ>mR`r)C|6OTgsoESc zp4}qel<+sPIw~G&R|j>Ts#C^t8q_MbY0#gvCKxS+RD%JRLb7+xQfMY(DP#aHwiLuM z*Ps#s@7XTqtQv!Ig+JKkj4@cfamxK>31FP#<_n@hy*^(>+637+{>i>cM{|I|9*Ylg z1`&I!t9O}1SC`)bpz6xaHTq(R z5?-7yt}R^B@UR4f?f!jnWevc$SEq;PzE@Jpp%4J3-&qBKmN0Uy!+$g?UjYgmljsn0O)x}opOKTe_vag*#uy|dax%<=wCV>Cd0E?+>!{}Tz0q*(OB~? z+n6%J#~YcAiK61hZvjT0d4+90tD8TcHMYlUu00jiy)YT$jlU0e#GAEh;1~APgu92b zo*5r3i68D)`GVs!c=DefEiIljqLJT{m$PL6i52O9+B#eD zyaM#v)yP6l0sUAK*8>4=@QB`m9w4B|^daW3MB78CX^8h7H*=@py1cOL7uC4lt_RKT z`;qHc811Q#8~S&!V(a6+i+B@Ltc0HP2>JlI$tYX2vuVaR_rzU`wvlS{)1R7@D!Scf zVSVy5EX_wm@GNf^IpCrdVEn!s=j@CedQEbVR%=-VCw*7FpEYnbJicQy$Y zsM8%}J5oc}nJ1iAV{O}|Uh$;FDHh`by7!bRyG0js4K+RvX(~vX(7ZrHI>78g_qq~x z)M+!^rEOFdImGc`;N74uBY%+|u%jnb0q2&F@8_w7{QF3iMT9ct-Y0b?XFNNy+4>^RW-XkkJf_P*|Z*ol0 zpy;;pIxDDq2?MeuAsLF0Ich4=^wi2c za}iAsLU!->Dhjlwg9}FjyWh@cFSg$#pTxAJ#;fWGKmyXm|x*}8ISX{{|X~^bcy6jE6T{+G7h0= z>C{X)c(le*vl44t?*G!uVf1qW=I=^Z1y=WlT%CwGH1i_vnH1Z?YtY_eVJb;M&2Yuhi&jG}8^I;Rd1N%Pq-=^>-WXN%&h~e~a)%Y`2HEE9A z>S_QVk7!(ik2`y948_r>X0W>OgVadJ*H2hOWn86e`i#C@`IvSEA;STvt z?0N;9bfK2sx+xuuQjQ!ll_^KbPMweKMEVL9UK?);lW>q=l|CkJ?!Ur=-F=Byr- z^geuV%=bQgF3tP!bgK8^a|5WChlzRk3&L@b^L9P$I0tHrQ`o!lxv|)aeAaUX)E1}l z!NiI=h1Ji~8( zGN$|iLAE|&TfPRbO0nfj8^5*XSN^{({|~k;|36lITYkjDV8tdRea1=+`fxGNdLMO> zE5!eNhV?$p4zJC6h=B6#@FcM%0*I{<*)rI9-yDxVJaas;j{bf4R{1Llt32~I5hIeA zNk8NKzf2hCe^MCdMcQ^^1c-dn8brc}6XAtw+d9=<5=ZM)sn!t{eUf9$1z(+3*-)n9|c}JDwjV2Y;U4PWNyN)C* zeTMi+SK^QbKqmFfyaFIo`cJaJ`854Cg71W#kTyK7@^OTI4*y;`D(YbaIKA2qWw=o0 zJak~DmSetp&o^ENT1>eHb8`I;X`4h=gN&mfe8z`}0;N*Hwp2Q~|7$u=t+TV!+MpZ< zQ=FX+q$cn#H4Lr0e7|4_LDD7)hEM_b2|Cy=!mPR|rHvtUwGhI&9;K=)ta8$_G}1LH zbR!NOpzb!9NC#+97_8jIut}ls_!1FS`z`--BG|aN2yHp?ATR?{T!c)?@NJag>^QAv z5&I3-t*s=2(j~pt2KE@ghHt79#Geq5LQ&x;Zh^`cMrEMHl3)|_PIrL0K~YM^p?|k^_fmX!?79VasG?Riu(8rx7()}z zUtUCFEED0&>J-Lq-+P=sR93sjKON323DPCNx5S}ITR?IT5>2dXc+$U^DA$Uju(zlL zZ)k1F&8Q%UEhr+L;mPqd>B4k{Anzh}<9meJ=v)kGO~@F)yB7V&Q9)PDuIxDUU`+Z_ zl}`@mv~fwY|Cr+efD}@ysl4Lvdve5#%in**i9@WQQcDjE7qeaPDpFk1Kz!D`ezw&3 z2bm=;h96S&<~Ij9#qlb@yA1UoER6 zQAgHo1az6yl0w3i>8^b~>O`!w|6Lve#{%4|Z5MUKoK6S#kfs)}Sh-Mu^#U_QU#BQr#!ljZBDFK%_;D#e0Ncc8lS_x80l_ZgU z3Ov>#O_j<9Y&CmZT7;P(5o@>G+3V<>GA*5l>+0AVmEc&$6#SkcH$kDoY)n8=4dv)3 z{fTM<7;l{NxwF^J;&aMd!D8V33`CZ_4z!y;3!czxJpk$pB>smiwis2;YmTsrsa5lx zd1WXI>p}B9R7^ANj6_9$ZUfaX`ZG~$jlR}`R`h3W_9L0lG+#H#jjX+p>h;~D>I4=} zQZbd9=y@iMx_dtJ@n>|6bIvt!#p@aCrxFRX{7S5DzHip7MM%m&*?fOp7ph?0tbq!B z_>0{KRwjh6Q10FOgBmJj^=Ru%Yvu zY={EX{s1>j3x@4qMtKqaIf75>{HHD!8^T%R}vB2k->Sk5~I@5Gp1gylLuG3X@b#E3QO-L3_1sha(xc9t6h zy(r`3Z_%PYN23pXMa|+@+6c@2n%!=b%S(H`?HVF70e|AMe^1U=EcBEqh}oiVTGtOKVHFk+HGP)laT3U0t|D8Rke65I^Hi@BNOHCm+Z=)3-bwU`9_L=rB z{>~+d{sMRgFC8f=qmq=ed5c|bUFBB1UuaVfCNh$@d9Ob zs81O2cb1O&T7ar`_IS(*g&^KwLz$pcL(_9A9+jZ<2s?T44V5EVE*?vC0yaXDG~oBX zRVt~!HKk?@s-~w+_gd<#E|#aXL3?ad5;sRYcT5l{$EJ8hO;i7JwC zVy}c>i_A0Ghe(gkt0Y*)Gj|?uFBO8*f+RbSu(2s;5V^&o7Nj(q7Gyk6oLbZZZWTL* z)?--dvc{?CS9o{;gT!y4>vix?fL2%`Khv%$D@fv1b#8ccbNWx*3;{PV&2$yR&*O}l z5=g$OEz14&Xo^LbEShS3fX7?c?|`Ndl@=7QPJG|yEhJq$mlO9f+i|I&I8Ef_2@`7) zF<(xxB@l>(!yz#xrSMCNrCpaUEc|J+ldOR;cu7KF*#)b+%zBb zuUXh@t=*7 zS9Z5Lz=ASv9IrNO115Stxplw*(UL#q+eqSL=PfaZpH-{#f+>S0n}z=dRyQX?(@2NM z)i{A18QNich@Sg%>ID5@QoNuSM5Jo)WAs;N)vU%yBAv|rLR(7A7fd}&DuKVY+hDC= z9u~71x0dAfGyX?rZX|6mk)YDSZ0fwKy7?eC3CjO;PfnicKnPjREc}Z^X3>T8auM)^ z^5Th;*BBEdV2rA=dkctStBdn0>&aa_!VB-Mk+sh}-?yGpzxTX4YUX9twbY=7c?7Asga5>dE&e@;t2yBZyvEEjDH*I369MrNy=Ack(1Q8H_$e_PS zUZ+h<2!2GBJRjF=0ZbPQ_{|z*ztst~lWqPP6ABT|-KVzaEAX1>V4+PTkU<_J=6|Tu zKqB<9_>u6I|CUlQ-#23qBuyP57o)cNX#wzJ>HI)D^m7GD!VaP$zwWJvNk99}3$gBD zk=n3>%qGF)X+k58rBv@(;}^%RS-{pb0UVl>h9%rm_qY-yNym>Y4tjF=WPx zS3i*`I3#e++B9Hc!<~g&pA6YF)3LCHbQoupd-uqGdHBPSiVZO=qW~iZgcYjfNNmp< z!8L=)zBmra=z9>6@$CY|Am_7+K_2y=GE)f>>0Kvox9-FG)#^r^>>zZGyiMyHCT3lY z%ie{bQ2?4u3}J}h#_MT*9G+J7J3AD0oi?AwH^(xnR(@U0;>DvHq4t{d$p{elEM8${ zVIhfUf9^@*C4gKVOTUvc>mu1>dOJIzJmI{IBGY!&mZ20}wfDJ1Wlzr0e(tS$B>0F# zrmM~!)CGL{FFDb~y|sC=+Ss?s1e;zStpR&u~2i^%Ojn|X7c``>YIl)8|OO67sr%+2XC06^#0rUJ2pwY=IF2V1T zeWx_C^U8@F<8ddZhw9frI&UZuJ0^y?>dJF-DWzC-{Me@7KdwgBs4UW6@C8pcCvi(yk;Gi)xm_f&Q}ApA ziEfkZLsUVV6d!)hPxDV;wHu+4Ev&XqOF9M9no|KnB*PGzGNI6E ztmxn+$AggqL6^SV;PVhjES6o@yygO&-x2h<*7spCj}dg%R&k%XJutB4G{5HMpfOV z1fk01-&T3s>Y3LbiR{U!574|JHBGRF3RSXP(c3bN z*t<=%&jQd|ceBbB-_NX8JOG8{iIwI!jjj8fZ;;EJy|rl5^kJGaMTmcQT+Qwp z_1t^Wvr6aYvb3shZoL~iyUSGcRGkKJLOhKG*u#Xy;+n`axF2WnODgY z$MsxrIqCla8L`dvFr6B;lq_{!joO-CvKNiSVN25zmWNZCmi`yfYO*x#kE>zb8%zeK zA}u{^qNa`AZ@RtF*w4jPIH^g7^84!)Qck6GS5TEeuLOZ)re1DpC_!SZ;3i`1Y-7#2 zCL6+NoM;mbHXr?qY=;+zR+rYoXW<~R{n1~kW+VQNq@FP~eChhq0_v-TUym#HvU?7a z0!*Dw&6;fM!h)K*M@t3-idi$Jos#yIJ?-$nLW(lRr{9O+iLSOrWjm2y|3NMPN^ljaMQgosdq1@0U(=>vxcuvdd6&T< z6dV<854rrSmJNSrSRJ=?vkqAZ>9(z|i7L)!`l*jZx?(5g$M7#xoUPS9+y^yyEv;y3 zf?qn)?n0zmy=-BYm=}nsAG<)h7`Mz#R%e#;lb zez@k^_;YE6S2xtSQ~fj$epYu=QqLn#=wQ1<2_;tBY7&FQz*n0xfzTVr`OZE1{zH{t zE!AIzd^RslAa3{eY^`vkAvH+6A*_aL?@ z@M!!r<_D2`@$#e|S5U+yGHa7^T=h8TChR3zF5!wi1>_R0B&RFAJ~jQnhA74{NJ{Ts z)Nroe?}o}BaFrCgPgSSbp>6kB2o!ntgeMm$Nc7w35P~2T1=nx7w*|TWCc6ccf8ZCr z9bgS_@;Nf|D{_vyth=e7zUfVtrk^Q&MUw@%!;ewP-P^VjLL_(c97G7Ky?2p}8~z?% zfz)EL-eigq&#SKV$m3@iXB_%1P5*#ul}FcODEoS_S*ggj>iX)@_3#r|$k0@Qf-&VV z1r4So&N*lMIe1iKY>@4+7Jf|~lbgr}>);X+o$MpKPIbLus$)N~2q<-rM$9avB1*dd zL%HJzKPJK0u#b&juOF0NT6fzJiU$W zUQjRihNYp~Hsz87>)>AdzEM7W47})D zydZGMt9Luy%`3VUA>U}94H-Pf$S4EcaR=9; z22nRF>NrHHZK$L7fOxk>VwLTzraKc1IPUB=nq5b$HQgxca9y1NY|}1I7twN%(bn$* zzJb6cn)hwT1n)fF5b@P<$IEVWTuiiHYu=9rKpoYs=EnQvs^K;#2pf95A;>H4?;3yY zc5t_|N3nvo1A>vx>0pt+wjC^*0<7Urz_jI@r*k_fUho)!S#<6r(UkmHBtwkptZgrx zj3l81JX-{j5&mn`C5}%W28}p}ie@a{T}N9_xHEwXW2=4BR3;lD-X-3yc)WS~Cs)h9 zIIIgf@1IB!fv znrA}-PWX&jU&xFYJ*t%H;DZiVl^mutH~5A5&vauAjrR!;eOVogo7{(MKWdlnf(E0v zO8_8wZ?E}aS9_pGtkdTE6~ad#PA|+F13gVCZ(B0@b`6*ka7fLDvj1aCMYhpY(>!dzbQkDi7~*b?R3^vmkRFKR*vbBR;?pAKs<@!8?xaU;p3vp(qd@i0p)6&*o z1B5yRyv{x_!UT2s0-F7(hl3sn-4uLx7LZjj-lZig=NQtRc&FmB5p+(~i|WEF=P$g{ zczrh?VWU3|wgqu>PwA-E0x38(X z=?_w@fQPigrU)m9dhwRu_!}_g!mOgVJW2`OQqvgM`%v0*-4h^;yTp#xbMZng4SuB4 z!J@hKa)%Lnk*d;o^AZh=O2cE9je++WNu*{kz(35t=EkB<+5K+G^?wh8S?*G05J!`k z@uJcAr|=tqN4}uDoanI@<3Q(X`G{b1^q2fYEI8H48~GvNGvTM& z#;`quetNNVfeJ(NZ@UooT42uYk6QYTs8NBFhfRp0RAu74@GNw(JhakGaIJxM&Sk;!wC524jHXMlh<{>oUZ#GSz?q$0|bJDoNe{kYK1 zVGr8SGuu!p>>BiKmJ1_0t*!xrN4C4Sze_7?cNl2194?d78c2G8Lk5Upvqy(W%hapa z5t5c_NXEdACilZ`g~ll(1O6C{fI5c)L6y2EBo$p$lMu{x=j(6{H!aH$dY4(gHxE)y F|1W_D3Yh=^ literal 0 HcmV?d00001 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..508cc4c70 --- /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.Field; +import java.lang.reflect.Modifier; +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 (Modifier.isStatic(f.getModifiers()) && ServiceMethod.class.isAssignableFrom(f.getType())) { + result.add((ServiceMethod) f.get(null)); + } + } + return result; + } +} 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") From 483d06cc00d804f24c9f3be966acfdb0b8ea4a14 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:21:15 +0100 Subject: [PATCH 03/54] test(conventions): check the catapult-bgs sources are written in English --- catapult-api/build.gradle.kts | 2 +- .../java/fr/enimaloc/catapult/EnglishConventionsTest.java | 7 ++++--- .../fr/enimaloc/catapult/bgs/proto/ServiceTableTest.java | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/catapult-api/build.gradle.kts b/catapult-api/build.gradle.kts index ad4fd6ebc..115f4ca2c 100644 --- a/catapult-api/build.gradle.kts +++ b/catapult-api/build.gradle.kts @@ -133,7 +133,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/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java index 0baebd701..71370e4e9 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 { @@ -129,11 +129,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-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/ServiceTableTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/proto/ServiceTableTest.java index 508cc4c70..b16ddb9c2 100644 --- 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 @@ -2,8 +2,8 @@ import org.junit.jupiter.api.Test; +import java.lang.reflect.AccessFlag; import java.lang.reflect.Field; -import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -80,7 +80,7 @@ void fnv1aMatchesTheReferenceVectors() { private static List> constants() throws IllegalAccessException { List> result = new ArrayList<>(); for (Field f : BgsServices.class.getDeclaredFields()) { - if (Modifier.isStatic(f.getModifiers()) && ServiceMethod.class.isAssignableFrom(f.getType())) { + if (f.accessFlags().contains(AccessFlag.STATIC) && ServiceMethod.class.isAssignableFrom(f.getType())) { result.add((ServiceMethod) f.get(null)); } } From 72913f6dc8d428c16d1d55bab83cea253cef9fed Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:24:17 +0100 Subject: [PATCH 04/54] feat(bgs): add the BGS frame codec and RPC session --- .../catapult/bgs/core/BgsChannel.java | 37 +++ .../catapult/bgs/core/BgsRpcException.java | 16 + .../catapult/bgs/core/BgsSession.java | 280 ++++++++++++++++++ .../catapult/bgs/core/BgsTransport.java | 62 ++++ .../catapult/bgs/core/FrameCodec.java | 53 ++++ .../catapult/bgs/core/BgsSessionTest.java | 232 +++++++++++++++ .../catapult/bgs/core/FrameCodecTest.java | 61 ++++ 7 files changed, 741 insertions(+) create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsChannel.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsRpcException.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsSession.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsTransport.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/FrameCodec.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsSessionTest.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/FrameCodecTest.java 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..4db0deeaf --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsSession.java @@ -0,0 +1,280 @@ +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.Field; +import java.lang.reflect.Modifier; +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 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 boolean closed; + private 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 ignored) { + // an undecodable notification is dropped; the ack is already sent + } + })); + } + + @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) { + post(() -> { + if (closed) { + runQuietly(() -> listener.accept(closeCause)); + } else { + closeListeners.add(listener); + } + }, null); + } + + @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.body().accept(frame.body())); + } + } + + private void shutdown(Throwable cause) { + if (closed) { + return; + } + closed = true; + closeCause = cause; + runQuietly(transport::close); + Throwable failure = cause != null ? cause : new IOException("Session closed"); + pending.values().forEach(f -> f.completeExceptionally(failure)); + pending.clear(); + closeListeners.forEach(l -> runQuietly(() -> 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(task::run)); + } catch (RejectedExecutionException e) { + if (onRejected != null) { + onRejected.completeExceptionally(new IOException("Session closed")); + } + } + } + + private interface Action { + void run() throws Exception; + } + + private static void runQuietly(Action action) { + try { + action.run(); + } catch (Exception ignored) { + // a failing callback must not take the session down + } + } + + 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 (Modifier.isStatic(f.getModifiers()) && 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..ba9df32de --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsTransport.java @@ -0,0 +1,62 @@ +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; + +/** 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; + + /** Plain TLS with the default trust store, SNI = host and hostname verification. */ + static BgsTransport tls(String host, int port) throws IOException { + Socket raw = new Socket(); + raw.connect(new InetSocketAddress(host, port), 10_000); + SSLSocket socket; + try { + socket = (SSLSocket) ((SSLSocketFactory) SSLSocketFactory.getDefault()).createSocket(raw, host, port, true); + SSLParameters params = socket.getSSLParameters(); + params.setEndpointIdentificationAlgorithm("HTTPS"); + socket.setSSLParameters(params); + socket.startHandshake(); + } 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/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..0967d347d --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsSessionTest.java @@ -0,0 +1,232 @@ +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); + } +} 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; + } +} From 27975367904588a2539c06e445ee5bc8f9a95e38 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:26:36 +0100 Subject: [PATCH 05/54] fix(bgs): notify late close listeners and log swallowed session errors --- .../catapult/bgs/core/BgsSession.java | 55 +++++++++++++------ .../catapult/bgs/core/BgsSessionTest.java | 26 +++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) 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 index 4db0deeaf..5f7dd50ff 100644 --- 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 @@ -30,6 +30,8 @@ */ 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. */ @@ -47,8 +49,8 @@ private record Handler(ServiceMethod method, Consumer body) { private final List> closeListeners = new ArrayList<>(); private volatile String ciid = ""; private int nextToken; - private boolean closed; - private Throwable closeCause; + private volatile boolean closed; + private volatile Throwable closeCause; private BgsSession(BgsTransport transport) { this.transport = transport; @@ -112,8 +114,10 @@ public void on(ServiceMethod method, Consumer handl handlers.put(key(method.serviceHash(), method.methodId()), new Handler(method, body -> { try { handler.accept(method.requestParser().parseFrom(body)); - } catch (InvalidProtocolBufferException ignored) { + } 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); } })); } @@ -130,13 +134,23 @@ public void setCiid(String ciid) { @Override public void onClose(Consumer listener) { - post(() -> { - if (closed) { - runQuietly(() -> listener.accept(closeCause)); - } else { - closeListeners.add(listener); - } - }, null); + // 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 @@ -202,7 +216,7 @@ private void onFrame(FrameCodec.Frame frame) { write(Header.newBuilder().setServiceId(REPLY).setToken(h.getToken()).build(), ByteString.EMPTY); } if (handler != null) { - runQuietly(() -> handler.body().accept(frame.body())); + runQuietly("handler " + describe(h.getServiceHash(), h.getMethodId()), () -> handler.body().accept(frame.body())); } } @@ -210,13 +224,13 @@ private void shutdown(Throwable cause) { if (closed) { return; } - closed = true; closeCause = cause; - runQuietly(transport::close); + 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(() -> l.accept(cause))); + closeListeners.forEach(l -> runQuietly("close listener", () -> l.accept(cause))); closeListeners.clear(); loop.shutdown(); } @@ -239,7 +253,7 @@ private void readLoop() { private void post(Runnable task, CompletableFuture onRejected) { try { - loop.execute(() -> runQuietly(task::run)); + loop.execute(() -> runQuietly("session task", task::run)); } catch (RejectedExecutionException e) { if (onRejected != null) { onRejected.completeExceptionally(new IOException("Session closed")); @@ -251,14 +265,19 @@ private interface Action { void run() throws Exception; } - private static void runQuietly(Action action) { + /** 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 ignored) { - // a failing callback must not take the session down + } 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); } 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 index 0967d347d..fcde621b3 100644 --- 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 @@ -229,4 +229,30 @@ void localCloseFailsPendingCallsAndNotifiesWithNoCause() throws Exception { 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(); + } } From a5c7987edaac81fa255d8f9863791f1d2bfe77d1 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:29:03 +0100 Subject: [PATCH 06/54] feat(bgs): merge presence deltas into per-friend game changes --- .../catapult/bgs/client/PresenceEvent.java | 21 ++++ .../catapult/bgs/client/PresenceTracker.java | 110 ++++++++++++++++++ .../bgs/client/PresenceReplayTest.java | 102 ++++++++++++++++ .../bgs/client/PresenceTrackerTest.java | 84 +++++++++++++ catapult-bgs/src/test/resources/presence.txt | 76 ++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceEvent.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/PresenceTracker.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceReplayTest.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/PresenceTrackerTest.java create mode 100644 catapult-bgs/src/test/resources/presence.txt 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/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/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) From e89cd29769e63f525399c6cc890d2178e0e59d52 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:40:05 +0100 Subject: [PATCH 07/54] feat(bgs): add the BGS client session, friends list and presence watch BgsClient runs the session sequence on an open BgsChannel: Connect, ticket mint between Connect and Logon, Logon, minimal account subscribe, game account selection, friends subscribe and paged sync, then the presence watch once the self presence push arrives. The friends list and the invitations survive across sessions and each resync reports the diff. Friend actions fail at once outside a ready session and never change the local state on their reply. --- .../catapult/bgs/client/BgsClient.java | 656 ++++++++++++++++++ .../catapult/bgs/client/BgsListener.java | 31 + .../catapult/bgs/client/BgsRequests.java | 155 +++++ .../catapult/bgs/client/FriendEvent.java | 32 + .../bgs/client/GameAccountSelector.java | 42 ++ .../catapult/bgs/client/TicketException.java | 22 + .../catapult/bgs/client/TicketProvider.java | 8 + .../catapult/bgs/client/ClientHarness.java | 141 ++++ .../catapult/bgs/client/FakeSession.java | 179 +++++ .../catapult/bgs/client/FriendsSyncTest.java | 266 +++++++ .../bgs/client/GameAccountSelectorTest.java | 43 ++ .../catapult/bgs/client/SessionOrderTest.java | 257 +++++++ 12 files changed, 1832 insertions(+) create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsClient.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsListener.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsRequests.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/FriendEvent.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/GameAccountSelector.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketException.java create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/TicketProvider.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ClientHarness.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FakeSession.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FriendsSyncTest.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/GameAccountSelectorTest.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/SessionOrderTest.java 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..a4e798901 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsClient.java @@ -0,0 +1,656 @@ +package fr.enimaloc.catapult.bgs.client; + +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.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#IO}. + * + * @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); + } + + /** Closes the current session, if any; its close reason is {@link CloseReason#IO}. */ + public void close() { + Session session = current; + if (session != null) { + session.channel.close(); + } + } + + /** + * 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. */ + private void mintThen(Consumer next) { + CompletableFuture.supplyAsync(() -> { + try { + return tickets.mint(); + } catch (TicketException e) { + throw new CompletionException(e); + } catch (RuntimeException e) { + throw new CompletionException(new TicketException("Ticket minting failed", false, e)); + } + }, mintExecutor).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))); + } + }); + } + + 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 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 real reason of an offline removal is unknown: reported as 0 + sentInvitations.keySet().stream().filter(id -> !now.containsKey(id)).sorted().toList() + .forEach(id -> sentInvitationRemoved(id, 0)); + 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/BgsListener.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsListener.java new file mode 100644 index 000000000..3f82d6138 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsListener.java @@ -0,0 +1,31 @@ +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, local close, or a failure before the session was ready. */ + IO, + /** 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..0491f0d1b --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/FriendEvent.java @@ -0,0 +1,32 @@ +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 { + + 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: {@code reason} 0 means accepted (a {@link FriendAdded} follows), any other value + * means declined or revoked. A removal found by the resync diff carries 0, the real reason being unknown. + */ + 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/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/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..69620c27f --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/FriendsSyncTest.java @@ -0,0 +1,266 @@ +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", 0)); + } + + @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/SessionOrderTest.java b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/SessionOrderTest.java new file mode 100644 index 000000000..20a4d2f3f --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/SessionOrderTest.java @@ -0,0 +1,257 @@ +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.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.util.List; +import java.util.Set; + +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 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 configToStringMasksTheEmail() { + assertThat(CONFIG.toString()).doesNotContain("bot@example.test").contains("bgs.test"); + } +} From f77c3b673227b965845e164d934bf1cb0e7838ab Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:42:19 +0100 Subject: [PATCH 08/54] fix(bgs): flag invitations gone while offline and local closes A sent invitation that vanished while offline is reported with REASON_GONE_WHILE_OFFLINE (-1) instead of 0, which means accepted. A close requested by the client reports CloseReason.LOCAL instead of IO. BgsSession reads static fields through AccessFlag, which the English conventions check accepts. --- .../catapult/bgs/client/BgsClient.java | 20 +++++++++--- .../catapult/bgs/client/BgsListener.java | 4 ++- .../catapult/bgs/client/FriendEvent.java | 12 +++++-- .../catapult/bgs/core/BgsSession.java | 4 +-- .../catapult/bgs/client/FriendsSyncTest.java | 31 ++++++++++++++++++- .../catapult/bgs/client/SessionOrderTest.java | 20 ++++++++++++ 6 files changed, 80 insertions(+), 11 deletions(-) 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 index a4e798901..70d3032eb 100644 --- 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 @@ -128,7 +128,7 @@ public CompletableFuture start(BgsChannel channel) { /** * Completes with the reason the current session ended: once per session, along with - * {@link BgsListener#onClosed}. A local {@link #close()} reports {@link CloseReason#IO}. + * {@link BgsListener#onClosed}. A local {@link #close()} reports {@link CloseReason#LOCAL}. * * @throws IllegalStateException before the first {@link #start} */ @@ -174,11 +174,15 @@ public Map friends() { return Map.copyOf(friends); } - /** Closes the current session, if any; its close reason is {@link CloseReason#IO}. */ + /** Closes the current session, if any; its close reason is {@link CloseReason#LOCAL}. */ public void close() { Session session = current; if (session != null) { - session.channel.close(); + try { + session.channel.loop().execute(session::closeLocally); + } catch (RejectedExecutionException e) { + // The loop is already shut down: the session is closed + } } } @@ -448,6 +452,12 @@ private void fail(CloseReason reason, Throwable cause) { channel.close(); } + 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; @@ -549,9 +559,9 @@ private void syncSentInvitations() { GetSentInvitationsResponse::getContinuation, new ArrayList<>(), 0, list -> { Map now = new LinkedHashMap<>(); list.forEach(i -> now.put(i.getId(), i.getTargetName())); - // The real reason of an offline removal is unknown: reported as 0 + // 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, 0)); + .forEach(id -> sentInvitationRemoved(id, FriendEvent.REASON_GONE_WHILE_OFFLINE)); now.forEach(this::sentInvitationAdded); }); } 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 index 3f82d6138..6e210804f 100644 --- 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 @@ -17,8 +17,10 @@ public interface BgsListener { void onClosed(CloseReason reason); enum CloseReason { - /** Transport error, end of stream, local close, or a failure before the session was ready. */ + /** 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. */ 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 index 0491f0d1b..853041b7b 100644 --- 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 @@ -6,6 +6,13 @@ */ 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 { } @@ -24,8 +31,9 @@ record InvitationSent(long invitationId, String targetName) implements FriendEve } /** - * A sent invitation is gone: {@code reason} 0 means accepted (a {@link FriendAdded} follows), any other value - * means declined or revoked. A removal found by the resync diff carries 0, the real reason being unknown. + * 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/core/BgsSession.java b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/core/BgsSession.java index 5f7dd50ff..8031c994f 100644 --- 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 @@ -9,8 +9,8 @@ import java.io.IOException; import java.io.OutputStream; +import java.lang.reflect.AccessFlag; import java.lang.reflect.Field; -import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -285,7 +285,7 @@ private static long key(int serviceHash, int methodId) { private static Map> knownMethods() { Map> map = new HashMap<>(); for (Field f : BgsServices.class.getFields()) { - if (Modifier.isStatic(f.getModifiers()) && ServiceMethod.class.isAssignableFrom(f.getType())) { + 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); 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 index 69620c27f..0cfc7389d 100644 --- 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 @@ -98,7 +98,36 @@ void resyncReportsInvitationsChangedOffline() { 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", 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 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 index 20a4d2f3f..dbe0a2141 100644 --- 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 @@ -250,6 +250,26 @@ void newSessionResetsTheSessionState() { .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"); From f8de5ff174710c02e7cd21e7097cac5f8d9b07e4 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:47:44 +0100 Subject: [PATCH 09/54] feat(bgs): reconnect BGS sessions with backoff and stop on replaced sessions --- .../catapult/bgs/client/BgsClient.java | 18 ++ .../catapult/bgs/client/BgsConnection.java | 180 ++++++++++++++++++ .../bgs/client/BgsConnectionTest.java | 154 +++++++++++++++ .../catapult/bgs/client/ManualScheduler.java | 149 +++++++++++++++ 4 files changed, 501 insertions(+) create mode 100644 catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsConnection.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/BgsConnectionTest.java create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/ManualScheduler.java 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 index 70d3032eb..de87e423a 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -174,6 +175,23 @@ 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; 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..29b5fa604 --- /dev/null +++ b/catapult-bgs/src/main/java/fr/enimaloc/catapult/bgs/client/BgsConnection.java @@ -0,0 +1,180 @@ +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); + + 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 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; + + public BgsConnection(BgsClient client, Supplier sessionFactory, ScheduledExecutorService scheduler, + Duration minBackoff, Duration maxBackoff) { + 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; + } + ready.whenComplete((v, error) -> onStarted(id, error)); + closed.thenAccept(reason -> onClosed(id, reason)); + } + + private void onStarted(long id, Throwable error) { + synchronized (lock) { + if (id != attempt || state != State.CONNECTING) { + return; + } + 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 cancelTimers() { + 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/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..81aebaebc --- /dev/null +++ b/catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/client/BgsConnectionTest.java @@ -0,0 +1,154 @@ +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 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); + } +} 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; + } +} From c61d642ce687192a01b402b5cb0f97163e4b230d Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:50:35 +0100 Subject: [PATCH 10/54] fix(bgs): abort BGS session starts that never complete --- .../catapult/bgs/client/BgsClient.java | 47 ++++++++++++--- .../catapult/bgs/client/BgsConnection.java | 34 +++++++++++ .../bgs/client/BgsConnectionTest.java | 57 +++++++++++++++++++ 3 files changed, 129 insertions(+), 9 deletions(-) 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 index de87e423a..b221a8a2e 100644 --- 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 @@ -204,6 +204,18 @@ public void close() { } } + /** 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. @@ -319,15 +331,23 @@ private void onConnected(ConnectResponse reply) { /** Mints off the loop, then continues on it; a mint failure ends the session. */ private void mintThen(Consumer next) { - CompletableFuture.supplyAsync(() -> { - try { - return tickets.mint(); - } catch (TicketException e) { - throw new CompletionException(e); - } catch (RuntimeException e) { - throw new CompletionException(new TicketException("Ticket minting failed", false, e)); - } - }, mintExecutor).whenCompleteAsync((ticket, error) -> { + CompletableFuture minted; + try { + minted = CompletableFuture.supplyAsync(() -> { + try { + return tickets.mint(); + } catch (TicketException e) { + throw new CompletionException(e); + } catch (RuntimeException e) { + throw new CompletionException(new TicketException("Ticket minting failed", false, e)); + } + }, mintExecutor); + } catch (RuntimeException e) { + LOG.log(Level.WARNING, "BGS ticket minting could not be scheduled: " + describe(e)); + fail(CloseReason.IO, e); + return; + } + minted.whenCompleteAsync((ticket, error) -> { if (!live()) { return; } @@ -366,6 +386,9 @@ private void onLogonComplete(LogonCompleteNotification n) { 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)); + } } }); } @@ -470,6 +493,12 @@ private void fail(CloseReason reason, Throwable 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")); 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 index 29b5fa604..3d6428834 100644 --- 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 @@ -26,6 +26,8 @@ public enum State { CONNECTING, READY, BACKOFF, STOPPED_REPLACED, STOPPED_CODE_R 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; @@ -33,6 +35,7 @@ public enum State { CONNECTING, READY, BACKOFF, STOPPED_REPLACED, STOPPED_CODE_R 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; @@ -41,9 +44,20 @@ public enum State { CONNECTING, READY, BACKOFF, STOPPED_REPLACED, STOPPED_CODE_R 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; @@ -97,15 +111,27 @@ private void connect() { 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); @@ -160,7 +186,15 @@ private void backoff() { }, 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; 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 index 81aebaebc..f6d872caf 100644 --- 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 @@ -8,7 +8,9 @@ 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; @@ -151,4 +153,59 @@ void stopWhileInBackoffCancelsTheRetry() { 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); + } } From d68085737f2bdc03504f82646d125fce1df98a9c Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 16:58:15 +0100 Subject: [PATCH 11/54] feat(battlenet): add the service account and friend link tables --- catapult-api/Dockerfile | 2 + catapult-api/build.gradle.kts | 1 + .../domain/battlenet/BattleNetFriendLink.java | 52 +++++++++++ .../battlenet/BattleNetServiceAccount.java | 73 +++++++++++++++ .../BattleNetFriendLinkRepository.java | 32 +++++++ .../BattleNetServiceAccountRepository.java | 21 +++++ .../src/main/resources/application.properties | 5 +- .../db/migration/V108__battlenet_presence.sql | 32 +++++++ .../battlenet/BattleNetRepositoriesTest.java | 90 +++++++++++++++++++ 9 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetFriendLink.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetServiceAccount.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetFriendLinkRepository.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetServiceAccountRepository.java create mode 100644 catapult-api/src/main/resources/db/migration/V108__battlenet_presence.sql create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java diff --git a/catapult-api/Dockerfile b/catapult-api/Dockerfile index 5815b8104..2ec03e741 100644 --- a/catapult-api/Dockerfile +++ b/catapult-api/Dockerfile @@ -7,11 +7,13 @@ 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 diff --git a/catapult-api/build.gradle.kts b/catapult-api/build.gradle.kts index 115f4ca2c..bccafdcc9 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") 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..d632ddde4 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/battlenet/BattleNetServiceAccount.java @@ -0,0 +1,73 @@ +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; + + @Column(nullable = false) + private String label; + + @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(); + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } +} 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..df6283990 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetFriendLinkRepository.java @@ -0,0 +1,32 @@ +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); + boolean existsByServiceAccountAndStatus(BattleNetServiceAccount serviceAccount, BattleNetFriendLink.Status status); + + Optional findByServiceAccountAndBnetAccountId(BattleNetServiceAccount serviceAccount, + long bnetAccountId); + + /** Attaches InvitationSent events, which carry account_id 0: matched by BattleTag instead. */ + Optional findByServiceAccountAndBattleTagIgnoreCase(BattleNetServiceAccount serviceAccount, + String battleTag); +} 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..9020d3b64 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetServiceAccountRepository.java @@ -0,0 +1,21 @@ +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(); + + Optional findByBnetAccountId(long bnetAccountId); +} 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/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..858dad015 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java @@ -0,0 +1,90 @@ +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 label, long bnetAccountId) { + BattleNetServiceAccount a = new BattleNetServiceAccount(); + a.setLabel(label); + a.setEmail(label + "@example.test"); + a.setPasswordEnc("enc-password"); + a.setBnetAccountId(bnetAccountId); + a.setBattleTag(label + "#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)).isPresent() + .get().extracting(BattleNetFriendLink::getStatus).isEqualTo(BattleNetFriendLink.Status.PENDING); + assertThat(links.findByServiceAccountAndBattleTagIgnoreCase(bot, "streamer#2928")).isPresent(); + 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::getLabel).containsExactly("first", "second"); + assertThat(accounts.countByEnabledTrue()).isEqualTo(3); + assertThat(accounts.countByEnabledTrueAndFriendLimitReachedFalse()).isEqualTo(2); + } + + @Test + void accountToStringHidesSecrets() { + BattleNetServiceAccount a = new BattleNetServiceAccount(); + a.setLabel("bot"); + 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"); + } +} From 1345097a444eb3b3f6a046b8e361247da4431791 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:00:25 +0100 Subject: [PATCH 12/54] feat(battlenet): gate the integration and expose the link state --- .../BattleNetServiceAccountRepository.java | 4 + .../battlenet/BattleNetGateService.java | 69 ++++++++++++++ .../battlenet/BattleNetLinkStateService.java | 67 ++++++++++++++ .../battlenet/BattleNetGateServiceTest.java | 91 +++++++++++++++++++ .../BattleNetLinkStateServiceTest.java | 83 +++++++++++++++++ .../connect/BattleNetLinkStateResponse.java | 4 + 6 files changed, 318 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateService.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateService.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetGateServiceTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetLinkStateServiceTest.java create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/connect/BattleNetLinkStateResponse.java 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 index 9020d3b64..5063d99c8 100644 --- 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 @@ -17,5 +17,9 @@ List findByEnabledTrueAndAuthStateOrderByFillOrderAsc( 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/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/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-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) { +} From bd47147ef38dd9acfa9f838246f158759ba7adb4 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:09:42 +0100 Subject: [PATCH 13/54] feat(battlenet): mint BGS tickets through a headless login with an admin-entered code --- .../login/BattleNetLoginBrowser.java | 49 ++++ .../login/BattleNetLoginCoordinator.java | 240 ++++++++++++++++++ .../battlenet/login/BattleNetLoginFlow.java | 120 +++++++++ .../login/BattleNetLoginCoordinatorTest.java | 238 +++++++++++++++++ .../login/BattleNetLoginFlowTest.java | 106 ++++++++ .../battlenet/login/FakeLoginBrowser.java | 73 ++++++ 6 files changed, 826 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginBrowser.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinator.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlow.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinatorTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlowTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/FakeLoginBrowser.java 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..58042678a --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginBrowser.java @@ -0,0 +1,49 @@ +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 failure, at most {@code timeout}. */ + 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 { + } + + /** {@code reason} is shown to the admin and logged: it never carries a credential or a ticket. */ + record Failed(String reason) 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..19ef534c5 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinator.java @@ -0,0 +1,240 @@ +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.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.stereotype.Service; +import org.springframework.transaction.support.TransactionOperations; + +import java.time.Duration; +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.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * 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<>(); + + 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) { + this.repository = repository; + this.encryption = encryption; + this.browsers = browsers; + this.transactions = transactions; + this.ticketTimeout = ticketTimeout; + this.codeTimeout = codeTimeout; + } + + /** + * 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) { + 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); + } 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); + } + + 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. */ + public TicketProvider ticketProviderFor(UUID accountId) { + return () -> mint(accountId); + } + + 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); + } + BattleNetLoginFlow flow = new BattleNetLoginFlow(false, ticketTimeout, codeTimeout, () -> { }); + Future ticket; + try { + ticket = executor.submit(() -> login(accountId, flow)); + } catch (RejectedExecutionException e) { + throw new TicketException("The Battle.net login executor is shut down", false); + } + // Another account's admin login may hold the executor while it waits for its code. + Duration maxWait = codeTimeout.plus(ticketTimeout.multipliedBy(3)); + try { + return 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) { + ticket.cancel(true); + throw new TicketException("Timed out waiting for the Battle.net login", false); + } catch (InterruptedException e) { + ticket.cancel(true); + Thread.currentThread().interrupt(); + throw new TicketException("Interrupted while waiting for the Battle.net login", false); + } + } + + /** Runs on the login thread: one attempt, then the resulting auth state is persisted. */ + private String login(UUID accountId, BattleNetLoginFlow flow) 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() + ")", + true); + } + 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", true); + + Result result = attempt(browser, flow, credentials); + return switch (result) { + case Result.Success success -> { + 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.Failed failed -> 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); + transactions.executeWithoutResult(status -> repository.findById(accountId).ifPresent(account -> { + if (encrypted != null) account.setStorageStateEnc(encrypted); + account.setAuthState(AuthState.READY); + repository.save(account); + })); + } + + private void setAuthState(UUID accountId, AuthState state) { + transactions.executeWithoutResult(status -> repository.findById(accountId).ifPresent(account -> { + account.setAuthState(state); + repository.save(account); + })); + } + + @PreDestroy + public void shutdown() { + executor.shutdownNow(); + } + + 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..64d5d3b6f --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlow.java @@ -0,0 +1,120 @@ +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 { + } + + record Failed(String reason) implements Result { + } + } + + 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.Failed(failed.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/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..6eb02386e --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinatorTest.java @@ -0,0 +1,238 @@ +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; + + @BeforeEach + void setUp() { + account.setId(UUID.randomUUID()); + account.setLabel("bot-1"); + 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); + } + + @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 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 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 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..a8a4c6259 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginFlowTest.java @@ -0,0 +1,106 @@ +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 failedOutcomeIsReturned() { + browser.script(new Outcome.Failed("timeout")); + + Result result = flow(true, Duration.ofMinutes(10)).run(browser.open(null), "e", "p"); + + assertThat(result).isEqualTo(new Result.Failed("timeout")); + } + + @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; + } + } +} From d4f37da6bdf7ffdae68fd14f9b2bd8d491b5c831 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:11:38 +0100 Subject: [PATCH 14/54] fix(battlenet): stop re-minting tickets after a rejected login --- .../login/BattleNetLoginBrowser.java | 15 ++++++++-- .../login/BattleNetLoginCoordinator.java | 22 ++++++++++++-- .../battlenet/login/BattleNetLoginFlow.java | 10 ++++++- .../login/BattleNetLoginCoordinatorTest.java | 29 +++++++++++++++++++ .../login/BattleNetLoginFlowTest.java | 17 ++++++++--- 5 files changed, 83 insertions(+), 10 deletions(-) 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 index 58042678a..12f1fd00a 100644 --- 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 @@ -17,7 +17,10 @@ interface LoginPage extends AutoCloseable { void submitCredentials(String email, String password); - /** Blocks until the ticket redirect, the code page or a failure, at most {@code timeout}. */ + /** + * 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}. */ @@ -42,8 +45,16 @@ public String toString() { record CodeRequired() implements Outcome { } - /** {@code reason} is shown to the admin and logged: it never carries a credential or a ticket. */ + /** + * 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 index 19ef534c5..f37fc66ef 100644 --- 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 @@ -112,7 +112,14 @@ public boolean waitingForCode(UUID accountId) { return flow != null && flow.waitingForCode(); } - /** Automatic re-mint for the account's BGS session: blocking, never waits for a code. */ + /** + * 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. + */ public TicketProvider ticketProviderFor(UUID accountId) { return () -> mint(accountId); } @@ -161,12 +168,14 @@ private String login(UUID accountId, BattleNetLoginFlow flow) throws TicketExcep .orElse(null)); } catch (RuntimeException e) { return fail(accountId, flow, "Could not read the stored credentials (" + e.getClass().getSimpleName() + ")", - true); + 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", true); + if (browser == null) { + return fail(accountId, flow, "No Battle.net login browser is available", flow.interactive()); + } Result result = attempt(browser, flow, credentials); return switch (result) { @@ -180,6 +189,13 @@ private String login(UUID accountId, BattleNetLoginFlow flow) throws TicketExcep 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 -> fail(accountId, flow, failed.reason(), flow.interactive()); }; } 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 index 64d5d3b6f..246ca1f6b 100644 --- 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 @@ -30,6 +30,11 @@ public String toString() { 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 { } } @@ -76,7 +81,10 @@ public Result run(LoginPage page, String email, String password) { return new Result.Success(ticket.value(), storageStateOf(page)); } case Outcome.Failed failed -> { - return new Result.Failed(failed.reason()); + return new Result.Rejected(failed.reason()); + } + case Outcome.TimedOut ignored -> { + return new Result.Failed("No answer from the login page in time"); } case Outcome.CodeRequired ignored -> { if (!interactive) return new Result.CodeRequired(); 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 index 6eb02386e..60f93debd 100644 --- 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 @@ -155,6 +155,35 @@ void automaticReMintNeedingACodeMarksCodeRequiredAndThrows() { 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 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)); 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 index a8a4c6259..6b6af4bfb 100644 --- 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 @@ -81,12 +81,21 @@ void submitCodeOutsideTheCodeWaitIsRejected() { } @Test - void failedOutcomeIsReturned() { - browser.script(new Outcome.Failed("timeout")); + void failedOutcomeIsARejection() { + browser.script(new Outcome.Failed("wrong password")); - Result result = flow(true, Duration.ofMinutes(10)).run(browser.open(null), "e", "p"); + Result result = flow(false, Duration.ofMinutes(10)).run(browser.open(null), "e", "p"); - assertThat(result).isEqualTo(new Result.Failed("timeout")); + 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 From cb5685b72ed26d43a1974b12db521c08e59f431a Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:15:53 +0100 Subject: [PATCH 15/54] feat(battlenet): drive the Battle.net login page with headless Chromium --- catapult-api/build.gradle.kts | 1 + .../login/PlaywrightLoginBrowser.java | 165 ++++++++++++++++++ .../login/PlaywrightLoginBrowserTest.java | 22 +++ 3 files changed, 188 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowser.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/PlaywrightLoginBrowserTest.java diff --git a/catapult-api/build.gradle.kts b/catapult-api/build.gradle.kts index bccafdcc9..1bd034d39 100644 --- a/catapult-api/build.gradle.kts +++ b/catapult-api/build.gradle.kts @@ -62,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") 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/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(); + } +} From b9e96195adfab2fd3897726e8be56d25fec79c15 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:15:53 +0100 Subject: [PATCH 16/54] chore(docker): run catapult-api on a glibc base so Chromium can start --- catapult-api/Dockerfile | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/catapult-api/Dockerfile b/catapult-api/Dockerfile index 2ec03e741..1ef858006 100644 --- a/catapult-api/Dockerfile +++ b/catapult-api/Dockerfile @@ -20,16 +20,23 @@ 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 --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 USER catapult From d2db4bdf27b558815e6114f440844eadda144c5f Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:17:28 +0100 Subject: [PATCH 17/54] fix(battlenet): stop re-minting after repeated login timeouts --- .../login/BattleNetLoginCoordinator.java | 28 +++++++++++++-- .../battlenet/login/BattleNetLoginFlow.java | 5 ++- .../login/BattleNetLoginCoordinatorTest.java | 34 ++++++++++++++++++- 3 files changed, 62 insertions(+), 5 deletions(-) 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 index f37fc66ef..a9f0f883a 100644 --- 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 @@ -49,19 +49,25 @@ public class BattleNetLoginCoordinator { 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<>(); 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.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; } /** @@ -70,6 +76,7 @@ public BattleNetLoginCoordinator(BattleNetServiceAccountRepository repository, * 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); @@ -118,7 +125,11 @@ public boolean waitingForCode(UUID accountId) { * 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. + * 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); @@ -180,6 +191,7 @@ private String login(UUID accountId, BattleNetLoginFlow flow) throws TicketExcep 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(); @@ -196,7 +208,17 @@ private String login(UUID accountId, BattleNetLoginFlow flow) throws TicketExcep // 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 -> fail(accountId, flow, failed.reason(), flow.interactive()); + 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()); + } }; } 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 index 246ca1f6b..691fdb938 100644 --- 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 @@ -39,6 +39,9 @@ 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; @@ -84,7 +87,7 @@ public Result run(LoginPage page, String email, String password) { return new Result.Rejected(failed.reason()); } case Outcome.TimedOut ignored -> { - return new Result.Failed("No answer from the login page in time"); + return new Result.Failed(TIMEOUT_REASON); } case Outcome.CodeRequired ignored -> { if (!interactive) return new Result.CodeRequired(); 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 index 60f93debd..164287b43 100644 --- 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 @@ -72,7 +72,7 @@ void tearDown() { private BattleNetLoginCoordinator coordinator(Duration codeTimeout) { return new BattleNetLoginCoordinator(repository, encryption, browserProvider, - TransactionOperations.withoutTransaction(), Duration.ofSeconds(60), codeTimeout); + TransactionOperations.withoutTransaction(), Duration.ofSeconds(60), codeTimeout, 3); } @Test @@ -174,6 +174,38 @@ void automaticReMintTimeoutLeavesTheStateAndStaysRetryable() { 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); From 36de5ed51be14650c0999f7d19a744fa421b13aa Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:19:25 +0100 Subject: [PATCH 18/54] fix(docker): keep Playwright from downloading browsers at runtime --- catapult-api/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/catapult-api/Dockerfile b/catapult-api/Dockerfile index 1ef858006..508d19398 100644 --- a/catapult-api/Dockerfile +++ b/catapult-api/Dockerfile @@ -38,6 +38,9 @@ RUN java -Dloader.main=com.microsoft.playwright.CLI -cp app.jar \ && 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 # 8081: catapult-api, reached by catapult-web only; 8091: the management port (actuator, Prometheus, the compose From 31c4c6aa075a0e0651a862b0c6435b7107d780e6 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:29:40 +0100 Subject: [PATCH 19/54] feat(battlenet): keep one BGS session per bot on the active site and detect games Sessions open only with battlenet.sessions.enabled on the active site, close on standby and reopen when the site becomes active. Presence pushes feed the Battle.net game getter; friend events are republished off the session loop. Replaced sessions and sessions needing an admin are logged and counted. --- .../catapult/getter/BattleNetGameGetter.java | 35 +- .../BattleNetFriendEventReceived.java | 12 + .../battlenet/BattleNetSessionManager.java | 416 ++++++++++++++++++ .../service/battlenet/BattleNetTitles.java | 26 ++ .../SimulatedBattleNetGameGetter.java | 4 +- .../getter/BattleNetGameGetterTest.java | 98 +++++ .../BattleNetSessionManagerTest.java | 289 ++++++++++++ .../battlenet/BattleNetTitlesTest.java | 26 ++ .../service/battlenet/FakeBgsChannel.java | 99 +++++ 9 files changed, 998 insertions(+), 7 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendEventReceived.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManager.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitles.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/getter/BattleNetGameGetterTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManagerTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetTitlesTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/FakeBgsChannel.java 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/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/BattleNetSessionManager.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManager.java new file mode 100644 index 000000000..678ff57bf --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManager.java @@ -0,0 +1,416 @@ +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.getLabel()); + try { + starter.execute(session::start); + } catch (RejectedExecutionException e) { + log.warn("Could not start the BGS session of {}: the executor is shut down", account.getLabel()); + } + } + + private void close(UUID accountId) { + Session session = sessions.remove(accountId); + if (session != null) { + log.info("Closing the BGS session of Battle.net service account {}", session.label); + 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, String label, 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 {}: {}", label, e.toString()); + } + } + + /** One account's connection and its callbacks. */ + private final class Session implements BgsListener { + + private final UUID accountId; + private final String label; + 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.label = account.getLabel(); + 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 {}", label, battleTag); + handOff("the BattleTag of " + label, () -> storeIdentity(accountId, label, 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 " + label, + () -> 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", label); + registry.counter(REPLACED_COUNTER, "account", label).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)", label); + registry.counter(ADMIN_ACTION_COUNTER, "account", label).increment(); + } + default -> log.debug("BGS session of Battle.net service account {} closed: {}", label, 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/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/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/service/battlenet/BattleNetSessionManagerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManagerTest.java new file mode 100644 index 000000000..175c83273 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetSessionManagerTest.java @@ -0,0 +1,289 @@ +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 label) { + BattleNetServiceAccount account = new BattleNetServiceAccount(); + account.setId(UUID.randomUUID()); + account.setLabel(label); + account.setEmail(label.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").count()) + .isEqualTo(1.0); + assertThat(channels).hasSize(2); // no new channel for Bot1 + } + + @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").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; + } +} From f7f2d81d97ec7a537be09d3fc01f2e64e5f0eac7 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:33:22 +0100 Subject: [PATCH 20/54] fix(battlenet): guard bot accounts against lost updates Version column on the service account; the login coordinator reloads and retries its auth state and storage state writes once when an admin edit raced them. --- .../battlenet/BattleNetServiceAccount.java | 4 +++ .../login/BattleNetLoginCoordinator.java | 31 ++++++++++++++----- .../V109__battlenet_account_version.sql | 1 + .../login/BattleNetLoginCoordinatorTest.java | 18 +++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 catapult-api/src/main/resources/db/migration/V109__battlenet_account_version.sql 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 index d632ddde4..c16ecbd69 100644 --- 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 @@ -66,6 +66,10 @@ public enum AuthState { @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/service/battlenet/login/BattleNetLoginCoordinator.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinator.java index a9f0f883a..925ae7918 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -13,6 +14,7 @@ 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; @@ -28,6 +30,7 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +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, @@ -250,18 +253,32 @@ private String fail(UUID accountId, BattleNetLoginFlow flow, String reason, bool private void saveSuccess(UUID accountId, @Nullable String storageState) { @Nullable String encrypted = storageState == null ? null : encryption.encrypt(storageState); - transactions.executeWithoutResult(status -> repository.findById(accountId).ifPresent(account -> { + update(accountId, account -> { if (encrypted != null) account.setStorageStateEnc(encrypted); account.setAuthState(AuthState.READY); - repository.save(account); - })); + }); } private void setAuthState(UUID accountId, AuthState state) { - transactions.executeWithoutResult(status -> repository.findById(accountId).ifPresent(account -> { - account.setAuthState(state); - repository.save(account); - })); + 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 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/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinatorTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/login/BattleNetLoginCoordinatorTest.java index 164287b43..85698f299 100644 --- 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 @@ -89,6 +89,24 @@ void ticketOnFirstTrySavesEncryptedStorageStateAndMarksReady() throws Exception 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)); From 1ba9db2fcc557a73623673643eb22983a14fbd77 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:38:45 +0100 Subject: [PATCH 21/54] feat(battlenet): manage the bot pool from the admin API Admin service and REST endpoints to enrol, re-authenticate, edit and delete the Battle.net bot accounts; the BGS session restarts once an interactive login is ready. --- .../ApiAdminBattleNetAccountsController.java | 57 ++++++ .../BattleNetFriendLinkRepository.java | 1 + .../BattleNetServiceAccountAdmin.java | 172 ++++++++++++++++ ...iAdminBattleNetAccountsControllerTest.java | 129 ++++++++++++ .../BattleNetServiceAccountAdminTest.java | 191 ++++++++++++++++++ 5 files changed, 550 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsController.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdmin.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsControllerTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdminTest.java 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..4bb669cb3 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsController.java @@ -0,0 +1,57 @@ +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("label"), 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/repository/battlenet/BattleNetFriendLinkRepository.java b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/battlenet/BattleNetFriendLinkRepository.java index df6283990..e1e0fbb3a 100644 --- 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 @@ -21,6 +21,7 @@ public interface BattleNetFriendLinkRepository extends JpaRepository 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); Optional findByServiceAccountAndBnetAccountId(BattleNetServiceAccount serviceAccount, 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..82ffb6d4e --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdmin.java @@ -0,0 +1,172 @@ +package fr.enimaloc.catapult.service.battlenet; + +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 fr.enimaloc.catapult.security.TokenEncryptionService; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginCoordinator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * 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, the password or the browser state. */ + public record AccountDto(UUID id, String label, String battleTag, String authState, String sessionState, + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount) {} + + private final BattleNetServiceAccountRepository accountRepository; + private final BattleNetFriendLinkRepository linkRepository; + private final TokenEncryptionService encryption; + private final BattleNetLoginCoordinator loginCoordinator; + private final BattleNetSessionManager sessionManager; + + 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 label, String email, String password) { + requireText(label, "label"); + requireText(email, "email"); + requireText(password, "password"); + BattleNetServiceAccount account = new BattleNetServiceAccount(); + account.setLabel(label); + 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.getLabel()); + startLogin(saved.getId()); + 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); + startLogin(id); + return toDto(account); + } + + /** 404 for an unknown account before 400 for a field of the wrong type. */ + public AccountDto update(UUID id, Map body) { + BattleNetServiceAccount account = findOrThrow(id); + boolean enabledChanged = false; + if (body.containsKey("label")) { + account.setLabel(requireText(field(body, "label", String.class), "label")); + } + if (body.containsKey("enabled")) { + boolean enabled = field(body, "enabled", Boolean.class); + enabledChanged = 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()); + } + boolean passwordChanged = body.containsKey("password"); + if (passwordChanged) { + account.setPasswordEnc(encryption.encrypt(requireText(field(body, "password", String.class), "password"))); + account.setAuthState(AuthState.NEW); + } + account.setUpdatedAt(Instant.now()); + BattleNetServiceAccount saved = accountRepository.save(account); + if (passwordChanged) { + // the bot must authenticate again with the new password + sessionManager.stop(id); + startLogin(id); + } else if (enabledChanged) { + sessionManager.restart(id); + } + return toDto(saved); + } + + /** Stops the session first, then drops the links and the account. */ + public void delete(UUID id) { + BattleNetServiceAccount account = findOrThrow(id); + sessionManager.stop(id); + linkRepository.deleteAll(linkRepository.findByServiceAccount(account)); + accountRepository.delete(account); + log.info("Battle.net service account deleted: {}", account.getLabel()); + } + + private void startLogin(UUID id) { + CompletableFuture login = loginCoordinator.startInteractive(id); + if (login.isCompletedExceptionally()) { + // a failure already there is a login refused to start (one is in progress), not a failed login + throw new ResponseStatusException(HttpStatus.CONFLICT, "A login is already in progress"); + } + 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()); + } + }); + } + + 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(), account.getLabel(), account.getBattleTag(), + account.getAuthState().name(), sessionManager.sessionState(account.getId()), + account.getFillOrder(), account.isFriendLimitReached(), account.isEnabled(), + linkRepository.countByServiceAccountAndStatus(account, BattleNetFriendLink.Status.ACCEPTED)); + } +} 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..7533353ce --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsControllerTest.java @@ -0,0 +1,129 @@ +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.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 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.setLabel("bot-1"); + 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))) + .setControllerAdvice(new ProblemAdvice()).build(); + } + + @Test + void listReturnsTheAccountsWithoutSecrets() throws Exception { + mvc.perform(get("/api/internal/admin/battlenet-accounts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].label").value("bot-1")) + .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("{\"label\":\"bot-2\",\"email\":\"a@b.c\",\"password\":\"pw\"}")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.authState").value("NEW")); + } + + @Test + void createWithoutPasswordIs400() throws Exception { + mvc.perform(post("/api/internal/admin/battlenet-accounts").contentType(MediaType.APPLICATION_JSON) + .content("{\"label\":\"bot-2\",\"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\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.label").value("renamed")); + 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/service/battlenet/BattleNetServiceAccountAdminTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdminTest.java new file mode 100644 index 000000000..bf977c13e --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetServiceAccountAdminTest.java @@ -0,0 +1,191 @@ +package fr.enimaloc.catapult.service.battlenet; + +import com.fasterxml.jackson.databind.ObjectMapper; +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 fr.enimaloc.catapult.security.TokenEncryptionService; +import fr.enimaloc.catapult.service.battlenet.BattleNetServiceAccountAdmin.AccountDto; +import fr.enimaloc.catapult.service.battlenet.login.BattleNetLoginCoordinator; +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 BattleNetServiceAccountAdmin admin = + new BattleNetServiceAccountAdmin(accounts, links, encryption, coordinator, sessions); + private final BattleNetServiceAccount account = new BattleNetServiceAccount(); + private CompletableFuture login; + + @BeforeEach + void setUp() { + account.setId(UUID.randomUUID()); + account.setLabel("bot-1"); + 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("bot-2", "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"); + } + + @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 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(); + 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 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() { + admin.update(account.getId(), + Map.of("label", "renamed", "fillOrder", 4, "friendLimitReached", false, "enabled", true)); + + assertThat(account.getLabel()).isEqualTo("renamed"); + assertThat(account.getFillOrder()).isEqualTo(4); + 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)); + } +} From e6fa26464854d83d9d50f9d5fdc33bff81d798d1 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:41:32 +0100 Subject: [PATCH 22/54] fix(battlenet): handle concurrent bot edits and in-flight logins in the admin API --- .../BattleNetServiceAccountAdmin.java | 110 ++++++++++++------ .../login/BattleNetLoginCoordinator.java | 5 + .../BattleNetServiceAccountAdminTest.java | 66 +++++++++++ 3 files changed, 147 insertions(+), 34 deletions(-) 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 index 82ffb6d4e..e8c730e35 100644 --- 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 @@ -11,6 +11,7 @@ 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; @@ -19,6 +20,7 @@ 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, @@ -59,7 +61,11 @@ public AccountDto create(String label, String email, String password) { account.setUpdatedAt(Instant.now()); BattleNetServiceAccount saved = accountRepository.save(account); log.info("Battle.net service account created: {}", saved.getLabel()); - startLogin(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); } @@ -77,40 +83,51 @@ public void submitCode(UUID id, String code) { /** Runs the interactive login again, for an account whose session needs an admin. */ public AccountDto reauth(UUID id) { BattleNetServiceAccount account = findOrThrow(id); - startLogin(id); + if (!startLogin(id)) throw loginInProgress(); return toDto(account); } - /** 404 for an unknown account before 400 for a field of the wrong type. */ + /** + * 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) { - BattleNetServiceAccount account = findOrThrow(id); - boolean enabledChanged = false; - if (body.containsKey("label")) { - account.setLabel(requireText(field(body, "label", String.class), "label")); - } - if (body.containsKey("enabled")) { - boolean enabled = field(body, "enabled", Boolean.class); - enabledChanged = 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()); - } + findOrThrow(id); boolean passwordChanged = body.containsKey("password"); - if (passwordChanged) { - account.setPasswordEnc(encryption.encrypt(requireText(field(body, "password", String.class), "password"))); - account.setAuthState(AuthState.NEW); - } - account.setUpdatedAt(Instant.now()); - BattleNetServiceAccount saved = accountRepository.save(account); + 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("label")) { + account.setLabel(requireText(field(body, "label", String.class), "label")); + } + 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); - startLogin(id); - } else if (enabledChanged) { + 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); @@ -118,18 +135,42 @@ public AccountDto update(UUID id, Map body) { /** Stops the session first, then drops the links and the account. */ public void delete(UUID id) { - BattleNetServiceAccount account = findOrThrow(id); + findOrThrow(id); sessionManager.stop(id); - linkRepository.deleteAll(linkRepository.findByServiceAccount(account)); - accountRepository.delete(account); - log.info("Battle.net service account deleted: {}", account.getLabel()); + BattleNetServiceAccount deleted = withRetry(() -> { + BattleNetServiceAccount account = findOrThrow(id); + linkRepository.deleteAll(linkRepository.findByServiceAccount(account)); + accountRepository.delete(account); + return account; + }); + log.info("Battle.net service account deleted: {}", deleted.getLabel()); + } + + /** + * 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"); } - private void startLogin(UUID id) { + /** 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()) { - // a failure already there is a login refused to start (one is in progress), not a failed login - throw new ResponseStatusException(HttpStatus.CONFLICT, "A login is already in progress"); + return false; } login.whenComplete((ignored, failure) -> { if (failure == null) { @@ -139,6 +180,7 @@ private void startLogin(UUID id) { failure.getClass().getSimpleName()); } }); + return true; } private BattleNetServiceAccount findOrThrow(UUID id) { 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 index 925ae7918..e7d80e244 100644 --- 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 @@ -117,6 +117,11 @@ public void submitCode(UUID accountId, String code) { 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(); 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 index bf977c13e..b6f327e83 100644 --- 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 @@ -188,4 +188,70 @@ void updateWithAWrongTypeIsABadRequest() { .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("label", "renamed")); + + assertThat(saves.get()).isEqualTo(2); + assertThat(dto.label()).isEqualTo("renamed"); + } + + @Test + void updateThatKeepsConflictingIsA409() { + when(accounts.save(any())).thenThrow(conflict()); + + assertThatThrownBy(() -> admin.update(account.getId(), Map.of("label", "renamed"))) + .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("bot-2", "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"); + } } From d1df9940f9aa2f3126b0c937dd88c55fb3d1e025 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:51:38 +0100 Subject: [PATCH 23/54] feat(battlenet): track friend links from BGS friend events Enrol streamers on the first ready bot with room and have it invite them; move links with the bot's friend events (accept, reject, remove), answer received invitations, mark a bot full on a friend cap failure and announce every change as a battlenet.link.changed channel event. Link lookups by BattleTag or account id now return lists, since nothing makes them unique per bot. --- .../BattleNetFriendLinkRepository.java | 20 +- .../battlenet/BattleNetFriendService.java | 389 ++++++++++++++ .../notification/ChannelEventPublisher.java | 11 + .../battlenet/BattleNetRepositoriesTest.java | 6 +- .../battlenet/BattleNetFriendServiceTest.java | 477 ++++++++++++++++++ .../ChannelEventPublisherTest.java | 9 + .../catapult/common/event/ChannelEvents.java | 8 + 7 files changed, 912 insertions(+), 8 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendService.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendServiceTest.java 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 index e1e0fbb3a..186681ca6 100644 --- 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 @@ -24,10 +24,20 @@ public interface BattleNetFriendLinkRepository extends JpaRepository findByServiceAccountAndBnetAccountId(BattleNetServiceAccount serviceAccount, - long bnetAccountId); + /** + * 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); - /** Attaches InvitationSent events, which carry account_id 0: matched by BattleTag instead. */ - Optional findByServiceAccountAndBattleTagIgnoreCase(BattleNetServiceAccount serviceAccount, - String battleTag); + List findByServiceAccountAndInvitationId(BattleNetServiceAccount serviceAccount, + long invitationId); } 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..288c8f936 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendService.java @@ -0,0 +1,389 @@ +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 {@code PENDING} link waits for on that bot. Every change of a link is announced to its channel. + *

+ * Database writes run in short transactions of their own: the BGS calls are asynchronous and never hold one. + */ +@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. 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.getLabel()); + channelEventPublisher.battleNetLinkChanged(user.getId(), Status.PENDING.name(), battleTag, bot.getBattleTag()); + + call(bot.getId(), client -> client.sendInvitation(battleTag)) + .whenCompleteAsync((ignored, failure) -> { + if (failure != null) { + invitationFailed(bot, unwrap(failure)); + } + }, followUps); + return link; + } + + /** + * 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 { + List changes = transactions.execute(status -> apply(received.serviceAccountId(), + received.event())); + if (changes != null) { + changes.forEach(change -> channelEventPublisher.battleNetLinkChanged(change.ownerId(), + change.status().name(), change.battleTag(), change.serviceAccountBattleTag())); + } + } 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) { + 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.getLabel()); + } + if (changes.isEmpty()) { + log.debug("Battle.net friend added on {} without a link to accept", bot.getLabel()); + } + } + 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.getLabel()); + } + } + if (changes.isEmpty()) { + log.debug("Battle.net friend removed on {} without an accepted link", bot.getLabel()); + } + } + 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.getLabel())); + } + 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.getLabel(), + sentRemoved.reason(), link.getUser().getId()); + } + } + case InvitationReceived invitation -> answer(bot, invitation); + case InvitationRemoved ignored -> { + // the outcome shows up as a FriendAdded, or not at all + } + } + return changes; + } + + /** Accepts the invitation a PENDING link of this bot waits for (same account id); declines any other. */ + private void answer(BattleNetServiceAccount bot, InvitationReceived invitation) { + boolean expected = linkRepository.findByServiceAccountAndBnetAccountId(bot, invitation.accountId()).stream() + .anyMatch(link -> link.getStatus() == Status.PENDING); + long invitationId = invitation.invitationId(); + if (expected) { + log.info("Accepting the Battle.net invitation of an enrolled streamer on {}", bot.getLabel()); + call(bot.getId(), client -> client.acceptInvitation(invitationId)).whenComplete((ignored, failure) -> { + if (failure != null) { + log.warn("acceptInvitation failed on {}: {}", bot.getLabel(), unwrap(failure).toString()); + } + }); + } else { + log.info("Declining a Battle.net invitation no link waits for on {}", bot.getLabel()); + call(bot.getId(), client -> client.declineInvitation(invitationId)).whenComplete((ignored, failure) -> { + if (failure != null) { + log.warn("declineInvitation failed on {}: {}", bot.getLabel(), unwrap(failure).toString()); + } + }); + } + } + + // --- internals --- + + /** Marks the bot full when the failure says so (or when it already holds a full friends list). */ + private void invitationFailed(BattleNetServiceAccount bot, Throwable failure) { + log.warn("The Battle.net invitation sent by {} failed, the link stays pending: {}", bot.getLabel(), + failure.toString()); + boolean full = failure instanceof BgsRpcException rpc && rpc.status() == STATUS_INVITER_AT_MAX_FRIENDS; + try { + if (full || linkRepository.countByServiceAccountAndStatus(bot, Status.ACCEPTED) >= FRIEND_CAP) { + markFull(bot.getId()); + } + } catch (RuntimeException e) { + log.warn("Could not check whether {} reached its friend cap: {}", bot.getLabel(), e.toString()); + } + } + + /** 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.getLabel()); + })); + } + + /** + * 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 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/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/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java index 858dad015..e210068c3 100644 --- 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 @@ -52,9 +52,9 @@ void savesAndFindsALinkByServiceAccountAndBnetAccountId() { em.persistAndFlush(link); em.clear(); - assertThat(links.findByServiceAccountAndBnetAccountId(bot, 100000002L)).isPresent() - .get().extracting(BattleNetFriendLink::getStatus).isEqualTo(BattleNetFriendLink.Status.PENDING); - assertThat(links.findByServiceAccountAndBattleTagIgnoreCase(bot, "streamer#2928")).isPresent(); + 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(); } 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..a726e3635 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/battlenet/BattleNetFriendServiceTest.java @@ -0,0 +1,477 @@ +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.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.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.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<>(); + + @BeforeEach + void setUp() { + service = new BattleNetFriendService(accountRepository, linkRepository, getterConfigRepository, publisher, + sessionManager, TransactionOperations.withoutTransaction(), 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 (!links.contains(link)) links.add(link); + return link; + }); + when(linkRepository.findWithServiceAccountByUser(any())).thenAnswer(inv -> links.stream() + .filter(link -> link.getUser() == inv.getArgument(0)).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); + return 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 label, int fillOrder, String battleTag) { + BattleNetServiceAccount bot = new BattleNetServiceAccount(); + bot.setId(UUID.randomUUID()); + bot.setLabel(label); + 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.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 enrollMarksTheBotFullWhenItsFriendsListIsFull() { + 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(); + verify(accountRepository).save(bot1); + } + + @Test + void enrollMarksTheBotFullOnAnyFailureAtTheFriendCap() { + when(client.sendInvitation(anyString())).thenReturn(CompletableFuture.failedFuture(new BgsRpcException(7))); + when(linkRepository.countByServiceAccountAndStatus(bot1, Status.ACCEPTED)) + .thenReturn((long) BattleNetFriendService.FRIEND_CAP); + + service.enroll(user, STREAMER_ID, STREAMER_TAG); + + assertThat(bot1.isFriendLimitReached()).isTrue(); + } + + @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 rejectedThenFriendAddedIsAccepted() { + BattleNetFriendLink link = link(bot1, Status.INVITE_REJECTED, 55L); + + receive(bot1, new FriendEvent.FriendAdded(STREAMER_ID, STREAMER_TAG)); + + assertThat(link.getStatus()).isEqualTo(Status.ACCEPTED); + } + + @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/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-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 From 95148c2c6bf6fe5628f869b42f648d4618446a64 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 17:56:31 +0100 Subject: [PATCH 24/54] fix(battlenet): answer invitations after commit and recover rejected or full links Send the accept/decline of a received invitation once the event's transaction is committed, accept the invitation of a streamer whose link was rejected, and move a link off a bot found full to the next bot with room (deleting it when none is left). --- .../battlenet/BattleNetFriendService.java | 138 +++++++++++++----- .../battlenet/BattleNetFriendServiceTest.java | 116 +++++++++++++-- 2 files changed, 209 insertions(+), 45 deletions(-) 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 index 288c8f936..ccdd89243 100644 --- 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 @@ -45,9 +45,11 @@ * {@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 {@code PENDING} link waits for on that bot. Every change of a link is announced to its channel. + * 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: the BGS calls are asynchronous and never hold one. + * Database writes run in short transactions of their own; every BGS call is made outside them, once they are + * committed. */ @Slf4j @Service @@ -105,8 +107,9 @@ public BattleNetFriendService(BattleNetServiceAccountRepository accountRepositor /** * 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. Without a bot to take the link, the existing one is left untouched. + * 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( @@ -135,13 +138,18 @@ public BattleNetFriendLink enroll(UserAccount user, long bnetAccountId, String b log.info("Battle.net link requested for user {} via {}", user.getId(), bot.getLabel()); 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, unwrap(failure)); + invitationFailed(bot, linkId, ownerId, battleTag, unwrap(failure)); } }, followUps); - return link; } /** @@ -190,19 +198,22 @@ public Optional getLink(UserAccount user) { @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())); + received.event(), afterCommit)); if (changes != null) { - changes.forEach(change -> channelEventPublisher.battleNetLinkChanged(change.ownerId(), - change.status().name(), change.battleTag(), change.serviceAccountBattleTag())); + 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) { + 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); @@ -270,7 +281,7 @@ private List apply(UUID botId, FriendEvent event) { sentRemoved.reason(), link.getUser().getId()); } } - case InvitationReceived invitation -> answer(bot, invitation); + case InvitationReceived invitation -> afterCommit.add(answer(bot, invitation)); case InvitationRemoved ignored -> { // the outcome shows up as a FriendAdded, or not at all } @@ -278,44 +289,96 @@ private List apply(UUID botId, FriendEvent event) { return changes; } - /** Accepts the invitation a PENDING link of this bot waits for (same account id); declines any other. */ - private void answer(BattleNetServiceAccount bot, InvitationReceived invitation) { + /** + * 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); + .anyMatch(link -> link.getStatus() == Status.PENDING || link.getStatus() == Status.INVITE_REJECTED); long invitationId = invitation.invitationId(); + UUID botId = bot.getId(); + String label = bot.getLabel(); if (expected) { - log.info("Accepting the Battle.net invitation of an enrolled streamer on {}", bot.getLabel()); - call(bot.getId(), client -> client.acceptInvitation(invitationId)).whenComplete((ignored, failure) -> { - if (failure != null) { - log.warn("acceptInvitation failed on {}: {}", bot.getLabel(), unwrap(failure).toString()); - } - }); - } else { - log.info("Declining a Battle.net invitation no link waits for on {}", bot.getLabel()); - call(bot.getId(), client -> client.declineInvitation(invitationId)).whenComplete((ignored, failure) -> { - if (failure != null) { - log.warn("declineInvitation failed on {}: {}", bot.getLabel(), unwrap(failure).toString()); - } - }); + log.info("Accepting the Battle.net invitation of an enrolled streamer on {}", label); + return () -> call(botId, client -> client.acceptInvitation(invitationId)) + .whenComplete((ignored, failure) -> { + if (failure != null) { + log.warn("acceptInvitation failed on {}: {}", label, unwrap(failure).toString()); + } + }); } + log.info("Declining a Battle.net invitation no link waits for on {}", label); + return () -> call(botId, client -> client.declineInvitation(invitationId)).whenComplete((ignored, failure) -> { + if (failure != null) { + log.warn("declineInvitation failed on {}: {}", label, unwrap(failure).toString()); + } + }); } // --- internals --- - /** Marks the bot full when the failure says so (or when it already holds a full friends list). */ - private void invitationFailed(BattleNetServiceAccount bot, Throwable failure) { - log.warn("The Battle.net invitation sent by {} failed, the link stays pending: {}", bot.getLabel(), - failure.toString()); - boolean full = failure instanceof BgsRpcException rpc && rpc.status() == STATUS_INVITER_AT_MAX_FRIENDS; + /** + * 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 { - if (full || linkRepository.countByServiceAccountAndStatus(bot, Status.ACCEPTED) >= FRIEND_CAP) { - markFull(bot.getId()); + 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.getLabel(), + failure.toString()); + return; } + log.warn("The Battle.net invitation sent by {} shows its friends list is full: {}", bot.getLabel(), + failure.toString()); + markFull(bot.getId()); + relocate(bot.getId(), linkId, ownerId, battleTag); } catch (RuntimeException e) { - log.warn("Could not check whether {} reached its friend cap: {}", bot.getLabel(), e.toString()); + log.warn("Could not handle the failed invitation of {}: {}", bot.getLabel(), 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].getLabel()); + 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 -> { @@ -350,6 +413,11 @@ private Change save(BattleNetFriendLink link, BattleNetServiceAccount bot) { 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); 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 index a726e3635..edba26c6b 100644 --- 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 @@ -14,6 +14,7 @@ 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; @@ -22,6 +23,9 @@ 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; @@ -43,6 +47,7 @@ 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; @@ -66,11 +71,43 @@ class BattleNetFriendServiceTest { 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, TransactionOperations.withoutTransaction(), Runnable::run); + sessionManager, transactions, Runnable::run); user = new UserAccount(); user.setId(UUID.randomUUID()); bot1 = bot("bot1", 1, "CatapultBot#1111"); @@ -82,11 +119,14 @@ void setUp() { 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() @@ -103,7 +143,8 @@ void setUp() { when(sessionManager.withClient(any(), any())).thenAnswer(inv -> { Function> action = inv.getArgument(1); - return action.apply(client); + 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)); @@ -124,6 +165,7 @@ private static BattleNetServiceAccount bot(String label, int fillOrder, String b 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); @@ -189,25 +231,54 @@ void enrollKeepsTheLinkWithoutAReadySession() { } @Test - void enrollMarksTheBotFullWhenItsFriendsListIsFull() { - when(client.sendInvitation(anyString())).thenReturn(CompletableFuture.failedFuture( - new BgsRpcException(BattleNetFriendService.STATUS_INVITER_AT_MAX_FRIENDS))); + void fullBotIsMarkedAndTheLinkMovesToTheNextBotWhichInvites() { + when(client.sendInvitation(anyString())) + .thenReturn(CompletableFuture.failedFuture( + new BgsRpcException(BattleNetFriendService.STATUS_INVITER_AT_MAX_FRIENDS))) + .thenReturn(CompletableFuture.completedFuture(null)); - service.enroll(user, STREAMER_ID, STREAMER_TAG); + 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 enrollMarksTheBotFullOnAnyFailureAtTheFriendCap() { - when(client.sendInvitation(anyString())).thenReturn(CompletableFuture.failedFuture(new BgsRpcException(7))); + 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 @@ -361,14 +432,39 @@ void sentInvitationRemovedOfAnotherInvitationLeavesTheLinkPending() { } @Test - void rejectedThenFriendAddedIsAccepted() { - BattleNetFriendLink link = link(bot1, Status.INVITE_REJECTED, 55L); + 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); From cdba7e04141096454d981c3df2a0bc5c9b8e5619 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:04:39 +0100 Subject: [PATCH 25/54] feat(battlenet): link a streamer's Battle.net account through Blizzard OAuth Secondary OAuth link on the signed-in Twitch user: the userinfo id and BattleTag enroll the account with a bot, no token is kept. Adds the connect endpoints (state, leave) and the Battle.net data on the channel dashboard. --- .../channel/ChannelDashboardAssembler.java | 14 ++ .../ApiBattleNetConnectController.java | 62 +++++++++ .../security/CatapultOAuth2UserService.java | 75 +++++++++- .../security/Oauth2LoginFailureHandler.java | 2 +- .../security/TwitchLoginSuccessHandler.java | 24 +++- .../ChannelDashboardAssemblerTest.java | 47 ++++++- .../ApiBattleNetConnectControllerTest.java | 107 ++++++++++++++ .../CatapultOAuth2UserServiceLoginTest.java | 7 +- .../CatapultOAuth2UserServiceTest.java | 130 +++++++++++++++++- .../account/FictitiousAccountServiceTest.java | 7 +- .../common/dto/channel/BattleNetData.java | 11 ++ .../common/dto/channel/ChannelPageData.java | 20 ++- 12 files changed, 481 insertions(+), 25 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectController.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectControllerTest.java create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/BattleNetData.java 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..c96a2a520 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, @@ -249,6 +254,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(), 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..ed9097c2f --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectController.java @@ -0,0 +1,62 @@ +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.repository.account.UserAccountRepository; +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 org.springframework.web.server.ResponseStatusException; + +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 UserAccountRepository userAccountRepository; + + @PreAuthorize("@authz.isSelf(#userId)") + @GetMapping + public BattleNetLinkStateResponse status(@PathVariable UUID userId, @AuthenticationPrincipal Jwt jwt) { + return linkStates.state(currentUser(jwt)); + } + + @PreAuthorize("@authz.isSelf(#userId)") + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unenroll(@PathVariable UUID userId, @AuthenticationPrincipal Jwt jwt) { + UserAccount user = currentUser(jwt); + if (!gateService.isAvailableFor(user)) { + throw new ApiProblem(ErrorCode.FEATURE_DISABLED, "Battle.net is not available for this account"); + } + friendService.unenroll(user); + } + + private UserAccount currentUser(Jwt jwt) { + UUID caller = UUID.fromString(ApiUserResolver.requireJwt(jwt).getSubject()); + return userAccountRepository.findById(caller) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user")); + } +} 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..67664a26d 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,13 @@ 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): nothing to + // save, only send the user back to their channel's integrations tab. + 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 +96,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 +105,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/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java index 4a3cfc844..30bedcc01 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) { @@ -391,7 +404,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 +457,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"); @@ -810,7 +849,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..9dd92f134 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/connect/ApiBattleNetConnectControllerTest.java @@ -0,0 +1,107 @@ +package fr.enimaloc.catapult.api.connect; + +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, 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/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/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-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); } } From 4486e5aa91f6aec69e3013dd9a9b5e186394ecba Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:12:18 +0100 Subject: [PATCH 26/54] feat(web): add the Battle.net connection card Live-updated from BattleNetLinkChanged, with a case per link status, the OAuth connect button, a disconnect action and mock data for the mock channel page. --- .../controller/ChannelActionsController.java | 15 ++++ .../controller/ChannelEventRedisBridge.java | 6 ++ .../catapult/controller/ChannelState.java | 7 ++ .../catapult/controller/IntegrationCards.java | 58 ++++++++++++++ .../event/battlenet/BattleNetLinkEvent.java | 8 ++ .../enimaloc/catapult/service/ApiService.java | 5 ++ .../catapult/service/mock/MockApiService.java | 18 +++++ .../catapult/service/mock/MockData.java | 31 ++++++- .../catapult/service/real/RealApiService.java | 15 ++++ .../main/resources/lang/messages.properties | 10 +++ .../resources/lang/messages_en.properties | 10 +++ .../resources/lang/messages_fr.properties | 10 +++ .../main/resources/static/css/catapult.css | 12 +++ .../static/js/channel-connections.js | 6 +- .../main/resources/static/js/channel-page.js | 3 +- .../ChannelActionsControllerTest.java | 11 +++ .../ChannelEventRedisBridgeTest.java | 11 +++ .../catapult/controller/ChannelStateTest.java | 11 +++ .../ChannelVisibilityRenderingTest.java | 80 +++++++++++++++++++ .../service/mock/MockApiServiceTest.java | 12 +++ .../service/real/RealApiServiceTest.java | 11 +++ .../src/test/js/channel-actions.test.js | 8 ++ 22 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 catapult-web/src/main/java/fr/enimaloc/catapult/event/battlenet/BattleNetLinkEvent.java 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/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..3d9cf0922 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"))))), + + // 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.SEGMENTED_GROUP, List.of( + segmented(Attrs.id("battlenet-connect-btn") + .withState(battleNetState("showConnect", status, + List.of("NONE", "INVITE_REJECTED", "REMOVED"))), + key(PREFIX + "battlenet.connect")), + segmented(Attrs.id("battlenet-disconnect-btn") + .withState(battleNetState("showDisconnect", status, List.of("ACCEPTED"))), + 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..af8b03fe7 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 @@ -33,6 +33,7 @@ import fr.enimaloc.catapult.common.dto.admin.TwRebuild; import fr.enimaloc.catapult.common.dto.admin.AccountDto; 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; @@ -103,6 +104,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. */ 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..51b35b1af 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 @@ -65,6 +65,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 +86,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; @@ -513,6 +515,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); 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/real/RealApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java index 162e4ac90..cb86b801b 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 @@ -45,6 +45,7 @@ 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; @@ -353,6 +354,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"), diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index cd0465355..e773d51c8 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 diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index a8f300f09..a9b182543 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 diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 8c791df0b..fbf191d29 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 diff --git a/catapult-web/src/main/resources/static/css/catapult.css b/catapult-web/src/main/resources/static/css/catapult.css index da308c081..bfc79c604 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. */ 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/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/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..d0609e0f9 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,69 @@ 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"); + } + + @Test + void pendingCaseShowsTheBotTag() throws Exception { + String html = battleNetPage(new BattleNetData("PENDING", "Me#1", "CatapultBot#4242")); + + String pendingCase = html.substring(html.lastIndexOf('<', html.indexOf("data-if=\"casePending\""))); + assertThat(pendingCase.substring(0, pendingCase.indexOf('>'))).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 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/real/RealApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java index fddaf193c..92c56d47d 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()); 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"; From 4b1b6b41cc89176b8fbcd878e771ef9d04e58fdc Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:14:31 +0100 Subject: [PATCH 27/54] fix(web): keep Battle.net tags out of moderators' channel streams --- .../controller/ChannelEventStreamController.java | 8 ++++++-- .../ChannelEventStreamControllerTest.java | 15 +++++++++++++++ .../ChannelVisibilityRenderingTest.java | 7 +++++++ 3 files changed, 28 insertions(+), 2 deletions(-) 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/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/ChannelVisibilityRenderingTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/ChannelVisibilityRenderingTest.java index d0609e0f9..2dceece0a 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 @@ -754,6 +754,13 @@ void rejectedAndRemovedOfferTheConnectButtonAgain() throws Exception { } } + @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")); From fb9983edb36211fb7354995ba4ca1e70610ca88a Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:20:56 +0100 Subject: [PATCH 28/54] feat(web): manage Battle.net bots from the admin Keys tab Adds the bot pool section to the Keys tab: list, add, 2FA code entry, reauth, enable/disable, lift full, change password and delete. The tab refreshes every 3 s while a login is pending. Includes the mock store. --- .../common/dto/admin/BattleNetAccountDto.java | 7 + .../controller/AdminBattleNetController.java | 102 ++++++++++ .../AdminTabsFragmentController.java | 3 +- .../enimaloc/catapult/service/ApiService.java | 17 ++ .../catapult/service/mock/MockApiService.java | 38 ++++ .../mock/MockBattleNetAccountsStore.java | 93 +++++++++ .../catapult/service/real/RealApiService.java | 36 ++++ .../main/resources/lang/messages.properties | 50 +++++ .../resources/lang/messages_en.properties | 50 +++++ .../resources/lang/messages_fr.properties | 50 +++++ .../main/resources/static/css/catapult.css | 21 +- .../resources/static/js/admin-battlenet.js | 164 ++++++++++++++++ .../src/main/resources/templates/index.html | 1 + .../templates/pages/admin-tab/keys.html | 100 ++++++++++ .../AdminBattleNetControllerTest.java | 112 +++++++++++ .../AdminTabsFragmentControllerTest.java | 53 +++++ .../mock/MockBattleNetAccountsStoreTest.java | 61 ++++++ .../service/real/RealApiServiceTest.java | 45 +++++ .../src/test/js/admin-battlenet.test.js | 185 ++++++++++++++++++ 19 files changed, 1183 insertions(+), 5 deletions(-) create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/BattleNetAccountDto.java create mode 100644 catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java create mode 100644 catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStore.java create mode 100644 catapult-web/src/main/resources/static/js/admin-battlenet.js create mode 100644 catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminBattleNetControllerTest.java create mode 100644 catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStoreTest.java create mode 100644 catapult-web/src/test/js/admin-battlenet.test.js 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..0376ff669 --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/BattleNetAccountDto.java @@ -0,0 +1,7 @@ +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. */ +public record BattleNetAccountDto(UUID id, String label, String battleTag, String authState, String sessionState, + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount) {} 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..ca1262d2f --- /dev/null +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java @@ -0,0 +1,102 @@ +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, 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 label, String email, String password) { + /** Never print the credentials. */ + @Override + public String toString() { + return "CreateBody[label=" + label + "]"; + } + } + + public record CodeBody(String code) { + @Override + public String toString() { + return "CodeBody[]"; + } + } + + public record EnabledBody(Boolean enabled) {} + + public record PasswordBody(String password) { + @Override + public String toString() { + return "PasswordBody[]"; + } + } + + @PostMapping + public ResponseEntity create(@RequestBody CreateBody body) { + AdminAccess.require(filler); + if (blank(body.label()) || blank(body.email()) || blank(body.password())) return ResponseEntity.badRequest().build(); + return ResponseEntity.status(apiService.adminCreateBattleNetAccount( + body.label().trim(), 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}/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/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/service/ApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java index af8b03fe7..6d277ddab 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,6 +32,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.MinecraftDeviceCodeDto; import fr.enimaloc.catapult.common.dto.connect.BattleNetLinkStateResponse; import fr.enimaloc.catapult.common.dto.admin.WhitelistPageDto; @@ -521,6 +522,22 @@ record MinecraftDeviceCodeStart(int status, MinecraftDeviceCodeDto code, ApiErro /** 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 label, 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 label}, {@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 51b35b1af..8ec71003b 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 @@ -35,6 +35,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; @@ -192,6 +193,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. */ @@ -1748,6 +1750,42 @@ public int adminRefreshApiKeys(ApiKeyProvider provider) { return apiKeys.refresh(); } + @Override + public List adminBattleNetAccounts() { + log.trace("adminBattleNetAccounts()"); + return battleNetAccounts.list(); + } + + @Override + public int adminCreateBattleNetAccount(String label, String email, String password) { + log.trace("adminCreateBattleNetAccount({})", label); + return battleNetAccounts.create(label); + } + + @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()"); 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..e91737dc4 --- /dev/null +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStore.java @@ -0,0 +1,93 @@ +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. + */ +class MockBattleNetAccountsStore { + static final String CODE = "123456"; + + private record Account(UUID id, String label, String battleTag, String authState, String sessionState, + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount) { + BattleNetAccountDto dto() { + return new BattleNetAccountDto(id, label, battleTag, authState, sessionState, fillOrder, + friendLimitReached, enabled, friendCount); + } + + Account with(String authState, String sessionState, boolean friendLimitReached, boolean enabled) { + return new Account(id, label, 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", "TestBot#1000", + "READY", "READY", 0, false, true, 42), + new Account(UUID.fromString("7b3a1d2f-0000-4000-8000-000000000002"), "TestBot2", null, + "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", 1, false, true, 0))); + + synchronized List list() { + return accounts.stream().map(Account::dto).toList(); + } + + synchronized int create(String label) { + String name = label == null || label.isBlank() ? "Battle.net bot" : label; + accounts.add(new Account(UUID.randomUUID(), name, 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)) { + accounts.set(index, new Account(a.id(), a.label(), a.battleTag() == null ? a.label() + "#0001" : a.battleTag(), + "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; + } +} 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 cb86b801b..3d5c79140 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,6 +39,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.MetaTagDto; import fr.enimaloc.catapult.common.dto.admin.MetaTagRequest; import fr.enimaloc.catapult.common.dto.admin.SocialLinkDto; @@ -1359,6 +1360,41 @@ public int adminRefreshApiKeys(ApiKeyProvider provider) { return result.status(); } + private static final String BATTLENET_ACCOUNTS = "/api/internal/admin/battlenet-accounts"; + + @Override + public List adminBattleNetAccounts() { + // a 404 (Battle.net disabled in catapult-api) is null, like any failed read + BattleNetAccountDto[] accounts = client.get(BATTLENET_ACCOUNTS, BattleNetAccountDto[].class); + return accounts == null ? null : List.of(accounts); + } + + @Override + public int adminCreateBattleNetAccount(String label, String email, String password) { + return client.call(HttpMethod.POST, BATTLENET_ACCOUNTS, + Map.of("label", label, "email", email, "password", password), Void.class).status(); + } + + @Override + public int adminSubmitBattleNetCode(UUID id, String code) { + return client.call(HttpMethod.POST, BATTLENET_ACCOUNTS + "/{id}/code", Map.of("code", code), Void.class, id).status(); + } + + @Override + public int adminReauthBattleNetAccount(UUID id) { + return client.call(HttpMethod.POST, BATTLENET_ACCOUNTS + "/{id}/reauth", null, Void.class, id).status(); + } + + @Override + public int adminPatchBattleNetAccount(UUID id, Map patch) { + return client.call(HttpMethod.PATCH, BATTLENET_ACCOUNTS + "/{id}", patch, Void.class, id).status(); + } + + @Override + public int adminDeleteBattleNetAccount(UUID id) { + return client.call(HttpMethod.DELETE, 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 diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index e773d51c8..84d25241a 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -1536,3 +1536,53 @@ 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.label=Label +admin.battlenet.col.battletag=BattleTag +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.password.label=New password +admin.battlenet.password.submit=Change password +admin.battlenet.enroll.label=Label (e.g. Bot 1) +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. diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index a9b182543..fc36cb2c5 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -1482,3 +1482,53 @@ 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.label=Label +admin.battlenet.col.battletag=BattleTag +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.password.label=New password +admin.battlenet.password.submit=Change password +admin.battlenet.enroll.label=Label (e.g. Bot 1) +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. diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index fbf191d29..57ea44bb1 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -1536,3 +1536,53 @@ 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.label=Libellé +admin.battlenet.col.battletag=BattleTag +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.password.label=Nouveau mot de passe +admin.battlenet.password.submit=Changer le mot de passe +admin.battlenet.enroll.label=Libellé (ex. Bot 1) +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. diff --git a/catapult-web/src/main/resources/static/css/catapult.css b/catapult-web/src/main/resources/static/css/catapult.css index bfc79c604..1d53e0b62 100644 --- a/catapult-web/src/main/resources/static/css/catapult.css +++ b/catapult-web/src/main/resources/static/css/catapult.css @@ -2109,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; @@ -2516,6 +2516,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) 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 { 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..e2d0832c8 --- /dev/null +++ b/catapult-web/src/main/resources/static/js/admin-battlenet.js @@ -0,0 +1,164 @@ +/** + * The admin Keys tab's Battle.net bot pool card. Add a bot (label, 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. Labels and BattleTags come from the server-rendered rows, never + * from innerHTML. + * + * While a bot is NEW or CODE_REQUIRED (its row's data-auth-state) 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"]'; + 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 = ["label", "email", "password"].map(name => enroll.querySelector(`[name="${name}"]`)); + const [label, email, password] = fields.map(field => (field?.value ?? "").trim()); + if (!label || !email || !(fields[2]?.value ?? "")) return show(card, card.dataset.labelInvalid, true); + // the password is sent as typed (not trimmed) + return act(panel, card, button, BASE, { label, email, password: fields[2].value }, 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]); + } + + function stopPolling() { + clearTimeout(timer); + timer = null; + } + + /** Patches the tab again in 3 s while a bot is NEW or CODE_REQUIRED; no second timer, none otherwise. */ + function schedule() { + const panel = panelOf(); + if (timer !== null || !panel || !panel.querySelector(PENDING)) return; + timer = setTimeout(async () => { + timer = null; + const current = panelOf(); + if (!current || !current.querySelector(PENDING)) return; + try { + await CatapultAdminTabs.patch(current); + } finally { + schedule(); + } + }, 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-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-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/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/keys.html b/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html index 5a474e780..018702ff9 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 @@ -158,5 +158,105 @@

Minecraft service accounts

+ +
+ +
+

Battle.net bots

+
+ +

Unavailable.

+ + +

None.

+
+ +
+ Bot1 + Bot#1234 + READY + READY + 0 + + Enabled + Disabled + Full + + + Re-authenticate + Disable + Lift full + + +
+ + Send the code +
+
+ + 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..5357eb74d --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminBattleNetControllerTest.java @@ -0,0 +1,112 @@ +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("Bot1", "bot@example.com", " s3cret ")).thenReturn(202); + + mvc.perform(admin(post(BASE), "{\"label\":\" Bot1 \",\"email\":\"bot@example.com \",\"password\":\" s3cret \"}")) + .andExpect(status().isAccepted()) + .andExpect(content().string("")); + verify(apiService).adminCreateBattleNetAccount("Bot1", "bot@example.com", " s3cret "); + } + + @Test + void createWithAMissingFieldIs400AndNeverCallsTheApi() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + + mvc.perform(admin(post(BASE), "{\"label\":\"Bot1\",\"email\":\"\",\"password\":\"x\"}")).andExpect(status().isBadRequest()); + mvc.perform(admin(post(BASE), "{\"label\":\"Bot1\",\"email\":\"a@b.c\"}")).andExpect(status().isBadRequest()); + verify(apiService, never()).adminCreateBattleNetAccount(org.mockito.ArgumentMatchers.any(), + 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 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), "{\"label\":\"a\",\"email\":\"b\",\"password\":\"c\"}")).andExpect(status().is4xxClientError()); + verify(apiService, never()).adminCreateBattleNetAccount(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.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..1eb82d3a5 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 @@ -991,6 +991,59 @@ 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 label, String tag, + String auth, String session, boolean enabled, boolean full) { + return new fr.enimaloc.catapult.common.dto.admin.BattleNetAccountDto(java.util.UUID.fromString(id), label, tag, auth, + session, 0, full, enabled, 4); + } + + @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", "Waiting", null, "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", false, false))); + + String html = asAdmin("/spa/admin/keys").replaceAll("\\s+", " "); + + assertThat(html).contains("id=\"admin-battlenet\"", "Battle.net bots", "Ready<b>", "Tag#1", "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 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/service/mock/MockBattleNetAccountsStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStoreTest.java new file mode 100644 index 000000000..cc7d46adc --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockBattleNetAccountsStoreTest.java @@ -0,0 +1,61 @@ +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(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")).isEqualTo(202); + BattleNetAccountDto created = store.list().stream().filter(a -> a.label().equals("Bot9")).findFirst().orElseThrow(); + assertThat(created.authState()).isEqualTo("CODE_REQUIRED"); + + 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/real/RealApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java index 92c56d47d..2250b0723 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 @@ -1877,6 +1877,51 @@ 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\"," + + "\"label\":\"Bot1\",\"battleTag\":\"Bot#1\",\"authState\":\"CODE_REQUIRED\",\"sessionState\":\"STOPPED_CODE_REQUIRED\"," + + "\"fillOrder\":2,\"friendLimitReached\":true,\"enabled\":false,\"friendCount\":7}]"); + assertThat(service.adminBattleNetAccounts()).singleElement().satisfies(a -> { + 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(); + }); + } + + @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("{\"label\":\"Bot\",\"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("Bot", "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( 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..72fbf2585 --- /dev/null +++ b/catapult-web/src/test/js/admin-battlenet.test.js @@ -0,0 +1,185 @@ +/* + * 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 CODE_REQUIRED. + */ +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) { + return ` +
+ + + + +
+
+
`; +} + +function page(state = "READY") { + document.body.innerHTML = ` + + +
+
+
+ ${row(state)} +
+ + +
+ +
+
+
+
+ +
`; + 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="label"]').value = " Bot1 "; + 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", { label: "Bot1", 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="label"]').value = "Bot1"; + + 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 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 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); + }); +}); From 6538ae89cec0e3295e1d1daaf26990c9912f9428 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:24:28 +0100 Subject: [PATCH 29/54] fix(web): stop Battle.net polling off the Keys tab and edit bot labels Polling only runs while the Keys tab is shown, including after an in-flight tick. Each bot row can now rename the bot and change its fill order. --- .../controller/AdminBattleNetController.java | 12 +++++- .../main/resources/lang/messages.properties | 3 ++ .../resources/lang/messages_en.properties | 3 ++ .../resources/lang/messages_fr.properties | 3 ++ .../main/resources/static/css/catapult.css | 2 +- .../resources/static/js/admin-battlenet.js | 24 ++++++++++-- .../templates/pages/admin-tab/keys.html | 8 ++++ .../AdminBattleNetControllerTest.java | 11 ++++++ .../src/test/js/admin-battlenet.test.js | 37 +++++++++++++++++++ 9 files changed, 97 insertions(+), 6 deletions(-) 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 index ca1262d2f..3470e6b95 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java @@ -14,7 +14,7 @@ /** * The admin Keys tab's Battle.net bot pool: add a bot, submit its 2FA code, re-authenticate, enable / - * disable, lift "full", change its password and delete, each answered with catapult-api's status + * disable, rename and 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. */ @@ -42,6 +42,8 @@ public String toString() { public record EnabledBody(Boolean enabled) {} + public record DetailsBody(String label, Integer fillOrder) {} + public record PasswordBody(String password) { @Override public String toString() { @@ -83,6 +85,14 @@ public ResponseEntity resetLimit(@PathVariable UUID id) { 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 (blank(body.label()) || body.fillOrder() == null || body.fillOrder() < 0) return ResponseEntity.badRequest().build(); + return ResponseEntity.status(apiService.adminPatchBattleNetAccount(id, + Map.of("label", body.label().trim(), "fillOrder", body.fillOrder()))).build(); + } + @PostMapping("/{id}/password") public ResponseEntity password(@PathVariable UUID id, @RequestBody PasswordBody body) { AdminAccess.require(filler); diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index 84d25241a..6e5b64d5a 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -1586,3 +1586,6 @@ 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.label=Label +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 fc36cb2c5..646c0f04d 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -1532,3 +1532,6 @@ 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.label=Label +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 57ea44bb1..8330b653e 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -1586,3 +1586,6 @@ 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.label=Libellé +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 1d53e0b62..36eef0bac 100644 --- a/catapult-web/src/main/resources/static/css/catapult.css +++ b/catapult-web/src/main/resources/static/css/catapult.css @@ -2519,7 +2519,7 @@ mdui-snackbar.broadcast-warning { --mdui-color-inverse-surface: var(--mdui-color .admin-battlenet-row, .admin-table:has(> .admin-battlenet-row) { grid-template-columns: minmax(0, 1fr) 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 { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.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); } diff --git a/catapult-web/src/main/resources/static/js/admin-battlenet.js b/catapult-web/src/main/resources/static/js/admin-battlenet.js index e2d0832c8..317f94dcd 100644 --- a/catapult-web/src/main/resources/static/js/admin-battlenet.js +++ b/catapult-web/src/main/resources/static/js/admin-battlenet.js @@ -102,6 +102,21 @@ window.CatapultAdminBattleNet = (function () { 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 label = (box?.querySelector('[name="label"]')?.value ?? "").trim(); + const order = Number(box?.querySelector('[name="fillOrder"]')?.value); + if (!label || !Number.isInteger(order) || order < 0) return show(card, card.dataset.labelInvalid, true); + return act(panel, card, button, `${BASE}/${encodeURIComponent(button.dataset.id)}/details`, + { label, fillOrder: order }, card.dataset.labelUpdated); + } + function stopPolling() { clearTimeout(timer); timer = null; @@ -110,15 +125,15 @@ window.CatapultAdminBattleNet = (function () { /** Patches the tab again in 3 s while a bot is NEW or CODE_REQUIRED; no second timer, none otherwise. */ function schedule() { const panel = panelOf(); - if (timer !== null || !panel || !panel.querySelector(PENDING)) return; + if (timer !== null || !panel || !keysShown() || !panel.querySelector(PENDING)) return; timer = setTimeout(async () => { timer = null; const current = panelOf(); - if (!current || !current.querySelector(PENDING)) return; + if (!current || !keysShown() || !current.querySelector(PENDING)) return; try { await CatapultAdminTabs.patch(current); } finally { - schedule(); + schedule(); // a no-op once the tab was left meanwhile } }, POLL_MS); } @@ -130,7 +145,7 @@ window.CatapultAdminBattleNet = (function () { 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-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"); @@ -139,6 +154,7 @@ window.CatapultAdminBattleNet = (function () { 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); 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 018702ff9..53ececae5 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 @@ -223,6 +223,14 @@

Battle.net bots

Send the code +
+ + + Save +
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 index 5357eb74d..320926f52 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminBattleNetControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminBattleNetControllerTest.java @@ -93,6 +93,17 @@ void enabledResetLimitAndPasswordPatchTheAccount() throws Exception { mvc.perform(admin(post(BASE + "/" + ID + "/password"), "{\"password\":\" \"}")).andExpect(status().isBadRequest()); } + @Test + void detailsForwardsLabelAndFillOrder_andValidates() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminPatchBattleNetAccount(ID, Map.of("label", "Bot2", "fillOrder", 3))).thenReturn(200); + + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{\"label\":\" Bot2 \",\"fillOrder\":3}")).andExpect(status().isOk()); + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{\"label\":\" \",\"fillOrder\":3}")).andExpect(status().isBadRequest()); + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{\"label\":\"a\",\"fillOrder\":-1}")).andExpect(status().isBadRequest()); + mvc.perform(admin(post(BASE + "/" + ID + "/details"), "{\"label\":\"a\"}")).andExpect(status().isBadRequest()); + } + @Test void deleteRelaysTheStatus() throws Exception { when(apiService.isAdmin()).thenReturn(true); diff --git a/catapult-web/src/test/js/admin-battlenet.test.js b/catapult-web/src/test/js/admin-battlenet.test.js index 72fbf2585..cc172441b 100644 --- a/catapult-web/src/test/js/admin-battlenet.test.js +++ b/catapult-web/src/test/js/admin-battlenet.test.js @@ -17,6 +17,7 @@ function row(state) {
+
`; } @@ -147,6 +148,22 @@ describe("admin-battlenet actions", () => { }); }); +describe("admin-battlenet details", () => { + it("sends the label and the fill order, refusing a blank label or a negative order", async () => { + await click(".admin-battlenet-details-submit"); + expect(posted).toHaveBeenLastCalledWith(`/admin/battlenet-accounts/${ID}/details`, { label: "Bot", 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"; + document.querySelector('.admin-battlenet-details [name="label"]').value = " "; + 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"); @@ -182,4 +199,24 @@ describe("admin-battlenet polling", () => { 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); + }); }); From 550093a8ffb99fc51ff567604e1e3633b3f19b05 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:36:15 +0100 Subject: [PATCH 30/54] fix(docker): copy the catapult-bgs build file into the web image builder settings.gradle.kts includes catapult-bgs, so Gradle refuses to configure the build when its directory is missing from the web builder stage. --- catapult-web/Dockerfile | 1 + 1 file changed, 1 insertion(+) 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 From 140ad6475085683b47056f5a441e8b684cf9518f Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:37:57 +0100 Subject: [PATCH 31/54] test(battlenet): cover the Battle.net routes in the route and access checks The route, convention and access-matrix contexts now enable Battle.net, the matrix pins its user and admin routes, and catapult-web spells each admin path in its call so the route check reads it. --- .../catapult/api/RouteConventionsTest.java | 6 +++++- .../catapult/api/WebCallsMatchApiRoutesTest.java | 6 +++++- .../security/access/AccessMatrixSupport.java | 6 +++++- catapult-api/src/test/resources/access-matrix.csv | 10 ++++++++++ .../catapult/service/real/RealApiService.java | 14 ++++++-------- 5 files changed, 31 insertions(+), 11 deletions(-) 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/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/resources/access-matrix.csv b/catapult-api/src/test/resources/access-matrix.csv index bb1dad6a5..2141ac664 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 @@ -305,6 +309,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-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 3d5c79140..41d611fde 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 @@ -1360,39 +1360,37 @@ public int adminRefreshApiKeys(ApiKeyProvider provider) { return result.status(); } - private static final String BATTLENET_ACCOUNTS = "/api/internal/admin/battlenet-accounts"; - @Override public List adminBattleNetAccounts() { // a 404 (Battle.net disabled in catapult-api) is null, like any failed read - BattleNetAccountDto[] accounts = client.get(BATTLENET_ACCOUNTS, BattleNetAccountDto[].class); + BattleNetAccountDto[] accounts = client.get("/api/internal/admin/battlenet-accounts", BattleNetAccountDto[].class); return accounts == null ? null : List.of(accounts); } @Override public int adminCreateBattleNetAccount(String label, String email, String password) { - return client.call(HttpMethod.POST, BATTLENET_ACCOUNTS, + return client.call(HttpMethod.POST, "/api/internal/admin/battlenet-accounts", Map.of("label", label, "email", email, "password", password), Void.class).status(); } @Override public int adminSubmitBattleNetCode(UUID id, String code) { - return client.call(HttpMethod.POST, BATTLENET_ACCOUNTS + "/{id}/code", Map.of("code", code), Void.class, id).status(); + 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, BATTLENET_ACCOUNTS + "/{id}/reauth", null, Void.class, id).status(); + 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, BATTLENET_ACCOUNTS + "/{id}", patch, Void.class, id).status(); + 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, BATTLENET_ACCOUNTS + "/{id}", null, Void.class, id).status(); + return client.call(HttpMethod.DELETE, "/api/internal/admin/battlenet-accounts/{id}", null, Void.class, id).status(); } @Override From 8f08de4b64aec8232e2d936e0b030c1957534197 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:41:36 +0100 Subject: [PATCH 32/54] fix(battlenet): run at most one ticket mint per bot and drop abandoned ones A re-mint asked while one is queued or running for the same bot now waits for that one, and a queued login whose callers all gave up is dropped before Chromium opens. A BGS session that ends while minting interrupts its mint, so an aborted attempt withdraws instead of logging in later. --- .../login/BattleNetLoginCoordinator.java | 102 +++++++++++++++--- .../login/BattleNetLoginCoordinatorTest.java | 76 +++++++++++++ .../catapult/bgs/client/BgsClient.java | 23 +++- .../catapult/bgs/client/SessionOrderTest.java | 33 ++++++ 4 files changed, 221 insertions(+), 13 deletions(-) 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 index e7d80e244..58099d148 100644 --- 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 @@ -19,6 +19,7 @@ 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; @@ -26,10 +27,10 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; 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; /** @@ -55,6 +56,8 @@ public class BattleNetLoginCoordinator { 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, @@ -94,7 +97,7 @@ public CompletableFuture startInteractive(UUID accountId) { executor.execute(() -> { Throwable failure = null; try { - login(accountId, flow); + login(accountId, flow, () -> true); } catch (TicketException | RuntimeException e) { failure = e; } finally { @@ -143,6 +146,11 @@ 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) { @@ -151,33 +159,92 @@ private String mint(UUID accountId) throws TicketException { throw new TicketException(codeRequired ? "An admin login waits for a 2FA code" : "An admin login is in progress", codeRequired); } - BattleNetLoginFlow flow = new BattleNetLoginFlow(false, ticketTimeout, codeTimeout, () -> { }); - Future ticket; - try { - ticket = executor.submit(() -> login(accountId, flow)); - } catch (RejectedExecutionException e) { - throw new TicketException("The Battle.net login executor is shut down", false); + 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 ticket.get(maxWait.toNanos(), TimeUnit.NANOSECONDS); + 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) { - ticket.cancel(true); + withdraw(remint); throw new TicketException("Timed out waiting for the Battle.net login", false); } catch (InterruptedException e) { - ticket.cancel(true); + 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) throws TicketException { + private String login(UUID accountId, BattleNetLoginFlow flow, BooleanSupplier wanted) throws TicketException { Credentials credentials; try { credentials = transactions.execute(status -> repository.findById(accountId) @@ -196,6 +263,11 @@ private String login(UUID accountId, BattleNetLoginFlow flow) throws TicketExcep 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 -> { @@ -291,6 +363,12 @@ 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() { 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 index 85698f299..f5dfa9d7b 100644 --- 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 @@ -48,6 +48,8 @@ class BattleNetLoginCoordinatorTest { 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() { @@ -248,6 +250,80 @@ void reMintWhileAnInteractiveFlowWaitsForACodeFailsFast() throws Exception { 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.setLabel("bot-2"); + 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)); 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 index b221a8a2e..497eef93f 100644 --- 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 @@ -44,6 +44,7 @@ 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; @@ -329,17 +330,31 @@ private void onConnected(ConnectResponse reply) { })); } - /** Mints off the loop, then continues on it; a mint failure ends the session. */ + /** + * 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) { @@ -347,6 +362,12 @@ private void mintThen(Consumer next) { 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; 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 index dbe0a2141..62011c1e1 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -18,8 +19,13 @@ 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.*; @@ -191,6 +197,33 @@ void otherMintFailureClosesWithIo() { 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(); From 70b20ce867ec44220840ea1024d736b38fd61ed2 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:42:13 +0100 Subject: [PATCH 33/54] fix(bgs): bound the TLS handshake so a silent server cannot hang a session The handshake now has a 15 s read timeout, lifted once connected; the socket is closed on any failure, connect included. --- .../catapult/bgs/core/BgsTransport.java | 16 +++++++- .../catapult/bgs/core/BgsTransportTest.java | 41 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 catapult-bgs/src/test/java/fr/enimaloc/catapult/bgs/core/BgsTransportTest.java 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 index ba9df32de..c48af50b3 100644 --- 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 @@ -8,6 +8,7 @@ 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 { @@ -19,17 +20,30 @@ public interface BgsTransport extends AutoCloseable { @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(); - raw.connect(new InetSocketAddress(host, port), 10_000); 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; 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(); + } + } +} From 5763fa0a0fa14cc858f0c0901addd1cc01797d34 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:42:18 +0100 Subject: [PATCH 34/54] fix(docker): give the catapult user a home directory for Chromium Headless Chromium writes its profile and crash data under $HOME, which a system user created without a home lacks. --- catapult-api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catapult-api/Dockerfile b/catapult-api/Dockerfile index 508d19398..cc480e5df 100644 --- a/catapult-api/Dockerfile +++ b/catapult-api/Dockerfile @@ -26,7 +26,7 @@ FROM eclipse-temurin:25-jre-noble WORKDIR /app 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 --gid catapult catapult +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 From c3d6ffd42503e0d1627722fc203c6f88d345ccba Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:44:12 +0100 Subject: [PATCH 35/54] fix(battlenet): forget the Blizzard token once the account is linked The Battle.net link keeps no token, so the authorized client Spring stored in memory after the OAuth callback is removed at once. --- .../security/TwitchLoginSuccessHandler.java | 6 ++++-- .../security/TwitchLoginSuccessHandlerTest.java | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) 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 67664a26d..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 @@ -81,8 +81,10 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo return; } } else if ("battlenet".equals(registrationId)) { - // Battle.net is a secondary link that keeps no token (the bot befriends the account): nothing to - // save, only send the user back to their channel's integrations tab. + // 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; 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"); From d171bd7c6d1f38ce67fa750885f343f5af32fbd4 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:47:02 +0100 Subject: [PATCH 36/54] fix(battlenet): only offer the code field while a login waits for it The admin bot list now says whether a login waits for the 2FA code. A bot left CODE_REQUIRED by an automatic login shows a hint to re-authenticate instead of a code field that could only answer 409, and the Keys tab stops polling for it. --- .../BattleNetServiceAccountAdmin.java | 11 +++++++--- .../BattleNetServiceAccountAdminTest.java | 9 +++++++++ .../common/dto/admin/BattleNetAccountDto.java | 9 +++++++-- .../mock/MockBattleNetAccountsStore.java | 3 ++- .../main/resources/lang/messages.properties | 1 + .../resources/lang/messages_en.properties | 1 + .../resources/lang/messages_fr.properties | 1 + .../resources/static/js/admin-battlenet.js | 11 +++++----- .../templates/pages/admin-tab/keys.html | 13 ++++++++---- .../AdminTabsFragmentControllerTest.java | 20 ++++++++++++++++++- .../service/real/RealApiServiceTest.java | 3 ++- .../src/test/js/admin-battlenet.test.js | 16 ++++++++++----- 12 files changed, 76 insertions(+), 22 deletions(-) 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 index e8c730e35..c54368aaa 100644 --- 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 @@ -33,9 +33,13 @@ @ConditionalOnBooleanProperty("battlenet.enabled") public class BattleNetServiceAccountAdmin { - /** What the admin UI sees of a bot account: never the e-mail, the password or the browser state. */ + /** + * What the admin UI sees of a bot account: never the e-mail, 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 label, String battleTag, String authState, String sessionState, - int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount) {} + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount, + boolean waitingForCode) {} private final BattleNetServiceAccountRepository accountRepository; private final BattleNetFriendLinkRepository linkRepository; @@ -209,6 +213,7 @@ private AccountDto toDto(BattleNetServiceAccount account) { return new AccountDto(account.getId(), account.getLabel(), account.getBattleTag(), account.getAuthState().name(), sessionManager.sessionState(account.getId()), account.getFillOrder(), account.isFriendLimitReached(), account.isEnabled(), - linkRepository.countByServiceAccountAndStatus(account, BattleNetFriendLink.Status.ACCEPTED)); + linkRepository.countByServiceAccountAndStatus(account, BattleNetFriendLink.Status.ACCEPTED), + loginCoordinator.waitingForCode(account.getId())); } } 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 index b6f327e83..aa7b0a00b 100644 --- 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 @@ -125,6 +125,15 @@ void friendCountIsTheAcceptedLinks() { 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"); 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 index 0376ff669..352660609 100644 --- 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 @@ -2,6 +2,11 @@ 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. */ +/** + * 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). + */ public record BattleNetAccountDto(UUID id, String label, String battleTag, String authState, String sessionState, - int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount) {} + int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount, + boolean waitingForCode) {} 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 index e91737dc4..7c2a03719 100644 --- 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 @@ -19,8 +19,9 @@ class MockBattleNetAccountsStore { private record Account(UUID id, String label, 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, label, battleTag, authState, sessionState, fillOrder, - friendLimitReached, enabled, friendCount); + friendLimitReached, enabled, friendCount, "CODE_REQUIRED".equals(authState)); } Account with(String authState, String sessionState, boolean friendLimitReached, boolean enabled) { diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index 6e5b64d5a..5d5a7e787 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -1567,6 +1567,7 @@ 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.label=Label (e.g. Bot 1) diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index 646c0f04d..242716b31 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -1513,6 +1513,7 @@ 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.label=Label (e.g. Bot 1) diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 8330b653e..f6dbd408f 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -1567,6 +1567,7 @@ 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.label=Libellé (ex. Bot 1) diff --git a/catapult-web/src/main/resources/static/js/admin-battlenet.js b/catapult-web/src/main/resources/static/js/admin-battlenet.js index 317f94dcd..27af402cf 100644 --- a/catapult-web/src/main/resources/static/js/admin-battlenet.js +++ b/catapult-web/src/main/resources/static/js/admin-battlenet.js @@ -9,14 +9,15 @@ * ever puts them in a URL or the page. Labels and BattleTags come from the server-rendered rows, never * from innerHTML. * - * While a bot is NEW or CODE_REQUIRED (its row's data-auth-state) 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. + * 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"]'; + 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; @@ -122,7 +123,7 @@ window.CatapultAdminBattleNet = (function () { timer = null; } - /** Patches the tab again in 3 s while a bot is NEW or CODE_REQUIRED; no second timer, none otherwise. */ + /** 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; 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 53ececae5..1627301d5 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 @@ -160,8 +160,10 @@

Minecraft service accounts

Battle.net bots
+ th:attr="data-admin-key=${a.id()},data-auth-state=${a.authState()},data-waiting-code=${a.waitingForCode()}"> Bot1 Bot#1234 Battle.net bots th:attr="data-id=${a.id()},aria-label=#{admin.battlenet.action.delete}, data-confirm=#{admin.battlenet.action.delete-confirm(${a.label()})}"> -
+

Re-authenticate to enter a code.

+
"); } + @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)); 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 2250b0723..e26bcfed9 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 @@ -1881,13 +1881,14 @@ void minecraftAccountActions_sendTheirShapes_andReturnTheStatus() { void adminBattleNetAccounts_readCatapultApisDto() { respond(HttpMethod.GET, "/api/internal/admin/battlenet-accounts", "[{\"id\":\"00000000-0000-0000-0000-00000000000a\"," + "\"label\":\"Bot1\",\"battleTag\":\"Bot#1\",\"authState\":\"CODE_REQUIRED\",\"sessionState\":\"STOPPED_CODE_REQUIRED\"," - + "\"fillOrder\":2,\"friendLimitReached\":true,\"enabled\":false,\"friendCount\":7}]"); + + "\"fillOrder\":2,\"friendLimitReached\":true,\"enabled\":false,\"friendCount\":7,\"waitingForCode\":true}]"); assertThat(service.adminBattleNetAccounts()).singleElement().satisfies(a -> { 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(); }); } diff --git a/catapult-web/src/test/js/admin-battlenet.test.js b/catapult-web/src/test/js/admin-battlenet.test.js index cc172441b..445a2dc09 100644 --- a/catapult-web/src/test/js/admin-battlenet.test.js +++ b/catapult-web/src/test/js/admin-battlenet.test.js @@ -1,6 +1,6 @@ /* * 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 CODE_REQUIRED. + * 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"; @@ -9,9 +9,9 @@ const ID = "00000000-0000-0000-0000-00000000000b"; let routes; let posted; -function row(state) { +function row(state, waitingForCode) { return ` -
+
@@ -22,7 +22,7 @@ function row(state) {
`; } -function page(state = "READY") { +function page(state = "READY", waitingForCode = state === "CODE_REQUIRED") { document.body.innerHTML = ` @@ -32,7 +32,7 @@ function page(state = "READY") { data-label-unavailable="Down" data-label-updated="Updated" data-label-deleted="Deleted" data-label-created="Created" data-label-code-sent="Code sent" data-label-reauthed="Reauthed" data-label-invalid="Invalid"> - ${row(state)} + ${row(state, waitingForCode)}
@@ -181,6 +181,12 @@ describe("admin-battlenet polling", () => { 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); From c8a67dca0e16a08057a3c54ebf2775ccda4be4ce Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:48:56 +0100 Subject: [PATCH 37/54] fix(battlenet): notify streamers when their bot is deleted Deleting a bot now disables the Battle.net getter of every streamer linked through it, as unenrolling does, and tells their dashboards the link is gone once the bot is deleted. --- .../BattleNetServiceAccountAdmin.java | 29 ++++++++++++++-- ...iAdminBattleNetAccountsControllerTest.java | 5 ++- .../BattleNetServiceAccountAdminTest.java | 33 +++++++++++++++++-- 3 files changed, 61 insertions(+), 6 deletions(-) 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 index c54368aaa..820fbb125 100644 --- 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 @@ -1,12 +1,16 @@ 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; @@ -16,6 +20,7 @@ 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; @@ -46,6 +51,8 @@ public record AccountDto(UUID id, String label, String battleTag, String authSta 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(); @@ -137,17 +144,33 @@ public AccountDto update(UUID id, Map body) { return toDto(saved); } - /** Stops the session first, then drops the links and the account. */ + /** + * 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); - linkRepository.deleteAll(linkRepository.findByServiceAccount(account)); + 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: {}", deleted.getLabel()); + log.info("Battle.net service account deleted: {} ({} link(s) dropped)", deleted.getLabel(), owners.size()); + owners.forEach(owner -> channelEventPublisher.battleNetLinkChanged(owner, "NONE", null, null)); } /** 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 index 7533353ce..a72356a02 100644 --- 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 @@ -4,10 +4,12 @@ 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; @@ -58,7 +60,8 @@ void setUp() { 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))) + new BattleNetServiceAccountAdmin(accounts, links, encryption, coordinator, sessions, + mock(GetterConfigRepository.class), mock(ChannelEventPublisher.class)))) .setControllerAdvice(new ProblemAdvice()).build(); } 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 index aa7b0a00b..d07aa6f08 100644 --- 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 @@ -1,14 +1,18 @@ 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; @@ -38,8 +42,10 @@ class BattleNetServiceAccountAdminTest { private final TokenEncryptionService encryption = mock(TokenEncryptionService.class); private final BattleNetLoginCoordinator coordinator = mock(BattleNetLoginCoordinator.class); private final BattleNetSessionManager sessions = mock(BattleNetSessionManager.class); - private final BattleNetServiceAccountAdmin admin = - new BattleNetServiceAccountAdmin(accounts, links, encryption, coordinator, sessions); + 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; @@ -160,6 +166,7 @@ void unknownAccountIsNotFound() { @Test void deleteStopsTheSessionFirst() { BattleNetFriendLink link = new BattleNetFriendLink(); + link.setUser(new UserAccount()); when(links.findByServiceAccount(account)).thenReturn(List.of(link)); admin.delete(account.getId()); @@ -170,6 +177,28 @@ void deleteStopsTheSessionFirst() { 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")); From aa1549bbb7b16da9a337e08719513b872fff0ae8 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 18:55:09 +0100 Subject: [PATCH 38/54] fix(battlenet): resolve the caller through ApiUserResolver in the link controller Controllers hold no repository (ControllerConventionsTest): the Battle.net link controller now reads the caller like the other user routes. --- .../api/connect/ApiBattleNetConnectController.java | 14 +++----------- .../connect/ApiBattleNetConnectControllerTest.java | 3 ++- 2 files changed, 5 insertions(+), 12 deletions(-) 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 index ed9097c2f..5ed15b0af 100644 --- 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 @@ -6,7 +6,6 @@ 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.repository.account.UserAccountRepository; import fr.enimaloc.catapult.service.battlenet.BattleNetFriendService; import fr.enimaloc.catapult.service.battlenet.BattleNetGateService; import fr.enimaloc.catapult.service.battlenet.BattleNetLinkStateService; @@ -17,7 +16,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.*; -import org.springframework.web.server.ResponseStatusException; import java.util.UUID; @@ -35,28 +33,22 @@ public class ApiBattleNetConnectController { private final BattleNetFriendService friendService; private final BattleNetLinkStateService linkStates; private final BattleNetGateService gateService; - private final UserAccountRepository userAccountRepository; + private final ApiUserResolver userResolver; @PreAuthorize("@authz.isSelf(#userId)") @GetMapping public BattleNetLinkStateResponse status(@PathVariable UUID userId, @AuthenticationPrincipal Jwt jwt) { - return linkStates.state(currentUser(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 = currentUser(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); } - - private UserAccount currentUser(Jwt jwt) { - UUID caller = UUID.fromString(ApiUserResolver.requireJwt(jwt).getSubject()); - return userAccountRepository.findById(caller) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user")); - } } 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 index 9dd92f134..660187095 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -46,7 +47,7 @@ class ApiBattleNetConnectControllerTest { void setup() { controller = new ApiBattleNetConnectController(friendService, new BattleNetLinkStateService(gateService, linkRepository, accountRepository), - gateService, userAccountRepository); + gateService, new ApiUserResolver(userAccountRepository)); user = new UserAccount(); UUID userId = UUID.randomUUID(); when(jwt.getSubject()).thenReturn(userId.toString()); From c78e520c5cf23c0f874c59f77eaa2630405b8417 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 19:13:22 +0100 Subject: [PATCH 39/54] fix(battlenet): declare the battlenet.integration experiment --- .../BattleNetIntegrationExperiment.java | 20 +++++++++ .../catapult/EnglishConventionsTest.java | 3 ++ .../GateExperimentDeclarationTest.java | 41 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/experiment/BattleNetIntegrationExperiment.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/experiment/GateExperimentDeclarationTest.java 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/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java index 71370e4e9..a97e71ed0 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/EnglishConventionsTest.java @@ -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"), 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); + } +} From bd146c9d822d1d7330bb9a64e825553ee0a3d775 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 19:17:14 +0100 Subject: [PATCH 40/54] fix(battlenet): link through plain OAuth2 so the Catapult user service runs --- .../fr/enimaloc/catapult/config/OAuth2ClientConfig.java | 2 +- .../fr/enimaloc/catapult/config/OAuth2ClientConfigTest.java | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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"); } From cd9643c5ee911f4a960c067167eb8ea997410fad Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 19:39:06 +0100 Subject: [PATCH 41/54] fix(web): style the Battle.net connect button like the other account links --- .../catapult/controller/IntegrationCards.java | 11 ++++++----- .../controller/ChannelVisibilityRenderingTest.java | 5 +++++ 2 files changed, 11 insertions(+), 5 deletions(-) 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 3d9cf0922..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 @@ -225,16 +225,17 @@ private static IntegrationDto battleNet(BattleNetData battleNet) { 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.SEGMENTED_GROUP, List.of( - segmented(Attrs.id("battlenet-connect-btn") + Container.Kind.DIV, List.of( + new Button(Attrs.id("battlenet-connect-btn") .withState(battleNetState("showConnect", status, List.of("NONE", "INVITE_REJECTED", "REMOVED"))), - key(PREFIX + "battlenet.connect")), - segmented(Attrs.id("battlenet-disconnect-btn") + Button.Kind.FILLED, key(PREFIX + "battlenet.connect")), + new Button(Attrs.id("battlenet-disconnect-btn") .withState(battleNetState("showDisconnect", status, List.of("ACCEPTED"))), - key(PREFIX + "battlenet.disconnect"))))); + Button.Kind.TEXT, key(PREFIX + "battlenet.disconnect"))))); } private static Text battleNetTag(String className, String field, String tag) { 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 2dceece0a..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 @@ -721,6 +721,11 @@ void battleNetCardRendersOneCasePerStatus() throws Exception { 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(" Date: Fri, 13 Nov 2026 20:21:16 +0100 Subject: [PATCH 42/54] fix(api): show a bindings page whose bindings have no Twitch category --- .../channel/ChannelDashboardAssembler.java | 9 ++++++-- .../ChannelDashboardAssemblerTest.java | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) 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 c96a2a520..87697d894 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 @@ -204,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); } @@ -219,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(), @@ -383,7 +388,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/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java index 30bedcc01..292ece5a3 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 @@ -356,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())) @@ -505,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(); From 1ef74de59377fc951d3f3d69302adaf01b49df64 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 21:34:53 +0100 Subject: [PATCH 43/54] feat(mappings): store an IGDB game on game mappings and seed Blizzard titles --- .../catapult/domain/binding/GameMapping.java | 4 ++ .../binding/AdminGameMappingService.java | 3 +- .../db/migration/V110__game_mapping_igdb.sql | 16 ++++++ .../GameMappingIgdbSeedMigrationTest.java | 51 +++++++++++++++++++ .../binding/AdminGameMappingServiceTest.java | 6 +-- .../common/dto/admin/GameMappingDto.java | 8 +-- .../common/dto/admin/GameMappingRequest.java | 10 ++-- .../catapult/common/dto/AdminDtosTest.java | 12 ++++- .../service/mock/MockGameMappingsStore.java | 7 +-- .../mock/MockGameMappingsStoreTest.java | 2 +- 10 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 catapult-api/src/main/resources/db/migration/V110__game_mapping_igdb.sql create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/migration/GameMappingIgdbSeedMigrationTest.java 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/service/binding/AdminGameMappingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/AdminGameMappingService.java index 45be8b8c0..ab67de7d6 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 @@ -474,7 +474,8 @@ 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(), null); }).toList(); } 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/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/service/binding/AdminGameMappingServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java index 0a4de32c7..ef64ba2bb 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 @@ -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"); } 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/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/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..a4de03192 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 @@ -50,7 +50,7 @@ class MockGameMappingsStore { "Halo Infinite", java.util.Set.of("violent-graphic"), java.util.Set.of("violence"), 6, now.minus(1, ChronoUnit.DAYS), "enimaloc")); 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)); } synchronized AdminPage list(int page) { @@ -87,7 +87,7 @@ synchronized ApiClient.Result create(GameMappingRequest reque GameMappingDto mapping = new GameMappingDto(UUID.randomUUID(), request.sourceType(), sourceId, game == null ? null : game.sourceName(), request.twitchGameId(), request.twitchGameName(), request.ccls(), request.tws(), game == null ? 0 : game.streamerCount(), clock.instant(), updatedBy, - request.ignoredByDefault()); + request.ignoredByDefault(), request.igdbId(), null); mappings.add(mapping); return new ApiClient.Result<>(201, result(mapping, request.applyLive())); } @@ -102,7 +102,8 @@ synchronized ApiClient.Result update(UUID id, GameMappingRequ } 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()); + current.bindingCount(), clock.instant(), updatedBy, request.ignoredByDefault(), + request.igdbId(), null); mappings.set(index, changed); return new ApiClient.Result<>(200, result(changed, request.applyLive())); } 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..356d7455e 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 @@ -122,7 +122,7 @@ 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(); From 2a6822552f0b148280ec604dc38f06698a2e4bcf Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 21:42:53 +0100 Subject: [PATCH 44/54] feat(igdb): resolve detected games through their mapping's IGDB game IgdbService.resolveDetected is the single entry point turning a detected game into its IGDB game: the game mapping's IGDB id first, nothing for an unmapped Battle.net game (never searched by name), else the platform id then the name. The binding, game context, game info, dashboard and DTDD resolutions all go through it. --- .../channel/ChannelDashboardAssembler.java | 6 +- .../api/userapi/GameInfoAssembler.java | 5 +- .../service/binding/BindingService.java | 14 +- .../service/binding/GameContextService.java | 6 +- .../service/dtdd/DtddMappingEvents.java | 7 +- .../catapult/service/igdb/IgdbService.java | 66 ++++++- .../simulation/GameSourceResolver.java | 2 +- .../ChannelDashboardAssemblerTest.java | 12 +- .../api/contract/FrozenRouteContractTest.java | 2 +- .../userapi/GameInfoAssemblerLinksTest.java | 3 +- .../api/userapi/UserApiV1ControllerTest.java | 16 +- .../api/userapi/UserApiV2ControllerTest.java | 17 +- .../catapult/perf/RouteBudgetTest.java | 3 +- .../binding/AdminGameMappingServiceTest.java | 2 +- .../binding/BindingServiceEditsTest.java | 15 +- .../binding/BindingServiceMappingTest.java | 3 +- .../binding/BindingServiceSteamLimitTest.java | 5 +- .../service/binding/BindingServiceTest.java | 39 +++- .../binding/GameContextServiceTest.java | 25 +-- .../service/dtdd/DtddMappingEventsTest.java | 8 +- .../igdb/IgdbServiceDetectedGameTest.java | 178 ++++++++++++++++++ .../service/igdb/IgdbServiceFlowsTest.java | 3 +- .../igdb/IgdbServiceSimulationTest.java | 3 +- 23 files changed, 345 insertions(+), 95 deletions(-) create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDetectedGameTest.java 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 87697d894..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 @@ -350,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) { 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/service/binding/BindingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java index bf32f9700..ff80328cb 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,7 @@ 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); + Optional igdbId = resolveViaIgdb(game).map(IgdbService.IgdbGame::id); 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()); @@ -298,6 +294,7 @@ private GameBinding updateWithoutMapping(UserAccount user, DetectedGame detected 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) @@ -305,7 +302,7 @@ private GameBinding updateWithoutMapping(UserAccount user, DetectedGame detected // 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)) + .or(() -> gameName == null ? Optional.empty() : twitchService.findCategoryIdByName(user, gameName)) .orElse(null); binding.setTwitchGameId(twitchId); binding.setTwitchGameName(gameName); @@ -343,10 +340,11 @@ 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(); } 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/IgdbService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/igdb/IgdbService.java index 8e7e0c944..77b9850cc 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 @@ -3,10 +3,12 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; 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; @@ -43,6 +45,7 @@ public class IgdbService { 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 +57,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; @@ -498,16 +502,66 @@ 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())); + } + 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(); } - return findByName(b.getSourceName()).map(IgdbGame::id); + + 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); + } + 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/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/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java index 292ece5a3..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 @@ -757,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); @@ -788,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); @@ -804,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); @@ -816,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); @@ -839,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); 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/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/service/binding/AdminGameMappingServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java index ef64ba2bb..c75f8c65b 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 @@ -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.resolveDetected(eq(SourceType.STEAM), eq("730"), any())) .thenReturn(Optional.of(new IgdbService.IgdbGame("42", "Counter-Strike 2"))); when(igdbService.suggestCcls("42")).thenReturn(Set.of("ViolentGraphic")); 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/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/IgdbServiceDetectedGameTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDetectedGameTest.java new file mode 100644 index 000000000..64cf69e13 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceDetectedGameTest.java @@ -0,0 +1,178 @@ +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 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/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; From 4292c9cf95b8feadc944f4d8b29cd68d7508ece2 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 21:50:52 +0100 Subject: [PATCH 45/54] feat(mappings): pick an IGDB game and deduce its Twitch category An admin mapping can name an IGDB game. With no Twitch category typed, the category is deduced from that game (400 when none, unless the mapping is ignored by default). The suggestions applied in the same write now come from the request's IGDB game. Adds the admin IGDB game search endpoint. --- .../admin/ApiAdminGameMappingController.java | 8 + .../binding/AdminGameMappingService.java | 74 +++++++-- .../service/binding/BindingService.java | 42 +++-- .../catapult/service/igdb/IgdbClient.java | 4 +- .../catapult/service/igdb/IgdbService.java | 44 ++++++ .../ApiAdminGameMappingControllerTest.java | 25 ++- .../binding/AdminGameMappingServiceTest.java | 145 +++++++++++++++++- .../catapult/service/igdb/IgdbClientTest.java | 2 +- .../IgdbServiceDescriptorHelpersTest.java | 2 + .../common/dto/admin/IgdbGameOption.java | 7 + 10 files changed, 327 insertions(+), 26 deletions(-) create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/IgdbGameOption.java 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/service/binding/AdminGameMappingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/AdminGameMappingService.java index ab67de7d6..0f73fcd55 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); @@ -330,8 +345,11 @@ 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) + return target.igdbId() != null + ? bindingService.igdbSuggestions(target.igdbId(), detected, request.ccls() == null, request.tws() == null) + : bindingService.igdbSuggestions(detected, request.ccls() == null, request.tws() == null); } /** @@ -400,6 +418,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 +431,47 @@ 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(); 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). */ @@ -475,10 +520,15 @@ private List toDtos(List mappings, UserAccount admi m.isTwsOverridden() ? Set.copyOf(m.getTws()) : null, followers.getOrDefault(key, 0L), m.getUpdatedAt(), m.getUpdatedBy() == null ? null : logins.get(m.getUpdatedBy()), m.isIgnoredByDefault(), - m.getIgdbId(), null); + 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 ff80328cb..844a2495d 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,7 +234,19 @@ public record MappingSuggestions(Set ccls, Set tws) { */ public MappingSuggestions igdbSuggestions(DetectedGame game, boolean ccls, boolean tws) { if (!ccls && !tws) return MappingSuggestions.NONE; - Optional igdbId = resolveViaIgdb(game).map(IgdbService.IgdbGame::id); + return igdbSuggestions(resolveViaIgdb(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()); @@ -296,14 +308,7 @@ private GameBinding updateWithoutMapping(UserAccount user, DetectedGame detected 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(() -> gameName == null ? Optional.empty() : 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); @@ -335,6 +340,25 @@ 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'); 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 77b9850cc..75d55ad81 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,6 +2,7 @@ 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; @@ -28,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; @@ -41,6 +43,8 @@ public class IgdbService { private static final String TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"; /** The DB cache key prefix of a game looked up by its Steam app id. */ + /** 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/"; public static final String KEY_STEAM_PREFIX = "steam:"; private static final String KEY_NAME_PREFIX = "name:"; private static final String KEY_EXE_PREFIX = "exe:"; @@ -318,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). 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..3e6119f5b 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,26 @@ 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()); + } + + @Test + void igdbFailureIsEmpty() { + when(igdbService.searchGameOptions("boom")).thenReturn(List.of()); + + assertThat(controller.igdbGames("boom")).isEmpty(); + } } 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 c75f8c65b..041f9e177 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"); @@ -711,4 +711,147 @@ 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"); + } } 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-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) {} From 72eb335c88fe8f08a684dac2d9f3f3cbbd568a75 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 21:54:02 +0100 Subject: [PATCH 46/54] test(mappings): cover the IGDB game search and its admin route --- .../catapult/service/igdb/IgdbService.java | 2 +- .../ApiAdminGameMappingControllerTest.java | 7 -- .../igdb/IgdbServiceGameSearchTest.java | 113 ++++++++++++++++++ .../src/test/resources/access-matrix.csv | 1 + 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/igdb/IgdbServiceGameSearchTest.java 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 75d55ad81..5ab3ef4af 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 @@ -42,9 +42,9 @@ public class IgdbService { private static final String TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"; - /** The DB cache key prefix of a game looked up by its Steam app id. */ /** 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:"; 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 3e6119f5b..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 @@ -162,11 +162,4 @@ void blankQueryIsEmpty() { assertThat(controller.igdbGames(null)).isEmpty(); verify(igdbService, never()).searchGameOptions(any()); } - - @Test - void igdbFailureIsEmpty() { - when(igdbService.searchGameOptions("boom")).thenReturn(List.of()); - - assertThat(controller.igdbGames("boom")).isEmpty(); - } } 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/resources/access-matrix.csv b/catapult-api/src/test/resources/access-matrix.csv index 2141ac664..181cd672a 100644 --- a/catapult-api/src/test/resources/access-matrix.csv +++ b/catapult-api/src/test/resources/access-matrix.csv @@ -260,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 From dce5d3fe04308b964ac881a3cc556d3175253288 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 22:02:31 +0100 Subject: [PATCH 47/54] feat(web): pick an IGDB game for a game mapping Add an IGDB game picker to the game mappings form (search as you type, clearable, emptying the Twitch category it deduces), an IGDB column in the list, the igdb-games proxy route, and the Blizzard presets in the mock. --- .../AdminGameMappingsController.java | 8 ++ .../enimaloc/catapult/service/ApiService.java | 3 + .../catapult/service/mock/MockApiService.java | 7 ++ .../service/mock/MockGameMappingsStore.java | 91 ++++++++++++++++-- .../catapult/service/real/RealApiService.java | 7 ++ .../main/resources/lang/messages.properties | 4 + .../resources/lang/messages_en.properties | 4 + .../resources/lang/messages_fr.properties | 4 + .../main/resources/static/css/catapult.css | 6 +- .../static/js/admin-game-mappings.js | 29 +++++- .../main/resources/static/js/game-search.js | 6 +- .../pages/admin-tab/game-mappings.html | 17 +++- .../AdminGameMappingsControllerTest.java | 22 ++++- .../AdminGameMappingsRenderingTest.java | 36 +++++++ .../mock/MockGameMappingsStoreTest.java | 53 +++++++++-- .../src/test/js/admin-game-mappings.test.js | 93 ++++++++++++++++++- catapult-web/src/test/js/game-search.test.js | 18 ++++ 17 files changed, 374 insertions(+), 34 deletions(-) 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/service/ApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java index 6d277ddab..b40e3aff6 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 @@ -48,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; @@ -354,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. */ 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 8ec71003b..145703e88 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; @@ -821,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); 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 a4de03192..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,10 +73,38 @@ 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)); } + /** 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) { return page(mappings.stream().sorted(Comparator.comparing(GameMappingDto::updatedAt).reversed()).toList(), 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.igdbId(), null); + request.ignoredByDefault(), request.igdbId(), igdbName(request.igdbId())); mappings.add(mapping); return new ApiClient.Result<>(201, result(mapping, request.applyLive())); } @@ -97,13 +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(), + category[0], category[1], request.ccls(), request.tws(), current.bindingCount(), clock.instant(), updatedBy, request.ignoredByDefault(), - request.igdbId(), null); + request.igdbId(), igdbName(request.igdbId())); mappings.set(index, changed); return new ApiClient.Result<>(200, result(changed, request.applyLive())); } @@ -121,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/real/RealApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java index 41d611fde..dd7a293cf 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 @@ -62,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; @@ -173,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<>() {}; @@ -1009,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); diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index 5d5a7e787..224612211 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -783,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 @@ -812,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 diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index 242716b31..396587444 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -783,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 @@ -812,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 diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index f6dbd408f..170782416 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -783,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 @@ -812,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 diff --git a/catapult-web/src/main/resources/static/css/catapult.css b/catapult-web/src/main/resources/static/css/catapult.css index 36eef0bac..a986e3b02 100644 --- a/catapult-web/src/main/resources/static/css/catapult.css +++ b/catapult-web/src/main/resources/static/css/catapult.css @@ -2366,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; } 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/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/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/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/service/mock/MockGameMappingsStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockGameMappingsStoreTest.java index 356d7455e..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,6 +124,36 @@ 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") 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/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()); From 058377399acda7165a0bcd08ed8b713fb23032a0 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 22:11:41 +0100 Subject: [PATCH 48/54] fix(mappings): stop applying a cleared IGDB game's suggestions Suggestions were resolved before the write through the stored mapping row, so an update clearing the IGDB game still applied the old game's CCLs/TWs, and a mapping moved to another game re-resolved the old game's bindings through its IGDB game. Both now resolve the game with its mapping left aside (IgdbService#resolveUnmapped): nothing for Battle.net, else platform id then name. --- .../binding/AdminGameMappingService.java | 19 +++- .../service/binding/BindingService.java | 34 +++++++- .../catapult/service/igdb/IgdbService.java | 12 +++ .../binding/AdminGameMappingServiceTest.java | 86 ++++++++++++++++++- .../igdb/IgdbServiceDetectedGameTest.java | 15 ++++ 5 files changed, 160 insertions(+), 6 deletions(-) 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 0f73fcd55..edf97bf34 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 @@ -216,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); } @@ -346,10 +347,11 @@ private BindingService.MappingSuggestions igdbSuggestions(Target target, GameMap GameKey game = GameKey.of(target.sourceType(), target.sourceId()); String name = mostFrequentNames(List.of(game)).get(game); 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) + // 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.igdbSuggestions(detected, request.ccls() == null, request.tws() == null); + : bindingService.igdbSuggestionsUnmapped(detected, request.ccls() == null, request.tws() == null); } /** @@ -374,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; } 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 844a2495d..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 @@ -237,6 +237,16 @@ public MappingSuggestions igdbSuggestions(DetectedGame game, boolean ccls, boole 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). @@ -301,8 +311,22 @@ 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(); @@ -372,6 +396,14 @@ private Optional resolveViaIgdb(DetectedGame detectedGame) : 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(); + } + @Transactional public void updateBinding(UserAccount user, UUID bindingId, String twitchGameId, String twitchGameName, Set ccls, boolean ignored) { 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 5ab3ef4af..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 @@ -568,6 +568,18 @@ public Optional resolveDetected(GameBinding.SourceType sourceType, Str .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; 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 041f9e177..5bc05b6f9 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 @@ -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.resolveDetected(eq(SourceType.STEAM), eq("730"), any())) + 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")); @@ -854,4 +854,88 @@ void updateChangingTheIgdbIdTakesItsSuggestionsFromTheNewId() { 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"); + } } 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 index 64cf69e13..d723e51bc 100644 --- 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 @@ -135,6 +135,21 @@ void steamWithoutMappingStopsAtTheAppId() { 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"); From c4e972bf74f2feb1bd2bf7ef32dd2f70021a8f92 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 22:12:31 +0100 Subject: [PATCH 49/54] fix(mappings): reject a non-numeric IGDB game id IGDB ids are parsed as numbers: a non-numeric one made a save with a typed category fail with a 500, and an ignored mapping holding one failed on every detection. It is now refused with a 400 before anything is stored. --- .../binding/AdminGameMappingService.java | 4 +++ .../binding/AdminGameMappingServiceTest.java | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+) 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 edf97bf34..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 @@ -448,6 +448,10 @@ private Target validate(GameMappingRequest request, UserAccount admin) { 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()) { 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 5bc05b6f9..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 @@ -938,4 +938,32 @@ void movingAMappingResolvesTheOldGameWithoutItsIgdbGame() { 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()); + } } From cbd48bd81cb44af121fad3534bbeeb57267e5390 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 22:13:12 +0100 Subject: [PATCH 50/54] fix(web): explain the IGDB category deduction in mapping errors The category and 400 errors only mentioned picking a Twitch category: they now also say an IGDB game can be picked instead, its category deduced, and that one must be entered when it has none. --- catapult-web/src/main/resources/lang/messages.properties | 4 ++-- catapult-web/src/main/resources/lang/messages_en.properties | 4 ++-- catapult-web/src/main/resources/lang/messages_fr.properties | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index 224612211..bedfac6fe 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -828,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. diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index 396587444..bc8ca15af 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -828,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. diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 170782416..71d5a194e 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -828,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. From 5ebb8db34d3e57c2184319b88419b65b16de0bd5 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 22:41:27 +0100 Subject: [PATCH 51/54] test(perf): budget the game routes with their resolved IGDB and DTDD data The budget fixture's game was never resolved before: IgdbService is mocked and the assembler called methods the mock did not stub, so these routes were measured without their IGDB details and DTDD data. Resolving through resolveDetected now runs those reads. --- catapult-api/src/test/resources/route-budgets.csv | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 From b24b1a8fe2f2386ce14e06b4706e370bd9cdd4ed Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 22:59:18 +0100 Subject: [PATCH 52/54] refactor(minecraft): drop the service account label A Minecraft service account is now shown by its Minecraft username: the label column is dropped (V111), the admin DTO carries a displayName, the device-code create no longer takes a label and the logs name the account by its username. --- .../ApiAdminMinecraftAccountsController.java | 2 +- .../minecraft/MinecraftServiceAccount.java | 3 -- .../getter/MinecraftPresenceGetter.java | 4 +- .../minecraft/MinecraftFriendService.java | 20 ++++----- .../MinecraftServiceAccountAdmin.java | 13 +++--- .../minecraft/MinecraftTokenService.java | 2 +- ...__minecraft_service_account_drop_label.sql | 2 + ...iAdminMinecraftAccountsControllerTest.java | 7 ++-- .../ApiAdminMinecraftAccountsFlowsTest.java | 13 +++--- ...MinecraftPresenceGetterSimulationTest.java | 2 +- .../getter/MinecraftPresenceGetterTest.java | 2 +- .../debug/MinecraftBatchDebugTest.java | 2 +- .../MinecraftFriendServiceSimulationTest.java | 2 +- .../minecraft/MinecraftFriendServiceTest.java | 4 +- .../minecraft/MinecraftTokenServiceTest.java | 4 +- .../catapult/common/dto/admin/AccountDto.java | 3 +- .../controller/AdminMinecraftController.java | 15 +++---- .../enimaloc/catapult/service/ApiService.java | 4 +- .../catapult/service/mock/MockApiService.java | 6 +-- .../mock/MockMinecraftAccountsStore.java | 21 +++++----- .../catapult/service/real/RealApiService.java | 5 +-- .../main/resources/lang/messages.properties | 3 -- .../resources/lang/messages_en.properties | 3 -- .../resources/lang/messages_fr.properties | 3 -- .../main/resources/static/css/catapult.css | 2 +- .../resources/static/js/admin-minecraft.js | 8 +--- .../templates/pages/admin-tab/keys.html | 8 +--- .../AdminMinecraftControllerTest.java | 42 +++++++------------ .../AdminTabsFragmentControllerTest.java | 11 +++-- .../mock/MockMinecraftAccountsStoreTest.java | 13 +++--- .../service/real/RealApiServiceTest.java | 12 +++--- .../src/test/js/admin-minecraft.test.js | 9 ++-- 32 files changed, 106 insertions(+), 144 deletions(-) create mode 100644 catapult-api/src/main/resources/db/migration/V111__minecraft_service_account_drop_label.sql 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/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/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/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/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/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/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/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/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-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-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/service/ApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java index b40e3aff6..28832a5af 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 @@ -518,10 +518,10 @@ 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); 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 145703e88..36550aa16 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 @@ -1818,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/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 dd7a293cf..c9ffabefd 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 @@ -1439,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 bedfac6fe..f1c37aad9 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -1008,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 @@ -1022,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. diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index bc8ca15af..234d57445 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -1008,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 @@ -1022,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. diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 71d5a194e..75cacb4d8 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -1008,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 @@ -1022,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. diff --git a/catapult-web/src/main/resources/static/css/catapult.css b/catapult-web/src/main/resources/static/css/catapult.css index a986e3b02..1025a34d9 100644 --- a/catapult-web/src/main/resources/static/css/catapult.css +++ b/catapult-web/src/main/resources/static/css/catapult.css @@ -2509,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; } 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/templates/pages/admin-tab/keys.html b/catapult-web/src/main/resources/templates/pages/admin-tab/keys.html index 1627301d5..18ef444b4 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
    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 7fba2a9df..1ff02ae44 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); 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 e26bcfed9..5b82b2955 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 @@ -1843,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); @@ -1950,17 +1950,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-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); From d91fdb981e5c47a659ed7a2ce64b5176e37244ee Mon Sep 17 00:00:00 2001 From: enimaloc Date: Fri, 13 Nov 2026 23:04:26 +0100 Subject: [PATCH 53/54] refactor(battlenet): drop the service account label A Battle.net bot is now shown by its BattleTag, or before its first login by its masked e-mail (b***@example.com), computed in the admin DTO's displayName. The label column is dropped (V112), the create takes only the e-mail and the password, a PATCH ignores a legacy label, and logs and the session metrics name the bot by its account id. --- .../ApiAdminBattleNetAccountsController.java | 3 +- .../battlenet/BattleNetServiceAccount.java | 3 -- .../battlenet/BattleNetFriendService.java | 33 +++++++------ .../BattleNetServiceAccountAdmin.java | 35 +++++++++----- .../battlenet/BattleNetSessionManager.java | 28 +++++------ ...__battlenet_service_account_drop_label.sql | 2 + ...iAdminBattleNetAccountsControllerTest.java | 17 ++++--- .../battlenet/BattleNetRepositoriesTest.java | 10 ++-- .../battlenet/BattleNetFriendServiceTest.java | 4 +- .../BattleNetServiceAccountAdminTest.java | 47 +++++++++++++++---- .../BattleNetSessionManagerTest.java | 12 +++-- .../login/BattleNetLoginCoordinatorTest.java | 2 - .../common/dto/admin/BattleNetAccountDto.java | 5 +- .../controller/AdminBattleNetController.java | 18 ++++--- .../enimaloc/catapult/service/ApiService.java | 4 +- .../catapult/service/mock/MockApiService.java | 6 +-- .../mock/MockBattleNetAccountsStore.java | 27 +++++++---- .../catapult/service/real/RealApiService.java | 4 +- .../main/resources/lang/messages.properties | 5 +- .../resources/lang/messages_en.properties | 5 +- .../resources/lang/messages_fr.properties | 5 +- .../main/resources/static/css/catapult.css | 2 +- .../resources/static/js/admin-battlenet.js | 20 ++++---- .../templates/pages/admin-tab/keys.html | 12 ++--- .../AdminBattleNetControllerTest.java | 35 ++++++++------ .../AdminTabsFragmentControllerTest.java | 14 +++--- .../mock/MockBattleNetAccountsStoreTest.java | 8 +++- .../service/real/RealApiServiceTest.java | 7 +-- .../src/test/js/admin-battlenet.test.js | 18 ++++--- 29 files changed, 215 insertions(+), 176 deletions(-) create mode 100644 catapult-api/src/main/resources/db/migration/V112__battlenet_service_account_drop_label.sql 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 index 4bb669cb3..78411b405 100644 --- 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 @@ -28,8 +28,7 @@ public List 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("label"), body.get("email"), body.get("password"))); + 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. */ 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 index c16ecbd69..130bde27d 100644 --- 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 @@ -23,9 +23,6 @@ public enum AuthState { @GeneratedValue(strategy = GenerationType.UUID) private UUID id; - @Column(nullable = false) - private String label; - @ToString.Exclude @Column(nullable = false) private String email; 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 index ccdd89243..be8a98ec7 100644 --- 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 @@ -135,7 +135,7 @@ public BattleNetFriendLink enroll(UserAccount user, long bnetAccountId, String b created.setRequestedAt(Instant.now()); return linkRepository.save(created); }); - log.info("Battle.net link requested for user {} via {}", user.getId(), bot.getLabel()); + 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); @@ -231,10 +231,10 @@ private List apply(UUID botId, FriendEvent event, List afterCo 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.getLabel()); + 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.getLabel()); + log.debug("Battle.net friend added on {} without a link to accept", bot.getId()); } } case FriendRemoved removed -> { @@ -245,11 +245,11 @@ private List apply(UUID botId, FriendEvent event, List afterCo link.setStatus(Status.REMOVED); changes.add(save(link, bot)); log.info("Battle.net link removed by the streamer: user {} via {}", link.getUser().getId(), - bot.getLabel()); + bot.getId()); } } if (changes.isEmpty()) { - log.debug("Battle.net friend removed on {} without an accepted link", bot.getLabel()); + log.debug("Battle.net friend removed on {} without an accepted link", bot.getId()); } } case InvitationSent sent -> { @@ -261,7 +261,7 @@ private List apply(UUID botId, FriendEvent event, List afterCo pending.ifPresentOrElse(link -> { link.setInvitationId(sent.invitationId()); linkRepository.save(link); - }, () -> log.debug("Battle.net invitation sent by {} matches no pending link", bot.getLabel())); + }, () -> log.debug("Battle.net invitation sent by {} matches no pending link", bot.getId())); } case InvitationSentRemoved sentRemoved -> { if (sentRemoved.reason() == 0) { @@ -277,7 +277,7 @@ private List apply(UUID botId, FriendEvent event, List afterCo } link.setStatus(Status.INVITE_REJECTED); changes.add(save(link, bot)); - log.info("Battle.net invitation of {} not accepted (reason {}) for user {}", bot.getLabel(), + log.info("Battle.net invitation of {} not accepted (reason {}) for user {}", bot.getId(), sentRemoved.reason(), link.getUser().getId()); } } @@ -299,20 +299,19 @@ private Runnable answer(BattleNetServiceAccount bot, InvitationReceived invitati .anyMatch(link -> link.getStatus() == Status.PENDING || link.getStatus() == Status.INVITE_REJECTED); long invitationId = invitation.invitationId(); UUID botId = bot.getId(); - String label = bot.getLabel(); if (expected) { - log.info("Accepting the Battle.net invitation of an enrolled streamer on {}", label); + 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 {}: {}", label, unwrap(failure).toString()); + log.warn("acceptInvitation failed on {}: {}", botId, unwrap(failure).toString()); } }); } - log.info("Declining a Battle.net invitation no link waits for on {}", label); + 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 {}: {}", label, unwrap(failure).toString()); + log.warn("declineInvitation failed on {}: {}", botId, unwrap(failure).toString()); } }); } @@ -330,16 +329,16 @@ private void invitationFailed(BattleNetServiceAccount bot, UUID linkId, UUID own 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.getLabel(), + 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.getLabel(), + 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.getLabel(), e.toString()); + log.warn("Could not handle the failed invitation of {}: {}", bot.getId(), e.toString()); } } @@ -374,7 +373,7 @@ private void relocate(UUID fullBotId, UUID linkId, UUID ownerId, String battleTa channelEventPublisher.battleNetLinkChanged(ownerId, "NONE", null, null); return; } - log.info("Battle.net link of user {} moved to {}", ownerId, next[0].getLabel()); + 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); } @@ -387,7 +386,7 @@ private void markFull(UUID botId) { } account.setFriendLimitReached(true); accountRepository.save(account); - log.warn("Battle.net service account {} marked full", account.getLabel()); + log.warn("Battle.net service account {} marked full", account.getId()); })); } 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 index 820fbb125..fc7a90674 100644 --- 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 @@ -39,10 +39,11 @@ public class BattleNetServiceAccountAdmin { /** - * What the admin UI sees of a bot account: never the e-mail, the password or the browser state. - * {@code waitingForCode}: an admin login waits for its 2FA code, so {@link #submitCode} can take one. + * 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 label, String battleTag, String authState, String sessionState, + public record AccountDto(UUID id, String displayName, String battleTag, String authState, String sessionState, int fillOrder, boolean friendLimitReached, boolean enabled, long friendCount, boolean waitingForCode) {} @@ -59,19 +60,17 @@ public List list() { } /** Stores the account with encrypted credentials and starts its interactive login in the background. */ - public AccountDto create(String label, String email, String password) { - requireText(label, "label"); + public AccountDto create(String email, String password) { requireText(email, "email"); requireText(password, "password"); BattleNetServiceAccount account = new BattleNetServiceAccount(); - account.setLabel(label); 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.getLabel()); + 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); @@ -110,9 +109,6 @@ public AccountDto update(UUID id, Map body) { BattleNetServiceAccount saved = withRetry(() -> { BattleNetServiceAccount account = findOrThrow(id); enabledChanged[0] = false; - if (body.containsKey("label")) { - account.setLabel(requireText(field(body, "label", String.class), "label")); - } if (body.containsKey("enabled")) { boolean enabled = field(body, "enabled", Boolean.class); enabledChanged[0] = enabled != account.isEnabled(); @@ -169,7 +165,7 @@ public void delete(UUID id) { accountRepository.delete(account); return account; }); - log.info("Battle.net service account deleted: {} ({} link(s) dropped)", deleted.getLabel(), owners.size()); + log.info("Battle.net service account deleted: {} ({} link(s) dropped)", deleted.getId(), owners.size()); owners.forEach(owner -> channelEventPublisher.battleNetLinkChanged(owner, "NONE", null, null)); } @@ -233,10 +229,25 @@ private static T field(Map body, String name, Class type) /** 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(), account.getLabel(), account.getBattleTag(), + 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 index 678ff57bf..7d2c04ba1 100644 --- 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 @@ -281,18 +281,18 @@ private void open(BattleNetServiceAccount account) { } Session session = new Session(account); sessions.put(account.getId(), session); - log.info("Opening the BGS session of Battle.net service account {}", account.getLabel()); + 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.getLabel()); + 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 {}", session.label); + log.info("Closing the BGS session of Battle.net service account {}", accountId); session.stop(); } } @@ -309,7 +309,7 @@ private void handOff(String what, Runnable task) { } } - private void storeIdentity(UUID accountId, String label, long bnetAccountId, String battleTag) { + private void storeIdentity(UUID accountId, long bnetAccountId, String battleTag) { try { transactions.executeWithoutResult(status -> repository.findById(accountId).ifPresent(account -> { if (Objects.equals(account.getBnetAccountId(), bnetAccountId) @@ -321,7 +321,7 @@ private void storeIdentity(UUID accountId, String label, long bnetAccountId, Str repository.save(account); })); } catch (RuntimeException e) { - log.warn("Could not store the BattleTag of Battle.net service account {}: {}", label, e.toString()); + log.warn("Could not store the BattleTag of Battle.net service account {}: {}", accountId, e.toString()); } } @@ -329,7 +329,6 @@ private void storeIdentity(UUID accountId, String label, long bnetAccountId, Str private final class Session implements BgsListener { private final UUID accountId; - private final String label; private final BgsClient client; private final BgsConnection connection; /** Friends whose game this session reported, forgotten when it ends. */ @@ -338,7 +337,6 @@ private final class Session implements BgsListener { Session(BattleNetServiceAccount account) { this.accountId = account.getId(); - this.label = account.getLabel(); 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); @@ -369,8 +367,8 @@ private void forgetGames() { @Override public void onReady(long bnetAccountId, String battleTag) { - log.info("BGS session of Battle.net service account {} is ready as {}", label, battleTag); - handOff("the BattleTag of " + label, () -> storeIdentity(accountId, label, bnetAccountId, 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 @@ -388,7 +386,7 @@ public void onPresence(PresenceEvent event) { @Override public void onFriend(FriendEvent event) { - handOff("a friend event of " + label, + handOff("a friend event of " + accountId, () -> events.publishEvent(new BattleNetFriendEventReceived(accountId, event))); } @@ -401,15 +399,15 @@ public void onClosed(CloseReason reason) { 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", label); - registry.counter(REPLACED_COUNTER, "account", label).increment(); + + "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)", label); - registry.counter(ADMIN_ACTION_COUNTER, "account", label).increment(); + + "(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: {}", label, reason); + default -> log.debug("BGS session of Battle.net service account {} closed: {}", accountId, reason); } } } 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/api/admin/ApiAdminBattleNetAccountsControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminBattleNetAccountsControllerTest.java index a72356a02..5b971bbad 100644 --- 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 @@ -50,7 +50,6 @@ class ApiAdminBattleNetAccountsControllerTest { @BeforeEach void setUp() { account.setId(UUID.randomUUID()); - account.setLabel("bot-1"); account.setEmail("bot-mailbox@example.com"); account.setPasswordEnc("enc:pw"); when(accounts.findById(account.getId())).thenReturn(Optional.of(account)); @@ -69,7 +68,8 @@ void setUp() { void listReturnsTheAccountsWithoutSecrets() throws Exception { mvc.perform(get("/api/internal/admin/battlenet-accounts")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].label").value("bot-1")) + .andExpect(jsonPath("$[0].displayName").value("b***@example.com")) + .andExpect(jsonPath("$[0].label").doesNotExist()) .andExpect(jsonPath("$[0].email").doesNotExist()) .andExpect(jsonPath("$[0].passwordEnc").doesNotExist()); } @@ -77,15 +77,16 @@ void listReturnsTheAccountsWithoutSecrets() throws Exception { @Test void createReturns202() throws Exception { mvc.perform(post("/api/internal/admin/battlenet-accounts").contentType(MediaType.APPLICATION_JSON) - .content("{\"label\":\"bot-2\",\"email\":\"a@b.c\",\"password\":\"pw\"}")) + .content("{\"email\":\"a@b.c\",\"password\":\"pw\"}")) .andExpect(status().isAccepted()) - .andExpect(jsonPath("$.authState").value("NEW")); + .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("{\"label\":\"bot-2\",\"email\":\"a@b.c\"}")) + .content("{\"email\":\"a@b.c\"}")) .andExpect(status().isBadRequest()); } @@ -123,9 +124,11 @@ void reauthReturns202() throws Exception { @Test void patchUpdatesAndDeleteReturns204() throws Exception { mvc.perform(patch("/api/internal/admin/battlenet-accounts/{id}", account.getId()) - .contentType(MediaType.APPLICATION_JSON).content("{\"label\":\"renamed\"}")) + .contentType(MediaType.APPLICATION_JSON).content("{\"label\":\"renamed\",\"fillOrder\":3}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.label").value("renamed")); + .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/repository/battlenet/BattleNetRepositoriesTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/repository/battlenet/BattleNetRepositoriesTest.java index e210068c3..79a48686a 100644 --- 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 @@ -24,13 +24,12 @@ class BattleNetRepositoriesTest { @Autowired BattleNetFriendLinkRepository links; @Autowired TestEntityManager em; - private BattleNetServiceAccount account(String label, long bnetAccountId) { + private BattleNetServiceAccount account(String name, long bnetAccountId) { BattleNetServiceAccount a = new BattleNetServiceAccount(); - a.setLabel(label); - a.setEmail(label + "@example.test"); + a.setEmail(name + "@example.test"); a.setPasswordEnc("enc-password"); a.setBnetAccountId(bnetAccountId); - a.setBattleTag(label + "#1234"); + a.setBattleTag(name + "#1234"); return em.persist(a); } @@ -72,7 +71,7 @@ void filtersEnabledAccountsByAuthStateInFillOrder() { em.flush(); assertThat(accounts.findByEnabledTrueAndAuthStateOrderByFillOrderAsc(BattleNetServiceAccount.AuthState.READY)) - .extracting(BattleNetServiceAccount::getLabel).containsExactly("first", "second"); + .extracting(BattleNetServiceAccount::getBattleTag).containsExactly("first#1234", "second#1234"); assertThat(accounts.countByEnabledTrue()).isEqualTo(3); assertThat(accounts.countByEnabledTrueAndFriendLimitReachedFalse()).isEqualTo(2); } @@ -80,7 +79,6 @@ void filtersEnabledAccountsByAuthStateInFillOrder() { @Test void accountToStringHidesSecrets() { BattleNetServiceAccount a = new BattleNetServiceAccount(); - a.setLabel("bot"); a.setEmail("secret@example.test"); a.setPasswordEnc("enc-password-blob"); a.setStorageStateEnc("enc-storage-blob"); 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 index edba26c6b..76099acec 100644 --- 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 @@ -153,10 +153,10 @@ void setUp() { when(client.removeFriend(anyLong())).thenReturn(CompletableFuture.completedFuture(null)); } - private static BattleNetServiceAccount bot(String label, int fillOrder, String battleTag) { + private static BattleNetServiceAccount bot(String email, int fillOrder, String battleTag) { BattleNetServiceAccount bot = new BattleNetServiceAccount(); bot.setId(UUID.randomUUID()); - bot.setLabel(label); + bot.setEmail(email); bot.setFillOrder(fillOrder); bot.setBattleTag(battleTag); bot.setAuthState(AuthState.READY); 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 index d07aa6f08..76be0e634 100644 --- 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 @@ -52,7 +52,6 @@ class BattleNetServiceAccountAdminTest { @BeforeEach void setUp() { account.setId(UUID.randomUUID()); - account.setLabel("bot-1"); account.setEmail("bot-mailbox@example.com"); account.setPasswordEnc("enc:old"); account.setStorageStateEnc("enc:state"); @@ -75,13 +74,44 @@ void createEncryptsPasswordAndStartsLogin() { return saved; }); - AccountDto dto = admin.create("bot-2", "mail@example.com", "s3cret"); + 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 @@ -212,11 +242,12 @@ void updatePasswordReEncrypts() { @Test void updateEditsPlainFieldsWithoutLoggingIn() { - admin.update(account.getId(), + // 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.getLabel()).isEqualTo("renamed"); assertThat(account.getFillOrder()).isEqualTo(4); + assertThat(dto.displayName()).isEqualTo("Bot#1234"); verify(coordinator, never()).startInteractive(any()); } @@ -235,17 +266,17 @@ void updateRetriesOnceWhenALoginCommittedInBetween() { return inv.getArgument(0); }); - AccountDto dto = admin.update(account.getId(), Map.of("label", "renamed")); + AccountDto dto = admin.update(account.getId(), Map.of("fillOrder", 7)); assertThat(saves.get()).isEqualTo(2); - assertThat(dto.label()).isEqualTo("renamed"); + assertThat(dto.fillOrder()).isEqualTo(7); } @Test void updateThatKeepsConflictingIsA409() { when(accounts.save(any())).thenThrow(conflict()); - assertThatThrownBy(() -> admin.update(account.getId(), Map.of("label", "renamed"))) + assertThatThrownBy(() -> admin.update(account.getId(), Map.of("fillOrder", 7))) .isInstanceOfSatisfying(ResponseStatusException.class, e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); } @@ -282,7 +313,7 @@ void createWhoseLoginCannotStartStillSucceedsAsAuthFailed() { when(coordinator.startInteractive(any())).thenReturn( CompletableFuture.failedFuture(new IllegalStateException("executor shut down"))); - AccountDto dto = admin.create("bot-2", "mail@example.com", "s3cret"); + AccountDto dto = admin.create("mail@example.com", "s3cret"); assertThat(dto.authState()).isEqualTo("AUTH_FAILED"); verify(accounts, org.mockito.Mockito.times(2)).save(any()); 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 index 175c83273..394facb28 100644 --- 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 @@ -73,11 +73,10 @@ void tearDown() { scheduler.shutdownNow(); } - private static BattleNetServiceAccount account(String label) { + private static BattleNetServiceAccount account(String name) { BattleNetServiceAccount account = new BattleNetServiceAccount(); account.setId(UUID.randomUUID()); - account.setLabel(label); - account.setEmail(label.toLowerCase() + "@example.test"); + account.setEmail(name.toLowerCase() + "@example.test"); account.setAuthState(AuthState.READY); account.setEnabled(true); return account; @@ -234,9 +233,12 @@ void sessionReplacedIsCountedAndNotRestarted() { DisconnectNotification.newBuilder().setErrorCode(60).build()); assertThat(manager.sessionState(bot1.getId())).isEqualTo("STOPPED_REPLACED"); - assertThat(registry.counter(BattleNetSessionManager.REPLACED_COUNTER, "account", "Bot1").count()) + 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 @@ -248,7 +250,7 @@ void codeRequiredLeavesTheAccountInCodeRequired() { connect(channels.getFirst()); assertThat(manager.sessionState(bot1.getId())).isEqualTo("STOPPED_CODE_REQUIRED"); - assertThat(registry.counter(BattleNetSessionManager.ADMIN_ACTION_COUNTER, "account", "Bot1").count()) + 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()); 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 index f5dfa9d7b..701d7d348 100644 --- 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 @@ -54,7 +54,6 @@ class BattleNetLoginCoordinatorTest { @BeforeEach void setUp() { account.setId(UUID.randomUUID()); - account.setLabel("bot-1"); account.setEmail(EMAIL); account.setPasswordEnc(encryption.encrypt(PASSWORD)); account.setStorageStateEnc(encryption.encrypt(OLD_STATE)); @@ -294,7 +293,6 @@ void aReMintWhoseCallerGaveUpBeforeItsTurnNeverOpensTheBrowser() throws Exceptio private CompletableFuture blockTheLoginThread() throws InterruptedException { other = new BattleNetServiceAccount(); other.setId(UUID.randomUUID()); - other.setLabel("bot-2"); other.setEmail("other-mailbox@example.com"); other.setPasswordEnc(encryption.encrypt(PASSWORD)); when(repository.findById(other.getId())).thenReturn(Optional.of(other)); 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 index 352660609..8f0755370 100644 --- 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 @@ -5,8 +5,9 @@ /** * 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). + * 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 label, String battleTag, String authState, String sessionState, +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-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java index 3470e6b95..420f12508 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminBattleNetController.java @@ -14,7 +14,7 @@ /** * The admin Keys tab's Battle.net bot pool: add a bot, submit its 2FA code, re-authenticate, enable / - * disable, rename and reorder, lift "full", change its password and delete, each answered with catapult-api's status + * 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. */ @@ -25,11 +25,11 @@ public class AdminBattleNetController { private final ApiService apiService; private final ModelFiller filler; - public record CreateBody(String label, String email, String password) { + public record CreateBody(String email, String password) { /** Never print the credentials. */ @Override public String toString() { - return "CreateBody[label=" + label + "]"; + return "CreateBody[]"; } } @@ -42,7 +42,7 @@ public String toString() { public record EnabledBody(Boolean enabled) {} - public record DetailsBody(String label, Integer fillOrder) {} + public record DetailsBody(Integer fillOrder) {} public record PasswordBody(String password) { @Override @@ -54,9 +54,8 @@ public String toString() { @PostMapping public ResponseEntity create(@RequestBody CreateBody body) { AdminAccess.require(filler); - if (blank(body.label()) || blank(body.email()) || blank(body.password())) return ResponseEntity.badRequest().build(); - return ResponseEntity.status(apiService.adminCreateBattleNetAccount( - body.label().trim(), body.email().trim(), body.password())).build(); + 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") @@ -88,9 +87,8 @@ public ResponseEntity resetLimit(@PathVariable UUID id) { @PostMapping("/{id}/details") public ResponseEntity details(@PathVariable UUID id, @RequestBody DetailsBody body) { AdminAccess.require(filler); - if (blank(body.label()) || body.fillOrder() == null || body.fillOrder() < 0) return ResponseEntity.badRequest().build(); - return ResponseEntity.status(apiService.adminPatchBattleNetAccount(id, - Map.of("label", body.label().trim(), "fillOrder", body.fillOrder()))).build(); + 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") 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 28832a5af..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 @@ -531,12 +531,12 @@ record MinecraftDeviceCodeStart(int status, MinecraftDeviceCodeDto code, ApiErro * 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 label, String email, String password); + 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 label}, {@code fillOrder}, {@code enabled}, {@code password}, {@code friendLimitReached}): 200; 409; 404; 502. */ + /** 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); 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 36550aa16..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 @@ -1764,9 +1764,9 @@ public List adminBattleNetAccounts() { } @Override - public int adminCreateBattleNetAccount(String label, String email, String password) { - log.trace("adminCreateBattleNetAccount({})", label); - return battleNetAccounts.create(label); + public int adminCreateBattleNetAccount(String email, String password) { + log.trace("adminCreateBattleNetAccount()"); + return battleNetAccounts.create(email); } @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 index 7c2a03719..39e788ba9 100644 --- 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 @@ -11,37 +11,37 @@ * 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. + * 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 label, String battleTag, String authState, String sessionState, + 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, label, battleTag, authState, sessionState, fillOrder, + 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, label, battleTag, authState, sessionState, fillOrder, friendLimitReached, enabled, friendCount); + 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", "TestBot#1000", + 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", null, + 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 label) { - String name = label == null || label.isBlank() ? "Battle.net bot" : label; - accounts.add(new Account(UUID.randomUUID(), name, null, "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", + 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; } @@ -52,7 +52,8 @@ synchronized int submitCode(UUID id, String code) { Account a = accounts.get(index); if (!"CODE_REQUIRED".equals(a.authState())) return 409; if (CODE.equals(code)) { - accounts.set(index, new Account(a.id(), a.label(), a.battleTag() == null ? a.label() + "#0001" : a.battleTag(), + 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; @@ -91,4 +92,10 @@ private int indexOf(UUID id) { } 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/real/RealApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java index c9ffabefd..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 @@ -1375,9 +1375,9 @@ public List adminBattleNetAccounts() { } @Override - public int adminCreateBattleNetAccount(String label, String email, String password) { + public int adminCreateBattleNetAccount(String email, String password) { return client.call(HttpMethod.POST, "/api/internal/admin/battlenet-accounts", - Map.of("label", label, "email", email, "password", password), Void.class).status(); + Map.of("email", email, "password", password), Void.class).status(); } @Override diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index f1c37aad9..b5b675aad 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -1540,8 +1540,7 @@ 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.label=Label -admin.battlenet.col.battletag=BattleTag +admin.battlenet.col.account=Account admin.battlenet.col.auth=Login admin.battlenet.col.session=Session admin.battlenet.col.friends=Friends @@ -1571,7 +1570,6 @@ 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.label=Label (e.g. Bot 1) admin.battlenet.enroll.email=Bot e-mail admin.battlenet.enroll.password=Bot password admin.battlenet.enroll.start=Add a bot @@ -1588,6 +1586,5 @@ 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.label=Label 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 234d57445..d5d0bb1f2 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -1486,8 +1486,7 @@ 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.label=Label -admin.battlenet.col.battletag=BattleTag +admin.battlenet.col.account=Account admin.battlenet.col.auth=Login admin.battlenet.col.session=Session admin.battlenet.col.friends=Friends @@ -1517,7 +1516,6 @@ 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.label=Label (e.g. Bot 1) admin.battlenet.enroll.email=Bot e-mail admin.battlenet.enroll.password=Bot password admin.battlenet.enroll.start=Add a bot @@ -1534,6 +1532,5 @@ 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.label=Label 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 75cacb4d8..c7d9a4dc0 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -1540,8 +1540,7 @@ admin.members.premium.until.hint=Dernier jour premium (heure de Paris). Vide : s 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.label=Libellé -admin.battlenet.col.battletag=BattleTag +admin.battlenet.col.account=Compte admin.battlenet.col.auth=Connexion admin.battlenet.col.session=Session admin.battlenet.col.friends=Amis @@ -1571,7 +1570,6 @@ 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.label=Libellé (ex. Bot 1) admin.battlenet.enroll.email=E-mail du bot admin.battlenet.enroll.password=Mot de passe du bot admin.battlenet.enroll.start=Ajouter un bot @@ -1588,6 +1586,5 @@ 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.label=Libellé 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 1025a34d9..3a09d23d3 100644 --- a/catapult-web/src/main/resources/static/css/catapult.css +++ b/catapult-web/src/main/resources/static/css/catapult.css @@ -2518,7 +2518,7 @@ 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) minmax(0, 1fr) 7rem 10rem 4rem 12rem auto; } +.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; } diff --git a/catapult-web/src/main/resources/static/js/admin-battlenet.js b/catapult-web/src/main/resources/static/js/admin-battlenet.js index 27af402cf..6bd782e43 100644 --- a/catapult-web/src/main/resources/static/js/admin-battlenet.js +++ b/catapult-web/src/main/resources/static/js/admin-battlenet.js @@ -1,13 +1,13 @@ /** - * The admin Keys tab's Battle.net bot pool card. Add a bot (label, e-mail, password), send the 2FA code of + * 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. Labels and BattleTags come from the server-rendered rows, never - * from innerHTML. + * 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 @@ -80,11 +80,12 @@ window.CatapultAdminBattleNet = (function () { function add(panel, card, button) { const enroll = card.querySelector(".admin-battlenet-enroll"); - const fields = ["label", "email", "password"].map(name => enroll.querySelector(`[name="${name}"]`)); - const [label, email, password] = fields.map(field => (field?.value ?? "").trim()); - if (!label || !email || !(fields[2]?.value ?? "")) return show(card, card.dataset.labelInvalid, true); + 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, { label, email, password: fields[2].value }, card.dataset.labelCreated, fields); + return act(panel, card, button, BASE, { email, password }, card.dataset.labelCreated, fields); } function sendCode(panel, card, button) { @@ -111,11 +112,10 @@ window.CatapultAdminBattleNet = (function () { function saveDetails(panel, card, button) { const box = button.closest(".admin-battlenet-details"); - const label = (box?.querySelector('[name="label"]')?.value ?? "").trim(); const order = Number(box?.querySelector('[name="fillOrder"]')?.value); - if (!label || !Number.isInteger(order) || order < 0) return show(card, card.dataset.labelInvalid, true); + if (!Number.isInteger(order) || order < 0) return show(card, card.dataset.labelInvalid, true); return act(panel, card, button, `${BASE}/${encodeURIComponent(button.dataset.id)}/details`, - { label, fillOrder: order }, card.dataset.labelUpdated); + { fillOrder: order }, card.dataset.labelUpdated); } function stopPolling() { 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 18ef444b4..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 @@ -180,8 +180,7 @@

    Battle.net bots

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

    - Bot1 - Bot#1234 + Bot#1234 READY Battle.net bots

    th:attr="data-id=${a.id()}" th:text="#{admin.battlenet.action.lift_full}">Lift full + data-confirm=#{admin.battlenet.action.delete-confirm(${a.displayName()})}">

    Battle.net bots th:attr="data-id=${a.id()}" th:text="#{admin.battlenet.code.submit}">Send the code

    - Battle.net bots
    - ", "Tag#1", "READY", "READY", true, true), - bot("00000000-0000-0000-0000-00000000000b", "Waiting", null, "CODE_REQUIRED", "STOPPED_CODE_REQUIRED", false, false))); + 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+", " "); - assertThat(html).contains("id=\"admin-battlenet\"", "Battle.net bots", "Ready<b>", "Tag#1", "Add a bot", + // 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); 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 index cc7d46adc..3106e0db6 100644 --- 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 @@ -32,6 +32,7 @@ void theRightCodeMovesTheAccountToReady_aWrongOneLeavesItWaiting() { 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); @@ -39,9 +40,12 @@ void theRightCodeMovesTheAccountToReady_aWrongOneLeavesItWaiting() { @Test void createWaitsForACode_reauthPatchAndDelete() { - assertThat(store.create("Bot9")).isEqualTo(202); - BattleNetAccountDto created = store.list().stream().filter(a -> a.label().equals("Bot9")).findFirst().orElseThrow(); + 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); 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 5b82b2955..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 @@ -1880,9 +1880,10 @@ void minecraftAccountActions_sendTheirShapes_andReturnTheStatus() { @Test void adminBattleNetAccounts_readCatapultApisDto() { respond(HttpMethod.GET, "/api/internal/admin/battlenet-accounts", "[{\"id\":\"00000000-0000-0000-0000-00000000000a\"," - + "\"label\":\"Bot1\",\"battleTag\":\"Bot#1\",\"authState\":\"CODE_REQUIRED\",\"sessionState\":\"STOPPED_CODE_REQUIRED\"," + + "\"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"); @@ -1903,7 +1904,7 @@ void adminBattleNetAccounts_isNullWhenTheApiFails() { void battleNetAccountActions_sendTheirShapes_andReturnTheStatus() { UUID id = UUID.fromString("00000000-0000-0000-0000-00000000000a"); expect(HttpMethod.POST, "/api/internal/admin/battlenet-accounts") - .andExpect(content().json("{\"label\":\"Bot\",\"email\":\"b@x.y\",\"password\":\"pw\"}", + .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") @@ -1916,7 +1917,7 @@ void battleNetAccountActions_sendTheirShapes_andReturnTheStatus() { .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); expect(HttpMethod.DELETE, "/api/internal/admin/battlenet-accounts/" + id).andRespond(withNoContent()); - assertThat(service.adminCreateBattleNetAccount("Bot", "b@x.y", "pw")).isEqualTo(202); + 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); diff --git a/catapult-web/src/test/js/admin-battlenet.test.js b/catapult-web/src/test/js/admin-battlenet.test.js index 445a2dc09..65bfe3061 100644 --- a/catapult-web/src/test/js/admin-battlenet.test.js +++ b/catapult-web/src/test/js/admin-battlenet.test.js @@ -17,7 +17,7 @@ function row(state, waitingForCode) {
    -
    +
    `; } @@ -34,7 +34,7 @@ function page(state = "READY", waitingForCode = state === "CODE_REQUIRED") { data-label-invalid="Invalid"> ${row(state, waitingForCode)}
    - +
    @@ -75,20 +75,19 @@ afterEach(() => { 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="label"]').value = " Bot1 "; - document.querySelector('.admin-battlenet-enroll [name="email"]').value = "bot@example.com"; + 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", { label: "Bot1", email: "bot@example.com", password: "s3cret" }); + 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="label"]').value = "Bot1"; + document.querySelector('.admin-battlenet-enroll [name="email"]').value = "bot@example.com"; await click(".admin-battlenet-add"); @@ -149,15 +148,14 @@ describe("admin-battlenet actions", () => { }); describe("admin-battlenet details", () => { - it("sends the label and the fill order, refusing a blank label or a negative order", async () => { + 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`, { label: "Bot", fillOrder: 2 }); + 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"; - document.querySelector('.admin-battlenet-details [name="label"]').value = " "; + 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"); From 792ed4be54a6c72b448ce6b6059bccb8ad9ff3ab Mon Sep 17 00:00:00 2001 From: "catapult-counterweight[bot]" <349249181+catapult-counterweight[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:23:37 +0000 Subject: [PATCH 54/54] chore(release): bump version to v1.3.0 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 {