From 96551c2788c8ad1412a57542a0660dd190ad4204 Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Mon, 1 Jun 2026 18:11:23 +0200 Subject: [PATCH] Add trusted header auth and publisher shell --- README.md | 6 +- repub/auth_headers.py | 77 +++++++ repub/jobs.py | 2 +- repub/pages/__init__.py | 2 + repub/pages/publisher.py | 19 ++ repub/web.py | 130 ++++++++++-- tests/test_header_auth.py | 344 ++++++++++++++++++++++++++++++++ tests/test_scheduler_runtime.py | 8 +- 8 files changed, 569 insertions(+), 19 deletions(-) create mode 100644 repub/auth_headers.py create mode 100644 repub/pages/publisher.py create mode 100644 tests/test_header_auth.py diff --git a/README.md b/README.md index 28e74da..8a200a9 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,11 @@ In `--dev-mode`, requests under `/feeds/...` are served from `out/feeds/...`. In production, do not rely on Quart to serve published feeds. Configure the reverse proxy to serve `out/feeds/...` directly at `/feeds/...`. -Important: the admin UI has no built-in authentication. Keep it bound to localhost or put it behind a trusted network layer such as Tailscale. +By default the UI runs with `REPUBLISHER_AUTH_MODE=disabled` for local development. + +For production, set `REPUBLISHER_AUTH_MODE=trusted-headers`, keep the app bound to `127.0.0.1`, and put it behind nginx plus oauth2-proxy. + +In trusted-header mode, nginx must overwrite the `X-Republisher-*` identity headers before proxying to the app. Once the UI is running: diff --git a/repub/auth_headers.py b/repub/auth_headers.py new file mode 100644 index 0000000..83e3d3b --- /dev/null +++ b/repub/auth_headers.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Literal, cast + +AUTH_MODE_DISABLED = "disabled" +AUTH_MODE_TRUSTED_HEADERS = "trusted-headers" +AUTH_MODE_ENV = "REPUBLISHER_AUTH_MODE" + +AuthMode = Literal["disabled", "trusted-headers"] +AuthRole = Literal["admin", "publisher"] + +ROLE_HEADER = "X-Republisher-Auth-Role" +PROVIDER_HEADER = "X-Republisher-Auth-Provider" +USER_HEADER = "X-Republisher-Auth-User" +EMAIL_HEADER = "X-Republisher-Auth-Email" +PREFERRED_USERNAME_HEADER = "X-Republisher-Auth-Preferred-Username" +GROUPS_HEADER = "X-Republisher-Auth-Groups" +VALID_ROLES = frozenset({"admin", "publisher"}) + + +@dataclass(frozen=True) +class TrustedIdentity: + role: AuthRole + provider: str + user: str + email: str + preferred_username: str + groups: tuple[str, ...] + + +def load_auth_mode(environ: Mapping[str, str] | None = None) -> AuthMode: + raw_mode = (environ or os.environ).get(AUTH_MODE_ENV, AUTH_MODE_DISABLED).strip() + if raw_mode in {AUTH_MODE_DISABLED, AUTH_MODE_TRUSTED_HEADERS}: + return cast(AuthMode, raw_mode) + raise ValueError( + f"Unsupported {AUTH_MODE_ENV}: {raw_mode!r}. " + f"Expected {AUTH_MODE_DISABLED!r} or {AUTH_MODE_TRUSTED_HEADERS!r}." + ) + + +def load_trusted_identity(headers: Mapping[str, str]) -> TrustedIdentity | None: + role = _read_header(headers, ROLE_HEADER) + if role not in VALID_ROLES: + return None + + provider = _read_header(headers, PROVIDER_HEADER) + user = _read_header(headers, USER_HEADER) + email = _read_header(headers, EMAIL_HEADER) + if provider is None or user is None or email is None: + return None + + preferred_username = _read_header(headers, PREFERRED_USERNAME_HEADER) or user + return TrustedIdentity( + role=cast(AuthRole, role), + provider=provider, + user=user, + email=email, + preferred_username=preferred_username, + groups=_read_groups(headers.get(GROUPS_HEADER, "")), + ) + + +def _read_header(headers: Mapping[str, str], name: str) -> str | None: + value = headers.get(name) + if value is None: + return None + stripped = value.strip() + return stripped or None + + +def _read_groups(value: str) -> tuple[str, ...]: + return tuple( + group for group in (part.strip() for part in value.split(",")) if group + ) diff --git a/repub/jobs.py b/repub/jobs.py index 2664d10..15218da 100644 --- a/repub/jobs.py +++ b/repub/jobs.py @@ -166,7 +166,7 @@ class JobRuntime: worker = self._workers.pop(execution_id) if worker.process.poll() is None: worker.process.kill() - worker.process.wait(timeout=2) + worker.process.wait(timeout=10) worker.log_handle.close() if self._started: diff --git a/repub/pages/__init__.py b/repub/pages/__init__.py index 38a43e1..b02b702 100644 --- a/repub/pages/__init__.py +++ b/repub/pages/__init__.py @@ -1,4 +1,5 @@ from repub.pages.dashboard import dashboard_page, dashboard_page_with_data +from repub.pages.publisher import publisher_page from repub.pages.runs import execution_logs_page, runs_page from repub.pages.settings import settings_page from repub.pages.shim import shim_page @@ -10,6 +11,7 @@ __all__ = [ "dashboard_page_with_data", "edit_source_page", "execution_logs_page", + "publisher_page", "runs_page", "settings_page", "shim_page", diff --git a/repub/pages/publisher.py b/repub/pages/publisher.py new file mode 100644 index 0000000..f7dc28f --- /dev/null +++ b/repub/pages/publisher.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import htpy as h +from htpy import Renderable + +from repub.components import app_shell + + +def publisher_page(*, current_path: str) -> Renderable: + return app_shell( + current_path=current_path, + content=( + h.section[ + h.h1(class_="text-3xl font-semibold tracking-tight text-slate-950")[ + "Hello publishers" + ] + ], + ), + ) diff --git a/repub/web.py b/repub/web.py index 372e121..e9da8ad 100644 --- a/repub/web.py +++ b/repub/web.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio import hashlib -from collections.abc import AsyncGenerator, Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from contextlib import suppress from datetime import timedelta +from functools import wraps from pathlib import Path -from typing import TypedDict, cast +from typing import Any, TypedDict, cast from urllib.parse import urlparse import htpy as h @@ -17,6 +18,13 @@ from htpy import Renderable from peewee import IntegrityError from quart import Quart, Response, request, send_from_directory, url_for +from repub.auth_headers import ( + AUTH_MODE_DISABLED, + AUTH_MODE_TRUSTED_HEADERS, + AuthRole, + load_auth_mode, + load_trusted_identity, +) from repub.datastar import RefreshBroker, TabStateStore, render_stream from repub.jobs import ( COMPLETED_EXECUTION_PAGE_SIZE, @@ -44,6 +52,7 @@ from repub.pages import ( dashboard_page_with_data, edit_source_page, execution_logs_page, + publisher_page, runs_page, settings_page, shim_page, @@ -143,11 +152,14 @@ def create_app(*, dev_mode: bool = False) -> Quart: app.config.setdefault("REPUB_LOG_DIR", DEFAULT_LOG_DIR) app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR) app.config["REPUB_DEV_MODE"] = dev_mode + app.config["REPUB_AUTH_MODE"] = load_auth_mode() app.extensions[REFRESH_BROKER_KEY] = RefreshBroker() app.extensions[JOB_RUNTIME_KEY] = None app.extensions[TAB_STATE_STORE_KEY] = TabStateStore() app.extensions[TAB_STATE_CLEANER_TASK_KEY] = None app.extensions[SHUTDOWN_EVENT_KEY] = None + admin_required = _require_role(app, "admin") + publisher_required = _require_role(app, "publisher") @app.get("/feeds/") async def published_feed(feed_path: str) -> Response: @@ -177,6 +189,16 @@ def create_app(*, dev_mode: bool = False) -> Quart: response = await send_from_directory(str(STATIC_DIR), requested_filename) return response + @app.get("/publisher") + @publisher_required + async def publisher_home() -> Response: + return _shim_page_response(current_path="/publisher") + + @app.get("/admin/publisher") + @admin_required + async def admin_publisher_home() -> Response: + return _shim_page_response(current_path="/admin/publisher") + @app.get("/") @app.get("/sources") @app.get("/sources/create") @@ -184,51 +206,62 @@ def create_app(*, dev_mode: bool = False) -> Quart: @app.get("/runs") @app.get("/settings") @app.get("/job//execution//logs") + @admin_required async def page_shim( slug: str | None = None, job_id: int | None = None, execution_id: int | None = None, ) -> Response: del slug, job_id, execution_id - body, etag = _render_shim_page( - stylesheet_href=versioned_static_asset_href("app.css"), - datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), - current_path=request.path, - ) - if request.if_none_match.contains(etag): - response = Response(status=304) - response.set_etag(etag) - return response - - response = Response(body, mimetype="text/html") - response.set_etag(etag) - return response + return _shim_page_response(current_path=request.path) @app.post("/") + @admin_required async def dashboard_patch() -> DatastarResponse: return await _page_patch_response(app, lambda _tab_id: render_dashboard(app)) + @app.post("/publisher") + @publisher_required + async def publisher_patch() -> DatastarResponse: + return await _page_patch_response( + app, + lambda _tab_id: render_publisher(current_path="/publisher"), + ) + + @app.post("/admin/publisher") + @admin_required + async def admin_publisher_patch() -> DatastarResponse: + return await _page_patch_response( + app, + lambda _tab_id: render_publisher(current_path="/admin/publisher"), + ) + @app.post("/sources") + @admin_required async def sources_patch() -> DatastarResponse: return await _page_patch_response(app, lambda _tab_id: render_sources(app)) @app.post("/sources/create") + @admin_required async def create_source_patch() -> DatastarResponse: return await _page_patch_response( app, lambda _tab_id: render_create_source(app) ) @app.post("/sources//edit") + @admin_required async def edit_source_patch(slug: str) -> DatastarResponse: return await _page_patch_response( app, lambda _tab_id: render_edit_source(slug, app) ) @app.post("/settings") + @admin_required async def settings_patch() -> DatastarResponse: return await _page_patch_response(app, lambda _tab_id: render_settings(app)) @app.post("/actions/sources/create") + @admin_required async def create_source_action() -> DatastarResponse: signals = cast(dict[str, object], await read_signals()) source, error = validate_source_form( @@ -254,6 +287,7 @@ def create_app(*, dev_mode: bool = False) -> Quart: return DatastarResponse(SSE.redirect("/sources")) @app.post("/actions/sources//edit") + @admin_required async def edit_source_action(slug: str) -> DatastarResponse: signals = cast(dict[str, object], await read_signals()) source, error = validate_source_form( @@ -279,6 +313,7 @@ def create_app(*, dev_mode: bool = False) -> Quart: return DatastarResponse(SSE.redirect("/sources")) @app.post("/actions/sources//delete") + @admin_required async def delete_source_action(slug: str) -> Response: delete_source(slug) get_job_runtime(app).sync_jobs() @@ -286,6 +321,7 @@ def create_app(*, dev_mode: bool = False) -> Quart: return Response(status=204) @app.post("/actions/settings") + @admin_required async def update_settings_action() -> DatastarResponse: signals = cast(dict[str, object], await read_signals()) settings, error = validate_settings_form(signals) @@ -301,6 +337,7 @@ def create_app(*, dev_mode: bool = False) -> Quart: return DatastarResponse(SSE.redirect("/settings")) @app.post("/runs") + @admin_required async def runs_patch() -> DatastarResponse: return await _page_patch_response( app, @@ -308,6 +345,7 @@ def create_app(*, dev_mode: bool = False) -> Quart: ) @app.post("/actions/runs/completed-page/") + @admin_required async def set_completed_runs_page_action(page: int) -> Response: signals = await _read_optional_signals() tab_id = _read_tab_id(signals) @@ -322,12 +360,14 @@ def create_app(*, dev_mode: bool = False) -> Quart: return Response(status=204) @app.post("/actions/jobs//run-now") + @admin_required async def run_job_now_action(job_id: int) -> Response: get_job_runtime(app).run_job_now(job_id, reason="manual") trigger_refresh(app) return Response(status=204) @app.post("/actions/jobs//toggle-enabled") + @admin_required async def toggle_job_enabled_action(job_id: int) -> Response: enabled = load_job_enabled(job_id) if enabled is not None: @@ -336,6 +376,7 @@ def create_app(*, dev_mode: bool = False) -> Quart: return Response(status=204) @app.post("/actions/jobs//delete") + @admin_required async def delete_job_action(job_id: int) -> Response: delete_job_source(job_id) get_job_runtime(app).sync_jobs() @@ -343,34 +384,40 @@ def create_app(*, dev_mode: bool = False) -> Quart: return Response(status=204) @app.post("/actions/executions//cancel") + @admin_required async def cancel_execution_action(execution_id: int) -> Response: get_job_runtime(app).request_execution_cancel(execution_id) trigger_refresh(app) return Response(status=204) @app.post("/actions/queued-executions//cancel") + @admin_required async def cancel_queued_execution_action(execution_id: int) -> Response: get_job_runtime(app).cancel_queued_execution(execution_id) trigger_refresh(app) return Response(status=204) @app.post("/actions/queued-executions//move-up") + @admin_required async def move_queued_execution_up_action(execution_id: int) -> Response: get_job_runtime(app).move_queued_execution(execution_id, direction="up") return Response(status=204) @app.post("/actions/queued-executions//move-down") + @admin_required async def move_queued_execution_down_action(execution_id: int) -> Response: get_job_runtime(app).move_queued_execution(execution_id, direction="down") return Response(status=204) @app.post("/actions/completed-executions/clear") + @admin_required async def clear_completed_executions_action() -> Response: clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"]) trigger_refresh(app) return Response(status=204) @app.post("/job//execution//logs") + @admin_required async def logs_patch(job_id: int, execution_id: int) -> DatastarResponse: async def render() -> Renderable: return await render_execution_logs( @@ -400,6 +447,55 @@ def create_app(*, dev_mode: bool = False) -> Quart: return app +def _shim_page_response(*, current_path: str) -> Response: + body, etag = _render_shim_page( + stylesheet_href=versioned_static_asset_href("app.css"), + datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"), + current_path=current_path, + ) + if request.if_none_match.contains(etag): + response = Response(status=304) + response.set_etag(etag) + return response + + response = Response(body, mimetype="text/html") + response.set_etag(etag) + return response + + +def _require_role( + app: Quart, *roles: AuthRole +) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]: + def decorate( + handler: Callable[..., Awaitable[Any]], + ) -> Callable[..., Awaitable[Any]]: + @wraps(handler) + async def wrapped(*args: object, **kwargs: object) -> Any: + failure = _authorization_failure(app, roles) + if failure is not None: + return failure + return await handler(*args, **kwargs) + + return wrapped + + return decorate + + +def _authorization_failure(app: Quart, roles: Sequence[AuthRole]) -> Response | None: + auth_mode = cast(str, app.config["REPUB_AUTH_MODE"]) + if auth_mode == AUTH_MODE_DISABLED: + return None + if auth_mode != AUTH_MODE_TRUSTED_HEADERS: + return Response(status=401) + + identity = load_trusted_identity(cast(Mapping[str, str], request.headers)) + if identity is None: + return Response(status=401) + if identity.role not in roles: + return Response(status=403) + return None + + def get_refresh_broker(app: Quart) -> RefreshBroker: return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY]) @@ -442,6 +538,10 @@ async def render_dashboard(app: Quart | None = None) -> Renderable: ) +async def render_publisher(*, current_path: str) -> Renderable: + return publisher_page(current_path=current_path) + + async def render_sources(app: Quart | None = None) -> Renderable: if app is None: return sources_page() diff --git a/tests/test_header_auth.py b/tests/test_header_auth.py new file mode 100644 index 0000000..88987ea --- /dev/null +++ b/tests/test_header_auth.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any, cast + +from repub.auth_headers import load_trusted_identity +from repub.web import create_app + + +def _trusted_headers(*, role: str, provider: str | None = None) -> dict[str, str]: + resolved_provider = provider or ("gp" if role == "admin" else "ocb") + return { + "X-Republisher-Auth-Role": role, + "X-Republisher-Auth-Provider": resolved_provider, + "X-Republisher-Auth-User": f"{role}-user", + "X-Republisher-Auth-Email": f"{role}@example.org", + "X-Republisher-Auth-Preferred-Username": f"{role}-user", + "X-Republisher-Auth-Groups": ( + "/ocb-republisher-admins, /staff" + if role == "admin" + else "/ocb-republisher-publishers, /publishers" + ), + } + + +def _configure_trusted_auth(monkeypatch, tmp_path: Path, name: str) -> None: + monkeypatch.setenv("REPUBLISHER_AUTH_MODE", "trusted-headers") + monkeypatch.setenv("REPUBLISHER_DB_PATH", str(tmp_path / f"{name}.db")) + + +def _assert_datastar_shell(body: str) -> None: + assert body.startswith("") + assert 'id="js"' in body + assert 'src="/static/datastar@1.0.0-RC.8.js"' in body + assert 'data-init="@post(window.location.pathname +' in body + assert '
None: + identity = load_trusted_identity( + { + "X-Republisher-Auth-Role": "admin", + "X-Republisher-Auth-Provider": "gp", + "X-Republisher-Auth-User": "abel", + "X-Republisher-Auth-Email": "abel@example.org", + "X-Republisher-Auth-Groups": " /staff, ,/ocb-republisher-admins ", + } + ) + + assert identity is not None + assert identity.preferred_username == "abel" + assert identity.groups == ("/staff", "/ocb-republisher-admins") + + +def test_trusted_header_mode_rejects_admin_route_without_identity( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "missing-identity") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get("/") + + assert response.status_code == 401 + + asyncio.run(run()) + + +def test_trusted_header_mode_ignores_generic_forwarded_identity_headers( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "generic-forwarded") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get( + "/", + headers={ + "X-Forwarded-User": "mallory", + "X-Forwarded-Email": "mallory@example.org", + "X-Forwarded-Groups": "/ocb-republisher-admins", + }, + ) + + assert response.status_code == 401 + + asyncio.run(run()) + + +def test_trusted_header_mode_rejects_malformed_trusted_identity_headers( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "malformed-identity") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get( + "/", + headers={ + "X-Republisher-Auth-Role": "admin", + "X-Republisher-Auth-Provider": "gp", + }, + ) + + assert response.status_code == 401 + + asyncio.run(run()) + + +def test_trusted_header_mode_allows_admin_identity_on_admin_route( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "admin-allowed") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get("/", headers=_trusted_headers(role="admin")) + + assert response.status_code == 200 + + asyncio.run(run()) + + +def test_trusted_header_mode_rejects_publisher_identity_on_admin_route( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "publisher-admin-rejected") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get("/", headers=_trusted_headers(role="publisher")) + + assert response.status_code == 403 + + asyncio.run(run()) + + +def test_trusted_header_mode_rejects_admin_action_without_identity( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "action-missing-identity") + + async def run() -> None: + app = create_app() + app.config["REPUB_LOG_DIR"] = tmp_path / "logs" + client = app.test_client() + + response = await client.post("/actions/completed-executions/clear") + + assert response.status_code == 401 + + asyncio.run(run()) + + +def test_trusted_header_mode_rejects_publisher_identity_on_admin_action( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "action-publisher-rejected") + + async def run() -> None: + app = create_app() + app.config["REPUB_LOG_DIR"] = tmp_path / "logs" + client = app.test_client() + + response = await client.post( + "/actions/completed-executions/clear", + headers=_trusted_headers(role="publisher"), + ) + + assert response.status_code == 403 + + asyncio.run(run()) + + +def test_trusted_header_mode_allows_publisher_identity_on_publisher_route( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "publisher-allowed") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get( + "/publisher", + headers=_trusted_headers(role="publisher"), + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + _assert_datastar_shell(body) + + asyncio.run(run()) + + +def test_trusted_header_mode_publisher_post_serves_hello_publishers_morph( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "publisher-post") + + async def run() -> None: + client = create_app().test_client() + + async with client.request( + "/publisher?u=shim", + method="POST", + headers=_trusted_headers(role="publisher"), + ) as connection: + await connection.send_complete() + chunk = await asyncio.wait_for(connection.receive(), timeout=1) + raw_connection = cast(Any, connection) + + assert raw_connection.status_code == 200 + assert raw_connection.headers["Content-Type"] == "text/event-stream" + assert b"event: datastar-patch-elements" in chunk + assert b'
None: + _configure_trusted_auth(monkeypatch, tmp_path, "admin-publisher-rejected") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get( + "/publisher", headers=_trusted_headers(role="admin") + ) + + assert response.status_code == 403 + + asyncio.run(run()) + + +def test_trusted_header_mode_allows_admin_identity_on_admin_publisher_alias( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "admin-alias-allowed") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get( + "/admin/publisher", + headers=_trusted_headers(role="admin"), + ) + body = await response.get_data(as_text=True) + + assert response.status_code == 200 + _assert_datastar_shell(body) + + asyncio.run(run()) + + +def test_trusted_header_mode_admin_publisher_post_serves_hello_publishers_morph( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "admin-alias-post") + + async def run() -> None: + client = create_app().test_client() + + async with client.request( + "/admin/publisher?u=shim", + method="POST", + headers=_trusted_headers(role="admin"), + ) as connection: + await connection.send_complete() + chunk = await asyncio.wait_for(connection.receive(), timeout=1) + raw_connection = cast(Any, connection) + + assert raw_connection.status_code == 200 + assert raw_connection.headers["Content-Type"] == "text/event-stream" + assert b"event: datastar-patch-elements" in chunk + assert b'
None: + _configure_trusted_auth(monkeypatch, tmp_path, "publisher-alias-rejected") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get( + "/admin/publisher", + headers=_trusted_headers(role="publisher"), + ) + + assert response.status_code == 403 + + asyncio.run(run()) + + +def test_trusted_header_mode_keeps_static_assets_public( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "static-public") + + async def run() -> None: + client = create_app().test_client() + + response = await client.get("/static/datastar@1.0.0-RC.8.js") + + assert response.status_code == 200 + + asyncio.run(run()) + + +def test_trusted_header_mode_keeps_dev_feeds_public( + monkeypatch, tmp_path: Path +) -> None: + _configure_trusted_auth(monkeypatch, tmp_path, "feeds-public") + + async def run() -> None: + feeds_dir = tmp_path / "out" / "feeds" + feed_path = feeds_dir / "demo-source" / "feed.rss" + feed_path.parent.mkdir(parents=True) + feed_path.write_text("\n", encoding="utf-8") + app = create_app(dev_mode=True) + app.config["REPUB_FEEDS_DIR"] = feeds_dir + client = app.test_client() + + response = await client.get("/feeds/demo-source/feed.rss") + + assert response.status_code == 200 + + asyncio.run(run()) diff --git a/tests/test_scheduler_runtime.py b/tests/test_scheduler_runtime.py index 362db11..c5d6295 100644 --- a/tests/test_scheduler_runtime.py +++ b/tests/test_scheduler_runtime.py @@ -781,7 +781,9 @@ def test_job_runtime_start_reattaches_live_worker_after_app_restart( assert running_execution.running_status == JobExecutionStatus.RUNNING assert running_execution.ended_at is None - completed_execution = _wait_for_terminal_execution(int(execution.get_id())) + completed_execution = _wait_for_terminal_execution( + int(execution.get_id()), timeout_seconds=10.0 + ) assert completed_execution.running_status == JobExecutionStatus.SUCCEEDED assert "reattached" in artifacts.log_path.read_text(encoding="utf-8") finally: @@ -861,7 +863,9 @@ def test_job_runtime_start_restores_live_worker_marked_failed_by_restart_bug( assert restored_execution.running_status == JobExecutionStatus.RUNNING assert restored_execution.ended_at is None - completed_execution = _wait_for_terminal_execution(int(execution.get_id())) + completed_execution = _wait_for_terminal_execution( + int(execution.get_id()), timeout_seconds=10.0 + ) assert completed_execution.running_status == JobExecutionStatus.SUCCEEDED assert "restored execution state" in artifacts.log_path.read_text( encoding="utf-8"