feat!: require explicit user on every tenant-data endpoint

Remove the implicit jpmschweitzer default tenant (DEFAULT_USER) from
src/core/multi_tenancy.py and every endpoint and request model that
inherited it (~40 endpoints across /query, /wiki, /vector, /graph,
/ingest, /volatile, /documents, /stats, /rag).

- Add validate_required_user() + RequiredUser pydantic type in
  multi_tenancy and a shared require_user FastAPI dependency
  (RequiredUserQuery) that rejects missing, empty, and whitespace-only
  users with 422, following the /maintenance/* pattern.
- Wiki page create / smart-create / dossier request models now require
  user (no fallback in wiki_service).
- /maintenance/cleanup/test-data derives the tenant from the page path
  instead of using the production tenant collection.
- Wiki.js change listener skips changes when no tenant user can be
  derived from the notification email instead of defaulting to the
  production tenant.
- Consolidation service internal helpers no longer default to the
  production tenant.
- Tool catalog marks user as required with honest descriptions.
- OpenAPI descriptions updated honestly; CHANGELOG notes that callers
  (tatlock, Scheduler ingest tasks) must now send explicit user.
- Offline tests: 422 coverage for query/body endpoints, required-user
  validator tests; updated legacy tests that assumed a default tenant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:06:11 +02:00
co-authored by Claude Fable 5
parent a66d801abd
commit 84e9185371
27 changed files with 481 additions and 153 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **BREAKING: `user` is now required on every tenant-data endpoint** - The implicit `jpmschweitzer` default tenant (`DEFAULT_USER`) has been removed everywhere. All endpoints that read or write tenant data (`/query/*`, `/wiki/*`, `/vector/*`, `/graph/*`, `/ingest/*`, `/volatile/*`, `/documents/*`, `/stats`, `/rag/search`) now reject requests without an explicit, non-empty, non-whitespace `user` (HTTP 422), matching the existing `/maintenance/*` pattern. A shared validator (`require_user` dependency / `RequiredUser` model type) also rejects blank users. The Wiki.js change listener now skips changes whose notification email yields no user instead of attributing them to the production tenant. **Caller coordination required:** tatlock and the Scheduler ingest/prefetch/consolidation tasks must send an explicit `user` on every call — see the deploy checklist.
### Added
- **Degradation signaling** - `HybridRAGResponse` now includes `source_status` (per-leg `'ok'`/`'failed'`/`'disabled'` for vector, graph, web, volatile, documents) and `degraded` (true when any enabled leg failed). Retrieval legs report errors instead of silently swallowing them; failed legs are logged at WARNING. Both fields are additive and optional, so clients that ignore them are unaffected.
+59
View File
@@ -0,0 +1,59 @@
# Tatlock Claudification Handover - library-desk
## Context
Tatlock (the butler) is being upgraded to use Claude as its primary LLM backend instead of Ollama. This gives all agents 200k token context and improved reasoning. Library-desk is the backend for **The Librarian** agent.
**Parent Issue:** See `/mnt/media/Projects/tatlock/PROJECT_CLAUDIFICATION.md`
## Impact on library-desk
Library-desk's API is consumed by The Librarian agent via `LibraryDeskClient`. With Claude's larger context and better reasoning:
1. **Larger response payloads are now viable** - Claude can process more search results
2. **Better synthesis** - Claude can better combine HybridRAG sources
3. **Faster processing** - May need to review rate limiting
## Required Changes
### Priority: Low (No blocking changes)
Library-desk likely works as-is. These are optimizations:
- [ ] **Review `hybrid_search` response size limits**
- Current defaults may be conservative for 8k Ollama context
- Consider increasing `max_results` defaults for Claude's 200k context
- Add optional `context_budget` parameter?
- [ ] **Review `smart_create` endpoint**
- Claude's reasoning may benefit from more research context
- Consider returning more source material for synthesis
- [ ] **Evaluate response formats**
- Are responses optimized for LLM consumption?
- Could structured metadata help Claude's reasoning?
### Priority: None (Infrastructure)
- No API key changes needed (library-desk doesn't call LLMs directly)
- No authentication changes
- Existing endpoints remain compatible
## Testing
Once Tatlock is running with Claude backend:
```bash
# Test from Tatlock
curl -X POST http://localhost:8777/v1/responses \
-H "Content-Type: application/json" \
-d '{"input": "Search for Docker networking best practices"}'
# Verify Librarian delegation works with Claude
```
## Timeline
- **Blocking:** No
- **When to implement:** After Tatlock Phase 1 is tested and stable
- **Effort:** ~2-4 hours for optimizations
+30 -1
View File
@@ -10,7 +10,7 @@ Provides FastAPI dependencies for service clients with:
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from fastapi import Depends, HTTPException, Query
import logging
import redis.asyncio as aioredis
@@ -38,6 +38,35 @@ logger = logging.getLogger(__name__)
SettingsDep = Annotated[Settings, Depends(get_settings)]
# Tenant user dependency
def require_user(
user: str = Query(
...,
description=(
"User identifier (tenant). Required — every operation is scoped to "
"this tenant's namespace (Qdrant collection, Neo4j labels, wiki path, "
"Redis keys). Requests without an explicit non-empty user are "
"rejected with 422. There is no default tenant."
),
)
) -> str:
"""
FastAPI dependency: required tenant user query parameter.
Rejects missing (FastAPI returns 422 automatically), empty, and
whitespace-only user values. Use via the RequiredUserQuery alias.
"""
from src.core.multi_tenancy import validate_required_user
try:
return validate_required_user(user)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
RequiredUserQuery = Annotated[str, Depends(require_user)]
# Client factory functions with @lru_cache for singletons
@lru_cache
def get_neo4j_client() -> Neo4jClient:
+41 -2
View File
@@ -8,9 +8,9 @@ Provides utilities for user namespace management across:
"""
import re
from typing import Annotated
# Default user for all operations
DEFAULT_USER = "jpmschweitzer"
from pydantic import AfterValidator
def sanitize_user_id(user_id: str) -> str:
@@ -186,6 +186,45 @@ def validate_user_id(user_id: str) -> bool:
return True
def validate_required_user(user: str) -> str:
"""
Validate that a tenant user identifier is present and usable.
There is NO default tenant: every operation that touches tenant data
must receive an explicit user. Empty or whitespace-only values are
rejected, as are values that fail :func:`validate_user_id`.
Args:
user: Raw user identifier from a request
Returns:
The stripped user identifier
Raises:
ValueError: If the user is missing, blank, or invalid
Examples:
>>> validate_required_user("llm_tester")
'llm_tester'
>>> validate_required_user(" ")
Traceback (most recent call last):
...
ValueError: user is required and must be a non-empty, non-whitespace string
"""
if user is None or not str(user).strip():
raise ValueError(
"user is required and must be a non-empty, non-whitespace string"
)
stripped = str(user).strip()
if not validate_user_id(stripped):
raise ValueError(f"Invalid user identifier: {user!r}")
return stripped
# Pydantic annotated type for request models: a required, validated tenant user.
RequiredUser = Annotated[str, AfterValidator(validate_required_user)]
def is_path_in_user_namespace(path: str, user_id: str) -> bool:
"""
Check if a Wiki.js path belongs to user's namespace.
+5 -5
View File
@@ -18,9 +18,9 @@ from pathlib import Path
from src.config import Settings, get_settings, __version__
from src.core.dependencies import (
verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep
verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep,
RequiredUserQuery
)
from src.core.multi_tenancy import DEFAULT_USER
# Configure logging
logging.basicConfig(
@@ -151,7 +151,7 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
@app.get("/stats", response_model=StatsResponse, tags=["System"])
async def stats(
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
neo4j: Neo4jDep = None,
qdrant: QdrantDep = None,
wikijs: WikiJSDep = None,
@@ -290,8 +290,8 @@ async def get_repo_status(
@app.post("/query/semantic", tags=["Query"])
async def semantic_query(
user: RequiredUserQuery,
query: str = Query(..., min_length=1, description="Search query text"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=10, ge=1, le=100, description="Maximum results"),
score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"),
qdrant_client: QdrantDep = None,
@@ -331,8 +331,8 @@ async def semantic_query(
@app.post("/query/graph", tags=["Query"])
async def graph_query(
user: RequiredUserQuery,
query: str = Query(..., description="Cypher query to execute"),
user: str = Query(default=DEFAULT_USER, description="User for scoping (auto-filters results)"),
neo4j_client: Neo4jDep = None,
wiki_client: WikiJSDep = None,
api_key: str = Depends(verify_api_key)
+12 -6
View File
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional
from datetime import datetime
from src.core.multi_tenancy import RequiredUser
class GraphNode(BaseModel):
"""Graph node representation."""
@@ -45,9 +47,13 @@ class CypherQueryRequest(BaseModel):
default_factory=dict,
description="Query parameters"
)
user: str = Field(
default="jpmschweitzer",
description="User for filtering (automatically scopes query)"
user: RequiredUser = Field(
...,
description=(
"User identifier (tenant). Required. NOTE: raw Cypher queries are NOT "
"automatically scoped to this tenant — the endpoint is read-only and "
"intended for admin/debug use. Results may span all tenants."
)
)
@@ -61,9 +67,9 @@ class CypherQueryResponse(BaseModel):
class UpdateFromPageRequest(BaseModel):
"""Request to update graph from a wiki page."""
page_id: int = Field(..., description="Wiki page ID to process")
user: str = Field(
default="jpmschweitzer",
description="User identifier for namespace scoping"
user: RequiredUser = Field(
...,
description="User identifier (tenant). Required — graph writes are scoped to this tenant's labels."
)
force_refresh: bool = Field(
default=False,
+4 -2
View File
@@ -5,11 +5,13 @@ from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from src.core.multi_tenancy import RequiredUser
class IngestionRequest(BaseModel):
"""Request to ingest a wiki page."""
page_id: int = Field(..., description="Wiki page ID to ingest")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
force_refresh: bool = Field(
default=False,
description="Force re-ingestion even if page hasn't changed"
@@ -21,7 +23,7 @@ class IngestionRequest(BaseModel):
class BatchIngestionRequest(BaseModel):
"""Request to ingest multiple wiki pages."""
page_ids: List[int] = Field(..., description="List of wiki page IDs to ingest")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
force_refresh: bool = Field(default=False)
skip_vectors: bool = Field(default=False)
skip_graph: bool = Field(default=False)
+4 -4
View File
@@ -8,7 +8,7 @@ from enum import Enum
from typing import Optional, List
from pydantic import BaseModel, Field
from src.core.multi_tenancy import DEFAULT_USER
from src.core.multi_tenancy import RequiredUser
class SearchType(str, Enum):
@@ -37,9 +37,9 @@ class RAGSearchRequest(BaseModel):
le=20,
description="Maximum number of results (1-20)"
)
user: str = Field(
default=DEFAULT_USER,
description="User identifier for rate limiting/personalization"
user: RequiredUser = Field(
...,
description="User identifier (tenant). Required — used for rate limiting/personalization."
)
+7 -5
View File
@@ -7,6 +7,8 @@ Provides models for semantic search, document chunks, and embeddings.
from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional
from src.core.multi_tenancy import RequiredUser
class DocumentChunk(BaseModel):
"""Document chunk with embedding."""
@@ -32,7 +34,7 @@ class SearchResult(BaseModel):
class SearchRequest(BaseModel):
"""Semantic search request."""
query: str = Field(..., min_length=1, description="Search query")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — search is scoped to this tenant's collection.")
limit: int = Field(default=10, ge=1, le=100, description="Maximum results")
score_threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score")
@@ -48,9 +50,9 @@ class SearchResponse(BaseModel):
class VectorUpdateRequest(BaseModel):
"""Request to update vectors from a wiki page."""
page_id: int = Field(..., description="Wiki page ID to process")
user: str = Field(
default="jpmschweitzer",
description="User identifier for namespace scoping"
user: RequiredUser = Field(
...,
description="User identifier (tenant). Required — vectors are written to this tenant's collection."
)
force_refresh: bool = Field(
default=False,
@@ -89,7 +91,7 @@ class CollectionListResponse(BaseModel):
class DeletePageChunksRequest(BaseModel):
"""Request to delete all chunks for a page."""
page_id: int = Field(..., description="Wiki page ID")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — deletion is scoped to this tenant's collection.")
class DeletePageChunksResponse(BaseModel):
+5 -3
View File
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from src.core.multi_tenancy import RequiredUser
# Base models
class WikiPageBase(BaseModel):
@@ -35,7 +37,7 @@ class WikiPageCreate(WikiPageBase):
content: str = Field(..., description="Page content (markdown)")
path: str = Field(..., min_length=1, max_length=500, description="Page path (e.g., '/projects/library-desk')")
editor: str = Field(default="markdown", description="Editor type")
user: Optional[str] = Field(None, description="User identifier (defaults to configured user)")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — the page is created inside this tenant's namespace.")
@field_validator("path")
@classmethod
@@ -106,7 +108,7 @@ class DossierCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200, description="Human-readable title")
description: str = Field(..., min_length=1, description="Dossier description")
create_index_page: bool = Field(default=True, description="Create an index page for the dossier")
user: Optional[str] = Field(None, description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required.")
@field_validator("name")
@classmethod
@@ -198,7 +200,7 @@ class WikiSmartCreateRequest(BaseModel):
topic: str = Field(..., min_length=1, max_length=500, description="Topic to research and create page about")
path: Optional[str] = Field(None, description="Page path (auto-generated from topic if not provided)")
tags: List[str] = Field(default_factory=list, description="Tags for the page")
user: Optional[str] = Field(None, description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — research results and the created page are scoped to this tenant.")
include_web_research: bool = Field(default=True, description="Include web search results")
include_wiki_search: bool = Field(default=True, description="Include existing wiki knowledge")
+4 -4
View File
@@ -29,7 +29,7 @@ from src.core.dependencies import (
Neo4jDep,
WikiJSDep,
)
from src.core.multi_tenancy import DEFAULT_USER
from src.core.dependencies import RequiredUserQuery
from src.config import get_settings
logger = logging.getLogger(__name__)
@@ -50,7 +50,7 @@ async def receive_webhook(
ollama: OllamaDep,
neo4j: Neo4jDep,
wiki: WikiJSDep,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
):
"""
Receive webhook events from Paperless-ngx.
@@ -161,9 +161,9 @@ async def capture_webhook(request: Request):
@router.post("/webhook-simple", response_model=WebhookResponse)
async def receive_webhook_simple(
user: RequiredUserQuery,
doc_url: str = Query(..., description="Paperless document URL containing ID"),
title: str = Query(default="", description="Document title"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
paperless: PaperlessDep = None,
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
@@ -347,7 +347,7 @@ async def search_documents(
request: DocumentSearchRequest,
qdrant: QdrantDep,
ollama: OllamaDep,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
api_key: str = Depends(verify_api_key),
):
"""
+12 -11
View File
@@ -15,8 +15,9 @@ from src.models.graph import (
MindMapResponse
)
from src.services.graph_service import GraphService
from src.core.dependencies import Neo4jDep, WikiJSDep, verify_api_key
from src.core.multi_tenancy import DEFAULT_USER
from src.core.dependencies import (
Neo4jDep, WikiJSDep, verify_api_key, RequiredUserQuery
)
logger = logging.getLogger(__name__)
@@ -49,7 +50,7 @@ async def execute_cypher_query(
{
"query": "MATCH (d:Document) RETURN d LIMIT 10",
"parameters": {},
"user": "jpmschweitzer"
"user": "<tenant>"
}
```
@@ -70,7 +71,7 @@ async def execute_cypher_query(
@router.get("/nodes", response_model=NodeListResponse)
async def list_nodes(
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
node_type: Optional[str] = Query(default=None, description="Node type filter"),
limit: int = Query(default=100, ge=1, le=500, description="Maximum nodes"),
graph_service: GraphService = Depends(get_graph_service),
@@ -81,7 +82,7 @@ async def list_nodes(
Optionally filter by node type (Document, Person, Project, Concept, etc.).
**Example:** `/graph/nodes?user=jpmschweitzer&node_type=Document&limit=50`
**Example:** `/graph/nodes?user=<tenant>&node_type=Document&limit=50`
"""
try:
return await graph_service.list_nodes(
@@ -97,7 +98,7 @@ async def list_nodes(
@router.get("/nodes/{node_id}", response_model=GraphNodeDetail)
async def get_node(
node_id: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
graph_service: GraphService = Depends(get_graph_service),
api_key: str = Depends(verify_api_key)
):
@@ -106,7 +107,7 @@ async def get_node(
Returns the node, its relationships, and connected nodes.
**Example:** `/graph/nodes/4:abc123def:0?user=jpmschweitzer`
**Example:** `/graph/nodes/4:abc123def:0?user=<tenant>`
"""
try:
node = await graph_service.get_node(node_id, user)
@@ -123,7 +124,7 @@ async def get_node(
@router.post("/update-from-page/{page_id}", response_model=GraphUpdateSummary)
async def update_graph_from_page(
page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
force_refresh: bool = Query(default=False, description="Force re-extraction"),
graph_service: GraphService = Depends(get_graph_service),
api_key: str = Depends(verify_api_key)
@@ -143,7 +144,7 @@ async def update_graph_from_page(
- Called manually by user/Librarian to refresh graph
- Called by Scheduler for batch processing
**Example:** `POST /graph/update-from-page/4?user=jpmschweitzer`
**Example:** `POST /graph/update-from-page/4?user=<tenant>`
**Returns:** Summary with nodes/relationships created and entities extracted
"""
@@ -171,8 +172,8 @@ async def update_graph_from_page(
@router.post("/mindmap", response_model=MindMapResponse)
async def generate_mindmap(
user: RequiredUserQuery,
center_node_id: str = Query(..., description="Central node ID"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
depth: int = Query(default=2, ge=1, le=5, description="Traversal depth"),
graph_service: GraphService = Depends(get_graph_service),
api_key: str = Depends(verify_api_key)
@@ -205,7 +206,7 @@ async def generate_mindmap(
@router.post("/generate-entity-pages")
async def generate_entity_pages(
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
min_mentions: int = Query(default=5, ge=1, le=100, description="Minimum mentions threshold"),
entity_types: Optional[List[str]] = Query(default=None, description="Entity types to process"),
graph_service: GraphService = Depends(get_graph_service),
+8 -5
View File
@@ -5,14 +5,15 @@ Provides endpoint for combining vector, graph, volatile cache, and web search
with RRF fusion and LLM re-ranking.
"""
from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi import APIRouter, HTTPException, Depends
import logging
from src.models.hybrid_rag import HybridRAGRequest, HybridRAGResponse
from src.services.hybrid_rag_service import HybridRAGService
from src.core.dependencies import (
Neo4jDep, WikiJSDep, QdrantDep, OllamaDep,
SearXNGDep, ContentExtractorDep, verify_api_key, get_settings
SearXNGDep, ContentExtractorDep, verify_api_key, get_settings,
RequiredUserQuery
)
from src.config import Settings
@@ -56,7 +57,7 @@ def get_hybrid_rag_service(
@router.post("/hybrid", response_model=HybridRAGResponse)
async def hybrid_search(
request: HybridRAGRequest,
user: str = Query(default="jpmschweitzer", description="User identifier for multi-tenancy"),
user: RequiredUserQuery,
hybrid_rag_service: HybridRAGService = Depends(get_hybrid_rag_service),
api_key: str = Depends(verify_api_key)
):
@@ -72,11 +73,13 @@ async def hybrid_search(
6. **Context Formatting**: Format for LLM consumption
7. **Persistence**: Store for Librarian knowledge consolidation
**Example Request:**
**Multi-tenancy:** the `user` query parameter is REQUIRED — all retrieval
legs and persistence are scoped to that tenant's namespaces.
**Example Request** (`POST /query/hybrid?user=<tenant>`):
```json
{
"query": "What's the weather in Rotterdam?",
"user": "jpmschweitzer",
"config": {
"vector_limit": 10,
"graph_limit": 10,
+7 -7
View File
@@ -13,7 +13,7 @@ from src.models.ingestion import (
BatchIngestionRequest,
BatchIngestionResult
)
from src.core.dependencies import get_ingestion_service, verify_api_key
from src.core.dependencies import get_ingestion_service, verify_api_key, RequiredUserQuery
router = APIRouter(prefix="/ingest", tags=["Document Ingestion"])
@@ -52,7 +52,7 @@ async def ingest_page(
-H "Content-Type: application/json" \
-d '{
"page_id": 19,
"user": "jpmschweitzer",
"user": "<tenant>",
"force_refresh": false
}'
```
@@ -104,7 +104,7 @@ async def ingest_batch(
-H "Content-Type: application/json" \
-d '{
"page_ids": [19, 20, 21, 22],
"user": "jpmschweitzer",
"user": "<tenant>",
"max_concurrent": 3
}'
```
@@ -123,8 +123,8 @@ async def ingest_batch(
@router.post("/all", response_model=BatchIngestionResult)
async def ingest_all_pages(
user: str = Query(default="jpmschweitzer", description="User identifier"),
path_prefix: Optional[str] = Query(None, description="Path prefix filter (e.g., 'users/jpmschweitzer/tech')"),
user: RequiredUserQuery,
path_prefix: Optional[str] = Query(None, description="Path prefix filter within the user's namespace (e.g., 'users/<tenant>/tech')"),
force_refresh: bool = Query(False, description="Force re-ingestion of all pages"),
max_concurrent: int = Query(3, ge=1, le=10, description="Maximum concurrent ingestion tasks"),
ingestion: IngestionService = Depends(get_ingestion_service),
@@ -151,11 +151,11 @@ async def ingest_all_pages(
```bash
# Ingest all pages for user
curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer" \
curl -X POST "http://192.168.86.149:8089/ingest/all?user=<tenant>" \
-H "Authorization: Bearer $API_KEY"
# Ingest only tech docs
curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer&path_prefix=users/jpmschweitzer/tech" \
curl -X POST "http://192.168.86.149:8089/ingest/all?user=<tenant>&path_prefix=users/<tenant>/tech" \
-H "Authorization: Bearer $API_KEY"
```
"""
+13 -4
View File
@@ -22,7 +22,6 @@ from src.core.dependencies import (
QdrantDep, OllamaDep, PaperlessDep, verify_api_key
)
from src.config import get_settings
from src.core.multi_tenancy import DEFAULT_USER
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -262,6 +261,14 @@ def _matches_test_user_path(path: str) -> bool:
return any(path_lower.startswith(prefix) for prefix in TEST_USER_PATH_PREFIXES)
def _tenant_from_path(path: str) -> str:
"""Extract the tenant user from a 'users/{tenant}/...' wiki path."""
parts = path.lstrip("/").split("/")
if len(parts) >= 2 and parts[0] == "users":
return parts[1]
raise ValueError(f"Cannot derive tenant from path: {path}")
# ========== Endpoints ==========
@router.post("/cleanup/vectors", response_model=VectorCleanupResponse)
@@ -652,12 +659,14 @@ async def cleanup_test_data(
page_path = page["path"]
try:
# Delete vector chunks for this page (using DEFAULT_USER collection)
chunks_removed = await vector_service.delete_page_chunks(page_id, DEFAULT_USER)
# Delete vector chunks/graph node scoped to the tenant that
# owns the page (derived from its users/{tenant}/ path)
tenant = _tenant_from_path(page_path)
chunks_removed = await vector_service.delete_page_chunks(page_id, tenant)
vector_deleted += chunks_removed
# Delete graph node for this page (returns count, may be 0 if no node)
graph_removed = await graph_service.delete_page(page_id, DEFAULT_USER)
graph_removed = await graph_service.delete_page(page_id, tenant)
graph_deleted += graph_removed
# Delete wiki page (raises exception on failure, returns None on success)
+18 -26
View File
@@ -25,10 +25,9 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier",
required=False,
default="jpmschweitzer",
example="jpmschweitzer"
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=True,
example="llm_tester"
),
ToolParameter(
name="tag",
@@ -66,9 +65,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier for access control",
required=False,
default="jpmschweitzer"
description="User identifier (tenant) for access control. Required",
required=True
),
],
returns="Complete page object with content",
@@ -119,9 +117,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier",
required=False,
default="jpmschweitzer"
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=True
),
],
returns="Created page object",
@@ -130,7 +127,7 @@ def get_wiki_tools() -> list[ToolDefinition]:
"path": "/projects/my-project",
"content": "# My Project\n\nProject description here.",
"tags": ["projects"],
"user": "jpmschweitzer"
"user": "<tenant>"
},
fast=True
),
@@ -175,9 +172,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier",
required=False,
default="jpmschweitzer"
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=True
),
],
returns="Updated page object",
@@ -200,9 +196,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier",
required=False,
default="jpmschweitzer"
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=True
),
],
returns="Success confirmation",
@@ -225,9 +220,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier",
required=False,
default="jpmschweitzer"
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=True
),
ToolParameter(
name="limit",
@@ -250,9 +244,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier",
required=False,
default="jpmschweitzer"
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=True
),
],
returns="List of dossiers with page counts",
@@ -275,9 +268,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
ToolParameter(
name="user",
type=ParameterType.STRING,
description="User identifier",
required=False,
default="jpmschweitzer"
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
required=True
),
ToolParameter(
name="limit",
+8 -7
View File
@@ -18,8 +18,9 @@ from src.services.vector_service import VectorService
from src.clients.qdrant_client import QdrantClientWrapper
from src.clients.wikijs_client import WikiJSClient
from src.clients.ollama_client import OllamaClient
from src.core.dependencies import QdrantDep, WikiJSDep, OllamaDep, verify_api_key
from src.core.multi_tenancy import DEFAULT_USER
from src.core.dependencies import (
QdrantDep, WikiJSDep, OllamaDep, verify_api_key, RequiredUserQuery
)
logger = logging.getLogger(__name__)
@@ -52,7 +53,7 @@ async def semantic_search(
```json
{
"query": "how to configure docker",
"user": "jpmschweitzer",
"user": "<tenant>",
"limit": 10,
"score_threshold": 0.5
}
@@ -77,7 +78,7 @@ async def semantic_search(
@router.post("/update-from-page/{page_id}", response_model=VectorUpdateSummary)
async def update_vectors_from_page(
page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
force_refresh: bool = Query(default=False, description="Force re-embedding"),
vector_service: VectorService = Depends(get_vector_service),
api_key: str = Depends(verify_api_key)
@@ -96,7 +97,7 @@ async def update_vectors_from_page(
- Called manually by user/Librarian to refresh vectors
- Called by Scheduler for batch processing
**Example:** `POST /vector/update-from-page/5?user=jpmschweitzer`
**Example:** `POST /vector/update-from-page/5?user=<tenant> (user is REQUIRED)`
**Returns:** Summary with chunks created and processing time
"""
@@ -125,7 +126,7 @@ async def update_vectors_from_page(
@router.delete("/pages/{page_id}", response_model=DeletePageChunksResponse)
async def delete_page_chunks(
page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
vector_service: VectorService = Depends(get_vector_service),
api_key: str = Depends(verify_api_key)
):
@@ -134,7 +135,7 @@ async def delete_page_chunks(
This is automatically called when a page is deleted from the wiki.
**Example:** `DELETE /vector/pages/5?user=jpmschweitzer`
**Example:** `DELETE /vector/pages/5?user=<tenant> (user is REQUIRED)`
"""
try:
deleted_count = await vector_service.delete_page_chunks(
+24 -24
View File
@@ -28,7 +28,7 @@ from src.core.dependencies import (
get_news_provider,
get_alphavantage_provider,
)
from src.core.multi_tenancy import DEFAULT_USER
from src.core.dependencies import RequiredUserQuery
from src.config import get_settings
logger = logging.getLogger(__name__)
@@ -48,7 +48,7 @@ def get_volatile_service(qdrant: QdrantDep, ollama: OllamaDep) -> VolatileCacheS
@router.get("/stats", response_model=VolatileStatsResponse)
async def get_stats(
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key)
@@ -72,7 +72,7 @@ async def get_stats(
@router.get("/scheduled", response_model=VolatileScheduledResponse)
async def get_scheduled(
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key)
@@ -134,8 +134,8 @@ def _get_namespace_description(ns: VolatileNamespace) -> str:
@router.get("/search")
async def search_volatile(
user: RequiredUserQuery,
q: str = Query(..., min_length=1, description="Search query"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=5, ge=1, le=20, description="Maximum results"),
threshold: float = Query(default=0.75, ge=0.5, le=1.0, description="Minimum similarity score"),
qdrant: QdrantDep = None,
@@ -166,10 +166,10 @@ async def search_volatile(
@router.post("/store", response_model=VolatileRecordResponse)
async def store_volatile(
user: RequiredUserQuery,
namespace: str = Query(..., description="Data namespace (weather, news, etc.)"),
key: str = Query(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')"),
request: VolatileRecordCreate = None,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key)
@@ -232,7 +232,7 @@ async def store_volatile(
@router.post("/fetch/weather/{city}")
async def fetch_weather(
city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"),
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
@@ -246,7 +246,7 @@ async def fetch_weather(
**Example:**
```
POST /volatile/fetch/weather/amsterdam?user=jpmschweitzer
POST /volatile/fetch/weather/amsterdam?user=<tenant>
```
"""
volatile_service = get_volatile_service(qdrant, ollama)
@@ -273,7 +273,7 @@ async def fetch_weather(
@router.post("/fetch/forecast/{city}")
async def fetch_forecast(
city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
days: int = Query(default=7, ge=1, le=16, description="Forecast days (1-16)"),
ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds (default 24 hours)"),
qdrant: QdrantDep = None,
@@ -288,7 +288,7 @@ async def fetch_forecast(
**Example:**
```
POST /volatile/fetch/forecast/amsterdam?user=jpmschweitzer&days=7
POST /volatile/fetch/forecast/amsterdam?user=<tenant>&days=7
```
"""
volatile_service = get_volatile_service(qdrant, ollama)
@@ -314,8 +314,8 @@ async def fetch_forecast(
@router.post("/fetch/news/{category}")
async def fetch_news(
user: RequiredUserQuery,
category: str = "general",
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=10, ge=1, le=50, description="Max headlines"),
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds"),
qdrant: QdrantDep = None,
@@ -330,7 +330,7 @@ async def fetch_news(
**Example:**
```
POST /volatile/fetch/news/tech?user=jpmschweitzer&limit=15
POST /volatile/fetch/news/tech?user=<tenant>&limit=15
```
"""
volatile_service = get_volatile_service(qdrant, ollama)
@@ -359,7 +359,7 @@ async def fetch_news(
@router.post("/fetch/stock/{symbol}")
async def fetch_stock(
symbol: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"),
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
@@ -372,7 +372,7 @@ async def fetch_stock(
**Example:**
```
POST /volatile/fetch/stock/AAPL?user=jpmschweitzer
POST /volatile/fetch/stock/AAPL?user=<tenant>
```
"""
volatile_service = get_volatile_service(qdrant, ollama)
@@ -407,8 +407,8 @@ async def fetch_stock(
@router.post("/fetch/crypto/{symbol}")
async def fetch_crypto(
symbol: str,
user: RequiredUserQuery,
market: str = Query(default="USD", description="Market currency"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"),
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
@@ -421,7 +421,7 @@ async def fetch_crypto(
**Example:**
```
POST /volatile/fetch/crypto/BTC?market=EUR&user=jpmschweitzer
POST /volatile/fetch/crypto/BTC?market=EUR&user=<tenant>
```
"""
volatile_service = get_volatile_service(qdrant, ollama)
@@ -456,7 +456,7 @@ async def fetch_crypto(
@router.post("/fetch/sun/{city}")
async def fetch_sun_times(
city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
ttl: int = Query(default=172800, ge=60, le=604800, description="TTL in seconds (default 48 hours)"),
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
@@ -469,7 +469,7 @@ async def fetch_sun_times(
**Example:**
```
POST /volatile/fetch/sun/rotterdam?user=jpmschweitzer
POST /volatile/fetch/sun/rotterdam?user=<tenant>
```
**Response data includes:**
@@ -502,7 +502,7 @@ async def fetch_sun_times(
@router.post("/fetch/air_quality/{city}")
async def fetch_air_quality(
city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"),
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
@@ -515,7 +515,7 @@ async def fetch_air_quality(
**Example:**
```
POST /volatile/fetch/air_quality/rotterdam?user=jpmschweitzer
POST /volatile/fetch/air_quality/rotterdam?user=<tenant>
```
**Response data includes:**
@@ -548,7 +548,7 @@ async def fetch_air_quality(
@router.post("/fetch/environment/{city}")
async def fetch_environment(
city: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
weather_ttl: int = Query(default=7200, ge=60, le=86400, description="Weather TTL in seconds (default 2 hours)"),
air_quality_ttl: int = Query(default=7200, ge=60, le=86400, description="Air quality TTL in seconds (default 2 hours)"),
qdrant: QdrantDep = None,
@@ -564,7 +564,7 @@ async def fetch_environment(
**Example:**
```
POST /volatile/fetch/environment/rotterdam?user=jpmschweitzer
POST /volatile/fetch/environment/rotterdam?user=<tenant>
```
**Response includes:**
@@ -607,7 +607,7 @@ async def fetch_environment(
async def get_record(
namespace: str,
key: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key)
@@ -617,7 +617,7 @@ async def get_record(
**Example:**
```
GET /volatile/weather/rotterdam?user=jpmschweitzer
GET /volatile/weather/rotterdam?user=<tenant>
```
"""
service = get_volatile_service(qdrant, ollama)
@@ -636,7 +636,7 @@ async def get_record(
async def delete_record(
namespace: str,
key: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
qdrant: QdrantDep = None,
ollama: OllamaDep = None,
api_key: str = Depends(verify_api_key)
+15 -13
View File
@@ -24,9 +24,9 @@ from src.clients.qdrant_client import QdrantClientWrapper
from src.clients.ollama_client import OllamaClient
from src.core.dependencies import (
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep, ContentExtractorDep,
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service,
RequiredUserQuery
)
from src.core.multi_tenancy import DEFAULT_USER
from src.services.hybrid_rag_service import HybridRAGService
from src.services.wiki_page_writer import WikiPageWriter
from src.services.entity_linking_utils import apply_bidirectional_entity_linking
@@ -62,7 +62,7 @@ def get_vector_service(
# Page operations
@router.get("/pages", response_model=WikiPageList)
async def list_pages(
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
tag: Optional[str] = Query(default=None, description="Filter by tag (dossier)"),
limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"),
wiki_service: WikiService = Depends(get_wiki_service),
@@ -87,7 +87,7 @@ async def list_pages(
@router.get("/pages/{page_id}", response_model=WikiPage)
async def get_page(
page_id: int,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key)
):
@@ -139,14 +139,16 @@ async def create_page(
"content": "# Architecture\\n\\nThis describes...",
"description": "Architecture documentation",
"tags": ["projects", "architecture"],
"user": "jpmschweitzer"
"user": "<tenant>"
}
```
The `user` field is REQUIRED (no default tenant).
"""
try:
page = await wiki_service.create_page(page_data)
user = page_data.user or DEFAULT_USER
user = page_data.user
# Schedule BOTH graph and vector updates in background (non-blocking)
background_tasks.add_task(
@@ -214,7 +216,7 @@ async def smart_create_page(
- Entity linking statistics (forward/backward links)
"""
try:
user = request.user or DEFAULT_USER
user = request.user
# Build services
wiki_service = WikiService(wiki_client)
@@ -294,7 +296,7 @@ async def update_page(
page_id: int,
page_data: WikiPageUpdate,
background_tasks: BackgroundTasks,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service),
graph_service: GraphService = Depends(get_graph_service),
vector_service: VectorService = Depends(get_vector_service),
@@ -353,7 +355,7 @@ async def update_page(
async def delete_page(
page_id: int,
background_tasks: BackgroundTasks,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service),
vector_service: VectorService = Depends(get_vector_service),
graph_service: GraphService = Depends(get_graph_service),
@@ -402,7 +404,7 @@ async def delete_page(
async def move_page(
page_id: int,
move_data: WikiPageMove,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key)
):
@@ -445,8 +447,8 @@ async def move_page(
# Search operations
@router.get("/search", response_model=WikiSearchResponse)
async def search_pages(
user: RequiredUserQuery,
q: str = Query(..., min_length=1, description="Search query"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=20, ge=1, le=100, description="Maximum results"),
wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key)
@@ -487,7 +489,7 @@ async def search_pages(
# Dossier operations
@router.get("/dossiers", response_model=DossierList)
async def list_dossiers(
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key)
):
@@ -510,7 +512,7 @@ async def list_dossiers(
@router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList)
async def get_dossier_pages(
dossier_name: str,
user: str = Query(default=DEFAULT_USER, description="User identifier"),
user: RequiredUserQuery,
limit: int = Query(default=100, ge=1, le=500, description="Maximum pages"),
wiki_service: WikiService = Depends(get_wiki_service),
api_key: str = Depends(verify_api_key)
+2 -2
View File
@@ -398,7 +398,7 @@ class ConsolidationService:
query: str,
web_results: List[Dict[str, Any]],
keywords: List[str],
user: str = "jpmschweitzer"
user: str
) -> Optional[Dict[str, Any]]:
"""
Analyze web results with Ollama for novel information.
@@ -977,7 +977,7 @@ JSON:"""
query: str,
web_results: List[Dict[str, Any]],
keywords: List[str],
user: str = "jpmschweitzer"
user: str
) -> MemoryRoutingResult:
"""
Unified classification of web results for memory routing.
+11 -2
View File
@@ -103,8 +103,17 @@ class WikiChangeListener:
}
event = event_map.get(operation, 'page.update')
# Extract user from email
user = user_email.split('@')[0] if '@' in user_email else 'jpmschweitzer'
# Extract user from email. There is NO default tenant: if no user
# can be derived from the notification, skip processing instead of
# attributing the change to an arbitrary tenant.
if '@' in user_email and user_email.split('@')[0].strip():
user = user_email.split('@')[0].strip()
else:
logger.warning(
f"Skipping page {page_id} change: cannot derive tenant user "
f"from notification email {user_email!r}"
)
return
# Process the change
await self._process_page_change(
+2 -2
View File
@@ -12,7 +12,7 @@ from typing import List, Optional, Dict, Any
import logging
from src.clients.wikijs_client import WikiJSClient
from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id, DEFAULT_USER
from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id
from src.models.wiki import (
WikiPage, WikiPageSummary, WikiPageList,
WikiPageCreate, WikiPageUpdate,
@@ -184,7 +184,7 @@ class WikiService:
Raises:
ValueError: If creation fails
"""
user = page_data.user or DEFAULT_USER
user = page_data.user
# Ensure path is in user's namespace
full_path = self._ensure_user_path(page_data.path, user)
+38 -3
View File
@@ -7,8 +7,8 @@ from src.core.multi_tenancy import (
get_wikijs_namespace,
get_neo4j_user_label,
validate_user_id,
validate_required_user,
is_path_in_user_namespace,
DEFAULT_USER
)
@@ -46,8 +46,8 @@ class TestQdrantCollectionName:
def test_email_user(self):
assert get_qdrant_collection_name("john@example.com") == "library_desk_john_at_example_com"
def test_default_user(self):
assert get_qdrant_collection_name(DEFAULT_USER) == f"library_desk_{DEFAULT_USER}"
def test_test_tenant(self):
assert get_qdrant_collection_name("llm_tester") == "library_desk_llm_tester"
class TestWikijsNamespace:
@@ -99,6 +99,41 @@ class TestValidateUserId:
assert validate_user_id("___") is False
class TestValidateRequiredUser:
"""Test the required-user validator (no default tenant)."""
def test_no_default_user_constant(self):
"""The DEFAULT_USER escape hatch must not exist anymore."""
import src.core.multi_tenancy as mt
assert not hasattr(mt, "DEFAULT_USER")
def test_valid_user_returned(self):
assert validate_required_user("llm_tester") == "llm_tester"
def test_valid_user_stripped(self):
assert validate_required_user(" llm_tester ") == "llm_tester"
def test_empty_rejected(self):
with pytest.raises(ValueError):
validate_required_user("")
def test_whitespace_rejected(self):
with pytest.raises(ValueError):
validate_required_user(" ")
def test_none_rejected(self):
with pytest.raises(ValueError):
validate_required_user(None)
def test_no_alphanumeric_rejected(self):
with pytest.raises(ValueError):
validate_required_user("___")
def test_too_long_rejected(self):
with pytest.raises(ValueError):
validate_required_user("a" * 101)
class TestPathInNamespace:
"""Test path namespace checking."""
+9 -2
View File
@@ -37,12 +37,19 @@ class TestRAGSearchModels:
"""Tests for RAG search Pydantic models."""
def test_search_request_defaults(self):
"""Test RAGSearchRequest with default values."""
request = RAGSearchRequest(query="test query")
"""Test RAGSearchRequest defaults (user is required, no default tenant)."""
request = RAGSearchRequest(query="test query", user="llm_tester")
assert request.query == "test query"
assert request.search_type == SearchType.WEB
assert request.limit == 10
assert request.user == "llm_tester"
def test_search_request_requires_user(self):
"""A request without an explicit user must be rejected."""
import pytest
with pytest.raises(ValueError):
RAGSearchRequest(query="test query")
def test_search_request_custom_values(self):
"""Test RAGSearchRequest with custom values."""
+117
View File
@@ -0,0 +1,117 @@
"""
Offline unit tests: every tenant-data endpoint must REQUIRE an explicit user.
A request without a user (query param or body field) must be rejected with
422 before any service is touched. Empty/whitespace users are also rejected.
No external services are contacted: validation failures short-circuit the
request before the endpoint body executes.
"""
import pytest
from fastapi.testclient import TestClient
from src.main import app
from src.core.dependencies import verify_api_key
@pytest.fixture(scope="module")
def client():
"""TestClient with API-key auth stubbed out (no lifespan startup)."""
app.dependency_overrides[verify_api_key] = lambda: "test-key"
try:
# No context manager: startup/lifespan events are NOT triggered,
# so no connections to external services are attempted.
yield TestClient(app)
finally:
app.dependency_overrides.pop(verify_api_key, None)
QUERY_PARAM_ENDPOINTS = [
("GET", "/stats"),
("POST", "/query/semantic?query=test"),
("POST", "/query/graph?query=MATCH%20(n)%20RETURN%20n"),
("GET", "/wiki/pages"),
("GET", "/wiki/pages/1"),
("PUT", "/wiki/pages/1"),
("DELETE", "/wiki/pages/1"),
("GET", "/wiki/search?q=test"),
("GET", "/wiki/dossiers"),
("POST", "/vector/update-from-page/1"),
("DELETE", "/vector/pages/1"),
("GET", "/graph/nodes"),
("POST", "/graph/update-from-page/1"),
("POST", "/graph/generate-entity-pages"),
("POST", "/ingest/all"),
("GET", "/volatile/stats"),
("GET", "/volatile/search?q=test"),
("POST", "/volatile/store?namespace=weather&key=test"),
("GET", "/volatile/weather/rotterdam"),
("DELETE", "/volatile/weather/rotterdam"),
("POST", "/documents/webhook-simple?doc_url=http://x/documents/1/"),
]
@pytest.mark.unit
class TestUserQueryParamRequired:
"""Endpoints with a user query parameter must 422 without it."""
@pytest.mark.parametrize("method,path", QUERY_PARAM_ENDPOINTS)
def test_missing_user_is_422(self, client, method, path):
response = client.request(method, path, json={})
assert response.status_code == 422, (
f"{method} {path} returned {response.status_code}, expected 422"
)
@pytest.mark.parametrize("blank", ["", " ", "%20%20"])
def test_blank_user_is_422(self, client, blank):
response = client.get(f"/wiki/pages?user={blank}")
assert response.status_code == 422
def test_hybrid_query_missing_user_is_422(self, client):
response = client.post("/query/hybrid", json={"query": "test"})
assert response.status_code == 422
def test_hybrid_query_whitespace_user_is_422(self, client):
response = client.post("/query/hybrid?user=%20", json={"query": "test"})
assert response.status_code == 422
@pytest.mark.unit
class TestUserBodyFieldRequired:
"""Request models with a user field must reject missing/blank values."""
def test_ingest_page_missing_user_is_422(self, client):
response = client.post("/ingest/page", json={"page_id": 1})
assert response.status_code == 422
def test_ingest_page_blank_user_is_422(self, client):
response = client.post("/ingest/page", json={"page_id": 1, "user": " "})
assert response.status_code == 422
def test_ingest_batch_missing_user_is_422(self, client):
response = client.post("/ingest/batch", json={"page_ids": [1]})
assert response.status_code == 422
def test_wiki_create_page_missing_user_is_422(self, client):
response = client.post(
"/wiki/pages",
json={"title": "T", "path": "/t", "content": "c"},
)
assert response.status_code == 422
def test_wiki_smart_create_missing_user_is_422(self, client):
response = client.post("/wiki/pages/smart-create", json={"topic": "T"})
assert response.status_code == 422
def test_vector_search_missing_user_is_422(self, client):
response = client.post("/vector/search", json={"query": "test"})
assert response.status_code == 422
def test_graph_query_missing_user_is_422(self, client):
response = client.post("/graph/query", json={"query": "MATCH (n) RETURN n"})
assert response.status_code == 422
def test_rag_search_missing_user_is_422(self, client):
response = client.post("/rag/search", json={"query": "test"})
assert response.status_code == 422
+20 -10
View File
@@ -26,15 +26,20 @@ class TestWikiSmartCreateRequest:
"""Tests for WikiSmartCreateRequest model validation."""
def test_minimal_request(self):
"""Test request with only required field."""
request = WikiSmartCreateRequest(topic="Docker containers")
"""Test request with only required fields (topic AND user)."""
request = WikiSmartCreateRequest(topic="Docker containers", user="llm_tester")
assert request.topic == "Docker containers"
assert request.path is None
assert request.tags == []
assert request.user is None
assert request.user == "llm_tester"
assert request.include_web_research is True
assert request.include_wiki_search is True
def test_user_is_required(self):
"""A request without an explicit user must be rejected."""
with pytest.raises(ValueError):
WikiSmartCreateRequest(topic="Docker containers")
def test_full_request(self):
"""Test request with all fields."""
request = WikiSmartCreateRequest(
@@ -68,7 +73,8 @@ class TestWikiSmartCreateRequest:
"""Test that path without leading slash gets one added."""
request = WikiSmartCreateRequest(
topic="Test",
path="technology/test"
path="technology/test",
user="llm_tester"
)
assert request.path == "/technology/test"
@@ -76,7 +82,8 @@ class TestWikiSmartCreateRequest:
"""Test that trailing slash is removed."""
request = WikiSmartCreateRequest(
topic="Test",
path="/technology/test/"
path="/technology/test/",
user="llm_tester"
)
assert request.path == "/technology/test"
@@ -84,7 +91,8 @@ class TestWikiSmartCreateRequest:
"""Test that duplicate tags are removed."""
request = WikiSmartCreateRequest(
topic="Test",
tags=["devops", "devops", "containers", "devops"]
tags=["devops", "devops", "containers", "devops"],
user="llm_tester"
)
assert len(request.tags) == 2
assert "devops" in request.tags
@@ -94,7 +102,8 @@ class TestWikiSmartCreateRequest:
"""Test that tag whitespace is cleaned."""
request = WikiSmartCreateRequest(
topic="Test",
tags=[" devops ", "containers", " ", ""]
tags=[" devops ", "containers", " ", ""],
user="llm_tester"
)
assert "devops" in request.tags
assert "containers" in request.tags
@@ -536,7 +545,8 @@ class TestSmartCreateEndpoint:
# For now, we test the model validation
request = WikiSmartCreateRequest(
topic="Test Topic",
tags=["test"]
tags=["test"],
user="llm_tester"
)
assert request.topic == "Test Topic"
@@ -546,8 +556,8 @@ class TestSmartCreateEndpoint:
WikiSmartCreateRequest(topic="")
def test_request_accepts_minimal_input(self):
"""Test that only topic is required."""
request = WikiSmartCreateRequest(topic="Minimal test")
"""Test that topic and user are the only required fields."""
request = WikiSmartCreateRequest(topic="Minimal test", user="llm_tester")
assert request.topic == "Minimal test"
assert request.include_web_research is True # default
assert request.include_wiki_search is True # default
+2 -3
View File
@@ -231,9 +231,8 @@ class TestWikiChangeListener:
'UPDATE:123:invaliduser'
)
# Should use default user
call_args = mock_process.call_args[1]
assert call_args['user'] == 'jpmschweitzer'
# No default tenant: change must be skipped entirely
mock_process.assert_not_awaited()
@pytest.mark.asyncio
async def test_process_page_delete_calls_cleanup(self, listener):