diff --git a/src/Server/WebSocket/WebSocketServer.php b/src/Server/WebSocket/WebSocketServer.php index b98beee1..dacb3e19 100644 --- a/src/Server/WebSocket/WebSocketServer.php +++ b/src/Server/WebSocket/WebSocketServer.php @@ -6,6 +6,7 @@ use Workerman\Worker; use Workerman\Connection\TcpConnection; +use Phlix\Auth\JwtHandler; use Phlix\Common\Logger\LoggerFactory; use Phlix\Common\Logger\LogChannels; use Phlix\Session\SyncPlay\SyncPlayManager; @@ -122,23 +123,47 @@ public function onStart(): void /** * Called when a new client connects. * - * Creates a Connection wrapper, adds it to the pool, and sends - * a welcome message with the connection ID. + * Validates JWT token from query string if present, then creates + * a Connection wrapper, adds it to the pool, and sends a welcome + * message with the connection ID. * * @param TcpConnection $connection The Workerman TCP connection * @return void */ public function onConnect(TcpConnection $connection): void { + $token = $_GET['token'] ?? null; + $userId = null; + + if (is_string($token) && $token !== '') { + $jwtSecret = $this->config['jwt_secret'] ?? null; + if (is_string($jwtSecret) && $jwtSecret !== '') { + $jwtHandler = new JwtHandler($jwtSecret); + $payload = $jwtHandler->validateToken($token); + if ($payload === null) { + $connection->close(); + return; + } + $sub = $payload['sub'] ?? null; + $userId = is_string($sub) ? $sub : null; + } + } + $wsConnection = new Connection($connection); + + if (is_string($userId)) { + $wsConnection->setAuthenticated(true, $userId); + } + $this->connections->add($wsConnection); $logger = LoggerFactory::get(LogChannels::WEBSOCKET); $logger->debug('New WebSocket connection', [ 'connection_id' => $wsConnection->getId(), + 'authenticated' => $wsConnection->isAuthenticated(), + 'user_id' => $wsConnection->getUserId(), ]); - // Send welcome message $wsConnection->sendMessage('connected', [ 'connection_id' => $wsConnection->getId(), 'timestamp' => time(), diff --git a/src/Session/SyncPlay/GroupState.php b/src/Session/SyncPlay/GroupState.php index 15f926d6..04cbde44 100644 --- a/src/Session/SyncPlay/GroupState.php +++ b/src/Session/SyncPlay/GroupState.php @@ -651,7 +651,7 @@ public function isInSync(int $memberPosition): bool /** * Get the full group state for broadcasting to clients. * - * Returns a comprehensive state array including members list, + * Returns a comprehensive state array including members dictionary, * playback info, queue, and timestamps. * * @return array Full group state @@ -659,14 +659,14 @@ public function isInSync(int $memberPosition): bool * @example * ```php * $state = $group->getState(); - * // ['group_id' => 'sp_abc123', 'group_name' => 'Movie Night', 'members' => [...], ...] + * // ['group_id' => 'sp_abc123', 'group_name' => 'Movie Night', 'members' => ['member_id' => [...]], ...] * ``` */ public function getState(): array { - $membersList = []; + $membersDict = []; foreach ($this->members as $id => $member) { - $membersList[] = [ + $membersDict[$id] = [ 'id' => $id, 'name' => $member['name'] ?? 'Unknown', 'is_host' => $id === $this->hostId, @@ -678,7 +678,7 @@ public function getState(): array 'group_id' => $this->id, 'group_name' => $this->name, 'member_count' => $this->getMemberCount(), - 'members' => $membersList, + 'members' => $membersDict, 'host_id' => $this->hostId, 'current_media_id' => $this->currentMediaId, 'current_media_duration' => $this->currentMediaDuration, diff --git a/src/Session/SyncPlay/SyncPlayManager.php b/src/Session/SyncPlay/SyncPlayManager.php index 77ada1e4..08ac7e7c 100644 --- a/src/Session/SyncPlay/SyncPlayManager.php +++ b/src/Session/SyncPlay/SyncPlayManager.php @@ -7,6 +7,7 @@ use Phlix\Common\Logger\LogChannels; use Phlix\Common\Logger\StructuredLogger; use Phlix\Server\WebSocket\Connection; +use Phlix\Server\WebSocket\ConnectionInterface; use Phlix\Server\WebSocket\ConnectionPool; use Phlix\Server\WebSocket\MessageHandler; @@ -134,7 +135,7 @@ private function registerMessageHandlers(): void return; } - $handler = function (Connection $connection, array $payload) { + $handler = function (ConnectionInterface $connection, array $payload) { $this->handleMessage($connection, $payload); }; @@ -156,13 +157,13 @@ private function registerMessageHandlers(): void * Routes the message to the appropriate handler based on message type. * All exceptions are caught and reported back to the client as error messages. * - * @param Connection $connection The WebSocket connection that sent the message + * @param ConnectionInterface $connection The WebSocket connection that sent the message * @param array $payload The decoded message payload * @return void * * @see Messages For all valid message type constants */ - private function handleMessage(Connection $connection, array $payload): void + protected function handleMessage(ConnectionInterface $connection, array $payload): void { $type = $payload['type'] ?? ''; @@ -492,21 +493,23 @@ public function listGroups(): array * Handle playback play command from a group host. * * Only the host can initiate playback commands. The position is updated - * and broadcast to all other group members. + * and broadcast to all other group members. Per SP4 spec, host authorization + * uses the server-derived member_id from the authenticated connection. * - * @param Connection $connection The WebSocket connection - * @param array $payload Payload containing member_id, position, server_time + * @param ConnectionInterface $connection The WebSocket connection + * @param array $payload Payload containing position, server_time * @return void * - * @fires Messages::TYPE_PLAYBACK_PLAY Broadcast to group members + * @fires Messages::TYPE_PLAYBACK_PLAY Sent to host directly, then broadcast to other members */ - private function handlePlaybackPlay(Connection $connection, array $payload): void + private function handlePlaybackPlay(ConnectionInterface $connection, array $payload): void { - $memberId = self::stringFromMixed($payload['member_id'] ?? null); - if ($memberId === '') { - $this->sendError($connection, 'NOT_IN_GROUP', 'You are not in a group'); + $memberId = $connection->getUserId(); + if ($memberId === null) { + $this->sendError($connection, 'NOT_AUTHENTICATED', 'Authentication required'); return; } + $groupId = $this->memberToGroup[$memberId] ?? null; $group = $groupId !== null ? ($this->groups[$groupId] ?? null) : null; @@ -525,29 +528,37 @@ private function handlePlaybackPlay(Connection $connection, array $payload): voi $group->updatePlayback(GroupState::STATE_PLAYING, $position); - $this->broadcastToGroup($groupId, Messages::TYPE_PLAYBACK_PLAY, [ + $playbackFrame = [ 'member_id' => $memberId, 'position' => $position, 'server_time' => $serverTime, - ], [$memberId]); + ]; + + // Send directly to host as confirmation of their command + $connection->sendFlat(Messages::TYPE_PLAYBACK_PLAY, $playbackFrame); + + // Broadcast to all OTHER members + $this->broadcastToGroup($groupId, Messages::TYPE_PLAYBACK_PLAY, $playbackFrame, [$memberId]); } /** * Handle playback pause command from a group host. + * Per SP4 spec, host authorization uses server-derived member_id. * - * @param Connection $connection The WebSocket connection - * @param array $payload Payload containing member_id, position, server_time + * @param ConnectionInterface $connection The WebSocket connection + * @param array $payload Payload containing position, server_time * @return void * * @fires Messages::TYPE_PLAYBACK_PAUSE Broadcast to group members */ - private function handlePlaybackPause(Connection $connection, array $payload): void + private function handlePlaybackPause(ConnectionInterface $connection, array $payload): void { - $memberId = self::stringFromMixed($payload['member_id'] ?? null); - if ($memberId === '') { - $this->sendError($connection, 'NOT_IN_GROUP', 'You are not in a group'); + $memberId = $connection->getUserId(); + if ($memberId === null) { + $this->sendError($connection, 'NOT_AUTHENTICATED', 'Authentication required'); return; } + $groupId = $this->memberToGroup[$memberId] ?? null; $group = $groupId !== null ? ($this->groups[$groupId] ?? null) : null; @@ -575,20 +586,22 @@ private function handlePlaybackPause(Connection $connection, array $payload): vo /** * Handle playback seek command from a group host. + * Per SP4 spec, host authorization uses server-derived member_id. * - * @param Connection $connection The WebSocket connection - * @param array $payload Payload containing member_id, from_position, to_position, server_time + * @param ConnectionInterface $connection The WebSocket connection + * @param array $payload Payload containing from_position, to_position, server_time * @return void * * @fires Messages::TYPE_PLAYBACK_SEEK Broadcast to group members */ - private function handlePlaybackSeek(Connection $connection, array $payload): void + private function handlePlaybackSeek(ConnectionInterface $connection, array $payload): void { - $memberId = self::stringFromMixed($payload['member_id'] ?? null); - if ($memberId === '') { - $this->sendError($connection, 'NOT_IN_GROUP', 'You are not in a group'); + $memberId = $connection->getUserId(); + if ($memberId === null) { + $this->sendError($connection, 'NOT_AUTHENTICATED', 'Authentication required'); return; } + $groupId = $this->memberToGroup[$memberId] ?? null; $group = $groupId !== null ? ($this->groups[$groupId] ?? null) : null; @@ -620,20 +633,22 @@ private function handlePlaybackSeek(Connection $connection, array $payload): voi * Handle playback queue update from a group host. * * Replaces the group's current playback queue with the provided queue items. + * Per SP4 spec, host authorization uses server-derived member_id. * - * @param Connection $connection The WebSocket connection - * @param array $payload Payload containing member_id, queue array + * @param ConnectionInterface $connection The WebSocket connection + * @param array $payload Payload containing queue array * @return void * * @fires Messages::TYPE_PLAYBACK_QUEUE Broadcast to group members */ - private function handlePlaybackQueue(Connection $connection, array $payload): void + private function handlePlaybackQueue(ConnectionInterface $connection, array $payload): void { - $memberId = self::stringFromMixed($payload['member_id'] ?? null); - if ($memberId === '') { - $this->sendError($connection, 'NOT_IN_GROUP', 'You are not in a group'); + $memberId = $connection->getUserId(); + if ($memberId === null) { + $this->sendError($connection, 'NOT_AUTHENTICATED', 'Authentication required'); return; } + $groupId = $this->memberToGroup[$memberId] ?? null; $group = $groupId !== null ? ($this->groups[$groupId] ?? null) : null; @@ -684,13 +699,13 @@ private function handlePlaybackQueue(Connection $connection, array $payload): vo * Broadcasts the chat message to all other group members along with * the sender's name and timestamp. * - * @param Connection $connection The WebSocket connection + * @param ConnectionInterface $connection The WebSocket connection * @param array $payload Payload containing member_id, message * @return void * * @fires Messages::TYPE_CHAT_MESSAGE Broadcast to group members (excluding sender) */ - private function handleChatMessage(Connection $connection, array $payload): void + private function handleChatMessage(ConnectionInterface $connection, array $payload): void { $memberId = self::stringFromMixed($payload['member_id'] ?? null); if ($memberId === '') { @@ -729,13 +744,13 @@ private function handleChatMessage(Connection $connection, array $payload): void * Processes the ping and returns a pong with server timestamp for * calculating network latency and clock offset. * - * @param Connection $connection The WebSocket connection + * @param ConnectionInterface $connection The WebSocket connection * @param array $payload Payload containing client_time * @return void * * @see TimeSync::processPing() For ping/pong protocol details */ - private function handleTimePing(Connection $connection, array $payload): void + private function handleTimePing(ConnectionInterface $connection, array $payload): void { $pong = $this->timeSync->processPing($payload); $message = Messages::timePong($pong['client_time'], $pong['server_time']); @@ -746,17 +761,23 @@ private function handleTimePing(Connection $connection, array $payload): void * Handle group creation request via WebSocket. * * Creates a new group with the requesting member as the host. + * Per SP4 spec, member_id is server-derived from the authenticated connection's userId. * - * @param Connection $connection The WebSocket connection - * @param array $payload Payload containing member_id, member_name, group_name, password + * @param ConnectionInterface $connection The WebSocket connection + * @param array $payload Payload containing member_name, group_name, password * @return void * * @fires Messages::TYPE_GROUP_STATE Sent to the creating member on success * @fires Messages::TYPE_ERROR Sent on failure */ - private function handleGroupCreate(Connection $connection, array $payload): void + private function handleGroupCreate(ConnectionInterface $connection, array $payload): void { - $memberId = self::stringFromMixed($payload['member_id'] ?? $connection->getId()); + $memberId = $connection->getUserId(); + if ($memberId === null) { + $this->sendError($connection, 'NOT_AUTHENTICATED', 'Authentication required'); + return; + } + $memberName = self::stringFromMixed($payload['member_name'] ?? 'Host'); $groupName = self::stringFromMixed($payload['group_name'] ?? 'New Group'); $password = self::stringOrNullFromMixed($payload['password'] ?? null); @@ -775,18 +796,24 @@ private function handleGroupCreate(Connection $connection, array $payload): void /** * Handle group join request via WebSocket. + * Per SP4 spec, member_id is server-derived from the authenticated connection's userId. * - * @param Connection $connection The WebSocket connection - * @param array $payload Payload containing group_id, member_id, member_name, password + * @param ConnectionInterface $connection The WebSocket connection + * @param array $payload Payload containing group_id, member_name, password * @return void * * @fires Messages::TYPE_GROUP_STATE Sent to the joining member on success * @fires Messages::TYPE_ERROR Sent on failure */ - private function handleGroupJoin(Connection $connection, array $payload): void + private function handleGroupJoin(ConnectionInterface $connection, array $payload): void { + $memberId = $connection->getUserId(); + if ($memberId === null) { + $this->sendError($connection, 'NOT_AUTHENTICATED', 'Authentication required'); + return; + } + $groupId = self::stringFromMixed($payload['group_id'] ?? ''); - $memberId = self::stringFromMixed($payload['member_id'] ?? $connection->getId()); $memberName = self::stringFromMixed($payload['member_name'] ?? 'User'); $password = self::stringOrNullFromMixed($payload['password'] ?? null); @@ -805,14 +832,14 @@ private function handleGroupJoin(Connection $connection, array $payload): void /** * Handle group leave request via WebSocket. * - * @param Connection $connection The WebSocket connection + * @param ConnectionInterface $connection The WebSocket connection * @param array $payload Payload containing member_id * @return void * * @fires Messages::TYPE_INFO Sent on success * @fires Messages::TYPE_ERROR Sent on failure */ - private function handleGroupLeave(Connection $connection, array $payload): void + private function handleGroupLeave(ConnectionInterface $connection, array $payload): void { $memberId = self::stringFromMixed($payload['member_id'] ?? null); if ($memberId === '') { @@ -880,14 +907,14 @@ private function broadcastToGroup(string $groupId, string $type, array $data, ar /** * Send an error message to a specific connection. * - * @param Connection $connection The WebSocket connection to send to + * @param ConnectionInterface $connection The WebSocket connection to send to * @param string $code Error code (e.g., 'NOT_IN_GROUP', 'NOT_HOST') * @param string $message Human-readable error message * @return void * * @see Messages::TYPE_ERROR For the error message format */ - private function sendError(Connection $connection, string $code, string $message): void + private function sendError(ConnectionInterface $connection, string $code, string $message): void { $connection->sendFlat(Messages::TYPE_ERROR, [ 'error_code' => $code, diff --git a/tests/Unit/Server/WebSocket/TestConnection.php b/tests/Unit/Server/WebSocket/TestConnection.php new file mode 100644 index 00000000..07ae2677 --- /dev/null +++ b/tests/Unit/Server/WebSocket/TestConnection.php @@ -0,0 +1,160 @@ + */ + private array $sessionData = []; + + /** @var list, payload?:array}> */ + private array $sentMessages = []; + + private bool $closed = false; + + private int $lastActivity = 0; + + public function __construct(string $id = 'test-conn-id') + { + $this->id = $id; + $this->lastActivity = time(); + } + + public function getId(): string + { + return $this->id; + } + + public function send(string|array $data): void + { + $this->sentMessages[] = is_array($data) ? $data : ['raw' => $data]; + $this->lastActivity = time(); + } + + public function sendMessage(string $type, array $data = []): void + { + $this->sentMessages[] = ['type' => $type, 'data' => $data]; + } + + public function sendFlat(string $type, array $payload): void + { + $this->sentMessages[] = ['type' => $type, 'payload' => $payload]; + } + + public function close(): void + { + $this->closed = true; + } + + public function updateActivity(): void + { + $this->lastActivity = time(); + } + + public function getLastActivity(): int + { + return $this->lastActivity; + } + + public function isAuthenticated(): bool + { + return $this->authenticated; + } + + public function setAuthenticated(bool $authenticated, ?string $userId = null): void + { + $this->authenticated = $authenticated; + $this->userId = $userId; + } + + public function getUserId(): ?string + { + return $this->userId; + } + + public function setSessionId(?string $sessionId): void + { + $this->sessionId = $sessionId; + } + + public function getSessionId(): ?string + { + return $this->sessionId; + } + + public function set(string $key, mixed $value): void + { + $this->sessionData[$key] = $value; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->sessionData[$key] ?? $default; + } + + public function has(string $key): bool + { + return isset($this->sessionData[$key]); + } + + public function remove(string $key): void + { + unset($this->sessionData[$key]); + } + + public function getAll(): array + { + return $this->sessionData; + } + + /** + * Get all messages sent via send() or sendMessage()/sendFlat(). + * + * @return list, payload?:array}> + */ + public function getSentMessages(): array + { + return $this->sentMessages; + } + + /** + * Get messages of a specific type sent via sendFlat. + * + * @param string $type Message type to filter by + * @return list> + */ + public function getSentFlatMessages(string $type): array + { + $messages = []; + foreach ($this->sentMessages as $msg) { + if (($msg['type'] ?? '') === $type) { + $messages[] = $msg['payload'] ?? $msg; + } + } + + return $messages; + } + + public function wasClosed(): bool + { + return $this->closed; + } +} diff --git a/tests/Unit/Server/WebSocket/WsAuthenticationTest.php b/tests/Unit/Server/WebSocket/WsAuthenticationTest.php new file mode 100644 index 00000000..22f0f878 --- /dev/null +++ b/tests/Unit/Server/WebSocket/WsAuthenticationTest.php @@ -0,0 +1,447 @@ +clear(); + $this->jwtHandler = new JwtHandler($this->jwtSecret, 'HS256', 3600, 604800); + } + + /** + * Creates a mock TcpConnection that tracks send and close calls. + * + * @param array $callTracker Tracks which methods were called + * @return \PHPUnit\Framework\MockObject\MockObject&\Workerman\Connection\TcpConnection + */ + private function createMockTcpConnection(array &$callTracker = []): \Workerman\Connection\TcpConnection|\PHPUnit\Framework\MockObject\MockObject + { + $callTracker = ['send' => false, 'close' => false]; + + $mockConnection = $this->createMock(\Workerman\Connection\TcpConnection::class); + $mockConnection->method('send')->willReturnCallback(function () use (&$callTracker) { + $callTracker['send'] = true; + }); + $mockConnection->method('close')->willReturnCallback(function () use (&$callTracker) { + $callTracker['close'] = true; + }); + + return $mockConnection; + } + + /** + * Creates a SyncPlayManager with handleMessage exposed for testing. + */ + private function createTestableSyncPlayManager(): SyncPlayManager + { + $pool = ConnectionPool::getInstance(); + $handler = new MessageHandler($pool); + + // Use anonymous class to expose protected handleMessage as public + return new class ($handler) extends SyncPlayManager { + public function __construct(MessageHandler $handler) + { + parent::__construct(); + $this->initialize($handler); + } + + public function publicHandleMessage(ConnectionInterface $connection, array $payload): void + { + $this->handleMessage($connection, $payload); + } + }; + } + + /** + * @covers \Phlix\Server\WebSocket\WebSocketServer::onConnect + */ + public function testValidTokenAuthenticatesConnection(): void + { + $config = [ + 'host' => '0.0.0.0', + 'port' => 8097, + 'jwt_secret' => $this->jwtSecret, + ]; + + $server = new WebSocketServer($config); + $callTracker = []; + $mockConnection = $this->createMockTcpConnection($callTracker); + + // Set the token in $_GET + $token = $this->jwtHandler->createAccessToken('user-123'); + $_GET['token'] = $token; + + // Call onConnect + $server->onConnect($mockConnection); + + // Verify the connection was added to pool and authenticated + $pool = ConnectionPool::getInstance(); + $connections = $pool->all(); + $this->assertCount(1, $connections); + + $wsConnection = $connections[0]; + $this->assertInstanceOf(Connection::class, $wsConnection); + $this->assertTrue($wsConnection->isAuthenticated()); + $this->assertEquals('user-123', $wsConnection->getUserId()); + $this->assertTrue($callTracker['send'], 'Welcome message should be sent'); + + // Clean up + unset($_GET['token']); + } + + /** + * @covers \Phlix\Server\WebSocket\WebSocketServer::onConnect + */ + public function testInvalidTokenClosesConnection(): void + { + $config = [ + 'host' => '0.0.0.0', + 'port' => 8097, + 'jwt_secret' => $this->jwtSecret, + ]; + + $server = new WebSocketServer($config); + $callTracker = []; + $mockConnection = $this->createMockTcpConnection($callTracker); + + // Set an invalid token in $_GET + $_GET['token'] = 'invalid.token.here'; + + // Call onConnect + $server->onConnect($mockConnection); + + // Verify close was called + $this->assertTrue($callTracker['close'], 'Connection should be closed for invalid token'); + + // Verify no connection was added to pool + $pool = ConnectionPool::getInstance(); + $this->assertCount(0, $pool->all()); + + // Clean up + unset($_GET['token']); + } + + /** + * @covers \Phlix\Server\WebSocket\WebSocketServer::onConnect + */ + public function testMissingTokenAllowsUnauthenticatedConnection(): void + { + $config = [ + 'host' => '0.0.0.0', + 'port' => 8097, + 'jwt_secret' => $this->jwtSecret, + ]; + + $server = new WebSocketServer($config); + $callTracker = []; + $mockConnection = $this->createMockTcpConnection($callTracker); + + // Don't set any token + unset($_GET['token']); + + // Call onConnect + $server->onConnect($mockConnection); + + // Verify connection was added but not authenticated + $pool = ConnectionPool::getInstance(); + $connections = $pool->all(); + $this->assertCount(1, $connections); + + $wsConnection = $connections[0]; + $this->assertFalse($wsConnection->isAuthenticated()); + $this->assertNull($wsConnection->getUserId()); + $this->assertTrue($callTracker['send'], 'Welcome message should be sent'); + } + + /** + * @covers \Phlix\Server\WebSocket\WebSocketServer::onConnect + */ + public function testExpiredTokenRejectsConnection(): void + { + $config = [ + 'host' => '0.0.0.0', + 'port' => 8097, + 'jwt_secret' => $this->jwtSecret, + ]; + + // Create an expired JWT handler + $expiredHandler = new JwtHandler($this->jwtSecret, 'HS256', -10, 604800); + $token = $expiredHandler->createAccessToken('user-456'); + + $server = new WebSocketServer($config); + $callTracker = []; + $mockConnection = $this->createMockTcpConnection($callTracker); + + // Set the expired token + $_GET['token'] = $token; + + // Call onConnect + $server->onConnect($mockConnection); + + // Verify close was called + $this->assertTrue($callTracker['close'], 'Connection should be closed for expired token'); + + // Verify no connection was added to pool + $pool = ConnectionPool::getInstance(); + $this->assertCount(0, $pool->all()); + + // Clean up + unset($_GET['token']); + } + + /** + * @covers \Phlix\Session\SyncPlay\SyncPlayManager::handleMessage + */ + public function testUnauthenticatedConnectionCannotCreateGroup(): void + { + $syncPlayManager = $this->createTestableSyncPlayManager(); + + // Track if sendFlat was called with error + $errorSent = false; + $errorCode = ''; + + // Create an unauthenticated mock connection + $mockConnection = $this->createMock(Connection::class); + $mockConnection->method('isAuthenticated')->willReturn(false); + $mockConnection->method('sendFlat')->willReturnCallback(function (string $type, array $data) use (&$errorSent, &$errorCode) { + if ($type === Messages::TYPE_ERROR) { + $errorSent = true; + $errorCode = $data['error_code'] ?? ''; + } + }); + $mockConnection->method('sendMessage')->willReturnCallback(function () { + }); + $mockConnection->method('getId')->willReturn('conn-123'); + $mockConnection->method('getUserId')->willReturn(null); + + // Try to create a group (should be rejected) + $payload = [ + 'type' => Messages::TYPE_GROUP_CREATE, + 'group_name' => 'Test Group', + ]; + + $syncPlayManager->publicHandleMessage($mockConnection, $payload); + + // Verify NOT_AUTHENTICATED error was sent + $this->assertTrue($errorSent, 'Error should be sent for unauthenticated create attempt'); + $this->assertEquals('NOT_AUTHENTICATED', $errorCode); + } + + /** + * @covers \Phlix\Session\SyncPlay\SyncPlayManager::handleMessage + */ + public function testUnauthenticatedConnectionCannotJoinGroup(): void + { + $syncPlayManager = $this->createTestableSyncPlayManager(); + + // Track if sendFlat was called with error + $errorSent = false; + $errorCode = ''; + + // Create an unauthenticated mock connection + $mockConnection = $this->createMock(Connection::class); + $mockConnection->method('isAuthenticated')->willReturn(false); + $mockConnection->method('sendFlat')->willReturnCallback(function (string $type, array $data) use (&$errorSent, &$errorCode) { + if ($type === Messages::TYPE_ERROR) { + $errorSent = true; + $errorCode = $data['error_code'] ?? ''; + } + }); + $mockConnection->method('sendMessage')->willReturnCallback(function () { + }); + $mockConnection->method('getId')->willReturn('conn-123'); + $mockConnection->method('getUserId')->willReturn(null); + + // Try to join a group (should be rejected) + $payload = [ + 'type' => Messages::TYPE_GROUP_JOIN, + 'group_id' => 'sp_abc123', + ]; + + $syncPlayManager->publicHandleMessage($mockConnection, $payload); + + // Verify NOT_AUTHENTICATED error was sent + $this->assertTrue($errorSent, 'Error should be sent for unauthenticated join attempt'); + $this->assertEquals('NOT_AUTHENTICATED', $errorCode); + } + + /** + * @covers \Phlix\Session\SyncPlay\SyncPlayManager::handleMessage + */ + public function testUnauthenticatedConnectionCannotControlPlayback(): void + { + $syncPlayManager = $this->createTestableSyncPlayManager(); + + // Track error count + $errorCount = 0; + + // Create an unauthenticated mock connection + $mockConnection = $this->createMock(Connection::class); + $mockConnection->method('isAuthenticated')->willReturn(false); + $mockConnection->method('sendFlat')->willReturnCallback(function (string $type, array $data) use (&$errorCount) { + if ($type === Messages::TYPE_ERROR) { + $errorCount++; + } + }); + $mockConnection->method('sendMessage')->willReturnCallback(function () { + }); + $mockConnection->method('getId')->willReturn('conn-123'); + $mockConnection->method('getUserId')->willReturn(null); + + // Try to send playback commands (should be rejected) + $payloads = [ + ['type' => Messages::TYPE_PLAYBACK_PLAY, 'position' => 1000, 'server_time' => time()], + ['type' => Messages::TYPE_PLAYBACK_PAUSE, 'position' => 1000, 'server_time' => time()], + ['type' => Messages::TYPE_PLAYBACK_SEEK, 'from_position' => 1000, 'to_position' => 2000, 'server_time' => time()], + ]; + + foreach ($payloads as $payload) { + $syncPlayManager->publicHandleMessage($mockConnection, $payload); + } + + // Verify NOT_AUTHENTICATED error was sent 3 times + $this->assertEquals(3, $errorCount, 'Should reject 3 playback commands from unauthenticated connection'); + } + + /** + * @covers \Phlix\Session\SyncPlay\SyncPlayManager::handleGroupCreate + */ + public function testServerDerivedMemberIdIsUsedInsteadOfClientSupplied(): void + { + $syncPlayManager = $this->createTestableSyncPlayManager(); + + // Create a test connection that properly tracks authenticated state + $testConnection = new TestConnection('conn-456'); + $testConnection->setAuthenticated(true, 'server-user-id-123'); + + // Send create group with a different client-supplied member_id + // The server should ignore it and use the userId instead + $payload = [ + 'type' => Messages::TYPE_GROUP_CREATE, + 'member_id' => 'client-claimed-member-id', // This should be IGNORED + 'member_name' => 'Test Host', + 'group_name' => 'Test Group', + ]; + + $syncPlayManager->publicHandleMessage($testConnection, $payload); + + // Verify the group was created with the server-derived userId + $groups = $syncPlayManager->listGroups(); + $this->assertCount(1, $groups); + + $groupState = $syncPlayManager->getGroupState($groups[0]['id']); + $this->assertNotNull($groupState); + + // The member should have the server-derived userId (not the client-supplied one) + $members = $groupState['members'] ?? []; + $this->assertArrayHasKey('server-user-id-123', $members, 'Should use server-derived userId'); + $this->assertArrayNotHasKey('client-claimed-member-id', $members, 'Should NOT use client-supplied member_id'); + } + + /** + * @covers \Phlix\Session\SyncPlay\SyncPlayManager::handlePlaybackPlay + */ + public function testHostAuthorizationUsesServerDerivedMemberId(): void + { + $syncPlayManager = $this->createTestableSyncPlayManager(); + + // Create a test connection that properly tracks authenticated state + $testConnection = new TestConnection('conn-789'); + $testConnection->setAuthenticated(true, 'authenticated-user'); + + // First create a group (authenticated user will be host) + $syncPlayManager->publicHandleMessage($testConnection, [ + 'type' => Messages::TYPE_GROUP_CREATE, + 'member_name' => 'Host User', + 'group_name' => 'Test Group', + ]); + + // Now try to send playback command with a spoofed member_id + // The server should use the authenticated userId for host check, not the spoofed one + $syncPlayManager->publicHandleMessage($testConnection, [ + 'type' => Messages::TYPE_PLAYBACK_PLAY, + 'member_id' => 'spoofed-member-id', // This should be IGNORED + 'position' => 1000, + 'server_time' => time(), + ]); + + // The playback command should succeed because the authenticated user is the host + $sentFlatMessages = $testConnection->getSentFlatMessages(Messages::TYPE_PLAYBACK_PLAY); + $this->assertNotEmpty($sentFlatMessages, 'Playback play should succeed for host'); + + // The member_id in the broadcast should be the server-derived userId + $playbackData = $sentFlatMessages[0] ?? []; + $this->assertEquals( + 'authenticated-user', + $playbackData['member_id'] ?? '', + 'Should use server-derived userId for host authorization' + ); + } + + /** + * @covers \Phlix\Session\SyncPlay\SyncPlayManager::handleGroupJoin + */ + public function testJoinGroupUsesServerDerivedMemberId(): void + { + $syncPlayManager = $this->createTestableSyncPlayManager(); + + // Create a test connection for host + $hostConnection = new TestConnection('conn-host'); + $hostConnection->setAuthenticated(true, 'host-user'); + + $syncPlayManager->publicHandleMessage($hostConnection, [ + 'type' => Messages::TYPE_GROUP_CREATE, + 'member_name' => 'Host User', + 'group_name' => 'Test Group', + ]); + + $groups = $syncPlayManager->listGroups(); + $this->assertCount(1, $groups); + $groupId = $groups[0]['id']; + + // Create a member connection that will try to join with a different member_id + $memberConnection = new TestConnection('conn-member'); + $memberConnection->setAuthenticated(true, 'member-user'); + + // Join with a spoofed member_id - should be ignored + $syncPlayManager->publicHandleMessage($memberConnection, [ + 'type' => Messages::TYPE_GROUP_JOIN, + 'group_id' => $groupId, + 'member_id' => 'spoofed-member-id', // Should be IGNORED + 'member_name' => 'Spoofed Name', + ]); + + // Verify the group has the server-derived userId as member, not the spoofed one + $groupState = $syncPlayManager->getGroupState($groupId); + $this->assertNotNull($groupState); + + $members = $groupState['members'] ?? []; + $this->assertArrayHasKey('member-user', $members, 'Should use server-derived userId as member'); + $this->assertArrayNotHasKey('spoofed-member-id', $members, 'Should NOT use client-supplied member_id'); + } +}