Files
Owlbot/tests/integration/test_owncast_admin_client.py
T
LogalDeveloper f20faa317e
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m55s
CI / Tests (Python 3.13) (push) Successful in 2m46s
CI / Tests (Python 3.14) (push) Successful in 2m42s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 9s
Updated Owncast clients to v0.2.5 and added spec-coverage and integration tests to enforce parity with the OpenAPI spec.
2026-04-24 16:57:11 -04:00

1149 lines
48 KiB
Python

# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Integration tests for ``OwncastAdminClient`` against a live Owncast server."""
from __future__ import annotations
import base64
import secrets
from typing import TYPE_CHECKING
import pytest
from owlbot.api.owncast_admin_client import (
ExternalAction,
OwncastAdminClient,
SocialHandle,
StreamKey,
VideoVariant,
)
from owlbot.owncast_http import OwncastError
if TYPE_CHECKING:
from owlbot.api.http_client import HttpClient
from owlbot.api.owncast_client import OwncastClient
from .conftest import AdminCredentials
pytestmark = pytest.mark.integration
# A 1x1 transparent PNG as a base64 data URL for logo/favicon upload tests.
TINY_PNG_DATA_URL = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def test_base_url_matches_server(
owncast_admin_client: OwncastAdminClient,
owncast_server: str,
) -> None:
"""The admin client's base_url property reflects the server it was built for."""
assert owncast_admin_client.base_url == owncast_server
class TestRoundTrip:
"""End-to-end tests whose effect is verified via readback or return value.
Setter tests follow a full-flow pattern: read the current value via
``get_server_config``, set a different value, read it back, and assert
the new value differs from the before value and matches the requested
value. Getter tests without a setter pair read an empty initial state
and assert the return shape. ``set_admin_password`` is exercised by the
session-bootstrap rotation in the ``admin_credentials`` fixture;
``test_default_credentials_are_rejected`` asserts the rotation took
effect.
"""
async def test_default_credentials_are_rejected(
self,
owncast_server: str,
owncast_http: HttpClient,
admin_credentials: AdminCredentials, # noqa: ARG002 # side effect: rotates password
) -> None:
"""A client built with the default abc123 creds fails with 401.
Proves the session bootstrap rotation actually changed the
server-side password. Depends on ``admin_credentials`` so the
rotation is a prerequisite under any test-selection order.
"""
default_client = OwncastAdminClient(
owncast_server,
"admin",
"abc123",
owncast_http,
)
with pytest.raises(OwncastError) as exc_info:
await default_client.get_status()
assert exc_info.value.status == 401
async def test_access_token_create_list_delete_round_trip(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""Create an access token, confirm it appears, delete it, confirm it's gone."""
name = f"test-token-{secrets.token_hex(4)}"
before = await owncast_admin_client.get_access_tokens()
assert name not in {entry.get("displayName") for entry in before}
create_result = await owncast_admin_client.create_access_token(
name, ["CAN_SEND_MESSAGES"]
)
assert isinstance(create_result, str)
with_token = await owncast_admin_client.get_access_tokens()
matching = [e for e in with_token if e.get("displayName") == name]
assert len(matching) == 1, f"expected one matching token, got {matching}"
assert with_token != before
token_value = str(matching[0]["accessToken"])
delete_result = await owncast_admin_client.delete_access_token(token_value)
assert isinstance(delete_result, str)
after = await owncast_admin_client.get_access_tokens()
assert name not in {entry.get("displayName") for entry in after}
assert after != with_token
async def test_get_status_returns_dict(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_status reports offline with no viewers on a fresh server."""
status = await owncast_admin_client.get_status()
assert isinstance(status, dict)
assert status["online"] is False
assert status["viewerCount"] == 0
async def test_get_hardware_stats_returns_dict(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_hardware_stats returns a dict with CPU/mem/disk data."""
assert isinstance(await owncast_admin_client.get_hardware_stats(), dict)
async def test_get_logs_returns_list(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_logs returns a list of log entries."""
assert isinstance(await owncast_admin_client.get_logs(), list)
async def test_get_warnings_returns_list(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_warnings returns a list of log entries."""
assert isinstance(await owncast_admin_client.get_warnings(), list)
async def test_get_active_viewers_is_empty_on_fresh_server(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_active_viewers returns [] on a fresh server."""
assert await owncast_admin_client.get_active_viewers() == []
async def test_get_viewers_over_time_is_empty_on_fresh_server(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_viewers_over_time returns [] for a fresh server with no history."""
assert await owncast_admin_client.get_viewers_over_time(0) == []
async def test_get_playback_metrics_returns_dict(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_playback_metrics returns a metrics dict."""
assert isinstance(await owncast_admin_client.get_playback_metrics(), dict)
async def test_get_connected_chat_clients_is_empty_on_fresh_server(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_connected_chat_clients returns [] on a fresh server."""
assert await owncast_admin_client.get_connected_chat_clients() == []
async def test_get_chat_messages_reflects_sent_message(
self,
owncast_admin_client: OwncastAdminClient,
owncast_client: OwncastClient,
) -> None:
"""Admin get_chat_messages surfaces messages sent via the integration client.
Owncast renders messages as Markdown, so the returned body is wrapped
in a ``<p>`` tag. The assertion matches on substring presence rather
than exact equality.
"""
assert await owncast_admin_client.get_chat_messages() == []
body = f"admin-view-test-{secrets.token_hex(4)}"
await owncast_client.send_message(body)
after = await owncast_admin_client.get_chat_messages()
assert any(body in str(entry.get("body", "")) for entry in after), (
f"sent message body {body!r} not found in admin view {after}"
)
async def test_get_disabled_users_is_empty_on_fresh_server(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_disabled_users returns [] on a fresh server.
Owncast's ``get_disabled_users`` filters bot-authored accounts out of
the returned list, and the integration test harness only has a bot
identity available (the admin access token that would be needed to
create a non-bot user). A real round trip cannot be demonstrated
from this harness, so ``set_user_enabled`` is covered separately in
``TestSmoke``.
"""
assert await owncast_admin_client.get_disabled_users() == []
async def test_user_moderator_grant_revoke_round_trip(
self,
owncast_admin_client: OwncastAdminClient,
owncast_client: OwncastClient,
) -> None:
"""Grant moderator status to a real user, verify via get_moderators, revoke."""
await owncast_client.send_message(f"mod-test-{secrets.token_hex(4)}")
history = await owncast_client.get_chat_history()
assert history
user_id = str(history[-1]["user"]["id"])
assert await owncast_admin_client.get_moderators() == []
assert isinstance(
await owncast_admin_client.set_user_moderator(user_id, is_mod=True), str
)
mods = await owncast_admin_client.get_moderators()
assert user_id in {entry.get("id") for entry in mods}, (
f"user {user_id} not in moderators list {mods}"
)
assert isinstance(
await owncast_admin_client.set_user_moderator(user_id, is_mod=False), str
)
after = await owncast_admin_client.get_moderators()
assert user_id not in {entry.get("id") for entry in after}
async def test_ban_list_unban_round_trip(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""Ban an IP, verify it appears in the list, unban, verify it's gone."""
ip = "192.0.2.1" # RFC 5737 documentation address
before = await owncast_admin_client.get_ip_address_bans()
assert ip not in {entry.get("ipAddress") for entry in before}
assert isinstance(await owncast_admin_client.ban_ip_address(ip), str)
banned = await owncast_admin_client.get_ip_address_bans()
assert ip in {entry.get("ipAddress") for entry in banned}, (
f"banned IP {ip} not found in {banned}"
)
assert banned != before
assert isinstance(await owncast_admin_client.unban_ip_address(ip), str)
after = await owncast_admin_client.get_ip_address_bans()
assert ip not in {entry.get("ipAddress") for entry in after}
assert after != banned
async def test_set_chat_disabled(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_chat_disabled toggles chatDisabled in server config."""
before = (await owncast_admin_client.get_server_config())["chatDisabled"]
await owncast_admin_client.set_chat_disabled(disabled=not before)
after = (await owncast_admin_client.get_server_config())["chatDisabled"]
assert after is not before
async def test_set_chat_join_messages_enabled(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_chat_join_messages_enabled toggles chatJoinMessagesEnabled."""
before = (await owncast_admin_client.get_server_config())[
"chatJoinMessagesEnabled"
]
await owncast_admin_client.set_chat_join_messages_enabled(enabled=not before)
after = (await owncast_admin_client.get_server_config())[
"chatJoinMessagesEnabled"
]
assert after is not before
async def test_set_chat_established_mode(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_chat_established_mode toggles chatEstablishedUserMode."""
before = (await owncast_admin_client.get_server_config())[
"chatEstablishedUserMode"
]
await owncast_admin_client.set_chat_established_mode(enabled=not before)
after = (await owncast_admin_client.get_server_config())[
"chatEstablishedUserMode"
]
assert after is not before
async def test_set_forbidden_usernames(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_forbidden_usernames replaces forbiddenUsernames in server config."""
before = (await owncast_admin_client.get_server_config())["forbiddenUsernames"]
new_names = ["integration-forbidden-1", "integration-forbidden-2"]
assert new_names != before
await owncast_admin_client.set_forbidden_usernames(new_names)
after = (await owncast_admin_client.get_server_config())["forbiddenUsernames"]
assert after == new_names
assert after != before
async def test_set_suggested_usernames(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_suggested_usernames replaces suggestedUsernames in server config."""
before = (await owncast_admin_client.get_server_config())["suggestedUsernames"]
new_names = ["integration-suggested-1", "integration-suggested-2"]
assert new_names != before
await owncast_admin_client.set_suggested_usernames(new_names)
after = (await owncast_admin_client.get_server_config())["suggestedUsernames"]
assert after == new_names
assert after != before
async def test_set_chat_spam_protection(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_chat_spam_protection toggles chatSpamProtectionEnabled."""
before = (await owncast_admin_client.get_server_config())[
"chatSpamProtectionEnabled"
]
await owncast_admin_client.set_chat_spam_protection(enabled=not before)
after = (await owncast_admin_client.get_server_config())[
"chatSpamProtectionEnabled"
]
assert after is not before
async def test_set_chat_slur_filter(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_chat_slur_filter toggles chatSlurFilterEnabled."""
before = (await owncast_admin_client.get_server_config())[
"chatSlurFilterEnabled"
]
await owncast_admin_client.set_chat_slur_filter(enabled=not before)
after = (await owncast_admin_client.get_server_config())[
"chatSlurFilterEnabled"
]
assert after is not before
async def test_set_chat_require_authentication(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_chat_require_authentication toggles chatRequireAuthentication."""
before = (await owncast_admin_client.get_server_config())[
"chatRequireAuthentication"
]
await owncast_admin_client.set_chat_require_authentication(required=not before)
after = (await owncast_admin_client.get_server_config())[
"chatRequireAuthentication"
]
assert after is not before
async def test_set_server_name(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_server_name replaces instanceDetails.name in server config."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"name"
]
new_name = f"Integration Test Server {secrets.token_hex(4)}"
assert new_name != before
await owncast_admin_client.set_server_name(new_name)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"name"
]
assert after == new_name
assert after != before
async def test_set_server_summary(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_server_summary replaces instanceDetails.summary in server config."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"summary"
]
new_summary = f"For integration tests {secrets.token_hex(4)}."
assert new_summary != before
await owncast_admin_client.set_server_summary(new_summary)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"summary"
]
assert after == new_summary
assert after != before
async def test_set_stream_title_reflected_in_server_config(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_stream_title replaces instanceDetails.streamTitle in server config."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"streamTitle"
]
new_title = f"Admin Integration Title {secrets.token_hex(4)}"
assert new_title != before
await owncast_admin_client.set_stream_title(new_title)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"streamTitle"
]
assert after == new_title
assert after != before
async def test_set_offline_message(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_offline_message replaces instanceDetails.offlineMessage."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"offlineMessage"
]
new_message = f"Be right back {secrets.token_hex(4)}."
assert new_message != before
await owncast_admin_client.set_offline_message(new_message)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"offlineMessage"
]
assert after == new_message
assert after != before
async def test_set_welcome_message(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_welcome_message replaces instanceDetails.welcomeMessage."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"welcomeMessage"
]
new_message = f"Welcome to integration tests {secrets.token_hex(4)}!"
assert new_message != before
await owncast_admin_client.set_welcome_message(new_message)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"welcomeMessage"
]
assert after == new_message
assert after != before
async def test_set_tags(self, owncast_admin_client: OwncastAdminClient) -> None:
"""set_tags replaces instanceDetails.tags in server config."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"tags"
]
new_tags = ["gaming", "music", "integration-test"]
assert new_tags != before
await owncast_admin_client.set_tags(new_tags)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"tags"
]
assert after == new_tags
assert after != before
async def test_set_social_handles(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_social_handles replaces instanceDetails.socialHandles."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"socialHandles"
]
expected = [{"platform": "mastodon", "url": "https://example.test/@me"}]
assert expected != before
await owncast_admin_client.set_social_handles(
[SocialHandle(platform="mastodon", url="https://example.test/@me")],
)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"socialHandles"
]
assert after == expected
assert after != before
async def test_set_page_content(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_page_content replaces instanceDetails.extraPageContent."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"extraPageContent"
]
new_content = f"# Integration Test Page {secrets.token_hex(4)}"
assert new_content != before
await owncast_admin_client.set_page_content(new_content)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"extraPageContent"
]
assert after == new_content
assert after != before
async def test_set_nsfw(self, owncast_admin_client: OwncastAdminClient) -> None:
"""set_nsfw toggles instanceDetails.nsfw in server config."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"nsfw"
]
await owncast_admin_client.set_nsfw(nsfw=not before)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"nsfw"
]
assert after is not before
async def test_set_logo_accepts_data_url(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_logo uploads a data URL and server config surfaces the stored file.
Owncast normalizes the stored filename (e.g. ``logo.png``) based on
the uploaded content type, so ``before`` and ``after`` can match
even when the bytes change. Only the truthiness of the stored
filename is asserted.
"""
await owncast_admin_client.set_logo(TINY_PNG_DATA_URL)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"logo"
]
assert after
async def test_favicon_set_and_reset_round_trip(
self,
owncast_admin_client: OwncastAdminClient,
owncast_server: str,
owncast_http: HttpClient,
) -> None:
"""set_favicon then reset_favicon: /favicon.ico serves and restores bytes."""
png_bytes = base64.b64decode(TINY_PNG_DATA_URL.partition(",")[2])
async with owncast_http.get(f"{owncast_server}/favicon.ico") as resp:
before_bytes = await resp.read() if resp.status == 200 else b""
assert isinstance(
await owncast_admin_client.set_favicon(TINY_PNG_DATA_URL), str
)
async with owncast_http.get(f"{owncast_server}/favicon.ico") as resp:
assert resp.status == 200
assert resp.headers.get("Content-Type") == "image/png"
uploaded = await resp.read()
assert uploaded == png_bytes
assert uploaded != before_bytes
assert isinstance(await owncast_admin_client.reset_favicon(), str)
async with owncast_http.get(f"{owncast_server}/favicon.ico") as resp:
assert resp.status == 200
restored = await resp.read()
assert restored == before_bytes
assert restored != uploaded
async def test_set_custom_styles(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_custom_styles replaces instanceDetails.customStyles."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"customStyles"
]
new_css = f"body {{ color: #123456; }} /* {secrets.token_hex(4)} */"
assert new_css != before
await owncast_admin_client.set_custom_styles(new_css)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"customStyles"
]
assert after == new_css
assert after != before
async def test_set_custom_javascript(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_custom_javascript replaces instanceDetails.customJavascript."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"customJavascript"
]
new_js = f"console.log('integration-test-{secrets.token_hex(4)}');"
assert new_js != before
await owncast_admin_client.set_custom_javascript(new_js)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"customJavascript"
]
assert after == new_js
assert after != before
async def test_set_color_variables(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_color_variables replaces instanceDetails.appearanceVariables."""
before = (await owncast_admin_client.get_server_config())["instanceDetails"][
"appearanceVariables"
]
new_variables = {"primary": "#ff0000", "secondary": "#00ff00"}
assert new_variables != before
await owncast_admin_client.set_color_variables(new_variables)
after = (await owncast_admin_client.get_server_config())["instanceDetails"][
"appearanceVariables"
]
assert after == new_variables
assert after != before
async def test_set_hide_viewer_count(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_hide_viewer_count toggles hideViewerCount in server config."""
before = (await owncast_admin_client.get_server_config())["hideViewerCount"]
await owncast_admin_client.set_hide_viewer_count(hide=not before)
after = (await owncast_admin_client.get_server_config())["hideViewerCount"]
assert after is not before
async def test_set_disable_search_indexing(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_disable_search_indexing toggles disableSearchIndexing."""
before = (await owncast_admin_client.get_server_config())[
"disableSearchIndexing"
]
await owncast_admin_client.set_disable_search_indexing(disabled=not before)
after = (await owncast_admin_client.get_server_config())[
"disableSearchIndexing"
]
assert after is not before
async def test_set_external_actions(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_external_actions replaces externalActions in server config."""
before = (await owncast_admin_client.get_server_config())["externalActions"]
await owncast_admin_client.set_external_actions(
[
ExternalAction(
url="https://example.test",
title="Example",
description="A test external action",
),
],
)
after = (await owncast_admin_client.get_server_config())["externalActions"]
assert after != before
assert isinstance(after, list)
assert len(after) == 1
assert after[0]["url"] == "https://example.test"
assert after[0]["title"] == "Example"
assert after[0]["description"] == "A test external action"
async def test_set_video_codec(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_video_codec replaces videoCodec in server config."""
before = (await owncast_admin_client.get_server_config())["videoCodec"]
new_codec = "libx264"
if new_codec == before:
new_codec = "h264_vaapi"
await owncast_admin_client.set_video_codec(new_codec)
after = (await owncast_admin_client.get_server_config())["videoCodec"]
assert after == new_codec
assert after != before
async def test_set_stream_latency(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_stream_latency replaces videoSettings.latencyLevel."""
before = (await owncast_admin_client.get_server_config())["videoSettings"][
"latencyLevel"
]
new_level = 1 if before != 1 else 4
await owncast_admin_client.set_stream_latency(new_level)
after = (await owncast_admin_client.get_server_config())["videoSettings"][
"latencyLevel"
]
assert after == new_level
assert after != before
async def test_set_video_variants(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_video_variants replaces videoSettings.videoQualityVariants."""
before = (await owncast_admin_client.get_server_config())["videoSettings"][
"videoQualityVariants"
]
await owncast_admin_client.set_video_variants(
[
VideoVariant(
name="720p",
video_bitrate=2500,
audio_bitrate=128,
scaled_width=1280,
scaled_height=720,
framerate=30,
cpu_usage_level=3,
),
],
)
after = (await owncast_admin_client.get_server_config())["videoSettings"][
"videoQualityVariants"
]
assert after != before
assert isinstance(after, list)
assert len(after) == 1
assert after[0]["videoBitrate"] == 2500
assert after[0]["framerate"] == 30
assert after[0]["cpuUsageLevel"] == 3
async def test_set_ffmpeg_path(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_ffmpeg_path replaces ffmpegPath in server config.
Owncast validates that the path exists on disk, so a random path
would be rejected. ``/bin/sh`` is guaranteed to exist on the
supported platforms (Linux, macOS).
"""
before = (await owncast_admin_client.get_server_config())["ffmpegPath"]
new_path = "/bin/sh"
assert new_path != before
await owncast_admin_client.set_ffmpeg_path(new_path)
after = (await owncast_admin_client.get_server_config())["ffmpegPath"]
assert after == new_path
assert after != before
async def test_set_stream_keys(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_stream_keys replaces streamKeys in server config."""
before = (await owncast_admin_client.get_server_config())["streamKeys"]
new_entry = {
"key": f"integration-test-key-{secrets.token_hex(4)}",
"comment": "tests",
}
assert new_entry not in before
await owncast_admin_client.set_stream_keys(
[StreamKey(key=new_entry["key"], comment=new_entry["comment"])],
)
after = (await owncast_admin_client.get_server_config())["streamKeys"]
assert new_entry in after
assert after != before
async def test_set_video_serving_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_video_serving_endpoint replaces videoServingEndpoint."""
before = (await owncast_admin_client.get_server_config())[
"videoServingEndpoint"
]
new_endpoint = f"https://cdn.example.test/owncast-{secrets.token_hex(4)}"
assert new_endpoint != before
await owncast_admin_client.set_video_serving_endpoint(new_endpoint)
after = (await owncast_admin_client.get_server_config())["videoServingEndpoint"]
assert after == new_endpoint
assert after != before
async def test_set_s3_config(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_s3_config is reflected in server config."""
before = (await owncast_admin_client.get_server_config())["s3"]
new_endpoint = f"https://s3.example.test/{secrets.token_hex(4)}"
new_bucket = f"integration-bucket-{secrets.token_hex(4)}"
new_region = "us-west-1"
await owncast_admin_client.set_s3_config(
enabled=True,
endpoint=new_endpoint,
access_key="integration-access-key",
secret=secrets.token_urlsafe(16),
bucket=new_bucket,
region=new_region,
)
after = (await owncast_admin_client.get_server_config())["s3"]
assert after != before
assert after["enabled"] is True
# Owncast surfaces the non-sensitive S3 fields; secret is not echoed back.
assert after.get("endpoint") == new_endpoint
assert after.get("bucket") == new_bucket
assert after.get("region") == new_region
async def test_set_federation_enabled(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_federation_enabled toggles federation.enabled."""
before = (await owncast_admin_client.get_server_config())["federation"][
"enabled"
]
await owncast_admin_client.set_federation_enabled(enabled=not before)
after = (await owncast_admin_client.get_server_config())["federation"][
"enabled"
]
assert after is not before
async def test_set_federation_activity_private(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_federation_activity_private toggles federation.isPrivate."""
before = (await owncast_admin_client.get_server_config())["federation"][
"isPrivate"
]
await owncast_admin_client.set_federation_activity_private(private=not before)
after = (await owncast_admin_client.get_server_config())["federation"][
"isPrivate"
]
assert after is not before
async def test_set_federation_show_engagement(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_federation_show_engagement toggles federation.showEngagement."""
before = (await owncast_admin_client.get_server_config())["federation"][
"showEngagement"
]
await owncast_admin_client.set_federation_show_engagement(enabled=not before)
after = (await owncast_admin_client.get_server_config())["federation"][
"showEngagement"
]
assert after is not before
async def test_set_federation_username(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_federation_username replaces federation.username."""
before = (await owncast_admin_client.get_server_config())["federation"][
"username"
]
new_username = f"integration-stream-{secrets.token_hex(4)}"
assert new_username != before
await owncast_admin_client.set_federation_username(new_username)
after = (await owncast_admin_client.get_server_config())["federation"][
"username"
]
assert after == new_username
assert after != before
async def test_set_federation_go_live_message(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_federation_go_live_message replaces federation.goLiveMessage."""
before = (await owncast_admin_client.get_server_config())["federation"][
"goLiveMessage"
]
new_message = f"Integration test: going live {secrets.token_hex(4)}!"
assert new_message != before
await owncast_admin_client.set_federation_go_live_message(new_message)
after = (await owncast_admin_client.get_server_config())["federation"][
"goLiveMessage"
]
assert after == new_message
assert after != before
async def test_set_federation_blocked_domains(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_federation_blocked_domains replaces federation.blockedDomains."""
before = (await owncast_admin_client.get_server_config())["federation"][
"blockedDomains"
]
new_domains = ["blocked.example.test", "another.example.test"]
assert new_domains != before
await owncast_admin_client.set_federation_blocked_domains(new_domains)
after = (await owncast_admin_client.get_server_config())["federation"][
"blockedDomains"
]
assert after == new_domains
assert after != before
async def test_get_federated_actions_returns_dict(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_federated_actions returns a paginated dict (empty initially)."""
assert isinstance(await owncast_admin_client.get_federated_actions(), dict)
async def test_get_followers_returns_dict(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_followers returns a paginated dict (empty initially)."""
assert isinstance(await owncast_admin_client.get_followers(), dict)
async def test_get_pending_follow_requests_is_empty_on_fresh_server(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_pending_follow_requests returns [] on a fresh server."""
assert await owncast_admin_client.get_pending_follow_requests() == []
async def test_get_blocked_followers_is_empty_on_fresh_server(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_blocked_followers returns [] on a fresh server."""
assert await owncast_admin_client.get_blocked_followers() == []
async def test_set_discord_notifications(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_discord_notifications toggles notifications.discord.enabled.
get_server_config surfaces ``notifications.discord.enabled`` but not
the webhook URL (likely because it's treated as a secret), so this
test only observes the toggle.
"""
before = (await owncast_admin_client.get_server_config())["notifications"][
"discord"
]["enabled"]
await owncast_admin_client.set_discord_notifications(
enabled=not before,
webhook="https://discord.example.test/webhook",
go_live_message="Integration live!",
)
after = (await owncast_admin_client.get_server_config())["notifications"][
"discord"
]["enabled"]
assert after is not before
async def test_set_browser_notifications(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_browser_notifications toggles enabled and replaces goLiveMessage."""
before = (await owncast_admin_client.get_server_config())["notifications"][
"browser"
]
new_message = f"Integration browser live message {secrets.token_hex(4)}"
assert new_message != before.get("goLiveMessage")
await owncast_admin_client.set_browser_notifications(
enabled=not before["enabled"], go_live_message=new_message
)
after = (await owncast_admin_client.get_server_config())["notifications"][
"browser"
]
assert after["enabled"] is not before["enabled"]
assert after["goLiveMessage"] == new_message
assert after != before
async def test_webhook_create_list_delete_round_trip(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""Create a webhook, confirm it appears, delete it, confirm it's gone."""
url = f"https://example.test/webhook-{secrets.token_hex(4)}"
before = await owncast_admin_client.get_webhooks()
assert url not in {entry.get("url") for entry in before}
assert isinstance(await owncast_admin_client.create_webhook(url, ["CHAT"]), str)
with_webhook = await owncast_admin_client.get_webhooks()
matching = [e for e in with_webhook if e.get("url") == url]
assert len(matching) == 1, f"expected one match, got {matching}"
assert with_webhook != before
webhook_id = int(matching[0]["id"])
assert isinstance(await owncast_admin_client.delete_webhook(webhook_id), str)
after = await owncast_admin_client.get_webhooks()
assert url not in {entry.get("url") for entry in after}
assert after != with_webhook
async def test_set_directory_enabled(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_directory_enabled toggles yp.enabled in server config."""
before = (await owncast_admin_client.get_server_config())["yp"]["enabled"]
await owncast_admin_client.set_directory_enabled(enabled=not before)
after = (await owncast_admin_client.get_server_config())["yp"]["enabled"]
assert after is not before
class TestSmoke:
"""Tests that only verify the wrapper reaches the right endpoint.
The backing effect is either unobservable (``get_server_config`` does not
surface the value, or the setting applies only at Owncast startup) or
requires a backend that is not present in a fresh install (Prometheus
proxy target, federation peers, connected inbound stream). Assertions
validate that the request is routed correctly (i.e. does not return 404)
rather than that the intended effect was applied.
"""
async def test_disconnect_stream_on_idle_server(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""disconnect_stream returns a response even when no stream is active.
A fresh Owncast has no connected stream. The endpoint is idempotent:
Owncast returns a success-style dict or an informational error. Either
way, the route is validated.
"""
try:
result = await owncast_admin_client.disconnect_stream()
except OwncastError as exc:
if exc.status == 404:
raise
return
assert isinstance(result, dict)
async def test_get_prometheus_metrics_returns_text(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""get_prometheus_metrics returns a string (possibly empty)."""
try:
result = await owncast_admin_client.get_prometheus_metrics()
except OwncastError as exc:
if exc.status == 404:
raise
return
assert isinstance(result, str)
async def test_post_prometheus_reaches_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""post_prometheus reaches the proxy route (not 404)."""
try:
await owncast_admin_client.post_prometheus({"ping": True})
except OwncastError as exc:
if exc.status == 404:
raise
async def test_put_prometheus_reaches_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""put_prometheus reaches the proxy route (not 404)."""
try:
await owncast_admin_client.put_prometheus({"ping": True})
except OwncastError as exc:
if exc.status == 404:
raise
async def test_delete_prometheus_reaches_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""delete_prometheus reaches the proxy route (not 404)."""
try:
await owncast_admin_client.delete_prometheus()
except OwncastError as exc:
if exc.status == 404:
raise
async def test_set_message_visibility_with_unknown_ids_reaches_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_message_visibility accepts unknown IDs without 404ing.
Owncast's admin visibility endpoint is lenient about unknown IDs and
returns a success-shape envelope. The assertion validates the request
path, not the value.
"""
try:
result = await owncast_admin_client.set_message_visibility(
["000000000"], visible=False
)
except OwncastError as exc:
if exc.status == 404:
raise
return
assert isinstance(result, str)
async def test_set_user_enabled_for_unknown_user_reaches_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_user_enabled reaches the endpoint for a synthetic user id.
A real round trip is infeasible from this harness because Owncast
filters bot users out of ``get_disabled_users``, and the integration
token only ever produces bot identities. Asserts either success
envelope or non-404 error.
"""
try:
result = await owncast_admin_client.set_user_enabled(
"000000000", enabled=False
)
except OwncastError as exc:
if exc.status == 404:
raise
return
assert isinstance(result, str)
async def test_set_server_url(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_server_url returns the success envelope.
get_server_config does not surface the configured server URL (it is
stored but only used for federation outbound URLs), so this test
asserts only that the call reaches the server without erroring.
"""
assert isinstance(
await owncast_admin_client.set_server_url("https://example.test"), str
)
async def test_set_web_server_port(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_web_server_port returns the success envelope.
Owncast only applies web-server bind changes at startup and
get_server_config surfaces the live bind value, not the saved
next-startup value, so a direct readback cannot confirm the write.
Re-pins to the live bind port for safety.
"""
config = await owncast_admin_client.get_server_config()
assert isinstance(
await owncast_admin_client.set_web_server_port(
int(config["webServerPort"])
),
str,
)
async def test_set_web_server_ip(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_web_server_ip returns the success envelope.
Like set_web_server_port, this only takes effect at startup and
get_server_config returns the live bind IP, not the saved value.
"""
assert isinstance(
await owncast_admin_client.set_web_server_ip("127.0.0.1"), str
)
async def test_set_rtmp_port(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_rtmp_port returns the success envelope.
Like set_web_server_port, this only takes effect at startup.
Re-pins to the live bind port for safety.
"""
config = await owncast_admin_client.get_server_config()
assert isinstance(
await owncast_admin_client.set_rtmp_port(int(config["rtmpServerPort"])),
str,
)
async def test_set_socket_host_override(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""set_socket_host_override returns the success envelope.
get_server_config does not surface the socket host override value, so
this test asserts only that the call reaches the server without error.
"""
assert isinstance(await owncast_admin_client.set_socket_host_override(""), str)
async def test_send_federated_message_reaches_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""send_federated_message reaches the route even without federation peers.
Federation is disabled by default; Owncast may return an error. The
assertion validates that the route exists (not a 404), not delivery.
"""
try:
result = await owncast_admin_client.send_federated_message("hello")
except OwncastError as exc:
if exc.status == 404:
raise
return
assert isinstance(result, str)
async def test_approve_follower_for_unknown_actor_reaches_endpoint(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""approve_follower reaches the route for a synthetic actor IRI.
No pending follow requests exist. Assertion validates the request path
(non-404), not approval success.
"""
try:
await owncast_admin_client.approve_follower(
"https://example.test/actors/nobody", approved=True
)
except OwncastError as exc:
if exc.status == 404:
raise
async def test_upload_and_delete_emoji(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""Upload an emoji, then delete it.
Owncast's upload endpoint expects a full data URL (it splits on the
comma separator); passing raw base64 fails with "error splitting
base64 image data". Neither call is readback-verified, so this
exercises only the wrappers and routes.
"""
name = f"test-emoji-{secrets.token_hex(4)}"
data_url = TINY_PNG_DATA_URL
assert isinstance(await owncast_admin_client.upload_emoji(name, data_url), str)
assert isinstance(await owncast_admin_client.delete_emoji(name), str)
async def test_reset_yp_registration(
self, owncast_admin_client: OwncastAdminClient
) -> None:
"""reset_yp_registration returns a response dict."""
assert isinstance(await owncast_admin_client.reset_yp_registration(), dict)