Add trusted header auth and publisher shell
All checks were successful
buildbot/nix-eval Build done.
buildbot/nix-build Build done.
buildbot/nix-effects Build done.

This commit is contained in:
Abel Luck 2026-06-01 18:11:23 +02:00
parent 89e6a4d78c
commit 96551c2788
8 changed files with 569 additions and 19 deletions

View file

@ -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:

77
repub/auth_headers.py Normal file
View file

@ -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
)

View file

@ -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:

View file

@ -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",

19
repub/pages/publisher.py Normal file
View file

@ -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"
]
],
),
)

View file

@ -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/<path:feed_path>")
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/<int:job_id>/execution/<int:execution_id>/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/<string:slug>/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/<string:slug>/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/<string:slug>/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/<int: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/<int:job_id>/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/<int:job_id>/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/<int:job_id>/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/<int:execution_id>/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/<int:execution_id>/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/<int:execution_id>/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/<int:execution_id>/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/<int:job_id>/execution/<int:execution_id>/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()

344
tests/test_header_auth.py Normal file
View file

@ -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("<!doctype html>")
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 '<main id="morph"' in body
assert "Connecting" in body
def test_load_trusted_identity_parses_groups_and_defaults_preferred_username() -> 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'<main id="morph"' in chunk
assert b"Hello publishers" in chunk
await connection.disconnect()
asyncio.run(run())
def test_trusted_header_mode_rejects_admin_identity_on_publisher_route(
monkeypatch, tmp_path: Path
) -> 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'<main id="morph"' in chunk
assert b"Hello publishers" in chunk
await connection.disconnect()
asyncio.run(run())
def test_trusted_header_mode_rejects_publisher_identity_on_admin_publisher_alias(
monkeypatch, tmp_path: Path
) -> 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("<rss/>\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())

View file

@ -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"