Add the JSON API and logins

Logins follow shelfmark: none, CWA accounts, or a reverse proxy header.
CWA accounts are checked against the OPDS feed, with a lockout after
repeated wrong passwords. An API key works in every mode, and changes
need an X-Requested-With header or the key. URL_BASE serves the app
under a path prefix.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 49cd18a09f
commit 09d6b76a17
6 changed files with 1320 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
"""Development server: python -m bindery. The container runs gunicorn instead."""
import os
from bindery.web import app
app.run(host="127.0.0.1", port=int(os.getenv("PORT", "8085")), threaded=True, use_reloader=False)
+167
View File
@@ -0,0 +1,167 @@
"""Logins: none, CWA accounts, or a reverse proxy header. An API key works in every mode.
CWA accounts use an optional read-only app.db, with OPDS as the fallback when
the database cannot answer. A rejected database login never falls back.
"""
import hmac
import logging
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from bindery import calibre_db, config
from bindery.cwa import CwaClient, CwaError
logger = logging.getLogger(__name__)
AUTH_NONE, AUTH_CWA, AUTH_PROXY = "none", "cwa", "proxy"
MAX_FAILURES = 5 # for one user name from one address
MAX_ADDRESS_FAILURES = 20 # for all user names from one address
LOCKOUT_SECONDS = 15 * 60
class AuthError(Exception):
pass
@dataclass
class Identity:
name: str
via: str # "none", "session", "proxy" or "api_key"
def config_error() -> str | None:
"""Why the login settings cannot work, or None.
Bindery then refuses every request, so a mistake in the settings never turns the logins off.
"""
method = config.AUTH_METHOD
if method not in (AUTH_NONE, AUTH_CWA, AUTH_PROXY):
return f"AUTH_METHOD must be none, cwa or proxy, not {method!r}"
if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH):
return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"
if method == AUTH_PROXY and not config.PROXY_AUTH_USER_HEADER:
return "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"
return None
def active_mode() -> str:
return config.AUTH_METHOD
def _allowed(name: str) -> bool:
names = {n.casefold() for n in config.CWA_ALLOWED_USERS}
return not names or name.casefold() in names
def _api_key_identity(headers: Mapping[str, str]) -> Identity | None:
if not config.API_KEY:
return None
candidates = [headers.get("X-Api-Key", "")]
auth = headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
candidates.append(auth[7:].strip())
if any(c and hmac.compare_digest(c.encode(), config.API_KEY.encode()) for c in candidates):
return Identity("api", "api_key")
return None
def current_identity(headers: Mapping[str, str], session: Mapping[str, Any]) -> Identity | None:
"""Who makes this request, or None when a login is needed."""
if config_error():
return None
identity = _api_key_identity(headers)
if identity:
return identity
mode = active_mode()
if mode == AUTH_NONE:
return Identity("", "none")
if mode == AUTH_PROXY:
name = (headers.get(config.PROXY_AUTH_USER_HEADER) or "").strip()
return Identity(name, "proxy") if name else None
name = session.get("user")
if name and session.get("mode") == AUTH_CWA and _allowed(name):
return Identity(name, "session")
return None
def client_address(headers: Mapping[str, str], remote_addr: str | None) -> str:
"""The address of the client. With TRUST_PROXY, the last X-Forwarded-For entry, which the proxy added."""
if config.TRUST_PROXY:
forwarded = [a.strip() for a in headers.get("X-Forwarded-For", "").split(",") if a.strip()]
if forwarded:
return forwarded[-1]
return remote_addr or ""
class LoginLimiter:
"""Refuses logins from a client address after too many wrong passwords.
A user name is locked only for the address that sent the wrong passwords,
so another client cannot lock a user out. Each address also has a limit
for all user names together.
"""
def __init__(self) -> None:
self._failures: dict[str, list[tuple[float, str]]] = {} # address: (time, user name)
self._lock = threading.Lock()
def _prune(self) -> None:
since = time.monotonic() - LOCKOUT_SECONDS
self._failures = {a: kept for a, f in self._failures.items() if (kept := [x for x in f if x[0] > since])}
def locked(self, name: str, address: str) -> bool:
with self._lock:
self._prune()
failures = self._failures.get(address, [])
mine = [n for _, n in failures if n == name.casefold()]
return len(mine) >= MAX_FAILURES or len(failures) >= MAX_ADDRESS_FAILURES
def failed(self, name: str, address: str) -> None:
with self._lock:
self._failures.setdefault(address, []).append((time.monotonic(), name.casefold()))
def clear(self, name: str, address: str) -> None:
with self._lock:
kept = [x for x in self._failures.get(address, []) if x[1] != name.casefold()]
self._failures[address] = kept
limiter = LoginLimiter()
def _login_ok(username: str, password: str, client: CwaClient) -> bool:
if config.CWA_DB_PATH:
try:
return calibre_db.login_ok(config.CWA_DB_PATH, username, password)
except calibre_db.DatabaseUnavailable as e:
logger.warning(f"Cannot read the Calibre-Web login database. Trying OPDS: {e}")
return client.login_ok(username, password)
def login_cwa(username: str, password: str, client: CwaClient, address: str) -> Identity:
"""Check a CWA login. Raises AuthError with a message for the user."""
username = username.strip()
if active_mode() != AUTH_CWA:
raise AuthError("Bindery does not use CWA logins")
if limiter.locked(username, address):
raise AuthError("Too many wrong passwords. Try again in 15 minutes.")
try:
ok = (
_allowed(username)
and username.casefold() != "guest"
and bool(password)
and _login_ok(username, password, client)
)
except CwaError as e:
# The error holds the address of CWA, which a user who is not logged in must not see.
logger.warning(f"Cannot check the login of {username}: {e}")
raise AuthError("Cannot check the login with CWA. The log has the details.") from e
if not ok:
limiter.failed(username, address)
raise AuthError("Wrong user name or password")
limiter.clear(username, address)
return Identity(username, "session")
+402
View File
@@ -0,0 +1,402 @@
"""The Flask app: login, the JSON API and the static web interface."""
import io
import logging
import os
import re
import secrets
import uuid
from collections.abc import Callable, Iterable
from datetime import timedelta
from typing import Any
from flask import Flask, Response, g, jsonify, request, send_file, session
from werkzeug.exceptions import HTTPException
from bindery import auth, config, orchestrator
from bindery.cwa import CwaError
from bindery.epub import EpubError
from bindery.fff import StoryError
from bindery.jobs import METADATA_FIELDS, MODE_LABELS, DownloadMode
from bindery.rules import RuleError
logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__)
# API paths that need no login.
PUBLIC_PATHS = {"/api/health", "/api/auth/check", "/api/auth/login", "/api/auth/logout"}
# The browser sends this header with every change. Another site cannot send it
# without a CORS preflight, which Bindery never allows, so it stops cross-site requests.
CSRF_HEADER = "X-Requested-With"
CSRF_VALUE = "bindery"
MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
class PrefixMiddleware:
"""Serve the app under URL_BASE, for a reverse proxy that forwards the full path."""
def __init__(self, app: Callable[..., Iterable[bytes]], prefix: str) -> None:
self.app = app
self.prefix = prefix
def __call__(self, environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]:
path = environ.get("PATH_INFO", "")
if path == "/api/health": # for the container health check
return self.app(environ, start_response)
if path == self.prefix or path.startswith(self.prefix + "/"):
environ["SCRIPT_NAME"] = self.prefix
environ["PATH_INFO"] = path[len(self.prefix) :] or "/"
return self.app(environ, start_response)
start_response("404 Not Found", [("Content-Type", "text/plain")])
return [b"Not found"]
def _secret_key() -> bytes:
"""A key kept in CONFIG_DIR, so logins survive a restart."""
try:
return config.SECRET_KEY_FILE.read_bytes()
except FileNotFoundError:
key = secrets.token_bytes(64)
fd = os.open(config.SECRET_KEY_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(key)
return key
config.prepare()
app = Flask(__name__)
app.config.update(
SECRET_KEY=_secret_key(),
SEND_FILE_MAX_AGE_DEFAULT=0,
SESSION_COOKIE_NAME="bindery_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=config.SESSION_COOKIE_SECURE,
SESSION_COOKIE_PATH=config.URL_BASE or "/",
# Bindery cannot check a CWA login again, so every session ends within a day.
PERMANENT_SESSION_LIFETIME=timedelta(days=1),
MAX_CONTENT_LENGTH=256 * 1024 * 1024, # epub uploads
)
if error := auth.config_error():
logger.error(f"{error}. Bindery refuses all requests until you fix the setting.")
if config.URL_BASE:
app.wsgi_app = PrefixMiddleware(app.wsgi_app, config.URL_BASE) # type: ignore[method-assign]
def _json() -> dict[str, Any]:
data = request.get_json(silent=True)
return data if isinstance(data, dict) else {}
# Login gate and errors
def _identity() -> auth.Identity | None:
return auth.current_identity(request.headers, session)
@app.before_request
def check_login() -> Any:
if not request.path.startswith("/api/") or request.path == "/api/health":
return None
error = auth.config_error()
if error:
return jsonify({"error": f"{error}. Fix the setting and restart Bindery."}), 503
identity = _identity()
if (
request.method in MUTATING
and (identity is None or identity.via != "api_key")
and request.headers.get(CSRF_HEADER) != CSRF_VALUE
):
return jsonify({"error": f"Missing the {CSRF_HEADER} header"}), 403
if request.path in PUBLIC_PATHS:
return None
if identity is None:
return jsonify({"error": "Unauthorized"}), 401
g.identity = identity
return None
@app.after_request
def security_headers(response: Response) -> Response:
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
response.headers.setdefault("Referrer-Policy", "same-origin")
return response
@app.errorhandler(KeyError)
def not_found(e: KeyError) -> Any:
return {"error": e.args[0] if e.args else "Not found"}, 404
@app.errorhandler(RuleError)
def bad_rule(e: RuleError) -> Any:
return {"error": str(e), "index": e.index}, 400
@app.errorhandler(ValueError)
@app.errorhandler(StoryError)
@app.errorhandler(CwaError)
@app.errorhandler(EpubError)
def bad_request(e: Exception) -> Any:
return {"error": str(e)}, 400
@app.errorhandler(Exception)
def server_error(e: Exception) -> Any:
if isinstance(e, HTTPException):
return {"error": e.description}, e.code
logger.exception(f"Error in {request.path}: {e}")
return {"error": f"{type(e).__name__}: {e}"}, 500
# Login
@app.get("/api/health")
def health() -> Any:
return {"status": "ok"}
@app.get("/api/auth/check")
def auth_check() -> Any:
identity = _identity()
mode = auth.active_mode()
return {
"authenticated": identity is not None,
"mode": mode,
"user": identity.name if identity else None,
"logout_url": config.PROXY_AUTH_LOGOUT_URL or None if mode == auth.AUTH_PROXY else None,
}
@app.post("/api/auth/login")
def auth_login() -> Any:
data = _json()
try:
identity = auth.login_cwa(
str(data.get("username", "")),
str(data.get("password", "")),
orchestrator.cwa(),
auth.client_address(request.headers, request.remote_addr),
)
except auth.AuthError as e:
return {"error": str(e)}, 401
session.clear()
session["user"] = identity.name
session["mode"] = auth.AUTH_CWA
session.permanent = bool(data.get("remember"))
logger.info(f"{identity.name} logged in")
return {"authenticated": True, "user": identity.name}
@app.post("/api/auth/logout")
def auth_logout() -> Any:
session.clear()
return {"authenticated": False}
@app.get("/api/config")
def app_config() -> Any:
return {
"metadata_fields": METADATA_FIELDS,
"mode_labels": MODE_LABELS,
"library_available": orchestrator.library_available(),
"update_interval": config.UPDATE_INTERVAL_HOURS,
}
@app.route("/api/settings", methods=["GET", "PUT"])
def settings() -> Any:
if request.method == "PUT":
data = _json()
return orchestrator.save_settings(
data.get("recent_chapters"), data.get("personal_ini"), data.get("hold_new_links"), data.get("pause_uploads")
)
return orchestrator.get_settings()
# Jobs
@app.get("/api/jobs")
def jobs() -> Any:
return orchestrator.queue_status(request.args.get("story_id", type=int))
@app.post("/api/jobs/<job_id>/cancel")
def cancel_job(job_id: str) -> Any:
return {"cancelled": orchestrator.cancel_job(job_id)}
# Stories
@app.get("/api/stories")
def stories() -> Any:
return orchestrator.list_stories()
@app.post("/api/stories")
def add_stories() -> Any:
urls = _json().get("urls", [])
if isinstance(urls, str):
urls = re.split(r"\s+", urls)
queued, errors = orchestrator.queue_urls([str(u) for u in urls])
return {"jobs": queued, "errors": errors}
@app.post("/api/stories/import-epub")
@app.post("/api/stories/<int:story_id>/import-epub")
def import_epub(story_id: int | None = None) -> Any:
upload = request.files.get("file")
if upload is None or not upload.filename:
raise ValueError("Choose an epub file")
path = config.UPLOAD_DIR / f"{uuid.uuid4().hex}.epub"
upload.save(path)
check = request.form.get("check", "true").lower() not in ("0", "false", "no", "off")
return orchestrator.queue_epub_file(path, os.path.basename(upload.filename), story_id, check)
@app.get("/api/stories/<int:story_id>")
def story(story_id: int) -> Any:
return orchestrator.story_details(story_id)
@app.delete("/api/stories/<int:story_id>")
def delete_story(story_id: int) -> Any:
orchestrator.delete_story(story_id)
return {"deleted": story_id}
@app.post("/api/stories/<int:story_id>/download")
def download(story_id: int) -> Any:
data = _json()
return orchestrator.queue_story(
story_id, data.get("mode", DownloadMode.NEW.value), data.get("chapter_ids"), data.get("force_fields")
)
@app.put("/api/stories/<int:story_id>/settings")
def story_settings(story_id: int) -> Any:
data = _json()
orchestrator.update_story_settings(
story_id, data.get("locked_fields"), data.get("auto_update"), data.get("recheck_days")
)
return orchestrator.story_details(story_id)["story"]
@app.put("/api/stories/<int:story_id>/metadata")
def edit_metadata(story_id: int) -> Any:
values = _json().get("values")
if not isinstance(values, dict):
raise ValueError("Send the edited fields as values")
return orchestrator.edit_metadata(story_id, values)
@app.post("/api/stories/<int:story_id>/link")
def link(story_id: int) -> Any:
orchestrator.link_story(story_id, _json().get("uuid") or None)
return orchestrator.story_details(story_id)["story"]
@app.post("/api/stories/<int:story_id>/approve")
def approve_upload(story_id: int) -> Any:
job = orchestrator.approve_upload(story_id, _json().get("uuid") or None)
return {"story": orchestrator.story_details(story_id)["story"], "job": job}
@app.post("/api/stories/<int:story_id>/chapters")
def chapters_included(story_id: int) -> Any:
data = _json()
return orchestrator.set_chapters_included(
story_id, [int(c) for c in data.get("chapter_ids", [])], bool(data.get("included", True))
)
@app.get("/api/stories/<int:story_id>/epub")
def story_epub(story_id: int) -> Any:
path, title = orchestrator.build_for_download(story_id)
name = re.sub(r'[\\/:*?"<>|]', "_", title.strip())[:200] or "story"
return send_file(path, download_name=f"{name}.epub", as_attachment=True)
@app.get("/api/stories/<int:story_id>/cover")
def story_cover(story_id: int) -> Any:
cover = orchestrator.story_cover(story_id)
if not cover:
raise KeyError("This story has no cover")
# The page puts the cover version in the URL, so the browser can keep the image for a year.
return send_file(io.BytesIO(cover["data"]), mimetype=cover["mime"], max_age=365 * 24 * 60 * 60)
# Replacement rules. Story rules run after the global rules.
@app.route("/api/stories/<int:story_id>/rules", methods=["GET", "PUT"])
@app.route("/api/rules", methods=["GET", "PUT"])
def rules(story_id: int | None = None) -> Any:
if request.method == "PUT":
data = _json()
return orchestrator.save_rules(story_id, data.get("rules", []), data.get("paragraphs", ...))
return orchestrator.get_rules(story_id)
@app.post("/api/stories/<int:story_id>/rules/preview")
@app.post("/api/rules/preview")
def rules_preview(story_id: int | None = None) -> Any:
data = _json()
return orchestrator.preview_rules(
story_id,
data.get("rules", []),
int(data.get("chapter_id", 0)),
bool(data.get("source")),
data.get("paragraphs"),
)
# Chapters
@app.get("/api/chapters/<int:chapter_id>/diff")
def chapter_diff(chapter_id: int) -> Any:
return orchestrator.chapter_diff(
chapter_id, request.args.get("a", type=int), request.args.get("b", type=int), request.args.get("source") == "1"
)
@app.post("/api/chapters/<int:chapter_id>/revision")
def chapter_revision(chapter_id: int) -> Any:
return orchestrator.set_chapter_revision(chapter_id, int(_json().get("rev", 0)))
@app.delete("/api/chapters/<int:chapter_id>/revisions/<int:rev>")
def delete_revision(chapter_id: int, rev: int) -> Any:
return orchestrator.delete_revision(chapter_id, rev)
# Library
@app.get("/api/library/candidates")
def library_candidates() -> Any:
return orchestrator.library_candidates()
@app.get("/api/library/search")
def library_search() -> Any:
return orchestrator.search_library(request.args.get("q", ""))
@app.post("/api/library/import")
def library_import() -> Any:
data = _json()
books = [b for b in data.get("books", []) if isinstance(b, dict) and "id" in b]
return orchestrator.queue_import(books, bool(data.get("check", True)))
# Gunicorn runs one worker, and the dev server runs without the reloader, so this runs once.
if config.START_THREADS:
orchestrator.start_threads()
+311
View File
@@ -0,0 +1,311 @@
"""The web API: login modes, the CSRF header, URL_BASE and the web interface."""
import io
import time
import pytest
from fake_cwa import FakeCwa
from itsdangerous import TimestampSigner
from test_build import PNG
from bindery import auth, config, orchestrator, web
from bindery.store import Store
H = {"X-Requested-With": "bindery"}
@pytest.fixture
def client(monkeypatch, tmp_path):
monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db"))
monkeypatch.setattr(auth, "limiter", auth.LoginLimiter())
web.app.config["TESTING"] = True
return web.app.test_client()
@pytest.fixture
def cwa(monkeypatch):
server = FakeCwa(username="reader", password="pw")
monkeypatch.setattr(config, "CWA_URL", server.url)
monkeypatch.setattr(config, "AUTH_METHOD", "cwa")
yield server
server.close()
def login(client, username="reader", password="pw", address="127.0.0.1", forwarded=None):
headers = {**H, "X-Forwarded-For": forwarded} if forwarded else H
return client.post(
"/api/auth/login",
json={"username": username, "password": password},
headers=headers,
environ_base={"REMOTE_ADDR": address},
)
def test_health_and_no_login_mode(client):
assert client.get("/api/health").json == {"status": "ok"}
check = client.get("/api/auth/check").json
assert check["authenticated"] and check["mode"] == "none"
assert client.get("/api/stories").json == []
def test_changes_need_the_csrf_header(client):
assert client.post("/api/stories", json={"urls": []}).status_code == 403
response = client.post("/api/stories", json={"urls": ["https://example.com/x"]}, headers=H)
assert response.status_code == 200
assert response.json["errors"] == ["Not a supported story URL: https://example.com/x"]
def test_errors_are_json(client):
assert client.get("/api/stories/99").json == {"error": "No story with id 99"}
assert client.get("/api/stories/99").status_code == 404
assert client.get("/api/nothing").status_code == 404
def test_cwa_login(client, cwa):
assert client.get("/api/stories").status_code == 401
assert client.get("/api/auth/check").json == {
"authenticated": False,
"mode": "cwa",
"user": None,
"logout_url": None,
}
assert login(client, password="wrong").status_code == 401
assert login(client, username="guest", password="").status_code == 401
response = login(client)
assert response.status_code == 200 and response.json["user"] == "reader"
assert client.get("/api/stories").status_code == 200
client.post("/api/auth/logout", headers=H)
assert client.get("/api/stories").status_code == 401
def test_a_session_ends_after_one_day(client, cwa, monkeypatch):
response = client.post(
"/api/auth/login", json={"username": "reader", "password": "pw", "remember": True}, headers=H
)
assert response.status_code == 200
assert client.get("/api/stories").status_code == 200
later = int(time.time()) + 24 * 60 * 60 + 1
monkeypatch.setattr(TimestampSigner, "get_timestamp", lambda self: later)
assert client.get("/api/stories").status_code == 401
def test_cwa_login_when_cwa_is_down(client, cwa):
cwa.close()
assert login(client).json["error"] == "Cannot check the login with CWA. The log has the details."
def test_cwa_login_lockout_and_allowed_users(client, cwa, monkeypatch):
for _ in range(auth.MAX_FAILURES):
login(client, password="wrong")
assert login(client).json["error"].startswith("Too many wrong passwords")
monkeypatch.setattr(auth, "limiter", auth.LoginLimiter())
monkeypatch.setattr(config, "CWA_ALLOWED_USERS", ["someone-else"])
assert login(client).status_code == 401
monkeypatch.setattr(config, "CWA_ALLOWED_USERS", ["someone-else", "READER"])
assert login(client).status_code == 200
def test_wrong_passwords_lock_a_name_only_for_their_address(client, cwa):
for _ in range(auth.MAX_FAILURES):
login(client, password="wrong", address="10.0.0.9")
assert login(client, address="10.0.0.9").json["error"].startswith("Too many wrong passwords")
assert login(client, address="10.0.0.1").status_code == 200
def test_an_address_has_a_limit_for_all_names(client, cwa):
for n in range(auth.MAX_ADDRESS_FAILURES):
login(client, username=f"user{n}", password="wrong", address="10.0.0.9")
assert login(client, address="10.0.0.9").json["error"].startswith("Too many wrong passwords")
assert login(client, address="10.0.0.1").status_code == 200
def test_client_address(monkeypatch):
headers = {"X-Forwarded-For": "1.1.1.1, 10.0.0.9"}
assert auth.client_address(headers, "172.16.0.2") == "172.16.0.2"
monkeypatch.setattr(config, "TRUST_PROXY", True)
assert auth.client_address(headers, "172.16.0.2") == "10.0.0.9"
assert auth.client_address({}, "172.16.0.2") == "172.16.0.2"
def test_only_a_trusted_proxy_sets_the_client_address(client, cwa, monkeypatch):
# Without TRUST_PROXY, a client cannot escape the lock with a false header.
for n in range(auth.MAX_FAILURES):
login(client, password="wrong", address="10.0.0.9", forwarded=f"1.1.1.{n}")
assert login(client, address="10.0.0.9", forwarded="1.1.1.9").status_code == 401
# With TRUST_PROXY, clients behind the proxy have their own locks. The proxy adds the last entry.
monkeypatch.setattr(config, "TRUST_PROXY", True)
monkeypatch.setattr(auth, "limiter", auth.LoginLimiter())
for n in range(auth.MAX_FAILURES):
login(client, password="wrong", address="172.16.0.2", forwarded=f"1.1.1.{n}, 10.0.0.9")
assert login(client, address="172.16.0.2", forwarded="10.0.0.9").status_code == 401
assert login(client, address="172.16.0.2", forwarded="10.0.0.1").status_code == 200
def test_proxy_header(client, monkeypatch):
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
monkeypatch.setattr(config, "PROXY_AUTH_LOGOUT_URL", "https://auth.example.com/logout")
assert client.get("/api/stories").status_code == 401
assert client.get("/api/stories", headers={"X-Remote-User": "alice"}).status_code == 200
check = client.get("/api/auth/check", headers={"X-Remote-User": "alice"}).json
assert check == {
"authenticated": True,
"mode": "proxy",
"user": "alice",
"logout_url": "https://auth.example.com/logout",
}
@pytest.mark.parametrize(
("setting", "value", "error"),
[
("AUTH_METHOD", "cwa", "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"),
("AUTH_METHOD", "cwa-login", "AUTH_METHOD must be none, cwa or proxy, not 'cwa-login'"),
("PROXY_AUTH_USER_HEADER", "", "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"),
],
)
def test_incomplete_login_settings_refuse_every_request(client, monkeypatch, setting, value, error):
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
monkeypatch.setattr(config, "API_KEY", "k" * 32)
monkeypatch.setattr(config, setting, value)
message = f"{error}. Fix the setting and restart Bindery."
for response in (
client.get("/api/auth/check"),
client.get("/api/stories", headers={"X-Remote-User": "alice"}),
client.get("/api/stories", headers={"X-Api-Key": "k" * 32}),
client.post("/api/auth/login", json={"username": "a", "password": "b"}, headers=H),
):
assert (response.status_code, response.json) == (503, {"error": message})
assert client.get("/api/health").status_code == 200
def test_api_key(client, cwa, monkeypatch):
monkeypatch.setattr(config, "API_KEY", "k" * 32)
assert client.get("/api/stories", headers={"X-Api-Key": "wrong"}).status_code == 401
assert client.get("/api/stories", headers={"Authorization": "Bearer " + "k" * 32}).status_code == 200
# A key needs no CSRF header.
response = client.post("/api/stories", json={"urls": []}, headers={"X-Api-Key": "k" * 32})
assert response.status_code == 200
def test_epub_upload(client, monkeypatch, tmp_path):
calls = []
def queue(path, name, story_id, check):
calls.append((path.read_bytes(), name, story_id, check))
return {"id": "job"}
monkeypatch.setattr(orchestrator, "queue_epub_file", queue)
upload = {"file": (io.BytesIO(b"epub"), "dir/story.epub"), "check": "false"}
assert client.post("/api/stories/3/import-epub", data=upload, headers=H).json == {"id": "job"}
assert calls == [(b"epub", "story.epub", 3, False)]
upload = {"file": (io.BytesIO(b"epub"), "story.epub")}
assert client.post("/api/stories/import-epub", data=upload, headers=H).status_code == 200
assert calls[1][2:] == (None, True)
missing = client.post("/api/stories/import-epub", data={}, headers=H)
assert (missing.status_code, missing.json) == (400, {"error": "Choose an epub file"})
assert client.post("/api/stories/import-epub", data={}).status_code == 403
def test_the_cover_of_a_story(client):
store = orchestrator.store
story_id = store.create_story("https://www.royalroad.com/fiction/1", "www.royalroad.com", "T", "A", [])
assert client.get(f"/api/stories/{story_id}").json["story"]["cover"] is None
assert client.get(f"/api/stories/{story_id}/cover").status_code == 404
store.put_image(story_id, "https://example.com/cover.png", "png", "image/png", PNG, is_cover=True)
version = client.get(f"/api/stories/{story_id}").json["story"]["cover"]
response = client.get(f"/api/stories/{story_id}/cover?v={version}")
assert (response.status_code, response.mimetype, response.data) == (200, "image/png", PNG)
assert response.cache_control.max_age == 365 * 24 * 60 * 60
# A new cover gets a new version, so the browser loads it.
store.put_image(story_id, "https://example.com/new.jpg", "jpg", "image/jpeg", b"new", is_cover=True)
assert client.get(f"/api/stories/{story_id}").json["story"]["cover"] not in (None, version)
# An SVG can hold a script, so Bindery does not serve it.
store.put_image(story_id, "https://example.com/cover.svg", "svg", "image/svg+xml", b"<svg/>", is_cover=True)
assert client.get(f"/api/stories/{story_id}").json["story"]["cover"] is None
assert client.get(f"/api/stories/{story_id}/cover").status_code == 404
assert client.get("/api/stories/999/cover").status_code == 404
def test_rules_and_the_paragraph_setting(client):
story_id = orchestrator.store.create_story("https://www.royalroad.com/fiction/1", "www.royalroad.com", "T", "A", [])
chapter_id = orchestrator.store.add_chapter(story_id, "u1", "One", "<div>a<br>b</div>", "h", "new", 0)
saved = client.put("/api/rules", json={"rules": [], "paragraphs": True}, headers=H).json
assert saved == {"rules": [], "paragraphs": True, "global_paragraphs": True}
story = client.get(f"/api/stories/{story_id}/rules").json
assert (story["paragraphs"], story["global_paragraphs"]) == (None, True)
assert orchestrator._paragraphs(story_id)
client.put(f"/api/stories/{story_id}/rules", json={"rules": [], "paragraphs": False}, headers=H)
assert not orchestrator._paragraphs(story_id)
# Rules without the setting keep it.
client.put(f"/api/stories/{story_id}/rules", json={"rules": []}, headers=H)
assert client.get(f"/api/stories/{story_id}/rules").json["paragraphs"] is False
preview = client.post(
f"/api/stories/{story_id}/rules/preview",
json={"rules": [], "chapter_id": chapter_id, "source": True},
headers=H,
).json
assert preview["identical"] is False # follows the global setting
preview = client.post(
f"/api/stories/{story_id}/rules/preview",
json={"rules": [], "chapter_id": chapter_id, "paragraphs": False},
headers=H,
).json
assert preview["identical"] is True
def test_delete_revisions(client):
store = orchestrator.store
story_id = store.create_story("https://www.royalroad.com/fiction/1", "www.royalroad.com", "T", "A", [])
chapter_id = store.add_chapter(story_id, "u1", "One", "<p>one</p>", "h1", "new", 0)
store.add_revision(chapter_id, "<p>two</p>", "h2", "recheck")
store.add_revision(chapter_id, "<p>three</p>", "h3", "recheck")
response = client.delete(f"/api/chapters/{chapter_id}/revisions/3", headers=H)
assert response.status_code == 400 and "in the book" in response.json["error"]
assert [r["rev"] for r in client.delete(f"/api/chapters/{chapter_id}/revisions/2", headers=H).json] == [1, 3]
diff = client.get(f"/api/chapters/{chapter_id}/diff").json
assert (diff["a"], diff["b"], diff["identical"], diff["story_title"]) == (1, 3, False, "T")
assert client.delete(f"/api/chapters/{chapter_id}/revisions/2", headers=H).status_code == 404
# The newest revision stays, also when an older revision is in the book.
client.post(f"/api/chapters/{chapter_id}/revision", json={"rev": 1}, headers=H)
response = client.delete(f"/api/chapters/{chapter_id}/revisions/3", headers=H)
assert response.status_code == 400 and "newest text from the site" in response.json["error"]
assert [r["rev"] for r in client.get(f"/api/chapters/{chapter_id}/diff").json["revisions"]] == [1, 3]
def test_settings(client, monkeypatch, tmp_path):
ini = tmp_path / "personal.ini"
ini.write_text("[defaults]\nis_adult:true\n")
monkeypatch.setattr(config, "PERSONAL_INI", ini)
assert client.get("/api/settings").json == {
"recent_chapters": 0,
"hold_new_links": False,
"pause_uploads": False,
"personal_ini": "[defaults]\nis_adult:true\n",
}
saved = client.put("/api/settings", json={"recent_chapters": 3}, headers=H).json
assert (saved["recent_chapters"], saved["warnings"]) == (3, [])
saved = client.put("/api/settings", json={"hold_new_links": True, "pause_uploads": True}, headers=H).json
assert (saved["hold_new_links"], saved["pause_uploads"], saved["recent_chapters"]) == (True, True, 3)
saved = client.put("/api/settings", json={"personal_ini": "[www.notasite.com]\nx:1\n"}, headers=H).json
assert saved["warnings"] == ["Line 1: Bad Section Name: [www.notasite.com]"]
assert ini.read_text() == "[www.notasite.com]\nx:1\n"
bad = client.put("/api/settings", json={"personal_ini": "[defaults\nfoo"}, headers=H)
assert bad.status_code == 400 and "line 1" in bad.json["error"]
assert ini.read_text() == "[www.notasite.com]\nx:1\n"
assert client.put("/api/settings", json={"recent_chapters": -1}, headers=H).status_code == 400
def test_metadata_edit(client):
story_id = orchestrator.store.create_story(
"https://www.royalroad.com/fiction/1", "www.royalroad.com", "T", "A", ["title"]
)
response = client.put(f"/api/stories/{story_id}/metadata", json={"values": {"authors": "A & B"}}, headers=H)
assert response.json["story"]["pinned"] == {"authors": ["A", "B"]}
response = client.put(f"/api/stories/{story_id}/metadata", json={"values": {"title": ""}}, headers=H)
assert (response.status_code, response.json) == (400, {"error": "A story needs a title"})
assert client.put(f"/api/stories/{story_id}/metadata", json={}, headers=H).status_code == 400
assert client.put("/api/stories/999/metadata", json={"values": {}}, headers=H).status_code == 404
+140
View File
@@ -0,0 +1,140 @@
"""State-changing API flows, using a real store and queue without site requests."""
from unittest.mock import Mock
import pytest
from test_api import H
from test_api import client as client
from test_build import URL, chapters, metahtml
from bindery import config
from bindery import orchestrator as o
from bindery.cwa import Book, CwaError
from bindery.jobs import JobQueue
@pytest.fixture(autouse=True)
def isolated_queue(monkeypatch, tmp_path):
monkeypatch.setattr(o, "job_queue", JobQueue(tmp_path / "jobs.db"))
@pytest.fixture
def story(client):
sid = o.store.create_story("https://www.royalroad.com/fiction/1234", "www.royalroad.com", "Story", "Writer", [])
c = chapters()[0]
cid = o.store.add_chapter(sid, c["url"], c["title"], c["html"], "hash", "new", 0)
return sid, cid
def test_add_list_download_and_cancel_jobs(client, story):
sid, _ = story
response = client.post("/api/stories", json={"urls": f" {URL}\ninvalid\n"}, headers=H)
assert response.status_code == 200
queued = response.json["jobs"][0]
assert queued["story_id"] == sid
assert response.json["errors"] == ["Not a supported story URL: invalid"]
assert client.get("/api/stories").json[0]["busy"]
assert client.get(f"/api/jobs?story_id={sid}").json[0]["id"] == queued["id"]
assert client.get("/api/jobs?story_id=999").json == []
assert client.post(f"/api/jobs/{queued['id']}/cancel", headers=H).json == {"cancelled": True}
assert client.post(f"/api/jobs/{queued['id']}/cancel", headers=H).json == {"cancelled": False}
result = client.post(
f"/api/stories/{sid}/download", json={"mode": "metadata", "force_fields": ["title"]}, headers=H
)
assert result.status_code == 200
assert result.json["force_fields"] == ["title"]
assert result.json["mode"] == "metadata"
assert client.get("/api/config").json["metadata_fields"]["title"]
def test_settings_and_inclusion_persist_and_delete_cascades(client, story):
sid, cid = story
response = client.put(f"/api/stories/{sid}/settings", json={"auto_update": False, "recheck_days": 4}, headers=H)
assert response.status_code == 200
assert not response.json["auto_update"]
assert response.json["recheck_days"] == 4
for data in [{"recheck_days": -1}, {"locked_fields": ["unknown"]}]:
assert client.put(f"/api/stories/{sid}/settings", json=data, headers=H).status_code == 400
response = client.post(
f"/api/stories/{sid}/chapters", json={"chapter_ids": [str(cid)], "included": False}, headers=H
)
assert response.json["mode"] == "rebuild"
assert not o.store.get_chapter(cid)["included"]
o.store.set_rules(sid, [{"pattern": "a", "replacement": "b", "enabled": True}])
response = client.delete(f"/api/stories/{sid}", headers=H)
assert response.json == {"deleted": sid}
assert o.store.get_story(sid) is None
assert o.store.get_chapter(cid) is None
assert o.store.revision_html(cid, 1) is None
assert o.store.rules(sid) == []
def test_library_search_link_import_and_unlink(client, story, monkeypatch):
sid, _ = story
book = Book(7, "book-7", "Title", ["Writer"], "www.royalroad.com", {"epub": "/book.epub"})
cwa = Mock()
cwa.search.return_value = [book]
cwa.site_books.return_value = [book]
cwa.get_book.return_value = book.to_dict()
monkeypatch.setattr(o, "cwa", lambda: cwa)
monkeypatch.setattr(config, "CWA_URL", "https://cwa.example")
assert client.get("/api/library/candidates").json[0]["calibre_id"] == 7
assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] is None
cwa.search.assert_called_once_with("Title")
linked = client.post(f"/api/stories/{sid}/link", json={"uuid": "book-7"}, headers=H)
assert linked.json["calibre_id"] == 7
assert client.get("/api/library/candidates").json == []
assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] == "Story"
cwa.get_book.side_effect = CwaError("offline")
assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline"
cwa.get_book.side_effect = None
cwa.get_book.return_value = None
assert client.post(f"/api/stories/{sid}/link", json={"uuid": "missing"}, headers=H).status_code == 404
unlinked = client.post(f"/api/stories/{sid}/link", json={"uuid": None}, headers=H)
assert unlinked.json["calibre_id"] is None
assert unlinked.json["ingested_at"] is None
o.save_settings(hold_new_links=True)
assert client.get(f"/api/stories/{sid}").json["story"]["needs_approval"]
wrong = client.post(f"/api/stories/{sid}/approve", json={"uuid": "book-7"}, headers=H)
assert wrong.status_code == 400 and "changed" in wrong.json["error"]
approved = client.post(f"/api/stories/{sid}/approve", json={"uuid": None}, headers=H).json
assert (approved["story"]["needs_approval"], approved["job"]) == (False, None)
response = client.post("/api/library/import", json={"books": [{"id": 7}, {}, "invalid"], "check": False}, headers=H)
assert len(response.json) == 1
assert response.json[0]["title"] == "Library book 7"
assert response.json[0]["options"] == {"uuid": "", "check": False}
def test_invalid_rules_and_chapter_requests_do_not_change_stored_data(client, story):
sid, cid = story
assert client.put("/api/rules", json={"rules": [{"pattern": "["}]}, headers=H).json["index"] == 0
assert o.store.rules(None) == []
for data in [{"recent_chapters": "bad"}, {"recent_chapters": -1}]:
assert client.put("/api/settings", json=data, headers=H).status_code == 400
assert client.get("/api/chapters/999/diff").status_code == 404
assert client.get(f"/api/chapters/{cid}/diff?a=1&b=999").status_code == 404
assert client.post(f"/api/chapters/{cid}/revision", json={"rev": 999}, headers=H).status_code == 404
assert o.store.get_chapter(cid)["current_rev"] == 1
response = client.post(f"/api/stories/{sid + 1}/rules/preview", json={"chapter_id": cid, "rules": []}, headers=H)
assert response.status_code == 404
response = client.put("/api/rules", json={"rules": [], "paragraphs": None}, headers=H)
assert response.json["paragraphs"] is False
def test_download_builds_from_stored_chapters_without_changing_them(client, story):
sid, cid = story
o.store.update_story(sid, metahtml=metahtml(), title="A/B")
original = o.store.revision_html(cid, 1)
response = client.get(f"/api/stories/{sid}/epub")
assert response.status_code == 200
assert response.data.startswith(b"PK")
assert "filename=A_B.epub" in response.headers["Content-Disposition"]
assert o.store.revision_html(cid, 1) == original
response.close()
def test_unexpected_backend_error_returns_json(client, monkeypatch):
monkeypatch.setattr(o, "list_stories", Mock(side_effect=RuntimeError("broken")))
response = client.get("/api/stories")
assert response.status_code == 500
assert response.json == {"error": "RuntimeError: broken"}
+293
View File
@@ -0,0 +1,293 @@
"""Read-only Calibre-Web accounts and calibre book IDs, with independent fallbacks."""
import json
import sqlite3
from unittest.mock import Mock
import pytest
from test_api import H, login
from test_api import client as client
from test_deliver import STORY_URL, add_story, rebuild
from test_deliver import fake as fake
from test_deliver import story_epub as story_epub
from werkzeug.security import generate_password_hash
from bindery import auth, calibre_db, config, orchestrator
from bindery.cwa import CwaClient
from bindery.ingest import IngestFolder
@pytest.fixture
def user_db(tmp_path):
path = tmp_path / "users #1?.db"
with sqlite3.connect(path) as db:
db.execute("CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT, password TEXT, role INTEGER)")
db.execute(
"INSERT INTO user (name, password, role) VALUES (?, ?, 0)",
("reader", generate_password_hash("pw", method="pbkdf2:sha256:1000")),
)
return path
@pytest.fixture
def library_db(tmp_path):
path = tmp_path / "metadata.db"
with sqlite3.connect(path) as db:
db.executescript(
"CREATE TABLE books (id INTEGER PRIMARY KEY, uuid TEXT, title TEXT);"
"CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);"
"CREATE TABLE books_authors_link (id INTEGER PRIMARY KEY, book INTEGER, author INTEGER);"
"CREATE TABLE data (book INTEGER, format TEXT);"
"CREATE TABLE identifiers (book INTEGER, type TEXT COLLATE NOCASE, val TEXT COLLATE NOCASE);"
"INSERT INTO books VALUES (42, 'uuid-42', 'Library Title');"
"INSERT INTO authors VALUES (1, 'Writer');"
"INSERT INTO books_authors_link VALUES (1, 42, 1);"
"INSERT INTO data VALUES (42, 'EPUB');"
)
db.execute("INSERT INTO identifiers VALUES (42, 'url', ?)", (STORY_URL,))
return path
@pytest.fixture
def db_login(client, user_db, monkeypatch):
monkeypatch.setattr(config, "AUTH_METHOD", "cwa")
monkeypatch.setattr(config, "CWA_DB_PATH", user_db)
remote = Mock(spec=CwaClient)
remote.login_ok.return_value = True
monkeypatch.setattr(orchestrator, "cwa", lambda: remote)
return remote
@pytest.mark.parametrize("method", ["pbkdf2:sha256:1000", "scrypt:1024:8:1"])
def test_calibre_web_hashes_and_read_only_access(user_db, method):
with sqlite3.connect(user_db) as db:
db.execute("UPDATE user SET password = ?", (generate_password_hash("pw", method=method),))
before = user_db.read_bytes()
assert calibre_db.login_ok(user_db, "READER", "pw")
assert not calibre_db.login_ok(user_db, "reader", "wrong")
assert not calibre_db.login_ok(user_db, "reader", "")
assert not calibre_db.login_ok(user_db, "reader' OR 1=1 --", "pw")
assert not calibre_db.login_ok(user_db, "missing", "pw")
with pytest.raises(calibre_db.DatabaseUnavailable, match="readonly"):
with calibre_db._connect(user_db) as db:
db.execute("DELETE FROM user")
assert user_db.read_bytes() == before
@pytest.mark.parametrize("name,role,password", [("Guest", 0, "pw"), ("reader", 32, "pw"), ("reader", 0, "")])
def test_anonymous_or_passwordless_accounts_are_rejected(user_db, name, role, password):
with sqlite3.connect(user_db) as db:
db.execute("UPDATE user SET name = ?, role = ?, password = ?", (name, role, password))
assert not calibre_db.login_ok(user_db, name, "pw")
def test_ambiguous_user_names_are_rejected(user_db):
with sqlite3.connect(user_db) as db:
db.execute("INSERT INTO user (name, password, role) SELECT 'READER', password, role FROM user")
assert not calibre_db.login_ok(user_db, "reader", "pw")
def test_database_login_and_logout_work_without_cwa_url(client, db_login):
assert not config.CWA_URL
assert auth.config_error() is None
assert client.get("/api/stories").status_code == 401
assert login(client).status_code == 200
assert client.get("/api/auth/check").json["user"] == "reader"
assert client.get("/api/stories").status_code == 200
assert client.post("/api/auth/logout", headers=H).status_code == 200
assert client.get("/api/stories").status_code == 401
db_login.login_ok.assert_not_called()
@pytest.mark.parametrize("username,password", [("reader", "wrong"), ("missing", "pw"), ("Guest", "pw")])
def test_rejected_database_login_never_falls_back(client, db_login, username, password):
assert login(client, username=username, password=password).status_code == 401
db_login.login_ok.assert_not_called()
def test_database_login_keeps_allowlist_and_rate_limits(client, db_login, monkeypatch):
monkeypatch.setattr(config, "CWA_ALLOWED_USERS", ["someone-else"])
assert login(client).status_code == 401
monkeypatch.setattr(config, "CWA_ALLOWED_USERS", ["READER"])
for _ in range(auth.MAX_FAILURES - 1):
assert login(client, password="wrong").status_code == 401
assert "Too many wrong passwords" in login(client).json["error"]
assert login(client, address="10.0.0.2").status_code == 200
db_login.login_ok.assert_not_called()
@pytest.mark.parametrize("failure", ["missing", "corrupt", "schema", "hash", "role", "binary_hash", "locked"])
def test_unavailable_user_database_falls_back_to_opds(client, db_login, user_db, failure, monkeypatch):
lock = None
if failure == "missing":
user_db.unlink()
elif failure == "corrupt":
user_db.write_bytes(b"not a database")
elif failure == "schema":
with sqlite3.connect(user_db) as db:
db.execute("DROP TABLE user")
elif failure == "hash":
with sqlite3.connect(user_db) as db:
db.execute("UPDATE user SET password = 'unsupported$salt$hash'")
elif failure == "role":
with sqlite3.connect(user_db) as db:
db.execute("UPDATE user SET role = NULL")
elif failure == "binary_hash":
with sqlite3.connect(user_db) as db:
db.execute("UPDATE user SET password = ?", (b"not text",))
else:
lock = sqlite3.connect(user_db)
lock.execute("BEGIN EXCLUSIVE")
try:
# No metadata.db is needed for this fallback.
assert config.CALIBRE_DB_PATH is None
assert login(client).status_code == 200
db_login.login_ok.assert_called_once_with("reader", "pw")
if failure == "missing":
assert not user_db.exists()
finally:
if lock:
lock.close()
def test_failed_fallback_does_not_open_a_session(client, db_login, user_db):
user_db.unlink()
db_login.login_ok.return_value = False
assert login(client).status_code == 401
assert client.get("/api/stories").status_code == 401
def test_user_database_reads_live_wal_changes(user_db):
db = sqlite3.connect(user_db)
try:
db.execute("PRAGMA journal_mode=WAL")
db.execute("UPDATE user SET password = ?", (generate_password_hash("new", method="pbkdf2:sha256:1000"),))
db.commit()
assert not calibre_db.login_ok(user_db, "reader", "pw")
assert calibre_db.login_ok(user_db, "reader", "new")
db.execute("DELETE FROM user")
db.commit()
assert not calibre_db.login_ok(user_db, "reader", "new")
finally:
db.close()
def test_book_lookup_uses_uuid_or_exact_story_url(library_db):
before = library_db.read_bytes()
expected = {"id": 42, "uuid": "uuid-42", "title": "Library Title", "authors": ["Writer"], "formats": ["EPUB"]}
assert calibre_db.book_by_uuid(library_db, "uuid-42") == expected
assert calibre_db.book_by_url(library_db, STORY_URL) == expected
assert calibre_db.book_by_uuid(library_db, "missing") is None
assert calibre_db.book_by_uuid(library_db, "' OR 1=1 --") is None
assert calibre_db.book_by_url(library_db, STORY_URL.upper()) is None
assert library_db.read_bytes() == before
@pytest.mark.parametrize("duplicate", ["uuid", "url", "no_uuid"])
def test_uncertain_book_identity_does_not_choose_an_update_target(library_db, duplicate):
with sqlite3.connect(library_db) as db:
if duplicate == "no_uuid":
db.execute("UPDATE books SET uuid = NULL")
else:
db.execute(
"INSERT INTO books VALUES (43, ?, 'Duplicate')", ("uuid-42" if duplicate == "uuid" else "uuid-43",)
)
db.execute("INSERT INTO identifiers VALUES (43, 'url', ?)", (STORY_URL,))
assert calibre_db.book_by_url(library_db, STORY_URL) is None
if duplicate == "uuid":
assert calibre_db.book_by_uuid(library_db, "uuid-42") is None
def test_library_database_alone_does_not_enable_cwa_logins(library_db, monkeypatch):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
monkeypatch.setattr(config, "AUTH_METHOD", "cwa")
assert auth.config_error() == "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"
def test_url_lookup_is_disabled_without_a_library_database():
assert CwaClient("").book_by_url(STORY_URL) is None
def test_book_id_reads_do_not_use_app_db(library_db, monkeypatch):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
monkeypatch.setattr(config, "CWA_DB_PATH", library_db.parent / "missing-app.db")
client = CwaClient("")
client.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed"))
assert client.get_book("uuid-42")["id"] == 42
assert client.book_by_url(STORY_URL)["id"] == 42
assert client.get_book("../bad") is None
client.session.get.assert_not_called()
def test_app_db_login_does_not_use_metadata_db(client, db_login, monkeypatch, tmp_path):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "missing-metadata.db")
assert login(client).status_code == 200
assert not config.CALIBRE_DB_PATH.exists()
db_login.login_ok.assert_not_called()
@pytest.mark.parametrize("failure", ["missing", "corrupt", "no_match"])
def test_library_database_failure_keeps_the_http_lookup(library_db, monkeypatch, failure):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
if failure == "missing":
library_db.unlink()
elif failure == "corrupt":
library_db.write_bytes(b"not a database")
else:
with sqlite3.connect(library_db) as db:
db.execute("DELETE FROM books")
client = CwaClient("https://cwa.example")
response = Mock(status_code=200, text="record")
response.json.return_value = {"application_id": 7, "uuid": "uuid-42", "title": "From CWA"}
client.session.get = Mock(return_value=response)
assert client.get_book("uuid-42")["id"] == 7
assert client.book_by_url(STORY_URL) is None
assert client.session.get.call_args.args[0] == "https://cwa.example/ajax/book/uuid-42"
if failure == "missing":
assert not library_db.exists()
@pytest.mark.parametrize("saved_id,saved_uuid", [(None, None), (1, "uuid-42"), (42, None)])
def test_database_book_id_reaches_the_epub_sidecar(client, library_db, monkeypatch, tmp_path, saved_id, saved_uuid):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
monkeypatch.setattr(orchestrator, "ingest_folder", IngestFolder(tmp_path / "ingest"))
remote = CwaClient("")
remote.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed"))
monkeypatch.setattr(orchestrator, "cwa", lambda: remote)
story_id = add_story(ingested_at="2026-01-01", calibre_id=saved_id, calibre_uuid=saved_uuid)
assert "library book 42" in rebuild(story_id)
story = orchestrator.store.get_story(story_id)
assert (story["calibre_id"], story["calibre_uuid"]) == (42, "uuid-42")
assert client.get(f"/api/stories/{story_id}").json["book"]["id"] == 42
manifest = next((tmp_path / "ingest").glob("*.cwa.json"))
assert json.loads(manifest.read_text()) == {"action": "add_format", "book_id": 42}
remote.session.get.assert_not_called()
def test_metadata_database_miss_falls_back_to_opds(fake, library_db, monkeypatch):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
with sqlite3.connect(library_db) as db:
db.execute("DELETE FROM books")
story_id = add_story(ingested_at="2026-01-01")
assert "library book 1" in rebuild(story_id)
assert orchestrator.store.get_story(story_id)["calibre_uuid"] == "uuid-1"
def test_user_database_fallback_does_not_disable_library_database(client, db_login, user_db, library_db, monkeypatch):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
user_db.unlink()
assert login(client).status_code == 200
db_login.login_ok.assert_called_once_with("reader", "pw")
assert CwaClient("").get_book("uuid-42")["id"] == 42
def test_database_match_does_not_take_another_storys_link(client, library_db, monkeypatch):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
other = add_story(calibre_id=42, calibre_uuid="uuid-42")
story_id = orchestrator.store.create_story(STORY_URL + "/other", "site", "Story", "Writer", [])
story = orchestrator.store.get_story(story_id)
with sqlite3.connect(library_db) as db:
db.execute("INSERT INTO identifiers VALUES (42, 'url', ?)", (story["url"],))
assert orchestrator._linked_book(story) is None
assert orchestrator.store.get_story(other)["calibre_id"] == 42
assert orchestrator.store.get_story(story_id)["calibre_id"] is None