From 09d6b76a170401f3af8b97f9e40009347e52834c Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sat, 26 Sep 2026 08:06:02 +0200 Subject: [PATCH] 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. --- bindery/__main__.py | 7 + bindery/auth.py | 167 ++++++++++++++++ bindery/web.py | 402 +++++++++++++++++++++++++++++++++++++++ tests/test_api.py | 311 ++++++++++++++++++++++++++++++ tests/test_api_flows.py | 140 ++++++++++++++ tests/test_calibre_db.py | 293 ++++++++++++++++++++++++++++ 6 files changed, 1320 insertions(+) create mode 100644 bindery/__main__.py create mode 100644 bindery/auth.py create mode 100644 bindery/web.py create mode 100644 tests/test_api.py create mode 100644 tests/test_api_flows.py create mode 100644 tests/test_calibre_db.py diff --git a/bindery/__main__.py b/bindery/__main__.py new file mode 100644 index 0000000..a6ec7b0 --- /dev/null +++ b/bindery/__main__.py @@ -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) diff --git a/bindery/auth.py b/bindery/auth.py new file mode 100644 index 0000000..48a468f --- /dev/null +++ b/bindery/auth.py @@ -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") diff --git a/bindery/web.py b/bindery/web.py new file mode 100644 index 0000000..925a300 --- /dev/null +++ b/bindery/web.py @@ -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//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//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/") +def story(story_id: int) -> Any: + return orchestrator.story_details(story_id) + + +@app.delete("/api/stories/") +def delete_story(story_id: int) -> Any: + orchestrator.delete_story(story_id) + return {"deleted": story_id} + + +@app.post("/api/stories//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//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//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//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//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//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//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//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//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//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//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//revision") +def chapter_revision(chapter_id: int) -> Any: + return orchestrator.set_chapter_revision(chapter_id, int(_json().get("rev", 0))) + + +@app.delete("/api/chapters//revisions/") +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() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..6a29718 --- /dev/null +++ b/tests/test_api.py @@ -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"", 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", "
a
b
", "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", "

one

", "h1", "new", 0) + store.add_revision(chapter_id, "

two

", "h2", "recheck") + store.add_revision(chapter_id, "

three

", "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 diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py new file mode 100644 index 0000000..7e45b6f --- /dev/null +++ b/tests/test_api_flows.py @@ -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"} diff --git a/tests/test_calibre_db.py b/tests/test_calibre_db.py new file mode 100644 index 0000000..9f6fa76 --- /dev/null +++ b/tests/test_calibre_db.py @@ -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