Compare commits
2 commits
87288561b9
...
96551c2788
| Author | SHA1 | Date | |
|---|---|---|---|
| 96551c2788 | |||
| 89e6a4d78c |
11 changed files with 600 additions and 22 deletions
10
README.md
10
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/...`.
|
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:
|
Once the UI is running:
|
||||||
|
|
||||||
|
|
@ -84,8 +88,8 @@ uv run repub cleanup-media --feeds-dir out/feeds --days 25 --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
- Remove `--dry-run` to delete matching files. The command protects media
|
- Remove `--dry-run` to delete matching files. The command protects media
|
||||||
referenced by the latest published feed and uses a lock to avoid racing with
|
referenced by the latest published feed, lists each matched file before the
|
||||||
active crawls.
|
aggregate summary, and uses a lock to avoid racing with active crawls.
|
||||||
- For config-driven deployments, pass the runtime config so cleanup uses the
|
- For config-driven deployments, pass the runtime config so cleanup uses the
|
||||||
configured `out_dir` and media directory names:
|
configured `out_dir` and media directory names:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ uv run repub cleanup-media --config demo/repub.toml --dry-run
|
||||||
With `--config`, cleanup scans `demo/out/feeds/` and honors any
|
With `--config`, cleanup scans `demo/out/feeds/` and honors any
|
||||||
`REPUBLISHER_*_DIR` media directory overrides in the config. Remove `--dry-run`
|
`REPUBLISHER_*_DIR` media directory overrides in the config. Remove `--dry-run`
|
||||||
to delete old unreferenced media. The default retention window is 25 days; use
|
to delete old unreferenced media. The default retention window is 25 days; use
|
||||||
`--days N` to override it.
|
`--days N` to override it. Cleanup prints each matched path before the aggregate
|
||||||
|
summary.
|
||||||
|
|
||||||
## Local File Feed
|
## Local File Feed
|
||||||
|
|
||||||
|
|
|
||||||
77
repub/auth_headers.py
Normal file
77
repub/auth_headers.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -160,6 +160,10 @@ def cleanup_media(
|
||||||
if path.resolve() in protected:
|
if path.resolve() in protected:
|
||||||
continue
|
continue
|
||||||
result.matched_files += 1
|
result.matched_files += 1
|
||||||
|
print(
|
||||||
|
f"media cleanup: matched path={path.resolve()} bytes={stat.st_size}",
|
||||||
|
file=output,
|
||||||
|
)
|
||||||
if dry_run:
|
if dry_run:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ class JobRuntime:
|
||||||
worker = self._workers.pop(execution_id)
|
worker = self._workers.pop(execution_id)
|
||||||
if worker.process.poll() is None:
|
if worker.process.poll() is None:
|
||||||
worker.process.kill()
|
worker.process.kill()
|
||||||
worker.process.wait(timeout=2)
|
worker.process.wait(timeout=10)
|
||||||
worker.log_handle.close()
|
worker.log_handle.close()
|
||||||
|
|
||||||
if self._started:
|
if self._started:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from repub.pages.dashboard import dashboard_page, dashboard_page_with_data
|
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.runs import execution_logs_page, runs_page
|
||||||
from repub.pages.settings import settings_page
|
from repub.pages.settings import settings_page
|
||||||
from repub.pages.shim import shim_page
|
from repub.pages.shim import shim_page
|
||||||
|
|
@ -10,6 +11,7 @@ __all__ = [
|
||||||
"dashboard_page_with_data",
|
"dashboard_page_with_data",
|
||||||
"edit_source_page",
|
"edit_source_page",
|
||||||
"execution_logs_page",
|
"execution_logs_page",
|
||||||
|
"publisher_page",
|
||||||
"runs_page",
|
"runs_page",
|
||||||
"settings_page",
|
"settings_page",
|
||||||
"shim_page",
|
"shim_page",
|
||||||
|
|
|
||||||
19
repub/pages/publisher.py
Normal file
19
repub/pages/publisher.py
Normal 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"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
130
repub/web.py
130
repub/web.py
|
|
@ -2,11 +2,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict, cast
|
from typing import Any, TypedDict, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import htpy as h
|
import htpy as h
|
||||||
|
|
@ -17,6 +18,13 @@ from htpy import Renderable
|
||||||
from peewee import IntegrityError
|
from peewee import IntegrityError
|
||||||
from quart import Quart, Response, request, send_from_directory, url_for
|
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.datastar import RefreshBroker, TabStateStore, render_stream
|
||||||
from repub.jobs import (
|
from repub.jobs import (
|
||||||
COMPLETED_EXECUTION_PAGE_SIZE,
|
COMPLETED_EXECUTION_PAGE_SIZE,
|
||||||
|
|
@ -44,6 +52,7 @@ from repub.pages import (
|
||||||
dashboard_page_with_data,
|
dashboard_page_with_data,
|
||||||
edit_source_page,
|
edit_source_page,
|
||||||
execution_logs_page,
|
execution_logs_page,
|
||||||
|
publisher_page,
|
||||||
runs_page,
|
runs_page,
|
||||||
settings_page,
|
settings_page,
|
||||||
shim_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_LOG_DIR", DEFAULT_LOG_DIR)
|
||||||
app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR)
|
app.config.setdefault("REPUB_FEEDS_DIR", DEFAULT_FEEDS_DIR)
|
||||||
app.config["REPUB_DEV_MODE"] = dev_mode
|
app.config["REPUB_DEV_MODE"] = dev_mode
|
||||||
|
app.config["REPUB_AUTH_MODE"] = load_auth_mode()
|
||||||
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker()
|
app.extensions[REFRESH_BROKER_KEY] = RefreshBroker()
|
||||||
app.extensions[JOB_RUNTIME_KEY] = None
|
app.extensions[JOB_RUNTIME_KEY] = None
|
||||||
app.extensions[TAB_STATE_STORE_KEY] = TabStateStore()
|
app.extensions[TAB_STATE_STORE_KEY] = TabStateStore()
|
||||||
app.extensions[TAB_STATE_CLEANER_TASK_KEY] = None
|
app.extensions[TAB_STATE_CLEANER_TASK_KEY] = None
|
||||||
app.extensions[SHUTDOWN_EVENT_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>")
|
@app.get("/feeds/<path:feed_path>")
|
||||||
async def published_feed(feed_path: str) -> Response:
|
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)
|
response = await send_from_directory(str(STATIC_DIR), requested_filename)
|
||||||
return response
|
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("/")
|
||||||
@app.get("/sources")
|
@app.get("/sources")
|
||||||
@app.get("/sources/create")
|
@app.get("/sources/create")
|
||||||
|
|
@ -184,51 +206,62 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
@app.get("/runs")
|
@app.get("/runs")
|
||||||
@app.get("/settings")
|
@app.get("/settings")
|
||||||
@app.get("/job/<int:job_id>/execution/<int:execution_id>/logs")
|
@app.get("/job/<int:job_id>/execution/<int:execution_id>/logs")
|
||||||
|
@admin_required
|
||||||
async def page_shim(
|
async def page_shim(
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
job_id: int | None = None,
|
job_id: int | None = None,
|
||||||
execution_id: int | None = None,
|
execution_id: int | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
del slug, job_id, execution_id
|
del slug, job_id, execution_id
|
||||||
body, etag = _render_shim_page(
|
return _shim_page_response(current_path=request.path)
|
||||||
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
|
|
||||||
|
|
||||||
@app.post("/")
|
@app.post("/")
|
||||||
|
@admin_required
|
||||||
async def dashboard_patch() -> DatastarResponse:
|
async def dashboard_patch() -> DatastarResponse:
|
||||||
return await _page_patch_response(app, lambda _tab_id: render_dashboard(app))
|
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")
|
@app.post("/sources")
|
||||||
|
@admin_required
|
||||||
async def sources_patch() -> DatastarResponse:
|
async def sources_patch() -> DatastarResponse:
|
||||||
return await _page_patch_response(app, lambda _tab_id: render_sources(app))
|
return await _page_patch_response(app, lambda _tab_id: render_sources(app))
|
||||||
|
|
||||||
@app.post("/sources/create")
|
@app.post("/sources/create")
|
||||||
|
@admin_required
|
||||||
async def create_source_patch() -> DatastarResponse:
|
async def create_source_patch() -> DatastarResponse:
|
||||||
return await _page_patch_response(
|
return await _page_patch_response(
|
||||||
app, lambda _tab_id: render_create_source(app)
|
app, lambda _tab_id: render_create_source(app)
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/sources/<string:slug>/edit")
|
@app.post("/sources/<string:slug>/edit")
|
||||||
|
@admin_required
|
||||||
async def edit_source_patch(slug: str) -> DatastarResponse:
|
async def edit_source_patch(slug: str) -> DatastarResponse:
|
||||||
return await _page_patch_response(
|
return await _page_patch_response(
|
||||||
app, lambda _tab_id: render_edit_source(slug, app)
|
app, lambda _tab_id: render_edit_source(slug, app)
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/settings")
|
@app.post("/settings")
|
||||||
|
@admin_required
|
||||||
async def settings_patch() -> DatastarResponse:
|
async def settings_patch() -> DatastarResponse:
|
||||||
return await _page_patch_response(app, lambda _tab_id: render_settings(app))
|
return await _page_patch_response(app, lambda _tab_id: render_settings(app))
|
||||||
|
|
||||||
@app.post("/actions/sources/create")
|
@app.post("/actions/sources/create")
|
||||||
|
@admin_required
|
||||||
async def create_source_action() -> DatastarResponse:
|
async def create_source_action() -> DatastarResponse:
|
||||||
signals = cast(dict[str, object], await read_signals())
|
signals = cast(dict[str, object], await read_signals())
|
||||||
source, error = validate_source_form(
|
source, error = validate_source_form(
|
||||||
|
|
@ -254,6 +287,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return DatastarResponse(SSE.redirect("/sources"))
|
return DatastarResponse(SSE.redirect("/sources"))
|
||||||
|
|
||||||
@app.post("/actions/sources/<string:slug>/edit")
|
@app.post("/actions/sources/<string:slug>/edit")
|
||||||
|
@admin_required
|
||||||
async def edit_source_action(slug: str) -> DatastarResponse:
|
async def edit_source_action(slug: str) -> DatastarResponse:
|
||||||
signals = cast(dict[str, object], await read_signals())
|
signals = cast(dict[str, object], await read_signals())
|
||||||
source, error = validate_source_form(
|
source, error = validate_source_form(
|
||||||
|
|
@ -279,6 +313,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return DatastarResponse(SSE.redirect("/sources"))
|
return DatastarResponse(SSE.redirect("/sources"))
|
||||||
|
|
||||||
@app.post("/actions/sources/<string:slug>/delete")
|
@app.post("/actions/sources/<string:slug>/delete")
|
||||||
|
@admin_required
|
||||||
async def delete_source_action(slug: str) -> Response:
|
async def delete_source_action(slug: str) -> Response:
|
||||||
delete_source(slug)
|
delete_source(slug)
|
||||||
get_job_runtime(app).sync_jobs()
|
get_job_runtime(app).sync_jobs()
|
||||||
|
|
@ -286,6 +321,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/settings")
|
@app.post("/actions/settings")
|
||||||
|
@admin_required
|
||||||
async def update_settings_action() -> DatastarResponse:
|
async def update_settings_action() -> DatastarResponse:
|
||||||
signals = cast(dict[str, object], await read_signals())
|
signals = cast(dict[str, object], await read_signals())
|
||||||
settings, error = validate_settings_form(signals)
|
settings, error = validate_settings_form(signals)
|
||||||
|
|
@ -301,6 +337,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return DatastarResponse(SSE.redirect("/settings"))
|
return DatastarResponse(SSE.redirect("/settings"))
|
||||||
|
|
||||||
@app.post("/runs")
|
@app.post("/runs")
|
||||||
|
@admin_required
|
||||||
async def runs_patch() -> DatastarResponse:
|
async def runs_patch() -> DatastarResponse:
|
||||||
return await _page_patch_response(
|
return await _page_patch_response(
|
||||||
app,
|
app,
|
||||||
|
|
@ -308,6 +345,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/actions/runs/completed-page/<int:page>")
|
@app.post("/actions/runs/completed-page/<int:page>")
|
||||||
|
@admin_required
|
||||||
async def set_completed_runs_page_action(page: int) -> Response:
|
async def set_completed_runs_page_action(page: int) -> Response:
|
||||||
signals = await _read_optional_signals()
|
signals = await _read_optional_signals()
|
||||||
tab_id = _read_tab_id(signals)
|
tab_id = _read_tab_id(signals)
|
||||||
|
|
@ -322,12 +360,14 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/jobs/<int:job_id>/run-now")
|
@app.post("/actions/jobs/<int:job_id>/run-now")
|
||||||
|
@admin_required
|
||||||
async def run_job_now_action(job_id: int) -> Response:
|
async def run_job_now_action(job_id: int) -> Response:
|
||||||
get_job_runtime(app).run_job_now(job_id, reason="manual")
|
get_job_runtime(app).run_job_now(job_id, reason="manual")
|
||||||
trigger_refresh(app)
|
trigger_refresh(app)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/jobs/<int:job_id>/toggle-enabled")
|
@app.post("/actions/jobs/<int:job_id>/toggle-enabled")
|
||||||
|
@admin_required
|
||||||
async def toggle_job_enabled_action(job_id: int) -> Response:
|
async def toggle_job_enabled_action(job_id: int) -> Response:
|
||||||
enabled = load_job_enabled(job_id)
|
enabled = load_job_enabled(job_id)
|
||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
|
|
@ -336,6 +376,7 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/jobs/<int:job_id>/delete")
|
@app.post("/actions/jobs/<int:job_id>/delete")
|
||||||
|
@admin_required
|
||||||
async def delete_job_action(job_id: int) -> Response:
|
async def delete_job_action(job_id: int) -> Response:
|
||||||
delete_job_source(job_id)
|
delete_job_source(job_id)
|
||||||
get_job_runtime(app).sync_jobs()
|
get_job_runtime(app).sync_jobs()
|
||||||
|
|
@ -343,34 +384,40 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/executions/<int:execution_id>/cancel")
|
@app.post("/actions/executions/<int:execution_id>/cancel")
|
||||||
|
@admin_required
|
||||||
async def cancel_execution_action(execution_id: int) -> Response:
|
async def cancel_execution_action(execution_id: int) -> Response:
|
||||||
get_job_runtime(app).request_execution_cancel(execution_id)
|
get_job_runtime(app).request_execution_cancel(execution_id)
|
||||||
trigger_refresh(app)
|
trigger_refresh(app)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/queued-executions/<int:execution_id>/cancel")
|
@app.post("/actions/queued-executions/<int:execution_id>/cancel")
|
||||||
|
@admin_required
|
||||||
async def cancel_queued_execution_action(execution_id: int) -> Response:
|
async def cancel_queued_execution_action(execution_id: int) -> Response:
|
||||||
get_job_runtime(app).cancel_queued_execution(execution_id)
|
get_job_runtime(app).cancel_queued_execution(execution_id)
|
||||||
trigger_refresh(app)
|
trigger_refresh(app)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/queued-executions/<int:execution_id>/move-up")
|
@app.post("/actions/queued-executions/<int:execution_id>/move-up")
|
||||||
|
@admin_required
|
||||||
async def move_queued_execution_up_action(execution_id: int) -> Response:
|
async def move_queued_execution_up_action(execution_id: int) -> Response:
|
||||||
get_job_runtime(app).move_queued_execution(execution_id, direction="up")
|
get_job_runtime(app).move_queued_execution(execution_id, direction="up")
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/queued-executions/<int:execution_id>/move-down")
|
@app.post("/actions/queued-executions/<int:execution_id>/move-down")
|
||||||
|
@admin_required
|
||||||
async def move_queued_execution_down_action(execution_id: int) -> Response:
|
async def move_queued_execution_down_action(execution_id: int) -> Response:
|
||||||
get_job_runtime(app).move_queued_execution(execution_id, direction="down")
|
get_job_runtime(app).move_queued_execution(execution_id, direction="down")
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/actions/completed-executions/clear")
|
@app.post("/actions/completed-executions/clear")
|
||||||
|
@admin_required
|
||||||
async def clear_completed_executions_action() -> Response:
|
async def clear_completed_executions_action() -> Response:
|
||||||
clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"])
|
clear_completed_executions(log_dir=app.config["REPUB_LOG_DIR"])
|
||||||
trigger_refresh(app)
|
trigger_refresh(app)
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
@app.post("/job/<int:job_id>/execution/<int:execution_id>/logs")
|
@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 logs_patch(job_id: int, execution_id: int) -> DatastarResponse:
|
||||||
async def render() -> Renderable:
|
async def render() -> Renderable:
|
||||||
return await render_execution_logs(
|
return await render_execution_logs(
|
||||||
|
|
@ -400,6 +447,55 @@ def create_app(*, dev_mode: bool = False) -> Quart:
|
||||||
return app
|
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:
|
def get_refresh_broker(app: Quart) -> RefreshBroker:
|
||||||
return cast(RefreshBroker, app.extensions[REFRESH_BROKER_KEY])
|
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:
|
async def render_sources(app: Quart | None = None) -> Renderable:
|
||||||
if app is None:
|
if app is None:
|
||||||
return sources_page()
|
return sources_page()
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,29 @@ def test_cleanup_media_dry_run_reports_matches_without_deleting(tmp_path: Path)
|
||||||
assert result.failures == 0
|
assert result.failures == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_media_lists_matched_files_before_summary(tmp_path: Path) -> None:
|
||||||
|
feeds_dir = tmp_path / "feeds"
|
||||||
|
old_file = feeds_dir / "demo" / "audio" / "old.mp3"
|
||||||
|
write_media(old_file, b"audio", age_days=40)
|
||||||
|
output = io.StringIO()
|
||||||
|
|
||||||
|
result = cleanup_media(
|
||||||
|
feeds_dir=feeds_dir,
|
||||||
|
retention_days=25,
|
||||||
|
now=NOW,
|
||||||
|
dry_run=True,
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert old_file.exists()
|
||||||
|
assert result.matched_files == 1
|
||||||
|
output_lines = output.getvalue().splitlines()
|
||||||
|
assert (
|
||||||
|
output_lines[0] == f"media cleanup: matched path={old_file.resolve()} bytes=5"
|
||||||
|
)
|
||||||
|
assert "matched_files=1" in output_lines[-1]
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_media_uses_configured_media_dirs(tmp_path: Path) -> None:
|
def test_cleanup_media_uses_configured_media_dirs(tmp_path: Path) -> None:
|
||||||
feeds_dir = tmp_path / "feeds"
|
feeds_dir = tmp_path / "feeds"
|
||||||
demo_dir = feeds_dir / "demo"
|
demo_dir = feeds_dir / "demo"
|
||||||
|
|
|
||||||
344
tests/test_header_auth.py
Normal file
344
tests/test_header_auth.py
Normal 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())
|
||||||
|
|
@ -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.running_status == JobExecutionStatus.RUNNING
|
||||||
assert running_execution.ended_at is None
|
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 completed_execution.running_status == JobExecutionStatus.SUCCEEDED
|
||||||
assert "reattached" in artifacts.log_path.read_text(encoding="utf-8")
|
assert "reattached" in artifacts.log_path.read_text(encoding="utf-8")
|
||||||
finally:
|
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.running_status == JobExecutionStatus.RUNNING
|
||||||
assert restored_execution.ended_at is None
|
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 completed_execution.running_status == JobExecutionStatus.SUCCEEDED
|
||||||
assert "restored execution state" in artifacts.log_path.read_text(
|
assert "restored execution state" in artifacts.log_path.read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue