feat: add authenticated HTTP service

This commit is contained in:
Lucas Winther committed 2026-09-26 11:15:32 +02:00
1 parent 57e5215a36
commit ad192365a1
31 files changed
+768 -21

No files matched your search

+10 -2
View File
@@ -5,8 +5,9 @@ How `recoll-modern` is built today. Update in the same commit as any structural
## Overview
The Python service scaffold, SQLite schema, collection and membership repositories, document
discovery/identity modules, durable job worker, engine adapter, and authentication service are built.
The HTTP API remains intended structure. Recoll behaviour below was verified against a real container.
discovery/identity modules, durable job worker, engine adapter, authentication service, and initial
HTTP shell are built. Membership, search and job resource routes remain to be added. Recoll behaviour
below was verified against a real container.
The goal: a self-hosted service that wraps Recoll's indexing and full-text search behind (a) a modern
web UI and (b) an MCP server, running in Docker, with several independent collections such as
@@ -30,6 +31,7 @@ web UI and (b) an MCP server, running in Docker, with several independent collec
| `helper/` | C reader run by Recoll's `metadatacmds` for each indexed file |
| `src/recoll_modern/engine/` | Shared Protocol, deterministic fake, and isolated Recoll Python/indexer adapter |
| `src/recoll_modern/domain/auth.py`, `storage/auth.py` | Argon2 password checks, seeded administrator, hashed server-side sessions and CSRF verification |
| `src/recoll_modern/api/` | HTTP auth, health, file browsing, collection metadata, prefix/proxy handling and JSON errors |
| `tests/` | Automated service tests |
Discovery produces content hashes and root-relative paths without writing to the document volume.
@@ -96,6 +98,12 @@ contract in `docs/specs/001-search-engine-api.md`.
### HTTP boundary
The app lifespan creates state, migrates SQLite, seeds the first administrator, exports the initial
membership map, recovers interrupted jobs, reconciles persistent rules, and starts/stops one worker.
Core routes use a server-side session cookie and CSRF token; `GET /auth/session` rotates the token so
the database only stores hashes. Forwarded scheme, host and prefix are applied only for configured
proxy networks.
The resource groups are auth, health, read-only file browsing, collections and membership sources,
directory rules, scoped search, document text/original retrieval, and indexing jobs. Search callers
select returned fields; only `document_id` is unconditional, and inline full text is bounded. Every
+12
View File
@@ -289,3 +289,15 @@ is Xapian's raw weight.
undocumented Recoll binding attribute for score.
**Consequences:** unusual filenames do not enter a query parser. Clients can compare score order within
one query, but the value is a rank proxy. Revisit this once the real Recoll container can be tested.
---
## 2026-09-26 — Rotate CSRF tokens on session reads
**Status:** accepted
**Context:** sessions store only SHA-256 token digests, but `GET /auth/session` must return a usable CSRF
token after a page reload. The raw token cannot be recovered from SQLite.
**Decision:** authenticate the session, replace its stored CSRF digest, and return a newly generated
token. The previous token becomes invalid immediately.
**Alternatives:** store the raw CSRF token; derive a repeatable token from the cookie and a server key.
**Consequences:** clients should refresh their CSRF token after calling the session endpoint. Session
storage contains no raw cookie or CSRF token.
+11 -6
View File
@@ -15,12 +15,12 @@ Where the workflow stands (`AGENTS.md` -> Workflow: brainstorm -> spec -> plan -
selectable search fields, subpath hosting and the packaging seam for deferred desktop spec 005.
- Plan `docs/plans/001-search-engine-api.md`: **approved 2026-09-18.** Its listed Python runtime and
development dependencies are approved. Execution follows the plan task by task.
- Plan Tasks 1–3 are complete and independently reviewed. Tasks 4–9 add discovery, content-hash
identity, additive membership, durable jobs, the atomic membership map, the engine boundary, and
session authentication. Their checkboxes are current in the plan. The next task is the HTTP shell.
- Plan Tasks 1–3 are complete and independently reviewed. Tasks 4–10 add discovery, content-hash
identity, additive membership, durable jobs, the atomic membership map, the engine boundary,
session authentication and the HTTP shell. Their checkboxes are current in the plan. The next task
is membership, rule and job routes.
The next action is Task 10, "Build the FastAPI shell, middleware, and core resources." Do not repeat
Tasks 1–9.
The next action is Task 11, "Expose membership, rule, and job APIs." Do not repeat Tasks 1–10.
Things a resuming agent will otherwise get wrong:
@@ -56,7 +56,8 @@ Things a resuming agent will otherwise get wrong:
extraction cost should dwarf the helper, but that is an assumption until measured.
- [ ] Promote a new canonical path and force a reindex when a canonical path disappears, or the
document silently leaves every collection it is in.
- [ ] Recover interrupted indexing jobs at startup; they do not survive a process restart on their own.
- [x] Recover interrupted indexing jobs at startup; Task 6 requeues valid running jobs and fails
unreadable requests.
- [ ] Write `PRODUCT.md` properly via `/impeccable init` — the current file is a stub from the initial
brief, not an interview. Blocks spec 002.
@@ -68,6 +69,10 @@ Things a resuming agent will otherwise get wrong:
paths exist, and fail if `AGENTS.md` exceeds 150 lines.
## Known issues
- The API test suite reports deprecation warnings from the installed FastAPI/Starlette TestClient
dependency. Replace its client integration when the dependency stack supports the successor API.
- Directory sources currently require a named child of the document root; decide whether the root
itself should be accepted as a persistent rule before finishing membership endpoints.
- Task 3's repository ID test checks the generated collection ID prefix and length rather than asserting
the full `^c[0-9a-f]{10}$` regex. The implementation uses `secrets.token_hex(5)` and is correct; add
the stronger regression assertion when work resumes.
+6 -6
View File
@@ -719,7 +719,7 @@ git commit -m "feat: add session authentication"
- Consumes: `Settings`, repositories, `AuthService`, `Engine`, `JobWorker`
- Produces: `create_app(settings: Settings | None = None, engine: Engine | None = None) -> FastAPI`
- [ ] **Step 1: Write failing API contract tests**
- [x] **Step 1: Write failing API contract tests**
```python
def test_error_envelope(client: TestClient) -> None:
@@ -741,18 +741,18 @@ def test_untrusted_forwarded_prefix_is_ignored(client: TestClient) -> None:
Add tests for every auth route, CSRF on unsafe methods, strict same-origin login checks when `Origin` is present, `Secure` production cookies, request IDs, health 200/503, path traversal, one-level file browsing, list pagination, CRUD, confirmation, and no job on rename/empty creation.
- [ ] **Step 2: Run API tests and observe failure**
- [x] **Step 2: Run API tests and observe failure**
Run: `uv run pytest tests/api/test_auth.py tests/api/test_health.py tests/api/test_files.py tests/api/test_collections.py tests/api/test_errors.py tests/api/test_root_path.py -v`
- [ ] **Step 3: Implement lifespan and dependency wiring**
- [x] **Step 3: Implement lifespan and dependency wiring**
The lifespan creates state directories, migrates the database, seeds/refuses auth, recovers jobs,
enqueues one reconciliation job for all persistent rules, starts the worker, and on exit calls
`await worker.stop()`. Dependency callables read services from `app.state`. Tests inject a fake engine
and temporary settings; production constructs `RecollEngine` only through the engine factory.
- [ ] **Step 4: Implement middleware and error mapping**
- [x] **Step 4: Implement middleware and error mapping**
Map every `AppError` code to the spec status and envelope. Add an opaque request ID to unexpected 500
responses and logs. Prefix middleware strips the configured prefix from incoming paths before routing,
@@ -761,11 +761,11 @@ configured proxy network, it may instead validate and apply forwarded scheme/hos
forwarded prefix only when it is present on the incoming path. Ignore all such headers from other
clients. Do not enable permissive CORS.
- [ ] **Step 5: Implement auth, health, files, and collections routes**
- [x] **Step 5: Implement auth, health, files, and collections routes**
Use JSON login. Set cookie `recoll_modern_session` as HttpOnly, SameSite=Lax, root-path scoped, and Secure outside explicit development mode. Return CSRF token in the login/session JSON and require `X-CSRF-Token` on POST/PATCH/DELETE. `GET /files` returns name, relative path, kind, size, and mtime for one sorted directory level. Collection create with no sources returns 201; with sources delegates to Task 11 and returns 202. Deletion validates exact confirmation, captures affected paths, deletes database state, and enqueues cleanup.
- [ ] **Step 6: Verify and commit**
- [x] **Step 6: Verify and commit**
Run: `uv run pytest tests/api -v && uv run ruff check . && uv run mypy src tests`
+1
View File
@@ -0,0 +1 @@
"""HTTP routes and transport adapters."""
+45
View File
@@ -0,0 +1,45 @@
"""Login, session refresh, and logout routes."""
from fastapi import APIRouter, Request, Response
from pydantic import BaseModel
from recoll_modern.api.dependencies import COOKIE_NAME, auth_service, require_user
from recoll_modern.domain.errors import AppError
router = APIRouter()
class LoginBody(BaseModel):
username: str
password: str
@router.post("/auth/login")
def login(body: LoginBody, request: Request, response: Response) -> dict[str, str]:
origin = request.headers.get("origin")
expected = f"{request.url.scheme}://{request.url.netloc}"
if origin is not None and origin != expected:
raise AppError("csrf_failed", "Origin is invalid", 403)
credentials = auth_service(request).login(body.username, body.password)
settings = request.app.state.settings
response.set_cookie(
COOKIE_NAME, credentials.cookie_token, httponly=True, samesite="lax",
secure=not settings.development_mode,
path=settings.root_path,
expires=credentials.expires_at,
)
return {"csrf_token": credentials.csrf_token}
@router.get("/auth/session")
def session(request: Request) -> dict[str, str]:
user = require_user(request)
token = request.cookies[COOKIE_NAME]
return {"username": user.username, "csrf_token": auth_service(request).rotate_csrf(token)}
@router.post("/auth/logout", status_code=204)
def logout(request: Request, response: Response) -> None:
require_user(request)
auth_service(request).logout(request.cookies.get(COOKIE_NAME))
response.delete_cookie(COOKIE_NAME, path=request.app.state.settings.root_path)
+82
View File
@@ -0,0 +1,82 @@
"""Collection metadata HTTP routes."""
from dataclasses import asdict
from typing import cast
from fastapi import APIRouter, Request, Response
from fastapi.encoders import jsonable_encoder
from recoll_modern.api.dependencies import require_user
from recoll_modern.api.schemas import CollectionCreateBody, CollectionPatchBody
from recoll_modern.domain.errors import AppError
from recoll_modern.domain.models import UNSET, CollectionCreate, CollectionPatch
router = APIRouter()
@router.get("/collections")
def list_collections(request: Request, limit: int = 50, offset: int = 0) -> dict[str, object]:
require_user(request)
if not 1 <= limit <= 200 or offset < 0:
raise AppError("validation_failed", "Invalid pagination", 422)
collections, total = request.app.state.collections.list(limit, offset)
return {"items": jsonable_encoder([asdict(item) for item in collections]), "total": total}
@router.get("/collections/{collection_id}")
def get_collection(request: Request, collection_id: str) -> dict[str, object]:
require_user(request)
return cast(
dict[str, object],
jsonable_encoder(asdict(request.app.state.collections.get(collection_id))),
)
@router.post("/collections")
def create_collection(
body: CollectionCreateBody, request: Request, response: Response
) -> dict[str, object]:
require_user(request)
collection = request.app.state.collections.create(
CollectionCreate(body.name, body.description)
)
payload = jsonable_encoder(asdict(collection))
if body.sources:
job = request.app.state.jobs.enqueue(
"membership_add",
{"collection_id": collection.id, "sources": body.sources},
body.fail_on_file_error,
)
request.app.state.worker.notify()
response.status_code = 202
return {"collection": payload, "job_id": job.id}
response.status_code = 201
return cast(dict[str, object], payload)
@router.patch("/collections/{collection_id}")
def patch_collection(
body: CollectionPatchBody, request: Request, collection_id: str
) -> dict[str, object]:
require_user(request)
description = body.description if "description" in body.model_fields_set else UNSET
changed = request.app.state.collections.patch(
collection_id, CollectionPatch(body.name, description)
)
return cast(dict[str, object], jsonable_encoder(asdict(changed)))
@router.delete("/collections/{collection_id}", status_code=202)
def delete_collection(
request: Request, collection_id: str, confirmation: str = ""
) -> dict[str, str]:
require_user(request)
collection = request.app.state.collections.get(collection_id)
if confirmation != collection.name:
raise AppError("confirmation_required", "Collection name confirmation is required", 409)
paths = request.app.state.collections.delete(collection_id)
job = request.app.state.jobs.enqueue(
"collection_cleanup", {"paths": paths}, False
)
request.app.state.worker.notify()
return {"job_id": job.id}
+21
View File
@@ -0,0 +1,21 @@
"""Read application services and enforce cookie sessions."""
from typing import cast
from fastapi import Request
from recoll_modern.domain.auth import AuthenticatedUser, AuthService
COOKIE_NAME = "recoll_modern_session"
def auth_service(request: Request) -> AuthService:
return cast(AuthService, request.app.state.auth)
def require_user(request: Request) -> AuthenticatedUser:
return auth_service(request).authenticate(
request.cookies.get(COOKIE_NAME),
csrf_token=request.headers.get("X-CSRF-Token"),
unsafe=request.method not in ("GET", "HEAD", "OPTIONS"),
)
+51
View File
@@ -0,0 +1,51 @@
"""Stable JSON error envelopes for expected and unexpected failures."""
import logging
import secrets
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from recoll_modern.domain.errors import AppError
_LOG = logging.getLogger(__name__)
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(StarletteHTTPException)
async def http_error(_request: Request, error: StarletteHTTPException) -> JSONResponse:
code = "path_not_found" if error.status_code == 404 else "invalid_query"
message = "Path was not found" if error.status_code == 404 else "Request is invalid"
return JSONResponse(
{"error": {"code": code, "message": message, "details": {}}},
status_code=error.status_code,
)
@app.exception_handler(AppError)
async def app_error(_request: Request, error: AppError) -> JSONResponse:
return JSONResponse(
{"error": {"code": error.code, "message": error.message,
"details": dict(error.details)}},
status_code=error.status,
)
@app.exception_handler(RequestValidationError)
async def validation_error(_request: Request, _error: RequestValidationError) -> JSONResponse:
return JSONResponse(
{"error": {"code": "validation_failed", "message": "Request validation failed",
"details": {}}},
status_code=422,
)
@app.exception_handler(Exception)
async def unexpected_error(_request: Request, error: Exception) -> JSONResponse:
request_id = secrets.token_hex(8)
_LOG.exception("Unhandled request error %s", request_id, exc_info=error)
return JSONResponse(
{"error": {"code": "internal_error", "message": "Internal server error",
"details": {"request_id": request_id}}},
status_code=500,
headers={"X-Request-ID": request_id},
)
+43
View File
@@ -0,0 +1,43 @@
"""Read-only single-directory browsing beneath the configured document root."""
import os
from pathlib import Path
from fastapi import APIRouter, Request
from recoll_modern.api.dependencies import require_user
from recoll_modern.domain.errors import AppError
from recoll_modern.indexing.paths import PathPolicyError
router = APIRouter()
@router.get("/files")
def list_files(request: Request, path: str = "") -> dict[str, object]:
require_user(request)
policy = request.app.state.path_policy
try:
directory = policy.root if path == "" else policy.resolve_directory(path)
except PathPolicyError:
raise AppError("path_outside_root", "Path is outside document root", 422) from None
if not directory.is_dir():
raise AppError("path_not_found", "Path was not found", 404)
items: list[dict[str, object]] = []
try:
with os.scandir(directory) as entries:
for entry in entries:
candidate = Path(entry.path)
if entry.is_symlink() and not candidate.resolve().is_relative_to(policy.root):
continue
stat = entry.stat(follow_symlinks=False)
relative = candidate.relative_to(policy.root).as_posix()
items.append({
"name": entry.name,
"path": relative,
"kind": "directory" if entry.is_dir(follow_symlinks=False) else "file",
"size": stat.st_size,
"mtime": stat.st_mtime,
})
except OSError:
raise AppError("path_not_found", "Path was not found", 404) from None
return {"path": path, "items": sorted(items, key=lambda item: str(item["name"]))}
+14
View File
@@ -0,0 +1,14 @@
"""Public readiness probe."""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/health")
def health(request: Request) -> JSONResponse:
status = request.app.state.engine.health()
if not status.available:
return JSONResponse({"status": "unavailable"}, status_code=503)
return JSONResponse({"status": "ready"})
+59
View File
@@ -0,0 +1,59 @@
"""Mount-prefix routing and conservative proxy-header handling."""
import ipaddress
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from recoll_modern.config import Settings
class PrefixMiddleware:
def __init__(self, app: ASGIApp, settings: Settings) -> None:
self.app = app
self.settings = settings
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
scope = dict(scope)
prefix = self.settings.root_path
client = scope.get("client")
try:
trusted = client is not None and any(
ipaddress.ip_address(client[0]) in network
for network in self.settings.trusted_proxies
)
except ValueError:
trusted = False
if trusted:
headers = {name.lower(): value for name, value in scope["headers"]}
forwarded_prefix = headers.get(b"x-forwarded-prefix", b"").decode("ascii", "ignore")
if (
forwarded_prefix.startswith("/")
and not forwarded_prefix.endswith("/")
and ".." not in forwarded_prefix
):
prefix = forwarded_prefix
scheme = headers.get(b"x-forwarded-proto", b"").decode("ascii", "ignore")
if scheme in ("http", "https"):
scope["scheme"] = scheme
host = headers.get(b"x-forwarded-host", b"")
if host and b"/" not in host and b"\r" not in host and b"\n" not in host:
scope["headers"] = [
(name, value) for name, value in scope["headers"] if name.lower() != b"host"
] + [(b"host", host)]
path = scope["path"]
if prefix != "/":
if path != prefix and not path.startswith(prefix + "/") and not trusted:
response = JSONResponse(
{"error": {"code": "path_not_found", "message": "Path was not found",
"details": {}}}, status_code=404
)
await response(scope, receive, send)
return
if path == prefix or path.startswith(prefix + "/"):
scope["path"] = path[len(prefix):] or "/"
scope["root_path"] = prefix
await self.app(scope, receive, send)
+15
View File
@@ -0,0 +1,15 @@
"""Small HTTP request bodies for collection metadata."""
from pydantic import BaseModel
class CollectionCreateBody(BaseModel):
name: str
description: str | None = None
sources: list[dict[str, object]] | None = None
fail_on_file_error: bool = False
class CollectionPatchBody(BaseModel):
name: str | None = None
description: str | None = None
+98 -7
View File
@@ -1,16 +1,107 @@
"""FastAPI application factory."""
"""FastAPI application factory and durable-service lifecycle."""
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from datetime import timedelta
from fastapi import FastAPI
from recoll_modern.api import auth, collections, files, health
from recoll_modern.api.errors import install_error_handlers
from recoll_modern.api.middleware import PrefixMiddleware
from recoll_modern.config import Settings
from recoll_modern.domain.auth import AuthService
from recoll_modern.domain.collections import CollectionService
from recoll_modern.domain.jobs import Job
from recoll_modern.engine.protocol import Engine
from recoll_modern.engine.recoll import RecollEngine
from recoll_modern.indexing.handlers import MembershipJobHandler
from recoll_modern.indexing.map_export import MembershipMapRows, export_membership_map
from recoll_modern.indexing.paths import PathPolicy
from recoll_modern.indexing.reconcile import reconcile_rule
from recoll_modern.indexing.worker import JobWorker
from recoll_modern.storage.auth import SQLiteAuthRepository
from recoll_modern.storage.collections import SQLiteCollectionRepository
from recoll_modern.storage.database import Database
from recoll_modern.storage.documents import DocumentRepository
from recoll_modern.storage.jobs import JobRepository
from recoll_modern.storage.membership import MembershipRepository
def create_app(settings: Settings) -> FastAPI:
"""Create the HTTP application with its configured mount prefix."""
app = FastAPI(root_path=settings.root_path)
def create_app(settings: Settings | None = None, engine: Engine | None = None) -> FastAPI:
"""Wire config, storage, indexing, and HTTP routes in one process boundary."""
settings = settings or Settings.from_env()
engine = engine or RecollEngine(settings.recoll_config_dir)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "starting"}
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings.state_root.mkdir(parents=True, exist_ok=True)
database = Database(settings.database_path)
database.migrate()
auth_service = AuthService(
SQLiteAuthRepository(database),
lifetime=timedelta(hours=settings.session_lifetime_hours),
)
auth_service.seed_initial_admin(
os.environ.get("RECOLL_MODERN_ADMIN_USERNAME"),
os.environ.get("RECOLL_MODERN_ADMIN_PASSWORD"),
)
policy = PathPolicy(settings.document_root)
memberships = MembershipRepository(database)
documents = DocumentRepository(database)
jobs = JobRepository(database)
map_rows = MembershipMapRows(memberships, policy.root)
export_membership_map(map_rows, settings.membership_map_path)
def cleanup(job: Job) -> None:
export_membership_map(map_rows, settings.membership_map_path)
paths = job.request.get("paths", [])
if isinstance(paths, list):
absolute = tuple(str(policy.root / path) for path in paths if isinstance(path, str))
if absolute:
engine.force_reindex(absolute)
def reconcile_all(_job: Job) -> None:
changed: set[str] = set()
for rule in memberships.list_rules():
result = reconcile_rule(rule.id, policy, documents, memberships)
changed.update(result.changed_paths)
export_membership_map(map_rows, settings.membership_map_path)
if changed:
engine.force_reindex(tuple(str(policy.root / path) for path in sorted(changed)))
membership_handler = MembershipJobHandler(
policy, documents, memberships, jobs, settings.membership_map_path, engine
)
worker = JobWorker(jobs, {
"membership_add": membership_handler.handle,
"collection_cleanup": cleanup,
"reconcile_rules": reconcile_all,
})
app.state.settings = settings
app.state.database = database
app.state.auth = auth_service
app.state.engine = engine
app.state.path_policy = policy
app.state.collections = CollectionService(SQLiteCollectionRepository(database))
app.state.memberships = memberships
app.state.documents = documents
app.state.jobs = jobs
app.state.worker = worker
if memberships.list_rules():
jobs.enqueue("reconcile_rules", {}, False)
worker.start()
try:
yield
finally:
await worker.stop()
app = FastAPI(root_path=settings.root_path, lifespan=lifespan)
app.add_middleware(PrefixMiddleware, settings=settings)
install_error_handlers(app)
app.include_router(auth.router)
app.include_router(health.router)
app.include_router(files.router)
app.include_router(collections.router)
return app
+3
View File
@@ -21,6 +21,7 @@ class Settings:
inline_text_limit: int
inline_text_limit_max: int
session_lifetime_hours: int
development_mode: bool
@property
def database_path(self) -> Path:
@@ -70,4 +71,6 @@ class Settings:
inline_text_limit=text_default,
inline_text_limit_max=text_max,
session_lifetime_hours=session_hours,
development_mode=environ.get("RECOLL_MODERN_DEVELOPMENT_MODE", "false").lower()
== "true",
)
+9
View File
@@ -65,6 +65,8 @@ class AuthRepository(Protocol):
def touch_session(self, cookie_token: str, now: datetime) -> None: ...
def update_csrf(self, cookie_token: str, csrf_token: str) -> None: ...
def normalize_username(username: str) -> tuple[str, str]:
display = normalize("NFC", username.strip())
@@ -142,6 +144,13 @@ class AuthService:
if cookie_token:
self._repository.delete_session(cookie_token)
def rotate_csrf(self, cookie_token: str) -> str:
"""Issue a fresh readable CSRF token for an authenticated session."""
self.authenticate(cookie_token)
csrf_token = secrets.token_urlsafe(32)
self._repository.update_csrf(cookie_token, csrf_token)
return csrf_token
def _invalid_credentials() -> AppError:
return AppError("invalid_credentials", "Invalid username or password", 401)
+7
View File
@@ -115,6 +115,13 @@ class SQLiteAuthRepository:
(now.isoformat(), _hash_token(cookie_token)),
)
def update_csrf(self, cookie_token: str, csrf_token: str) -> None:
with self._database.transaction() as connection:
connection.execute(
"UPDATE sessions SET csrf_hash = ? WHERE token_hash = ?",
(_hash_token(csrf_token), _hash_token(cookie_token)),
)
def cleanup_expired(self, now: datetime) -> int:
with self._database.transaction() as connection:
cursor = connection.execute(
+8
View File
@@ -64,6 +64,14 @@ class MembershipRepository:
)
return DirectoryRule(str(row[0]), str(row[1]), str(row[2]), exclusions)
def list_rules(self) -> list[DirectoryRule]:
with self._database.connect() as connection:
rule_ids = [
str(row[0])
for row in connection.execute("SELECT id FROM directory_rules ORDER BY id")
]
return [rule for rule_id in rule_ids if (rule := self.get_rule(rule_id)) is not None]
def patch_rule(self, rule_id: str, excluded_directories: tuple[str, ...]) -> None:
with self._database.transaction() as connection:
connection.execute(
+1
View File
@@ -0,0 +1 @@
"""HTTP contract tests."""
+47
View File
@@ -0,0 +1,47 @@
from collections.abc import Generator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from recoll_modern.app import create_app
from recoll_modern.config import Settings
from recoll_modern.engine.fake import FakeEngine
@pytest.fixture
def app_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient]:
monkeypatch.setenv("RECOLL_MODERN_ADMIN_USERNAME", "admin")
monkeypatch.setenv("RECOLL_MODERN_ADMIN_PASSWORD", "secret-password")
(tmp_path / "docs").mkdir()
settings = Settings.from_env({
"RECOLL_MODERN_DOCUMENT_ROOT": str(tmp_path / "docs"),
"RECOLL_MODERN_STATE_ROOT": str(tmp_path / "state"),
"RECOLL_MODERN_RECOLL_CONFIG_DIR": str(tmp_path / "recoll"),
})
with TestClient(create_app(settings, FakeEngine([])), base_url="https://testserver") as client:
yield client
@pytest.fixture
def prefixed_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[TestClient]:
monkeypatch.setenv("RECOLL_MODERN_ADMIN_USERNAME", "admin")
monkeypatch.setenv("RECOLL_MODERN_ADMIN_PASSWORD", "secret-password")
(tmp_path / "docs").mkdir()
settings = Settings.from_env({
"RECOLL_MODERN_DOCUMENT_ROOT": str(tmp_path / "docs"),
"RECOLL_MODERN_STATE_ROOT": str(tmp_path / "state"),
"RECOLL_MODERN_RECOLL_CONFIG_DIR": str(tmp_path / "recoll"),
"RECOLL_MODERN_ROOT_PATH": "/recoll",
})
with TestClient(create_app(settings, FakeEngine([])), base_url="https://testserver") as client:
yield client
@pytest.fixture
def authenticated(app_client: TestClient) -> tuple[TestClient, str]:
response = app_client.post(
"/auth/login", json={"username": "admin", "password": "secret-password"}
)
assert response.status_code == 200
return app_client, response.json()["csrf_token"]
+46
View File
@@ -0,0 +1,46 @@
from fastapi.testclient import TestClient
def test_login_sets_scoped_secure_cookie(app_client: TestClient) -> None:
response = app_client.post(
"/auth/login", json={"username": "admin", "password": "secret-password"}
)
assert response.status_code == 200
cookie = response.headers["set-cookie"]
assert "HttpOnly" in cookie and "SameSite=lax" in cookie
assert "Secure" in cookie and "Path=/" in cookie
assert response.json()["csrf_token"]
def test_unauthenticated_request_is_refused(app_client: TestClient) -> None:
response = app_client.get("/collections")
assert response.status_code == 401
assert response.json()["error"]["code"] == "unauthenticated"
def test_csrf_required_for_unsafe_request(authenticated: tuple[TestClient, str]) -> None:
client, csrf = authenticated
assert client.post("/collections", json={"name": "Books"}).status_code == 403
response = client.post(
"/collections", json={"name": "Books"}, headers={"X-CSRF-Token": csrf}
)
assert response.status_code == 201
def test_session_rotates_csrf_and_logout(authenticated: tuple[TestClient, str]) -> None:
client, old_csrf = authenticated
session = client.get("/auth/session")
assert session.status_code == 200
new_csrf = session.json()["csrf_token"]
assert new_csrf != old_csrf
assert client.post("/auth/logout", headers={"X-CSRF-Token": old_csrf}).status_code == 403
assert client.post("/auth/logout", headers={"X-CSRF-Token": new_csrf}).status_code == 204
assert client.get("/auth/session").status_code == 401
def test_login_rejects_cross_origin(app_client: TestClient) -> None:
response = app_client.post(
"/auth/login", json={"username": "admin", "password": "secret-password"},
headers={"Origin": "https://evil.example"},
)
assert response.status_code == 403
+48
View File
@@ -0,0 +1,48 @@
from typing import cast
from fastapi import FastAPI
from fastapi.testclient import TestClient
def test_collection_crud_and_confirmation(authenticated: tuple[TestClient, str]) -> None:
client, csrf = authenticated
headers = {"X-CSRF-Token": csrf}
created = client.post("/collections", json={"name": "Books"}, headers=headers)
assert created.status_code == 201
collection_id = created.json()["id"]
assert client.get(f"/collections/{collection_id}").json()["name"] == "Books"
assert client.get("/collections").json()["total"] == 1
renamed = client.patch(
f"/collections/{collection_id}", json={"name": "Reading"}, headers=headers
)
assert renamed.status_code == 200
assert renamed.json()["name"] == "Reading"
denied = client.delete(
f"/collections/{collection_id}", params={"confirmation": "Books"}, headers=headers
)
assert denied.status_code == 409
deleted = client.delete(
f"/collections/{collection_id}", params={"confirmation": "Reading"}, headers=headers
)
assert deleted.status_code == 202
assert client.get(f"/collections/{collection_id}").status_code == 404
def test_collection_list_validates_pagination(authenticated: tuple[TestClient, str]) -> None:
client, _ = authenticated
response = client.get("/collections", params={"limit": 201})
assert response.status_code == 422
assert response.json()["error"]["code"] == "validation_failed"
def test_empty_create_and_rename_do_not_enqueue_jobs(
authenticated: tuple[TestClient, str]
) -> None:
client, csrf = authenticated
headers = {"X-CSRF-Token": csrf}
created = client.post("/collections", json={"name": "Books"}, headers=headers)
collection_id = created.json()["id"]
client.patch(f"/collections/{collection_id}", json={"name": "Reading"}, headers=headers)
with cast(FastAPI, client.app).state.database.connect() as connection:
total = connection.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
assert total == 0
+37
View File
@@ -0,0 +1,37 @@
from typing import cast
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
def test_error_envelope(authenticated: tuple[TestClient, str]) -> None:
client, _ = authenticated
response = client.get("/collections/cffffffffff")
assert response.status_code == 404
assert response.json() == {"error": {
"code": "collection_not_found", "message": "Collection was not found", "details": {}
}}
def test_unknown_route_uses_error_envelope(app_client: TestClient) -> None:
response = app_client.get("/no-such-route")
assert response.status_code == 404
assert response.json()["error"]["code"] == "path_not_found"
def test_unexpected_error_has_request_id(
app_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
def fail() -> None:
raise RuntimeError("private filesystem path")
app = cast(FastAPI, app_client.app)
monkeypatch.setattr(app.state.engine, "health", fail)
with TestClient(
app_client.app, base_url="https://testserver", raise_server_exceptions=False
) as client:
response = client.get("/health")
assert response.status_code == 500
assert response.headers["X-Request-ID"] == response.json()["error"]["details"]["request_id"]
assert "private" not in response.text
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
from fastapi.testclient import TestClient
def test_files_browses_one_level_read_only(
authenticated: tuple[TestClient, str], tmp_path: Path
) -> None:
client, _ = authenticated
root = tmp_path / "docs"
(root / "a.txt").write_text("a")
(root / "sub").mkdir()
(root / "sub" / "deep.txt").write_text("deep")
response = client.get("/files")
assert response.status_code == 200
assert [item["name"] for item in response.json()["items"]] == ["a.txt", "sub"]
def test_files_rejects_traversal(authenticated: tuple[TestClient, str]) -> None:
client, _ = authenticated
response = client.get("/files", params={"path": "../"})
assert response.status_code == 422
assert response.json()["error"]["code"] == "path_outside_root"
+29
View File
@@ -0,0 +1,29 @@
from typing import cast
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from recoll_modern.engine.protocol import EngineHealth
def test_health_is_public_and_ready(app_client: TestClient) -> None:
response = app_client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ready"
def test_untrusted_forwarded_prefix_is_ignored(app_client: TestClient) -> None:
response = app_client.get("/health", headers={"X-Forwarded-Prefix": "/evil"})
assert response.status_code == 200
assert "/evil" not in response.text
def test_health_reports_engine_unavailable(
app_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
cast(FastAPI, app_client.app).state.engine, "health", lambda: EngineHealth(False)
)
response = app_client.get("/health")
assert response.status_code == 503
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from recoll_modern.app import create_app
from recoll_modern.config import Settings
from recoll_modern.engine.fake import FakeEngine
def test_cookie_and_openapi_honor_root_path(prefixed_client: TestClient) -> None:
response = prefixed_client.post(
"/recoll/auth/login", json={"username": "admin", "password": "secret-password"}
)
assert response.status_code == 200
assert "Path=/recoll" in response.headers["set-cookie"]
assert prefixed_client.get("/recoll/openapi.json").status_code == 200
assert prefixed_client.get("/auth/login").status_code == 404
def test_trusted_forwarded_prefix_routes_request(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("RECOLL_MODERN_ADMIN_USERNAME", "admin")
monkeypatch.setenv("RECOLL_MODERN_ADMIN_PASSWORD", "secret-password")
(tmp_path / "docs").mkdir()
settings = Settings.from_env({
"RECOLL_MODERN_DOCUMENT_ROOT": str(tmp_path / "docs"),
"RECOLL_MODERN_STATE_ROOT": str(tmp_path / "state"),
"RECOLL_MODERN_TRUSTED_PROXIES": "127.0.0.1/32",
})
with TestClient(
create_app(settings, FakeEngine([])), base_url="https://testserver",
client=("127.0.0.1", 50000),
) as client:
response = client.get("/recoll/health", headers={"X-Forwarded-Prefix": "/recoll"})
assert response.status_code == 200
+1
View File
@@ -0,0 +1 @@
"""Domain behavior tests."""
+1
View File
@@ -0,0 +1 @@
"""Native metadata helper tests."""
+1
View File
@@ -0,0 +1 @@
"""Discovery and job tests."""
+1
View File
@@ -0,0 +1 @@
"""SQLite persistence tests."""
+1
View File
@@ -0,0 +1 @@
"""Configuration tests."""