diff --git a/CHANGES.md b/CHANGES.md index 5c2967b..b82c46a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -64,13 +64,7 @@ ### misc -- [ADD] macos-15 を E2E テストに追加する - - @voluntas -- [ADD] canary.py を追加 - - @voluntas -- [ADD] Python 3.13 を E2E テストに追加する - - @voluntas -- [ADD] macos-15 を E2E テストに追加する +- [UPDATE] Boost のダウンロード URL を変更する - @voluntas - [UPDATE] ubuntu-latest を ubuntu-24.04 に変更する - @voluntas @@ -90,15 +84,25 @@ - @voluntas - [CHANGE] サンプルアプリの E2E テストを一旦削除する - @voluntas +- [ADD] pyjwt を dev-dependencies に追加する + - @voluntas +- [ADD] macos-15 を E2E テストに追加する + - @voluntas +- [ADD] canary.py を追加 + - @voluntas +- [ADD] Python 3.13 を E2E テストに追加する + - @voluntas +- [ADD] macos-15 を E2E テストに追加する + - @voluntas - [ADD] tests/ に E2E テストを追加する - @voluntas +- [ADD] examples に E2E テストを追加する + - @voluntas - [FIX] run.py で local_sora_cpp_sdk_dir を設定した際に boost が引けなくなってしまっている問題を修正する - @tnoho - [FIX] examples の設定に virtual = true を指定するようにする - これを指定しないとエラーになる - @voluntas -- [ADD] examples に E2E テストを追加する - - @voluntas - [FIX] サイマルキャストの E2E テストについて encoderImplementation の値チェック内容を緩和する - サイマルキャストの encoderImplementation のチェックを文字列一致としていたが、帯域推定機能を有効にした後、値が安定しなくなったためチェック内容を緩和した - サイマルキャストの encoderImplementation の結果を以下の通り修正 diff --git a/buildbase.py b/buildbase.py index 99c9e7d..d2cf719 100644 --- a/buildbase.py +++ b/buildbase.py @@ -691,7 +691,7 @@ def build_and_install_boost( ): version_underscore = version.replace(".", "_") archive = download( - f"https://boostorg.jfrog.io/artifactory/main/release/{version}/source/boost_{version_underscore}.tar.gz", + f"https://archives.boost.io/release/{version}/source/boost_{version_underscore}.tar.gz", source_dir, ) extract(archive, output_dir=build_dir, output_dirname="boost") diff --git a/pyproject.toml b/pyproject.toml index a631f8e..c688674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,11 +63,12 @@ dev-dependencies = [ "wheel~=0.45.1", "typing-extensions", "python-dotenv", - "numpy>=2.2.1", + "numpy", "httpx", "pytest", "ruff", "mypy", + "pyjwt", ] [tool.ruff] diff --git a/tests/test_authz.py b/tests/test_authz.py new file mode 100644 index 0000000..63edd7e --- /dev/null +++ b/tests/test_authz.py @@ -0,0 +1,125 @@ +import sys +import time +import uuid + +import jwt +import pytest +from client import SoraClient, SoraRole + + +@pytest.mark.skipif(reason="Sora C++ SDK 側の対応が必要") +def test_sendonly_authz_video_true(setup): + """ + - type: connect で audio: true / video: false で繫ぐ + - 認証成功時の払い出しで audio: false / video: true を払い出す + """ + signaling_urls = setup.get("signaling_urls") + channel_id_prefix = setup.get("channel_id_prefix") + secret = setup.get("secret") + + channel_id = f"{channel_id_prefix}_{__name__}_{sys._getframe().f_code.co_name}_{uuid.uuid4()}" + + access_token = jwt.encode( + { + "channel_id": channel_id, + "audio": False, + "video": True, + # 現在時刻 + 300 秒 (5分) + "exp": int(time.time()) + 300, + }, + secret, + algorithm="HS256", + ) + + sendonly = SoraClient( + signaling_urls, + SoraRole.SENDONLY, + channel_id, + audio=True, + video=False, + metadata={"access_token": access_token}, + ) + sendonly.connect(fake_video=False, fake_audio=True) + + time.sleep(5) + + assert sendonly.offer_message is not None + assert sendonly.offer_message["sdp"] is not None + assert "VP9" in sendonly.offer_message["sdp"] + + sendonly_stats = sendonly.get_stats() + + sendonly.disconnect() + + # codec が無かったら StopIteration 例外が上がる + # 統計で video が見つからないので謎挙動になってる + sendonly_codec_stats = next(s for s in sendonly_stats if s.get("type") == "codec") + assert sendonly_codec_stats["mimeType"] == "video/VP9" + + # outbound-rtp が無かったら StopIteration 例外が上がる + outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp") + assert outbound_rtp_stats["encoderImplementation"] == "libvpx" + assert outbound_rtp_stats["bytesSent"] > 0 + assert outbound_rtp_stats["packetsSent"] > 0 + + +@pytest.mark.parametrize( + "video_codec_params", + [ + # video_codec, encoder_implementation, decoder_implementation + ("VP8", "libvpx"), + ("VP9", "libvpx"), + ("AV1", "libaom"), + ], +) +def test_sendonly_authz_video_codec_type(setup, video_codec_params): + video_codec_type, encoder_implementation = video_codec_params + + signaling_urls = setup.get("signaling_urls") + channel_id_prefix = setup.get("channel_id_prefix") + secret = setup.get("secret") + + channel_id = f"{channel_id_prefix}_{__name__}_{sys._getframe().f_code.co_name}_{uuid.uuid4()}" + + access_token = jwt.encode( + { + "channel_id": channel_id, + "video": True, + "video_codec_type": video_codec_type, + # 現在時刻 + 300 秒 (5分) + "exp": int(time.time()) + 300, + }, + secret, + algorithm="HS256", + ) + + sendonly = SoraClient( + signaling_urls, + SoraRole.SENDONLY, + channel_id, + audio=False, + video=True, + metadata={"access_token": access_token}, + ) + sendonly.connect(fake_video=True) + + time.sleep(5) + + assert sendonly.offer_message is not None + assert sendonly.offer_message["sdp"] is not None + assert video_codec_type in sendonly.offer_message["sdp"] + + sendonly_stats = sendonly.get_stats() + + sendonly.disconnect() + + # codec が無かったら StopIteration 例外が上がる + # 統計で video が見つからないので謎挙動になってる + sendonly_codec_stats = next(s for s in sendonly_stats if s.get("type") == "codec") + assert sendonly_codec_stats["mimeType"] == f"video/{video_codec_type}" + + # outbound-rtp が無かったら StopIteration 例外が上がる + outbound_rtp_stats = next(s for s in sendonly_stats if s.get("type") == "outbound-rtp") + assert outbound_rtp_stats["encoderImplementation"] == encoder_implementation + assert outbound_rtp_stats["bytesSent"] > 0 + assert outbound_rtp_stats["packetsSent"] > 0 diff --git a/tests/test_sendonly_recvonly.py b/tests/test_sendonly_recvonly.py index eecdf31..89a2f99 100644 --- a/tests/test_sendonly_recvonly.py +++ b/tests/test_sendonly_recvonly.py @@ -60,18 +60,15 @@ def test_sendonly_recvonly_audio(setup): assert inbound_rtp_stats["packetsReceived"] > 0 -@pytest.fixture( - params=[ +@pytest.mark.parametrize( + "video_codec_params", + [ # video_codec, encoder_implementation, decoder_implementation ("VP8", "libvpx", "libvpx"), ("VP9", "libvpx", "libvpx"), ("AV1", "libaom", "dav1d"), - ] + ], ) -def video_codec_params(request): - return request.param - - def test_sendonly_recvonly_video(setup, video_codec_params): video_codec, encoder_implementation, decoder_implementation = video_codec_params diff --git a/tests/test_sora_disconnect.py b/tests/test_sora_disconnect.py index 05ae7d3..518250d 100644 --- a/tests/test_sora_disconnect.py +++ b/tests/test_sora_disconnect.py @@ -2,6 +2,7 @@ import time import uuid +import jwt import pytest from api import disconnect_connection_api from client import SoraClient, SoraRole @@ -45,7 +46,42 @@ def test_websocket_signaling_only_disconnect_api(setup): assert conn.disconnect_reason is not None assert "DISCONNECTED-API" in conn.disconnect_reason - # TODO: LIFETIME-EXPIRED のテスト + +def test_websocket_signaling_only_lifetime_expired(setup): + signaling_urls = setup.get("signaling_urls") + channel_id_prefix = setup.get("channel_id_prefix") + secret = setup.get("secret") + + channel_id = f"{channel_id_prefix}_{__name__}_{sys._getframe().f_code.co_name}_{uuid.uuid4()}" + + access_token = jwt.encode( + { + "channel_id": channel_id, + "audio": False, + "video": True, + "connection_lifetime": 3, + # 現在時刻 + 300 秒 (5分) + "exp": int(time.time()) + 300, + }, + secret, + algorithm="HS256", + ) + + with SoraClient( + signaling_urls, + SoraRole.RECVONLY, + channel_id, + audio=True, + video=True, + metadata={"access_token": access_token}, + data_channel_signaling=False, + ignore_disconnect_websocket=False, + ) as conn: + time.sleep(5) + + assert conn.ws_close is True + assert conn.ws_close_code == 1000 + assert conn.ws_close_reason == "LIFETIME-EXPIRED" @pytest.mark.skipif(sys.platform != "linux", reason="linux でのみ実行する") @@ -84,7 +120,42 @@ def test_websocket_datachannel_signaling_disconnect_api(setup): assert conn.disconnect_reason is not None assert "DISCONNECTED-API" in conn.disconnect_reason - # TODO: LIFETIME-EXPIRED のテスト + +def test_websocket_datachannel_signaling_lifetime_expired(setup): + signaling_urls = setup.get("signaling_urls") + channel_id_prefix = setup.get("channel_id_prefix") + secret = setup.get("secret") + + channel_id = f"{channel_id_prefix}_{__name__}_{sys._getframe().f_code.co_name}_{uuid.uuid4()}" + + access_token = jwt.encode( + { + "channel_id": channel_id, + "audio": True, + "video": True, + "connection_lifetime": 3, + # 現在時刻 + 300 秒 (5分) + "exp": int(time.time()) + 300, + }, + secret, + algorithm="HS256", + ) + + with SoraClient( + signaling_urls, + SoraRole.RECVONLY, + channel_id, + audio=True, + video=True, + metadata={"access_token": access_token}, + data_channel_signaling=True, + ignore_disconnect_websocket=False, + ) as conn: + time.sleep(5) + + assert conn.ws_close is True + assert conn.ws_close_code == 1000 + assert conn.ws_close_reason == "LIFETIME-EXPIRED" @pytest.mark.skipif(sys.platform != "linux", reason="linux でのみ実行する") @@ -130,4 +201,44 @@ def test_datachannel_only_signaling_disconnect_api(setup): assert conn.disconnect_reason is not None assert "DISCONNECTED-API" in conn.disconnect_reason - # TODO: LIFETIME-EXPIRED のテスト + +def test_datachannel_only_signaling_lifetime_expired(setup): + signaling_urls = setup.get("signaling_urls") + channel_id_prefix = setup.get("channel_id_prefix") + secret = setup.get("secret") + + channel_id = f"{channel_id_prefix}_{__name__}_{sys._getframe().f_code.co_name}_{uuid.uuid4()}" + + access_token = jwt.encode( + { + "channel_id": channel_id, + "audio": True, + "video": True, + "connection_lifetime": 3, + # 現在時刻 + 300 秒 (5分) + "exp": int(time.time()) + 300, + }, + secret, + algorithm="HS256", + ) + + with SoraClient( + signaling_urls, + SoraRole.RECVONLY, + channel_id, + audio=True, + video=True, + metadata={"access_token": access_token}, + data_channel_signaling=True, + ignore_disconnect_websocket=True, + ) as conn: + time.sleep(5) + + assert conn.close_message is not None + assert conn.close_message["type"] == "close" + assert conn.close_message["code"] == 1000 + assert conn.close_message["reason"] == "LIFETIME-EXPIRED" + + assert conn.disconnect_code == SoraSignalingErrorCode.CLOSE_SUCCEEDED + assert conn.disconnect_reason is not None + assert "LIFETIME-EXPIRED" in conn.disconnect_reason diff --git a/uv.lock b/uv.lock index 345cfbc..3ddb234 100644 --- a/uv.lock +++ b/uv.lock @@ -262,6 +262,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -352,6 +361,7 @@ dev = [ { name = "mypy" }, { name = "nanobind" }, { name = "numpy" }, + { name = "pyjwt" }, { name = "pytest" }, { name = "python-dotenv" }, { name = "ruff" }, @@ -368,7 +378,8 @@ dev = [ { name = "httpx" }, { name = "mypy" }, { name = "nanobind", specifier = "~=2.4.0" }, - { name = "numpy", specifier = ">=2.2.1" }, + { name = "numpy" }, + { name = "pyjwt" }, { name = "pytest" }, { name = "python-dotenv" }, { name = "ruff" },