From 7b87c24b9ebad330e8b1b4ee8af4858174f31adc Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Sun, 24 May 2026 19:35:02 -0400
Subject: [PATCH 01/11] fix: send structured banned payload for lobby bans
---
server/exceptions.py | 29 ++++++++---------------------
1 file changed, 8 insertions(+), 21 deletions(-)
diff --git a/server/exceptions.py b/server/exceptions.py
index ff7a7cc57..a13a9d300 100644
--- a/server/exceptions.py
+++ b/server/exceptions.py
@@ -2,10 +2,7 @@
Common exception definitions
"""
-import humanize
-
-from server.timing import datetime_now
-
+from datetime import timezone
class ClientError(Exception):
"""
@@ -31,23 +28,13 @@ def __init__(self, ban_expiry, ban_reason, *args, **kwargs):
self.ban_expiry = ban_expiry
self.ban_reason = ban_reason
- def message(self):
- return (
- f"You are banned from FAF {self._ban_duration_text()}.
"
- f"Reason:
{self.ban_reason}
"
- "If you would like to appeal this ban, please send an email to: "
- "moderation@faforever.com"
- )
-
- def _ban_duration_text(self):
- ban_duration = self.ban_expiry - datetime_now()
- if ban_duration.days > 365 * 100:
- return "forever"
- humanized_ban_duration = humanize.precisedelta(
- ban_duration,
- minimum_unit="hours"
- )
- return f"for {humanized_ban_duration}"
+ def to_payload(self):
+ expiry_utc = self.ban_expiry.astimezone(timezone.utc)
+ return {
+ "command": "banned",
+ "expires_at": expiry_utc.isoformat(),
+ "reason": self.ban_reason,
+ }
class AuthenticationError(Exception):
From fdfdd270f067e49f57da2d4858bb9fb135d04ac3 Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Sun, 24 May 2026 19:35:18 -0400
Subject: [PATCH 02/11] fix: send structured banned payload for lobby bans
---
server/lobbyconnection.py | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/server/lobbyconnection.py b/server/lobbyconnection.py
index 5b5ec2e10..78ab36589 100644
--- a/server/lobbyconnection.py
+++ b/server/lobbyconnection.py
@@ -222,12 +222,8 @@ async def on_message_received(self, message):
"text": e.message
})
except BanError as e:
- await self.send({
- "command": "notice",
- "style": "error",
- "text": e.message()
- })
- await self.abort(e.message())
+ await self.send(e.to_payload())
+ await self.abort("Banned user blocked from lobby session")
except ClientError as e:
self._logger.warning(
"ClientError[%s]: %s",
From 1f26b15cc1a2832c05052aede1feda36c9b57aa5 Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Sun, 24 May 2026 19:35:31 -0400
Subject: [PATCH 03/11] test: validate banned payload format in login flow
---
tests/integration_tests/test_login.py | 25 +++++++------------------
1 file changed, 7 insertions(+), 18 deletions(-)
diff --git a/tests/integration_tests/test_login.py b/tests/integration_tests/test_login.py
index 0d7c5c843..6cc261d36 100644
--- a/tests/integration_tests/test_login.py
+++ b/tests/integration_tests/test_login.py
@@ -1,4 +1,5 @@
from time import time
+from datetime import datetime, timezone
import jwt
import pytest
@@ -38,15 +39,9 @@ async def test_server_ban(lobby_server, user):
proto = await connect_client(lobby_server)
await perform_login(proto, user)
msg = await proto.read_message()
- assert msg == {
- "command": "notice",
- "style": "error",
- "text": (
- "You are banned from FAF forever.
Reason:
Test permanent ban"
- "
If you would like to appeal this ban, please send an "
- "email to: moderation@faforever.com"
- )
- }
+ assert msg["command"] == "banned"
+ assert msg["reason"] == "Test permanent ban"
+ assert datetime.fromisoformat(msg["expires_at"]).astimezone(timezone.utc).year >= 2500
@pytest.mark.parametrize("user", [
@@ -73,15 +68,9 @@ async def test_server_ban_token(lobby_server, user, jwk_priv_key, jwk_kid):
"unique_id": "some_id"
})
msg = await proto.read_message()
- assert msg == {
- "command": "notice",
- "style": "error",
- "text": (
- "You are banned from FAF forever.
Reason:
Test permanent ban"
- "
If you would like to appeal this ban, please send an "
- "email to: moderation@faforever.com"
- )
- }
+ assert msg["command"] == "banned"
+ assert msg["reason"] == "Test permanent ban"
+ assert datetime.fromisoformat(msg["expires_at"]).astimezone(timezone.utc).year >= 2500
@pytest.mark.parametrize("user", ["ban_revoked", "ban_expired"])
From 1a8b50d09da4a0c3228c2024dd79f6a3d0a833fe Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Sun, 24 May 2026 19:35:43 -0400
Subject: [PATCH 04/11] test: assert structured ban message for live-ban
enforcement
---
tests/integration_tests/test_server.py | 14 ++++----------
1 file changed, 4 insertions(+), 10 deletions(-)
diff --git a/tests/integration_tests/test_server.py b/tests/integration_tests/test_server.py
index ae00663bb..a4d90249c 100644
--- a/tests/integration_tests/test_server.py
+++ b/tests/integration_tests/test_server.py
@@ -721,7 +721,7 @@ async def test_game_host_name_non_ascii(lobby_server):
"command": "game_host",
"mod": "",
"visibility": "public",
- "title": "ÇÒÖL GÃMÊ"
+ "title": "ÇÒÖL GÃMÊ"
})
msg = await read_until_command(proto, "notice", timeout=10)
@@ -845,15 +845,9 @@ async def test_server_ban_prevents_hosting(lobby_server, database, command):
await proto.send_message({"command": command})
msg = await proto.read_message()
- assert msg == {
- "command": "notice",
- "style": "error",
- "text": (
- "You are banned from FAF forever.
Reason:
Test live ban
"
- "
If you would like to appeal this ban, please send an email "
- "to: moderation@faforever.com"
- )
- }
+ assert msg["command"] == "banned"
+ assert msg["reason"] == "Test live ban"
+ assert msg["expires_at"].endswith("+00:00")
@fast_forward(5)
From 23dcd55f41c6bce6999002b746051dd34ff11031 Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Sun, 24 May 2026 19:35:56 -0400
Subject: [PATCH 05/11] test: switch BanError assertions to structured payload
---
tests/unit_tests/test_lobbyconnection.py | 18 +++++++-----------
1 file changed, 7 insertions(+), 11 deletions(-)
diff --git a/tests/unit_tests/test_lobbyconnection.py b/tests/unit_tests/test_lobbyconnection.py
index ea764e84d..39b7cc068 100644
--- a/tests/unit_tests/test_lobbyconnection.py
+++ b/tests/unit_tests/test_lobbyconnection.py
@@ -1,4 +1,3 @@
-import re
from hashlib import sha256
from unittest import mock
@@ -1303,18 +1302,15 @@ async def test_abort_connection_if_banned(
lobbyconnection.player.id = 203
with pytest.raises(BanError) as banned_error:
await lobbyconnection.abort_connection_if_banned()
- assert banned_error.value.message() == (
- "You are banned from FAF forever.
Reason:
Test permanent ban"
- "
If you would like to appeal this ban, please send an email "
- "to: moderation@faforever.com"
- )
+ assert banned_error.value.to_payload()["command"] == "banned"
+ assert banned_error.value.to_payload()["reason"] == "Test permanent ban"
+ assert banned_error.value.to_payload()["expires_at"].endswith("+00:00")
# test user who is banned for another 46 hours
lobbyconnection.player.id = 204
with pytest.raises(BanError) as banned_error:
await lobbyconnection.abort_connection_if_banned()
- assert re.match(
- r"You are banned from FAF for 1 day and 2[12]\.[0-9]+ hours.
"
- "Reason:
Test ongoing ban with 46 hours left",
- banned_error.value.message()
- )
+ payload = banned_error.value.to_payload()
+ assert payload["command"] == "banned"
+ assert payload["reason"] == "Test ongoing ban with 46 hours left"
+ assert payload["expires_at"].endswith("+00:00")
From 59af757f5dca46efc506d128499602468b1c5f7a Mon Sep 17 00:00:00 2001
From: Zain Kazmi
Date: Sun, 24 May 2026 21:31:45 -0400
Subject: [PATCH 06/11] Address lint feedback on ban payload tests
Signed-off-by: Zain Kazmi
---
server/exceptions.py | 1 +
tests/integration_tests/test_login.py | 2 +-
tests/integration_tests/test_server.py | 2 +-
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/server/exceptions.py b/server/exceptions.py
index a13a9d300..3dd26aa9f 100644
--- a/server/exceptions.py
+++ b/server/exceptions.py
@@ -4,6 +4,7 @@
from datetime import timezone
+
class ClientError(Exception):
"""
Represents a protocol violation by the client.
diff --git a/tests/integration_tests/test_login.py b/tests/integration_tests/test_login.py
index 6cc261d36..493aabe8b 100644
--- a/tests/integration_tests/test_login.py
+++ b/tests/integration_tests/test_login.py
@@ -1,5 +1,5 @@
-from time import time
from datetime import datetime, timezone
+from time import time
import jwt
import pytest
diff --git a/tests/integration_tests/test_server.py b/tests/integration_tests/test_server.py
index a4d90249c..dc2e81647 100644
--- a/tests/integration_tests/test_server.py
+++ b/tests/integration_tests/test_server.py
@@ -721,7 +721,7 @@ async def test_game_host_name_non_ascii(lobby_server):
"command": "game_host",
"mod": "",
"visibility": "public",
- "title": "ÇÒÖL GÃMÊ"
+ "title": "ÇÖL GÂMÊ"
})
msg = await read_until_command(proto, "notice", timeout=10)
From c8b3265b3bc15f8aee5bf8e6e17558455eb02ddc Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Sun, 24 May 2026 22:33:14 -0400
Subject: [PATCH 07/11] fix(protocol): satisfy mypy by packing bytes
---
server/protocol/qdatastream.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/protocol/qdatastream.py b/server/protocol/qdatastream.py
index 999d2313c..6adea3227 100644
--- a/server/protocol/qdatastream.py
+++ b/server/protocol/qdatastream.py
@@ -92,7 +92,7 @@ def pack_message(*args: str) -> bytes:
raise NotImplementedError("Only string serialization is supported")
msg += QDataStreamProtocol.pack_qstring(arg)
- return QDataStreamProtocol.pack_block(msg)
+ return QDataStreamProtocol.pack_block(bytes(msg))
@staticmethod
def encode_message(message: dict) -> bytes:
From 9d3ac00755cd7c9597fc8b49eb16ea55f34b7b00 Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Sun, 24 May 2026 22:50:16 -0400
Subject: [PATCH 08/11] chore: refresh Pipfile lock hash
---
Pipfile.lock | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Pipfile.lock b/Pipfile.lock
index 615d5a42b..91020093f 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "67adacda446396d91de50fe41c426c738226113aa0b3e3f16a00f2141ff7bc8a"
+ "sha256": "a187fb4a62ec0cc2dab78542af7c21eb559388bac49e0f84f35f656aa1e19200"
},
"pipfile-spec": 6,
"requires": {
From 7db962f1630329d1bbe39e015c458fa7eb998f74 Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Mon, 25 May 2026 08:46:44 +0000
Subject: [PATCH 09/11] fix(protocol): keep qstream pack buffer behavior
---
server/protocol/qdatastream.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/server/protocol/qdatastream.py b/server/protocol/qdatastream.py
index 6adea3227..fbe93fe8d 100644
--- a/server/protocol/qdatastream.py
+++ b/server/protocol/qdatastream.py
@@ -71,7 +71,7 @@ def pack_qstring(message: str) -> bytes:
return struct.pack("!i", len(encoded)) + encoded
@staticmethod
- def pack_block(block: bytes) -> bytes:
+ def pack_block(block: bytes | bytearray) -> bytes:
return struct.pack("!I", len(block)) + block
@staticmethod
@@ -92,7 +92,7 @@ def pack_message(*args: str) -> bytes:
raise NotImplementedError("Only string serialization is supported")
msg += QDataStreamProtocol.pack_qstring(arg)
- return QDataStreamProtocol.pack_block(bytes(msg))
+ return QDataStreamProtocol.pack_block(msg)
@staticmethod
def encode_message(message: dict) -> bytes:
From 7e722a674b4accd4715a7091794610fb4ce0d266 Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Mon, 25 May 2026 07:40:51 -0400
Subject: [PATCH 10/11] docs(protocol): clarify pack_block bytes-like contract
Signed-off-by: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
---
server/protocol/qdatastream.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/server/protocol/qdatastream.py b/server/protocol/qdatastream.py
index fbe93fe8d..f0e737acd 100644
--- a/server/protocol/qdatastream.py
+++ b/server/protocol/qdatastream.py
@@ -72,6 +72,12 @@ def pack_qstring(message: str) -> bytes:
@staticmethod
def pack_block(block: bytes | bytearray) -> bytes:
+ """
+ Pack a bytes-like payload while preserving bytearray inputs.
+
+ pack_message builds a mutable buffer and passes it through unchanged to
+ avoid qstream behavior regressions seen when forcing bytes conversion.
+ """
return struct.pack("!I", len(block)) + block
@staticmethod
From 41d82aa588c5b2f7802aed9bf172842a83a26b7a Mon Sep 17 00:00:00 2001
From: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
Date: Mon, 25 May 2026 16:56:23 -0400
Subject: [PATCH 11/11] refactor(protocol): require bytes contract for
pack_block
Signed-off-by: ZainKazmiii <111486944+ZainKazmiii@users.noreply.github.com>
---
server/protocol/qdatastream.py | 10 ++--------
1 file changed, 2 insertions(+), 8 deletions(-)
diff --git a/server/protocol/qdatastream.py b/server/protocol/qdatastream.py
index f0e737acd..6adea3227 100644
--- a/server/protocol/qdatastream.py
+++ b/server/protocol/qdatastream.py
@@ -71,13 +71,7 @@ def pack_qstring(message: str) -> bytes:
return struct.pack("!i", len(encoded)) + encoded
@staticmethod
- def pack_block(block: bytes | bytearray) -> bytes:
- """
- Pack a bytes-like payload while preserving bytearray inputs.
-
- pack_message builds a mutable buffer and passes it through unchanged to
- avoid qstream behavior regressions seen when forcing bytes conversion.
- """
+ def pack_block(block: bytes) -> bytes:
return struct.pack("!I", len(block)) + block
@staticmethod
@@ -98,7 +92,7 @@ def pack_message(*args: str) -> bytes:
raise NotImplementedError("Only string serialization is supported")
msg += QDataStreamProtocol.pack_qstring(arg)
- return QDataStreamProtocol.pack_block(msg)
+ return QDataStreamProtocol.pack_block(bytes(msg))
@staticmethod
def encode_message(message: dict) -> bytes: