Files
Owlbot/tests/integration/conftest.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

399 lines
13 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 test fixtures and harness for the Owncast API clients."""
from __future__ import annotations
import asyncio
import hashlib
import platform
import secrets
import shutil
import socket
import subprocess
import tempfile
import urllib.request
import zipfile
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Final, NamedTuple
import pytest
import pytest_asyncio
import uvloop
from owlbot import OWNCAST_TARGET_VERSION
from owlbot.api.http_client import HttpClient
from owlbot.api.owncast_admin_client import OwncastAdminClient
from owlbot.api.owncast_client import OwncastClient
from owlbot.owncast_http import OwncastError
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
_RELEASE_URL_TEMPLATE: Final = (
"https://github.com/owncast/owncast/releases/download/"
"v{version}/owncast-{version}-{archive}.zip"
)
_REPO_ROOT: Final = Path(__file__).resolve().parents[2]
_CACHE_DIR: Final = _REPO_ROOT / ".owncast-test"
_DEFAULT_ADMIN_USERNAME: Final = "admin"
_DEFAULT_ADMIN_PASSWORD: Final = "abc123" # noqa: S105 # documented Owncast default
_INTEGRATION_SCOPES: Final[list[str]] = [
"CAN_SEND_SYSTEM_MESSAGES",
"CAN_SEND_MESSAGES",
"HAS_ADMIN_ACCESS",
]
class _PlatformRelease(NamedTuple):
"""Archive name and expected SHA256 for a single release platform."""
archive: str
sha256: str
_PLATFORM_RELEASES: Final[dict[tuple[str, str], _PlatformRelease]] = {
("Linux", "x86_64"): _PlatformRelease(
"linux-64bit",
"3a197c123d80e5902e70691483180c3305b7784e7c6efcd26730bfd0d5bbd465",
),
("Linux", "i686"): _PlatformRelease(
"linux-32bit",
"daf6a2df265260d950fa544af98a7f0ee5a89c86dd047d1c810a13cae576a71e",
),
("Linux", "aarch64"): _PlatformRelease(
"linux-arm64",
"4b2b9db1d2f899b854d1c670626f839ae7da6808daef4d73f0208432e16e1c65",
),
("Linux", "armv7l"): _PlatformRelease(
"linux-arm7",
"b6d5b1f06b3f5b78ea3c2af1bf582c1cae521235632af0c9383f4f7af0527738",
),
("Darwin", "x86_64"): _PlatformRelease(
"macOS-64bit",
"bfef49e719ad9316554855df44a9ac21aec5f2a74de79eef15b6947cb383c2bb",
),
("Darwin", "arm64"): _PlatformRelease(
"macOS-arm64",
"7c1479e3a2e2185587b2197511860b0446f9b96591c76a20d1e2319a346f4833",
),
}
@dataclass(slots=True)
class OwncastServer:
"""Running Owncast subprocess bound to chosen HTTP and RTMP ports."""
binary: Path
tmp_dir: Path
http_port: int
rtmp_port: int
process: subprocess.Popen[bytes] | None = None
@property
def base_url(self) -> str:
"""Base URL for the running server (no trailing slash)."""
return f"http://127.0.0.1:{self.http_port}"
def start(self, startup_timeout: float = 30.0) -> None:
"""Launch Owncast and poll until ``/api/status`` responds."""
self.process = subprocess.Popen( # noqa: S603 # binary path is a cached, hash-verified Owncast release
[
str(self.binary),
"-webserverport",
str(self.http_port),
"-rtmpport",
str(self.rtmp_port),
],
cwd=self.tmp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
uvloop.run(self._poll_until_ready(startup_timeout))
except BaseException:
self.stop()
raise
async def _poll_until_ready(self, startup_timeout: float) -> None:
last_exc: OwncastError | None = None
http = HttpClient()
await http._start()
try:
client = OwncastClient(
base_url=self.base_url, access_token="", http_client=http
)
async with asyncio.timeout(startup_timeout):
while True:
if self.process is not None and self.process.poll() is not None:
msg = (
f"Owncast exited before becoming ready "
f"(code={self.process.returncode})."
)
raise RuntimeError(msg)
try:
await client.get_status()
except OwncastError as exc:
last_exc = exc
else:
return
await asyncio.sleep(0.25)
except TimeoutError as exc:
msg = (
f"Owncast did not become ready within {startup_timeout}s. "
f"Last error: {last_exc}."
)
raise RuntimeError(msg) from exc
finally:
await http._close()
def stop(self, shutdown_timeout: float = 10.0) -> None:
"""Terminate the subprocess, escalating to SIGKILL if needed."""
if self.process is None:
return
if self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=shutdown_timeout)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
self.process = None
@dataclass(frozen=True, slots=True)
class AdminCredentials:
"""Admin credentials rotated at session start."""
username: str
password: str
def _cached_binary_path(release: _PlatformRelease) -> Path:
"""Return the path where an extracted binary for ``release`` lives."""
return _CACHE_DIR / f"v{OWNCAST_TARGET_VERSION}" / release.archive / "owncast"
def _cached_archive_path(release: _PlatformRelease) -> Path:
"""Return the path where the downloaded archive for ``release`` is cached."""
return _CACHE_DIR / f"v{OWNCAST_TARGET_VERSION}" / release.archive / "archive.zip"
@pytest.fixture(scope="session")
def owncast_binary() -> Path:
"""Path to a cached, verified Owncast binary for the current platform.
On cache hit the pinned SHA256 is re-checked against the cached
archive; on miss or mismatch the archive is re-downloaded, verified,
and extracted. Raises ``RuntimeError`` on network failure, hash
mismatch, or unsupported host platform.
"""
key = (platform.system(), platform.machine())
try:
release = _PLATFORM_RELEASES[key]
except KeyError as exc:
supported = ", ".join(f"{s}/{m}" for s, m in _PLATFORM_RELEASES)
msg = (
f"Unsupported platform {key[0]}/{key[1]} for Owncast integration "
f"tests. Supported: {supported}."
)
raise RuntimeError(msg) from exc
archive_path = _cached_archive_path(release)
binary_path = _cached_binary_path(release)
# Re-hash on hit so bumping the pinned SHA256 forces a re-download
# to pick up the new upstream archive.
if (
archive_path.is_file()
and binary_path.is_file()
and hashlib.sha256(archive_path.read_bytes()).hexdigest() == release.sha256
):
return binary_path
# Stream to a tempfile and hash while downloading, so a tampered or
# truncated download can't overwrite a valid cached copy.
url = _RELEASE_URL_TEMPLATE.format(
version=OWNCAST_TARGET_VERSION, archive=release.archive
)
hasher = hashlib.sha256()
with tempfile.NamedTemporaryFile(
prefix=f"owncast-v{OWNCAST_TARGET_VERSION}-{release.archive}-",
suffix=".zip",
delete=False,
) as tmp_file:
tmp_path = Path(tmp_file.name)
with urllib.request.urlopen(url) as response: # noqa: S310 # hash-verified below
while chunk := response.read(65536):
tmp_file.write(chunk)
hasher.update(chunk)
try:
actual_hash = hasher.hexdigest()
if actual_hash != release.sha256:
msg = (
f"SHA256 mismatch for downloaded Owncast archive. "
f"expected={release.sha256} actual={actual_hash}"
)
raise RuntimeError(msg)
# Hash matched: wipe the extract dir (in case of partial prior
# downloads), move the verified archive in, extract alongside.
extract_root = _CACHE_DIR / f"v{OWNCAST_TARGET_VERSION}" / release.archive
if extract_root.exists():
shutil.rmtree(extract_root)
extract_root.mkdir(parents=True)
shutil.move(tmp_path, archive_path)
with zipfile.ZipFile(archive_path) as archive:
archive.extractall(extract_root)
if not binary_path.exists():
msg = f"Extracted archive did not contain owncast binary at {binary_path}."
raise RuntimeError(msg)
binary_path.chmod(0o755)
return binary_path
finally:
tmp_path.unlink(missing_ok=True)
@pytest.fixture
def owncast_server(
owncast_binary: Path, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[str]:
"""Per-test Owncast server on two kernel-assigned ports; yields base URL.
Scoped per test so each test gets a fresh subprocess and a fresh
working directory, eliminating cross-test config-state pollution.
"""
tmp_dir = tmp_path_factory.mktemp("owncast")
# Bind both sockets before closing either so the kernel doesn't
# hand out the same port twice.
with (
socket.socket(socket.AF_INET, socket.SOCK_STREAM) as http_sock,
socket.socket(socket.AF_INET, socket.SOCK_STREAM) as rtmp_sock,
):
for sock in (http_sock, rtmp_sock):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
http_port = http_sock.getsockname()[1]
rtmp_port = rtmp_sock.getsockname()[1]
server = OwncastServer(
binary=owncast_binary,
tmp_dir=tmp_dir,
http_port=http_port,
rtmp_port=rtmp_port,
)
server.start()
try:
yield server.base_url
finally:
server.stop()
@asynccontextmanager
async def _admin_session(
base_url: str, username: str, password: str
) -> AsyncIterator[OwncastAdminClient]:
"""Yield a short-lived ``OwncastAdminClient`` with a managed HTTP session."""
http = HttpClient()
await http._start()
try:
yield OwncastAdminClient(base_url, username, password, http)
finally:
await http._close()
@pytest.fixture
def admin_credentials(owncast_server: str) -> AdminCredentials:
"""Rotate the default admin password via the real admin client."""
new_password = secrets.token_urlsafe(32)
async def _rotate() -> None:
async with _admin_session(
owncast_server, _DEFAULT_ADMIN_USERNAME, _DEFAULT_ADMIN_PASSWORD
) as client:
await client.set_admin_password(new_password)
uvloop.run(_rotate())
return AdminCredentials(username=_DEFAULT_ADMIN_USERNAME, password=new_password)
@pytest.fixture
def integration_token(owncast_server: str, admin_credentials: AdminCredentials) -> str:
"""Mint an integration access token via the real admin client.
``create_access_token`` on the client returns only the envelope message,
so the token value is retrieved with a follow-up ``get_access_tokens``
listing (matched by ``displayName``).
"""
token_name = f"owlbot-integration-tests-{secrets.token_hex(4)}"
async def _mint() -> str:
async with _admin_session(
owncast_server, admin_credentials.username, admin_credentials.password
) as client:
await client.create_access_token(token_name, _INTEGRATION_SCOPES)
tokens = await client.get_access_tokens()
for entry in tokens:
if isinstance(entry, dict) and entry.get("displayName") == token_name:
return str(entry["accessToken"])
msg = f"Newly-created token {token_name!r} not found in listing."
raise RuntimeError(msg)
return uvloop.run(_mint())
@pytest_asyncio.fixture
async def owncast_http() -> AsyncIterator[HttpClient]:
"""Fresh ``HttpClient`` per test, started and torn down cleanly."""
client = HttpClient()
await client._start()
try:
yield client
finally:
await client._close()
@pytest.fixture
def owncast_admin_client(
owncast_server: str,
admin_credentials: AdminCredentials,
owncast_http: HttpClient,
) -> OwncastAdminClient:
"""Real ``OwncastAdminClient`` bound to the live server and current creds."""
return OwncastAdminClient(
owncast_server,
admin_credentials.username,
admin_credentials.password,
owncast_http,
)
@pytest.fixture
def owncast_client(
owncast_server: str,
integration_token: str,
owncast_http: HttpClient,
) -> OwncastClient:
"""Real ``OwncastClient`` bound to the live server and bootstrap token."""
return OwncastClient(
owncast_server,
integration_token,
owncast_http,
)