Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,539 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from collections.abc import AsyncIterable
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import (
|
||||
get_control_application,
|
||||
get_credential_service,
|
||||
get_principal,
|
||||
)
|
||||
from ragcore.api.v1.uploads.dependencies import get_upload_service
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.control.api import ControlPlaneApplication
|
||||
from ragcore.application.documents import DocumentRecord
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.application.jobs import Job, JobService
|
||||
from ragcore.application.storage import BlobReference
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import (
|
||||
Application,
|
||||
Grant,
|
||||
KnowledgeSpace,
|
||||
Policy,
|
||||
Role,
|
||||
Scope,
|
||||
ServiceAccount,
|
||||
UserGrant,
|
||||
Workspace,
|
||||
)
|
||||
from ragcore.infrastructure.connectors.upload import (
|
||||
UploadIdempotencyStore,
|
||||
UploadPolicy,
|
||||
UploadService,
|
||||
)
|
||||
from ragcore.infrastructure.database.identity.memory import InMemoryCredentialRepository
|
||||
from ragcore.main import create_app
|
||||
|
||||
|
||||
class RecordingBlobStorage:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def put(
|
||||
self,
|
||||
chunks: AsyncIterable[bytes],
|
||||
*,
|
||||
media_type: str | None = None,
|
||||
original_name: str | None = None,
|
||||
expected_sha256: str | None = None,
|
||||
) -> BlobReference:
|
||||
self.calls += 1
|
||||
body = b"".join([chunk async for chunk in chunks])
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
return BlobReference(
|
||||
digest=digest,
|
||||
size=len(body),
|
||||
media_type=media_type,
|
||||
original_name=original_name,
|
||||
storage_key=f"blobs/{digest}",
|
||||
)
|
||||
|
||||
|
||||
class RecordingJobRepository:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
self.enqueued: list[Job] = []
|
||||
|
||||
async def enqueue(self, job: Job) -> Job:
|
||||
self.calls += 1
|
||||
self.enqueued.append(job)
|
||||
return job
|
||||
|
||||
|
||||
class FakeControlRepository:
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.workspace = workspace
|
||||
self.applications: list[Application] = []
|
||||
self.service_accounts: list[ServiceAccount] = []
|
||||
self.spaces: list[KnowledgeSpace] = []
|
||||
self.grants: list[Grant | UserGrant] = []
|
||||
self.policies: list[Policy] = []
|
||||
|
||||
async def get_workspace(self, workspace_id):
|
||||
return self.workspace if workspace_id == self.workspace.id else None
|
||||
|
||||
async def create_application(self, entity):
|
||||
self.applications.append(entity)
|
||||
return entity
|
||||
|
||||
async def get_application(self, application_id, workspace_id):
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in self.applications
|
||||
if item.id == application_id and item.workspace_id == workspace_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def list_applications(self, workspace_id):
|
||||
return tuple(item for item in self.applications if item.workspace_id == workspace_id)
|
||||
|
||||
async def create_service_account(self, entity):
|
||||
self.service_accounts.append(entity)
|
||||
return entity
|
||||
|
||||
async def get_service_account_by_id(self, service_account_id):
|
||||
return next((item for item in self.service_accounts if item.id == service_account_id), None)
|
||||
|
||||
async def create_space(self, entity):
|
||||
self.spaces.append(entity)
|
||||
return entity
|
||||
|
||||
async def list_spaces(self, workspace_id):
|
||||
return tuple(item for item in self.spaces if item.workspace_id == workspace_id)
|
||||
|
||||
async def update_space(self, entity):
|
||||
self.spaces = [entity if item.id == entity.id else item for item in self.spaces]
|
||||
return entity
|
||||
|
||||
async def create_grant(self, entity):
|
||||
self.grants.append(entity)
|
||||
return entity
|
||||
|
||||
async def create_user_grant(self, entity):
|
||||
self.grants.append(entity)
|
||||
return entity
|
||||
|
||||
async def create_policy(self, entity):
|
||||
self.policies.append(entity)
|
||||
return entity
|
||||
|
||||
async def list_policies(self, workspace_id):
|
||||
return tuple(item for item in self.policies if item.workspace_id == workspace_id)
|
||||
|
||||
async def list_grants(self, workspace_id):
|
||||
return tuple(
|
||||
item
|
||||
for item in self.grants
|
||||
if isinstance(item, Grant) and item.workspace_id == workspace_id
|
||||
)
|
||||
|
||||
|
||||
class FakeDocumentRepository:
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[DocumentRecord] = []
|
||||
|
||||
async def create_document(self, document: DocumentRecord) -> DocumentRecord:
|
||||
self.documents.append(document)
|
||||
return document
|
||||
|
||||
async def create_version(self, version): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("should not be reached in these tests")
|
||||
|
||||
async def get_version(self, version_id): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("should not be reached in these tests")
|
||||
|
||||
async def publish_validated(self, version_id, artifacts, chunks): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("should not be reached in these tests")
|
||||
|
||||
async def mark_source_deleted(self, document_id): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("should not be reached in these tests")
|
||||
|
||||
async def list_searchable_versions(self, document_id): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("should not be reached in these tests")
|
||||
|
||||
async def list_documents_by_space(self, space_id): # type: ignore[no-untyped-def]
|
||||
return tuple(item for item in self.documents if item.space_id == space_id)
|
||||
|
||||
async def list_documents_by_space_with_status(self, space_id): # type: ignore[no-untyped-def]
|
||||
from ragcore.application.documents import DocumentStatusRecord, DocumentVersionState
|
||||
|
||||
return tuple(
|
||||
DocumentStatusRecord(
|
||||
document=item,
|
||||
latest_state=DocumentVersionState.PUBLISHED,
|
||||
parser_version="text-parser-1",
|
||||
chunk_profile_version="prose-v1",
|
||||
updated_at=None,
|
||||
)
|
||||
for item in self.documents
|
||||
if item.space_id == space_id
|
||||
)
|
||||
|
||||
|
||||
def _fixture():
|
||||
workspace = Workspace(uuid4(), "Web workspace", "web-workspace")
|
||||
principal = Principal(
|
||||
uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.WORKSPACE_ADMIN})
|
||||
)
|
||||
repository = FakeControlRepository(workspace)
|
||||
document_repository = FakeDocumentRepository()
|
||||
repository.documents = document_repository
|
||||
application = workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="Web application",
|
||||
slug="web-application",
|
||||
scopes=frozenset({Scope.SEARCH}),
|
||||
)
|
||||
asyncio.run(repository.create_application(application))
|
||||
service = ControlPlaneApplication(
|
||||
repository, AuthorizationService(), document_repository=document_repository
|
||||
)
|
||||
credentials = InMemoryCredentialRepository()
|
||||
credential_service = ServiceAccountCredentialService(
|
||||
credentials,
|
||||
"x" * 32,
|
||||
repository.get_service_account_by_id,
|
||||
)
|
||||
blob_storage = RecordingBlobStorage()
|
||||
job_repository = RecordingJobRepository()
|
||||
repository.uploads = job_repository
|
||||
upload_service = UploadService(
|
||||
blob_storage,
|
||||
JobService(job_repository),
|
||||
policy=UploadPolicy(),
|
||||
idempotency=UploadIdempotencyStore(),
|
||||
)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.dependency_overrides[get_control_application] = lambda: service
|
||||
app.dependency_overrides[get_credential_service] = lambda: credential_service
|
||||
app.dependency_overrides[get_upload_service] = lambda: upload_service
|
||||
return app, repository, principal, application
|
||||
|
||||
|
||||
def test_control_screens_have_empty_states_and_accessible_landmarks() -> None:
|
||||
app, _, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
for path, heading in (
|
||||
("/admin/control/applications", "Applications"),
|
||||
("/admin/control/spaces", "Knowledge spaces"),
|
||||
("/admin/control/policies", "Policies"),
|
||||
):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200
|
||||
assert heading in response.text
|
||||
if path != "/admin/control/applications":
|
||||
assert 'role="status"' in response.text
|
||||
html = client.get("/admin/control/applications").text
|
||||
assert 'href="#main-content"' in html
|
||||
assert '<nav aria-label="Primary navigation">' in html
|
||||
assert 'id="main-content"' in html
|
||||
assert client.get("/static/admin.css").status_code == 200
|
||||
assert ":focus-visible" in client.get("/static/admin.css").text
|
||||
|
||||
|
||||
def test_forbidden_control_screen_is_explicit_and_does_not_leak_data() -> None:
|
||||
app, _, principal, _ = _fixture()
|
||||
denied = Principal(
|
||||
principal.id, principal.workspace_id, PrincipalType.HUMAN, frozenset({Role.VIEWER})
|
||||
)
|
||||
app.dependency_overrides[get_principal] = lambda: denied
|
||||
response = TestClient(app).get("/admin/control/applications")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.text
|
||||
assert "Web application" not in response.text
|
||||
assert "Reference:" in response.text
|
||||
|
||||
|
||||
def test_credential_plaintext_is_rendered_once() -> None:
|
||||
app, _, _, application = _fixture()
|
||||
client = TestClient(app)
|
||||
form = client.get(f"/admin/control/applications/{application.id}/credentials/new")
|
||||
assert form.status_code == 200
|
||||
assert "One-time plaintext token" not in form.text
|
||||
assert '<nav aria-label="Breadcrumb"' in form.text
|
||||
assert "Web application" in form.text
|
||||
issued = client.post(
|
||||
f"/admin/control/applications/{application.id}/credentials",
|
||||
data={"name": "browser credential", "scopes": ["search"]},
|
||||
)
|
||||
assert issued.status_code == 201
|
||||
assert "rc_sa_" in issued.text
|
||||
assert "It will not be shown again" in issued.text
|
||||
refreshed = client.get(f"/admin/control/applications/{application.id}/credentials/new")
|
||||
assert refreshed.status_code == 200
|
||||
assert "rc_sa_" not in refreshed.text
|
||||
|
||||
|
||||
def test_grant_screen_and_error_state_are_authorized() -> None:
|
||||
app, _, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
missing = client.get(f"/admin/control/spaces/{uuid4()}/grants")
|
||||
assert missing.status_code == 404
|
||||
assert "Knowledge space not found" in missing.text
|
||||
assert "Reference:" in missing.text
|
||||
|
||||
|
||||
def test_grants_page_lists_real_application_grants_for_the_space() -> None:
|
||||
app, repository, _, application = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Runbooks", slug="runbooks"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
grant = repository.workspace.grant(
|
||||
id=uuid4(), application=application, knowledge_space=space, role=Role.VIEWER
|
||||
)
|
||||
asyncio.run(repository.create_grant(grant))
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/admin/control/spaces/{space.id}/grants")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Web application" in response.text
|
||||
assert "viewer" in response.text
|
||||
assert '<nav aria-label="Breadcrumb"' in response.text
|
||||
assert "Runbooks" in response.text
|
||||
|
||||
|
||||
def test_documents_page_missing_space_is_a_stable_not_found() -> None:
|
||||
app, _, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/admin/control/spaces/{uuid4()}/documents")
|
||||
assert response.status_code == 404
|
||||
assert "Knowledge space not found" in response.text
|
||||
|
||||
|
||||
def test_documents_page_has_an_empty_state() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Fleet Ops Procedures", slug="fleet-ops-procedures"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/admin/control/spaces/{space.id}/documents")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No documents yet" in response.text
|
||||
assert 'role="status"' in response.text
|
||||
|
||||
|
||||
def test_documents_page_lists_ingested_documents_for_the_space() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Fleet Ops Procedures", slug="fleet-ops-procedures"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=repository.workspace.id,
|
||||
space_id=space.id,
|
||||
source_key="a" * 64,
|
||||
external_id="runbook.txt",
|
||||
title="Fleet restart runbook",
|
||||
)
|
||||
asyncio.run(repository.documents.create_document(document))
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/admin/control/spaces/{space.id}/documents")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Fleet restart runbook" in response.text
|
||||
assert "runbook.txt" in response.text
|
||||
assert '<nav aria-label="Breadcrumb"' in response.text
|
||||
assert "Fleet Ops Procedures" in response.text
|
||||
|
||||
|
||||
def test_new_space_form_renders() -> None:
|
||||
app, _, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
response = client.get("/admin/control/spaces/new")
|
||||
assert response.status_code == 200
|
||||
assert "Create a knowledge space" in response.text
|
||||
assert 'action="/admin/control/spaces"' in response.text
|
||||
|
||||
|
||||
def test_create_space_persists_and_redirects_to_the_list() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/admin/control/spaces",
|
||||
data={"name": "Fleet Ops Procedures", "slug": "fleet-ops-procedures"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/admin/control/spaces"
|
||||
created = asyncio.run(repository.list_spaces(repository.workspace.id))
|
||||
assert any(space.slug == "fleet-ops-procedures" for space in created)
|
||||
|
||||
listing = client.get("/admin/control/spaces")
|
||||
assert "Fleet Ops Procedures" in listing.text
|
||||
|
||||
|
||||
def test_create_space_rejects_an_invalid_slug_without_persisting() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/admin/control/spaces",
|
||||
data={"name": "Bad Slug", "slug": "Not A Valid Slug!"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "lowercase letters, numbers and single hyphens" in response.text
|
||||
assert not asyncio.run(repository.list_spaces(repository.workspace.id))
|
||||
|
||||
|
||||
def test_deactivate_then_activate_space_round_trips() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Runbooks", slug="runbooks"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
client = TestClient(app)
|
||||
|
||||
deactivated = client.post(
|
||||
f"/admin/control/spaces/{space.id}/deactivate", follow_redirects=False
|
||||
)
|
||||
assert deactivated.status_code == 303
|
||||
stored = asyncio.run(repository.list_spaces(repository.workspace.id))
|
||||
assert stored[0].active is False
|
||||
|
||||
reactivated = client.post(f"/admin/control/spaces/{space.id}/activate", follow_redirects=False)
|
||||
assert reactivated.status_code == 303
|
||||
stored = asyncio.run(repository.list_spaces(repository.workspace.id))
|
||||
assert stored[0].active is True
|
||||
|
||||
|
||||
def test_deactivate_missing_space_is_a_stable_not_found() -> None:
|
||||
app, _, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
response = client.post(f"/admin/control/spaces/{uuid4()}/deactivate")
|
||||
assert response.status_code == 404
|
||||
assert "Knowledge space not found" in response.text
|
||||
|
||||
|
||||
def test_space_actions_are_forbidden_for_an_unprivileged_principal() -> None:
|
||||
app, repository, principal, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Runbooks", slug="runbooks"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
viewer = Principal(
|
||||
principal.id, principal.workspace_id, PrincipalType.HUMAN, frozenset({Role.VIEWER})
|
||||
)
|
||||
app.dependency_overrides[get_principal] = lambda: viewer
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(f"/admin/control/spaces/{space.id}/deactivate")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.text
|
||||
|
||||
|
||||
def test_upload_form_renders_for_a_real_space() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Fleet Ops Procedures", slug="fleet-ops-procedures"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/admin/control/spaces/{space.id}/documents/upload")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Upload a document to Fleet Ops Procedures" in response.text
|
||||
assert 'enctype="multipart/form-data"' in response.text
|
||||
|
||||
|
||||
def test_upload_form_missing_space_is_a_stable_not_found() -> None:
|
||||
app, _, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
response = client.get(f"/admin/control/spaces/{uuid4()}/documents/upload")
|
||||
assert response.status_code == 404
|
||||
assert "Knowledge space not found" in response.text
|
||||
|
||||
|
||||
def test_upload_accepts_a_supported_file_and_enqueues_a_job() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Fleet Ops Procedures", slug="fleet-ops-procedures"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
f"/admin/control/spaces/{space.id}/documents/upload",
|
||||
files={"file": ("runbook.txt", b"restart the gateway", "text/plain")},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert "Upload accepted" in response.text
|
||||
assert "runbook.txt" in response.text
|
||||
assert repository.uploads.calls == 1
|
||||
assert repository.uploads.enqueued[0].operation == "ingest_upload"
|
||||
|
||||
|
||||
def test_upload_rejects_an_unsupported_file_type_without_enqueuing() -> None:
|
||||
app, repository, _, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Fleet Ops Procedures", slug="fleet-ops-procedures"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
f"/admin/control/spaces/{space.id}/documents/upload",
|
||||
files={"file": ("payload.exe", b"MZ", "application/octet-stream")},
|
||||
)
|
||||
|
||||
assert response.status_code == 415
|
||||
assert "not supported" in response.text
|
||||
assert repository.uploads.calls == 0
|
||||
|
||||
|
||||
def test_upload_missing_space_is_a_stable_not_found() -> None:
|
||||
app, _, _, _ = _fixture()
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
f"/admin/control/spaces/{uuid4()}/documents/upload",
|
||||
files={"file": ("runbook.txt", b"restart the gateway", "text/plain")},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "Knowledge space not found" in response.text
|
||||
|
||||
|
||||
def test_upload_is_forbidden_for_an_unprivileged_principal() -> None:
|
||||
app, repository, principal, _ = _fixture()
|
||||
space = repository.workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Fleet Ops Procedures", slug="fleet-ops-procedures"
|
||||
)
|
||||
asyncio.run(repository.create_space(space))
|
||||
viewer = Principal(
|
||||
principal.id, principal.workspace_id, PrincipalType.HUMAN, frozenset({Role.VIEWER})
|
||||
)
|
||||
app.dependency_overrides[get_principal] = lambda: viewer
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
f"/admin/control/spaces/{space.id}/documents/upload",
|
||||
files={"file": ("runbook.txt", b"restart the gateway", "text/plain")},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.text
|
||||
assert repository.uploads.calls == 0
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_principal
|
||||
from ragcore.application.profiles import (
|
||||
ActivationDecision,
|
||||
ActivationDecisionNotFoundError,
|
||||
AlreadyRolledBackError,
|
||||
CandidateAliasTarget,
|
||||
NotActivatedError,
|
||||
)
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.evaluation import RetrievalRunProfile
|
||||
from ragcore.evaluation.metrics.models import GateResult
|
||||
from ragcore.main import create_app
|
||||
from ragcore.web.profiles.dependencies import get_profile_activation_service
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
|
||||
|
||||
def _principal(role: Role) -> Principal:
|
||||
return Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset({role}))
|
||||
|
||||
|
||||
def _run_profile(version: str) -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="technical-v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version=version,
|
||||
)
|
||||
|
||||
|
||||
def _decision(**overrides: object) -> ActivationDecision:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"created_at": datetime.now(UTC),
|
||||
"dataset_id": "sample",
|
||||
"dataset_version": "v1",
|
||||
"baseline_run_id": uuid4(),
|
||||
"candidate_run_id": uuid4(),
|
||||
"baseline_profile": _run_profile("v1"),
|
||||
"candidate_profile": _run_profile("v2"),
|
||||
"gate": GateResult(thresholds_version="v1", passed=True, regressions=()),
|
||||
"activated": True,
|
||||
"candidate_target": CandidateAliasTarget(
|
||||
alias_name="rag_dense_hybrid_active",
|
||||
collection_name="rag_dense_hybrid_v2",
|
||||
version="v2",
|
||||
model_digest="b" * 64,
|
||||
),
|
||||
"index_operation_id": uuid4(),
|
||||
"previous_collection": "rag_dense_hybrid_v1",
|
||||
}
|
||||
values.update(overrides)
|
||||
return ActivationDecision(**values)
|
||||
|
||||
|
||||
class StubProfileActivationService:
|
||||
def __init__(self, decisions: tuple[ActivationDecision, ...] = ()) -> None:
|
||||
self._decisions = decisions
|
||||
self.rollback_calls: list[object] = []
|
||||
self.rollback_error: Exception | None = None
|
||||
|
||||
async def list_recent(self, limit: int = 20) -> tuple[ActivationDecision, ...]:
|
||||
return self._decisions[:limit]
|
||||
|
||||
async def rollback(self, decision_id: object) -> ActivationDecision:
|
||||
self.rollback_calls.append(decision_id)
|
||||
if self.rollback_error is not None:
|
||||
raise self.rollback_error
|
||||
return self._decisions[0].with_rollback(rolled_back_at=datetime.now(UTC))
|
||||
|
||||
|
||||
def _app(service: StubProfileActivationService, principal: Principal) -> TestClient:
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.dependency_overrides[get_profile_activation_service] = lambda: service
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_page_loads_with_landmarks_and_empty_state_for_platform_admin() -> None:
|
||||
client = _app(StubProfileActivationService(), _principal(Role.PLATFORM_ADMIN))
|
||||
response = client.get("/admin/profiles")
|
||||
assert response.status_code == 200
|
||||
assert 'href="#main-content"' in response.text
|
||||
assert "No profile activation decisions have been recorded yet." in response.text
|
||||
|
||||
|
||||
def test_non_platform_admin_is_forbidden() -> None:
|
||||
client = _app(StubProfileActivationService(), _principal(Role.WORKSPACE_ADMIN))
|
||||
response = client.get("/admin/profiles")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.text
|
||||
|
||||
|
||||
def test_unauthenticated_request_is_rejected() -> None:
|
||||
app = create_app()
|
||||
stub = StubProfileActivationService()
|
||||
app.dependency_overrides[get_profile_activation_service] = lambda: stub
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.get("/admin/profiles")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_decision_list_shows_gate_and_status_without_leaking_secrets() -> None:
|
||||
decision = _decision()
|
||||
service = StubProfileActivationService((decision,))
|
||||
client = _app(service, _principal(Role.PLATFORM_ADMIN))
|
||||
response = client.get("/admin/profiles")
|
||||
assert response.status_code == 200
|
||||
assert "v1" in response.text and "v2" in response.text
|
||||
assert "passed" in response.text
|
||||
assert "activated" in response.text
|
||||
assert "api_key" not in response.text.lower()
|
||||
assert "secret" not in response.text.lower()
|
||||
|
||||
|
||||
def test_rollback_succeeds_for_platform_admin() -> None:
|
||||
decision = _decision()
|
||||
service = StubProfileActivationService((decision,))
|
||||
client = _app(service, _principal(Role.PLATFORM_ADMIN))
|
||||
response = client.post(f"/admin/profiles/{decision.id}/rollback")
|
||||
assert response.status_code == 200
|
||||
assert service.rollback_calls == [decision.id]
|
||||
|
||||
|
||||
def test_rollback_is_forbidden_for_non_platform_admin() -> None:
|
||||
decision = _decision()
|
||||
service = StubProfileActivationService((decision,))
|
||||
client = _app(service, _principal(Role.WORKSPACE_ADMIN))
|
||||
response = client.post(f"/admin/profiles/{decision.id}/rollback")
|
||||
assert response.status_code == 403
|
||||
assert service.rollback_calls == []
|
||||
|
||||
|
||||
def test_rollback_of_unknown_decision_is_a_stable_not_found() -> None:
|
||||
service = StubProfileActivationService((_decision(),))
|
||||
service.rollback_error = ActivationDecisionNotFoundError("not found")
|
||||
client = _app(service, _principal(Role.PLATFORM_ADMIN))
|
||||
response = client.post(f"/admin/profiles/{uuid4()}/rollback")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_rollback_of_a_rejected_decision_is_a_stable_conflict() -> None:
|
||||
service = StubProfileActivationService((_decision(),))
|
||||
service.rollback_error = NotActivatedError("never activated")
|
||||
client = _app(service, _principal(Role.PLATFORM_ADMIN))
|
||||
response = client.post(f"/admin/profiles/{uuid4()}/rollback")
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_rollback_twice_is_a_stable_conflict() -> None:
|
||||
service = StubProfileActivationService((_decision(),))
|
||||
service.rollback_error = AlreadyRolledBackError("already rolled back")
|
||||
client = _app(service, _principal(Role.PLATFORM_ADMIN))
|
||||
response = client.post(f"/admin/profiles/{uuid4()}/rollback")
|
||||
assert response.status_code == 409
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Query Lab productization: selector-first form, no stale capability copy,
|
||||
reranking shown only when active, fused-stage explainability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_principal
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.main import create_app
|
||||
from ragcore.web.query_lab.dependencies import get_query_lab_service
|
||||
from ragcore.web.query_lab.models import (
|
||||
AnswerabilityTrace,
|
||||
CandidateBackendLabel,
|
||||
CandidateTrace,
|
||||
CitationTrace,
|
||||
ContextTrace,
|
||||
EffectiveFilterTrace,
|
||||
FusionTrace,
|
||||
InclusionState,
|
||||
ParentExpansionTrace,
|
||||
QueryLabQuery,
|
||||
QueryLabTrace,
|
||||
RerankTrace,
|
||||
RevisionTrace,
|
||||
)
|
||||
from ragcore.web.query_lab.routes import QueryLabCatalog, get_query_lab_catalog
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
SOURCE_ID = uuid4()
|
||||
DOCUMENT_ID = uuid4()
|
||||
|
||||
|
||||
class StubService:
|
||||
def __init__(self, trace: QueryLabTrace) -> None:
|
||||
self.trace = trace
|
||||
self.calls: list[QueryLabQuery] = []
|
||||
|
||||
def can_use(self, principal: Principal) -> bool:
|
||||
return True
|
||||
|
||||
async def run(self, principal: Principal, query: QueryLabQuery) -> QueryLabTrace:
|
||||
self.calls.append(query)
|
||||
return self.trace
|
||||
|
||||
|
||||
def _fused_candidate() -> CandidateTrace:
|
||||
return CandidateTrace(
|
||||
rank=1,
|
||||
chunk_id=uuid4(),
|
||||
document_id=DOCUMENT_ID,
|
||||
space_id=SPACE_ID,
|
||||
backend=CandidateBackendLabel.FUSED,
|
||||
score=0.0325,
|
||||
score_label="rrf_score_not_a_probability",
|
||||
content_type="text/markdown",
|
||||
trust_level="trusted_internal",
|
||||
language="en",
|
||||
text_preview="restart the service after the breaker",
|
||||
text_truncated=False,
|
||||
inclusion=InclusionState.INCLUDED,
|
||||
reason="fused_rank",
|
||||
dense_rank=2,
|
||||
dense_score=0.87,
|
||||
sparse_rank=1,
|
||||
sparse_score=12.5,
|
||||
rrf_dense=1 / 62,
|
||||
rrf_sparse=1 / 61,
|
||||
document_title="Gateway runbook",
|
||||
source_name="Infra runbooks",
|
||||
)
|
||||
|
||||
|
||||
def _trace(*, rerank_state: str = "skipped") -> QueryLabTrace:
|
||||
return QueryLabTrace(
|
||||
retrieval_run_id=uuid4(),
|
||||
query="restart breaker",
|
||||
profile="hybrid-v1",
|
||||
effective_filters=EffectiveFilterTrace(
|
||||
requested_space_ids=(SPACE_ID,),
|
||||
effective_space_ids=(SPACE_ID,),
|
||||
denied_space_ids=(),
|
||||
trust_required_space_ids=(),
|
||||
requested_content_types=(),
|
||||
applied_content_types=(),
|
||||
requested_languages=("en",),
|
||||
applied_languages=("en",),
|
||||
unsupported_filter_fields=(),
|
||||
),
|
||||
dense_candidates=(),
|
||||
sparse_candidates=(),
|
||||
fusion=FusionTrace(candidates=(_fused_candidate(),), suppressed_count=1),
|
||||
rerank=RerankTrace(
|
||||
state=rerank_state, degradation_code=None, candidates=(), elapsed_ms=0.0
|
||||
),
|
||||
parent_expansion=ParentExpansionTrace(1, 0, 40),
|
||||
context=ContextTrace(
|
||||
token_count=120, max_context_tokens=4000, block_count=1, citation_ids=()
|
||||
),
|
||||
answerability=AnswerabilityTrace(
|
||||
status="answerable",
|
||||
reasons=("sufficient_evidence",),
|
||||
evidence_count=1,
|
||||
document_diversity=1,
|
||||
source_independence=1,
|
||||
facet_coverage_ratio=1.0,
|
||||
thresholds_version="v1",
|
||||
),
|
||||
citations=(
|
||||
CitationTrace(
|
||||
id=uuid4(),
|
||||
document_id=DOCUMENT_ID,
|
||||
chunk_id=uuid4(),
|
||||
excerpt_preview="restart the service after the breaker",
|
||||
excerpt_truncated=False,
|
||||
location={"section": "Failover", "line_start": 12, "line_end": 14},
|
||||
source_uri=f"ragcore://source/{SOURCE_ID}",
|
||||
document_title="Gateway runbook",
|
||||
source_name="Infra runbooks",
|
||||
display="Gateway runbook · Failover · lines 12-14",
|
||||
),
|
||||
),
|
||||
timings_ms={"embed_query": 18.0, "dense_search": 8.0, "sparse_search": 6.0, "fusion": 1.0},
|
||||
revisions=RevisionTrace("hybrid/v1", None, "technical/v1", "rag_dense_hybrid_active", "v1"),
|
||||
degraded=False,
|
||||
degradation_codes=(),
|
||||
)
|
||||
|
||||
|
||||
def _catalog() -> QueryLabCatalog:
|
||||
return QueryLabCatalog(
|
||||
spaces=((SPACE_ID, "Production Knowledge Base"),),
|
||||
sources=((SOURCE_ID, "Infra runbooks", "Production Knowledge Base"),),
|
||||
documents=((DOCUMENT_ID, "Gateway runbook", "Production Knowledge Base"),),
|
||||
languages=("de", "en", "fr", "nl"),
|
||||
content_types=("text/markdown", "text/plain"),
|
||||
trust_levels=("trusted_internal", "user_uploaded"),
|
||||
)
|
||||
|
||||
|
||||
def _client(service: StubService, catalog: QueryLabCatalog | None = None) -> TestClient:
|
||||
app = create_app()
|
||||
principal = Principal(
|
||||
uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset({Role.SPACE_MANAGER})
|
||||
)
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.dependency_overrides[get_query_lab_service] = lambda: service
|
||||
app.dependency_overrides[get_query_lab_catalog] = lambda: catalog or _catalog()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_form_offers_selectors_from_the_catalog_and_an_advanced_section() -> None:
|
||||
client = _client(StubService(_trace()))
|
||||
body = client.get("/admin/query-lab").text
|
||||
assert "Production Knowledge Base" in body
|
||||
assert "Infra runbooks" in body
|
||||
assert "Gateway runbook" in body
|
||||
assert '<select id="space_ids" name="space_ids" multiple' in body
|
||||
assert '<select id="source_ids" name="source_ids" multiple' in body
|
||||
assert '<select id="document_ids" name="document_ids" multiple' in body
|
||||
assert '<select id="languages" name="languages" multiple' in body
|
||||
assert '<select id="trust_levels" name="trust_levels" multiple' in body
|
||||
assert '<select id="profile" name="profile"' in body
|
||||
assert "Advanced: raw identifiers" in body
|
||||
# Stale copy is gone.
|
||||
assert "Narrowing only" not in body
|
||||
assert "not yet applied server-side" not in body
|
||||
assert "not yet supported by the filter compiler" not in body
|
||||
|
||||
|
||||
def test_selected_values_become_server_side_filters() -> None:
|
||||
service = StubService(_trace())
|
||||
client = _client(service)
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={
|
||||
"query": "restart breaker",
|
||||
"profile": "hybrid-v1",
|
||||
"max_results": 5,
|
||||
"space_ids": [str(SPACE_ID)],
|
||||
"source_ids": [str(SOURCE_ID)],
|
||||
"document_ids": [str(DOCUMENT_ID)],
|
||||
"languages": ["en", "nl"],
|
||||
"trust_levels": ["trusted_internal"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
[query] = service.calls
|
||||
assert query.requested_space_ids == (SPACE_ID,)
|
||||
assert query.filters.source_ids == (SOURCE_ID,)
|
||||
assert query.filters.document_ids == (DOCUMENT_ID,)
|
||||
assert query.filters.languages == ("en", "nl")
|
||||
assert query.filters.trust_levels == ("trusted_internal",)
|
||||
|
||||
|
||||
def test_advanced_raw_ids_merge_with_selectors() -> None:
|
||||
service = StubService(_trace())
|
||||
client = _client(service)
|
||||
extra_source = uuid4()
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={
|
||||
"query": "restart breaker",
|
||||
"source_ids": [str(SOURCE_ID)],
|
||||
"raw_source_ids": f"{extra_source}, {SOURCE_ID}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
[query] = service.calls
|
||||
assert set(query.filters.source_ids) == {SOURCE_ID, extra_source}
|
||||
assert len(query.filters.source_ids) == 2
|
||||
|
||||
|
||||
def test_inspector_explains_fused_ranking_and_hides_inactive_reranking() -> None:
|
||||
client = _client(StubService(_trace()))
|
||||
body = client.post("/admin/query-lab/run", data={"query": "restart breaker"}).text
|
||||
assert "Retrieval inspector" in body
|
||||
assert "Fusion — RRF ranking" in body
|
||||
assert "Final rank" in body
|
||||
assert "#2 · 0.870" in body # dense rank + score
|
||||
assert "#1 · 12.500" in body # sparse rank + score
|
||||
assert "+0.0161" in body # RRF contribution 1/62
|
||||
assert "Gateway runbook" in body and "Infra runbooks" in body
|
||||
assert "Gateway runbook · Failover · lines 12-14" in body
|
||||
assert "no reranker is active" in body
|
||||
assert "Reranking (" not in body
|
||||
assert "Reranker profile" not in body
|
||||
assert "Generation is not run by Query Lab" in body
|
||||
assert "18.0 ms" in body and "embed_query" in body
|
||||
|
||||
|
||||
def test_reranking_section_appears_only_when_the_stage_is_active() -> None:
|
||||
client = _client(StubService(_trace(rerank_state="reranked")))
|
||||
body = client.post("/admin/query-lab/run", data={"query": "restart breaker"}).text
|
||||
assert "Reranking (reranked)" in body
|
||||
|
||||
|
||||
def test_unavailable_catalog_degrades_to_advanced_fields() -> None:
|
||||
client = _client(StubService(_trace()), QueryLabCatalog(available=False, languages=("en",)))
|
||||
body = client.get("/admin/query-lab").text
|
||||
assert "Selector lists could not be loaded" in body
|
||||
assert 'name="requested_space_ids"' in body
|
||||
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_principal
|
||||
from ragcore.api.v1.search.service import AuthorizationInputs
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.main import create_app
|
||||
from ragcore.web.query_lab.dependencies import get_query_lab_service
|
||||
from ragcore.web.query_lab.models import (
|
||||
AnswerabilityTrace,
|
||||
CandidateBackendLabel,
|
||||
CandidateTrace,
|
||||
CitationTrace,
|
||||
ContextTrace,
|
||||
EffectiveFilterTrace,
|
||||
FusionTrace,
|
||||
InclusionState,
|
||||
ParentExpansionTrace,
|
||||
QueryLabQuery,
|
||||
QueryLabTrace,
|
||||
RerankTrace,
|
||||
RevisionTrace,
|
||||
)
|
||||
from ragcore.web.query_lab.service import QueryLabService
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
DOCUMENT_ID = uuid4()
|
||||
|
||||
|
||||
class StubQueryLabService:
|
||||
def __init__(self, trace: QueryLabTrace | None = None, error: Exception | None = None) -> None:
|
||||
self._trace = trace
|
||||
self._error = error
|
||||
self.calls: list[QueryLabQuery] = []
|
||||
|
||||
def can_use(self, principal: Principal) -> bool:
|
||||
return True
|
||||
|
||||
async def run(self, principal: Principal, query: QueryLabQuery) -> QueryLabTrace:
|
||||
self.calls.append(query)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
assert self._trace is not None
|
||||
return self._trace
|
||||
|
||||
|
||||
def _principal(role: Role) -> Principal:
|
||||
return Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset({role}))
|
||||
|
||||
|
||||
def _candidate(text_preview: str = "hello world", **overrides: object) -> CandidateTrace:
|
||||
values: dict[str, object] = {
|
||||
"rank": 1,
|
||||
"chunk_id": uuid4(),
|
||||
"document_id": DOCUMENT_ID,
|
||||
"space_id": SPACE_ID,
|
||||
"backend": CandidateBackendLabel.DENSE,
|
||||
"score": 0.42,
|
||||
"score_label": "similarity_score",
|
||||
"content_type": "text/plain",
|
||||
"trust_level": "source",
|
||||
"language": "en",
|
||||
"text_preview": text_preview,
|
||||
"text_truncated": False,
|
||||
"inclusion": InclusionState.INCLUDED,
|
||||
"reason": "selected_for_fusion",
|
||||
}
|
||||
values.update(overrides)
|
||||
return CandidateTrace(**values)
|
||||
|
||||
|
||||
def _trace(**overrides: object) -> QueryLabTrace:
|
||||
values: dict[str, object] = {
|
||||
"retrieval_run_id": uuid4(),
|
||||
"query": "what does the runbook say",
|
||||
"profile": "hybrid-v1",
|
||||
"effective_filters": EffectiveFilterTrace(
|
||||
requested_space_ids=(SPACE_ID,),
|
||||
effective_space_ids=(SPACE_ID,),
|
||||
denied_space_ids=(),
|
||||
trust_required_space_ids=(),
|
||||
requested_content_types=(),
|
||||
applied_content_types=(),
|
||||
requested_languages=(),
|
||||
applied_languages=(),
|
||||
unsupported_filter_fields=(),
|
||||
),
|
||||
"dense_candidates": (_candidate(),),
|
||||
"sparse_candidates": (),
|
||||
"fusion": FusionTrace(
|
||||
candidates=(_candidate(backend=CandidateBackendLabel.FUSED),), suppressed_count=0
|
||||
),
|
||||
"rerank": RerankTrace(
|
||||
state="reranked", degradation_code=None, candidates=(), elapsed_ms=12.0
|
||||
),
|
||||
"parent_expansion": ParentExpansionTrace(0, 0, 0),
|
||||
"context": ContextTrace(
|
||||
token_count=10, max_context_tokens=4000, block_count=1, citation_ids=()
|
||||
),
|
||||
"answerability": AnswerabilityTrace(
|
||||
status="answerable",
|
||||
reasons=("sufficient_evidence",),
|
||||
evidence_count=2,
|
||||
document_diversity=2,
|
||||
source_independence=2,
|
||||
facet_coverage_ratio=1.0,
|
||||
thresholds_version="v1",
|
||||
),
|
||||
"citations": (
|
||||
CitationTrace(
|
||||
id=uuid4(),
|
||||
document_id=DOCUMENT_ID,
|
||||
chunk_id=uuid4(),
|
||||
excerpt_preview="the runbook says to restart the service",
|
||||
excerpt_truncated=False,
|
||||
location={"page": 1},
|
||||
source_uri="ragcore://source/abc",
|
||||
),
|
||||
),
|
||||
"timings_ms": {"embed_query": 5.0, "dense_search": 3.0},
|
||||
"revisions": RevisionTrace(
|
||||
"hybrid/v1", "query-lab/v1", "technical/v1", "rag_dense_hybrid_active", "v1"
|
||||
),
|
||||
"degraded": False,
|
||||
"degradation_codes": (),
|
||||
}
|
||||
values.update(overrides)
|
||||
return QueryLabTrace(**values)
|
||||
|
||||
|
||||
def _app(service: StubQueryLabService, principal: Principal) -> TestClient:
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.dependency_overrides[get_query_lab_service] = lambda: service
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_unauthenticated_request_is_rejected_without_reaching_the_service() -> None:
|
||||
service = StubQueryLabService(_trace())
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_query_lab_service] = lambda: service
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "test", "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_page_loads_with_accessible_landmarks_and_empty_state() -> None:
|
||||
client = _app(StubQueryLabService(), _principal(Role.SPACE_MANAGER))
|
||||
response = client.get("/admin/query-lab")
|
||||
assert response.status_code == 200
|
||||
assert 'href="#main-content"' in response.text
|
||||
assert '<nav aria-label="Primary navigation">' in response.text
|
||||
assert "Run a query above" in response.text
|
||||
|
||||
|
||||
class UnusableStubQueryLabService(StubQueryLabService):
|
||||
def can_use(self, principal: Principal) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def test_get_page_is_also_forbidden_for_an_unprivileged_principal() -> None:
|
||||
client = _app(UnusableStubQueryLabService(), _principal(Role.VIEWER))
|
||||
response = client.get("/admin/query-lab")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.text
|
||||
|
||||
|
||||
class DenyingInputsProvider:
|
||||
async def load(self, principal: Principal) -> AuthorizationInputs:
|
||||
return AuthorizationInputs(spaces=(), grants=(), user_grants=(), application=None)
|
||||
|
||||
|
||||
class ExplodingExecutor:
|
||||
async def execute(self, request): # pragma: no cover - must never be reached
|
||||
raise AssertionError("executor must not run for a denied principal")
|
||||
|
||||
|
||||
def test_viewer_role_is_forbidden_and_leaks_nothing() -> None:
|
||||
real_service = QueryLabService(
|
||||
authorization=AuthorizationService(),
|
||||
retrieval_authorization=RetrievalAuthorizationService(),
|
||||
inputs=DenyingInputsProvider(),
|
||||
executor=ExplodingExecutor(),
|
||||
)
|
||||
client = _app(real_service, _principal(Role.VIEWER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "secret question", "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.text
|
||||
assert "secret question" not in response.text
|
||||
assert "runbook" not in response.text
|
||||
|
||||
|
||||
def test_happy_path_shows_all_pipeline_phases() -> None:
|
||||
service = StubQueryLabService(_trace())
|
||||
client = _app(service, _principal(Role.SPACE_MANAGER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "what does the runbook say", "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "Effective authorization" in body
|
||||
assert "Answer & answerability" in body or "Answer" in body
|
||||
assert "answerable" in body
|
||||
assert "a category, not a confidence percentage" in body
|
||||
assert "not a probability" in body or "similarity_score" in body
|
||||
assert "the runbook says to restart the service" in body
|
||||
assert service.calls[0].query == "what does the runbook say"
|
||||
assert 'aria-label="Run summary"' in body
|
||||
assert "Evidence count" in body
|
||||
assert "8.0 ms" in body # sum of the fixture's real embed_query + dense_search timings
|
||||
|
||||
|
||||
def test_malicious_candidate_text_is_escaped_not_executed() -> None:
|
||||
malicious = "<script>alert('xss')</script>"
|
||||
trace = _trace(dense_candidates=(_candidate(text_preview=malicious),))
|
||||
client = _app(StubQueryLabService(trace), _principal(Role.SPACE_MANAGER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "test", "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "<script>alert" not in response.text
|
||||
assert "<script>" in response.text
|
||||
|
||||
|
||||
def test_degraded_run_shows_a_visible_non_color_only_banner() -> None:
|
||||
trace = _trace(degraded=True, degradation_codes=("no_active_embedding_profile",))
|
||||
client = _app(StubQueryLabService(trace), _principal(Role.SPACE_MANAGER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "test", "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Degraded run" in response.text
|
||||
assert "no_active_embedding_profile" in response.text
|
||||
assert 'role="alert"' in response.text
|
||||
|
||||
|
||||
def test_oversized_query_is_rejected_before_reaching_the_service() -> None:
|
||||
service = StubQueryLabService(_trace())
|
||||
client = _app(service, _principal(Role.SPACE_MANAGER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "x" * 8001, "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_invalid_requested_space_id_is_a_stable_bad_request() -> None:
|
||||
service = StubQueryLabService(_trace())
|
||||
client = _app(service, _principal(Role.SPACE_MANAGER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={
|
||||
"query": "test",
|
||||
"profile": "hybrid-v1",
|
||||
"max_results": 10,
|
||||
"requested_space_ids": "not-a-uuid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_response_never_contains_secret_or_credential_markers() -> None:
|
||||
client = _app(StubQueryLabService(_trace()), _principal(Role.SPACE_MANAGER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "test", "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
body_lower = response.text.lower()
|
||||
markers = (
|
||||
"api_key",
|
||||
"api_token_pepper",
|
||||
"secret_key",
|
||||
"authorization: bearer",
|
||||
"qdrant_api_key",
|
||||
)
|
||||
for marker in markers:
|
||||
assert marker not in body_lower
|
||||
|
||||
|
||||
def test_response_never_contains_a_raw_embedding_vector_shape() -> None:
|
||||
client = _app(StubQueryLabService(_trace()), _principal(Role.SPACE_MANAGER))
|
||||
response = client.post(
|
||||
"/admin/query-lab/run",
|
||||
data={"query": "test", "profile": "hybrid-v1", "max_results": 10},
|
||||
)
|
||||
assert "dense_vector" not in response.text
|
||||
assert "sparse_indices" not in response.text
|
||||
@@ -0,0 +1,150 @@
|
||||
"""'/admin/dashboard': real, never-fabricated per-section workspace counts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_control_application, get_principal
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.control.api import ControlPlaneApplication
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Application, KnowledgeSpace, Policy, Role, Workspace
|
||||
from ragcore.main import create_app
|
||||
|
||||
|
||||
class FakeControlRepository:
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self.workspace = workspace
|
||||
self.applications: list[Application] = []
|
||||
self.spaces: list[KnowledgeSpace] = []
|
||||
self.policies: list[Policy] = []
|
||||
self.grants: list[object] = []
|
||||
|
||||
async def get_workspace(self, workspace_id: object) -> Workspace | None:
|
||||
return self.workspace if workspace_id == self.workspace.id else None
|
||||
|
||||
async def list_applications(self, workspace_id: object) -> tuple[Application, ...]:
|
||||
return tuple(self.applications)
|
||||
|
||||
async def list_spaces(self, workspace_id: object) -> tuple[KnowledgeSpace, ...]:
|
||||
return tuple(self.spaces)
|
||||
|
||||
async def list_policies(self, workspace_id: object) -> tuple[Policy, ...]:
|
||||
return tuple(self.policies)
|
||||
|
||||
async def list_grants(self, workspace_id: object) -> tuple[object, ...]:
|
||||
return tuple(self.grants)
|
||||
|
||||
|
||||
def _client(principal: Principal, repository: FakeControlRepository) -> TestClient:
|
||||
service = ControlPlaneApplication(repository, AuthorizationService())
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.dependency_overrides[get_control_application] = lambda: service
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def test_dashboard_requires_authentication() -> None:
|
||||
client = TestClient(create_app(), raise_server_exceptions=False)
|
||||
response = client.get("/admin/dashboard")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_dashboard_shows_real_counts_for_a_workspace_admin() -> None:
|
||||
workspace = Workspace(uuid4(), "Dash workspace", "dash-workspace")
|
||||
principal = Principal(
|
||||
uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.WORKSPACE_ADMIN})
|
||||
)
|
||||
repository = FakeControlRepository(workspace)
|
||||
repository.applications.append(
|
||||
workspace.create_application(id=uuid4(), name="Ingest", slug="ingest")
|
||||
)
|
||||
client = _client(principal, repository)
|
||||
|
||||
response = client.get("/admin/dashboard")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="#main-content"' in response.text
|
||||
assert '<nav aria-label="Primary navigation">' in response.text
|
||||
assert "registered application</p>" in response.text
|
||||
assert "Not configured" in response.text
|
||||
|
||||
|
||||
def test_dashboard_presents_truthful_source_to_answer_lifecycle() -> None:
|
||||
workspace = Workspace(uuid4(), "Flow workspace", "flow-workspace")
|
||||
principal = Principal(
|
||||
uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.WORKSPACE_ADMIN})
|
||||
)
|
||||
repository = FakeControlRepository(workspace)
|
||||
response = _client(principal, repository).get("/admin/dashboard")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-shell-title="Dashboard"' in response.text
|
||||
assert 'data-rag-lifecycle="source-to-answer"' in response.text
|
||||
assert 'href="/admin/sources"' in response.text
|
||||
assert 'href="/admin/jobs"' in response.text
|
||||
assert 'href="/admin/query-lab"' in response.text
|
||||
assert 'href="/admin/control/applications"' in response.text
|
||||
assert "Dense + sparse" in response.text
|
||||
assert "RRF fusion" in response.text
|
||||
assert "versioned citations" in response.text
|
||||
assert "reranking" not in response.text.lower()
|
||||
|
||||
|
||||
def test_product_styles_define_tokens_responsiveness_and_reduced_motion() -> None:
|
||||
client = TestClient(create_app())
|
||||
response = client.get("/static/admin.css")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "--color-accent:" in response.text
|
||||
assert ".lifecycle-grid" in response.text
|
||||
assert "@media (max-width: 767px)" in response.text
|
||||
assert "@media (prefers-reduced-motion: reduce)" in response.text
|
||||
assert "animation-duration: 0.01ms" in response.text
|
||||
|
||||
script = client.get("/static/admin.js")
|
||||
assert script.status_code == 200
|
||||
assert 'matchMedia("(min-width: 1024px)")' in script.text
|
||||
assert 'toggleAttribute("inert"' in script.text
|
||||
assert 'addEventListener("change"' in script.text
|
||||
|
||||
|
||||
def test_dashboard_marks_sections_restricted_for_an_unprivileged_principal() -> None:
|
||||
workspace = Workspace(uuid4(), "Dash workspace", "dash-workspace")
|
||||
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.VIEWER}))
|
||||
repository = FakeControlRepository(workspace)
|
||||
client = _client(principal, repository)
|
||||
|
||||
response = client.get("/admin/dashboard")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "You are not authorized to view applications." in response.text
|
||||
assert "You are not authorized to view knowledge spaces." in response.text
|
||||
assert "You are not authorized to view policies." in response.text
|
||||
assert "Restricted to platform administrators." in response.text
|
||||
|
||||
|
||||
class _StubProfileActivationService:
|
||||
async def list_recent(self, limit: int = 1) -> tuple[object, ...]:
|
||||
return ()
|
||||
|
||||
|
||||
def test_dashboard_shows_empty_state_for_a_workspace_with_nothing_yet() -> None:
|
||||
workspace = Workspace(uuid4(), "Empty workspace", "empty-workspace")
|
||||
principal = Principal(
|
||||
uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.PLATFORM_ADMIN})
|
||||
)
|
||||
repository = FakeControlRepository(workspace)
|
||||
service = ControlPlaneApplication(repository, AuthorizationService())
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.dependency_overrides[get_control_application] = lambda: service
|
||||
app.state.profile_activation_service = _StubProfileActivationService()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
response = client.get("/admin/dashboard")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No profile activation decisions have been recorded yet." in response.text
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Global Documents, Ingestion Jobs (detail/retry) and System Health screens
|
||||
render real state and enforce role gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_control_application, get_principal
|
||||
from ragcore.application.documents import (
|
||||
ArtifactRecord,
|
||||
ChunkRecord,
|
||||
DocumentDetail,
|
||||
DocumentFailureCode,
|
||||
DocumentListFilter,
|
||||
DocumentListPage,
|
||||
DocumentRecord,
|
||||
DocumentStatusRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.jobs import Job, JobAttempt, JobListFilter, JobListPage, JobStatus
|
||||
from ragcore.application.sources import SourceRecord
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import KnowledgeSpace, Role
|
||||
from ragcore.domain.sources import ConnectorKind
|
||||
from ragcore.main import create_app
|
||||
from ragcore.web.documents import get_source_registry
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
SOURCE_ID = uuid4()
|
||||
JOB_ID = uuid4()
|
||||
|
||||
|
||||
def _principal(role: Role = Role.WORKSPACE_ADMIN) -> Principal:
|
||||
return Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset({role}))
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
def __init__(self) -> None:
|
||||
self.source = SourceRecord(
|
||||
id=SOURCE_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
name="Infra runbooks",
|
||||
kind=ConnectorKind.GIT,
|
||||
)
|
||||
|
||||
async def get_sources(
|
||||
self, source_ids: tuple[UUID, ...], *, workspace_id: UUID | None = None
|
||||
) -> dict[UUID, SourceRecord]:
|
||||
if workspace_id is not None and self.source.workspace_id != workspace_id:
|
||||
return {}
|
||||
return {SOURCE_ID: self.source} if SOURCE_ID in source_ids else {}
|
||||
|
||||
async def get_source(self, source_id: UUID) -> SourceRecord | None:
|
||||
return self.source if source_id == SOURCE_ID else None
|
||||
|
||||
async def get_owned_source(
|
||||
self, source_id: UUID, *, workspace_id: UUID, space_id: UUID | None = None
|
||||
) -> SourceRecord | None:
|
||||
if source_id != SOURCE_ID or workspace_id != WORKSPACE_ID:
|
||||
return None
|
||||
if space_id is not None and space_id != SPACE_ID:
|
||||
return None
|
||||
return self.source
|
||||
|
||||
|
||||
class FakeControl:
|
||||
def __init__(self) -> None:
|
||||
self.document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
source_key=str(SOURCE_ID),
|
||||
external_id="runbooks/backup.md",
|
||||
title="Backup runbook",
|
||||
source_id=SOURCE_ID,
|
||||
)
|
||||
self.failed_document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
source_key=str(SOURCE_ID),
|
||||
external_id="broken.pdf",
|
||||
title="Broken scan",
|
||||
source_id=SOURCE_ID,
|
||||
)
|
||||
self.version = DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=self.document.id,
|
||||
source_fingerprint="a" * 64,
|
||||
content_hash="b" * 64,
|
||||
parser_version="text-parser-1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=DocumentVersionState.PUBLISHED,
|
||||
created_at=datetime(2026, 8, 20, 9, 0, tzinfo=UTC),
|
||||
job_id=JOB_ID,
|
||||
source_revision="3fc821a9bd",
|
||||
)
|
||||
self.failed_version = DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=self.failed_document.id,
|
||||
source_fingerprint="c" * 64,
|
||||
content_hash="c" * 64,
|
||||
parser_version="none",
|
||||
chunk_profile_version="none",
|
||||
state=DocumentVersionState.FAILED,
|
||||
created_at=datetime(2026, 8, 20, 9, 5, tzinfo=UTC),
|
||||
job_id=uuid4(),
|
||||
failure_code=DocumentFailureCode.SECURITY_QUARANTINED.value,
|
||||
failure_retryable=False,
|
||||
failed_at=datetime(2026, 8, 20, 9, 6, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def list_spaces(self, principal: Principal) -> tuple[KnowledgeSpace, ...]:
|
||||
return (
|
||||
KnowledgeSpace(
|
||||
id=SPACE_ID, workspace_id=WORKSPACE_ID, name="Runbooks space", slug="runbooks"
|
||||
),
|
||||
)
|
||||
|
||||
async def list_workspace_documents(self, principal: Principal, filters: DocumentListFilter):
|
||||
items = [
|
||||
DocumentStatusRecord(
|
||||
document=self.document,
|
||||
latest_state=DocumentVersionState.PUBLISHED,
|
||||
parser_version="text-parser-1",
|
||||
chunk_profile_version="prose-v1",
|
||||
updated_at=datetime(2026, 8, 20, 9, 0, tzinfo=UTC),
|
||||
),
|
||||
DocumentStatusRecord(
|
||||
document=self.failed_document,
|
||||
latest_state=DocumentVersionState.FAILED,
|
||||
parser_version="none",
|
||||
chunk_profile_version="none",
|
||||
updated_at=datetime(2026, 8, 20, 9, 6, tzinfo=UTC),
|
||||
failure_code=DocumentFailureCode.SECURITY_QUARANTINED.value,
|
||||
failure_retryable=False,
|
||||
),
|
||||
]
|
||||
if filters.state == "failed":
|
||||
items = [item for item in items if item.failed]
|
||||
if filters.search:
|
||||
items = [
|
||||
item for item in items if filters.search.lower() in item.document.title.lower()
|
||||
]
|
||||
return DocumentListPage(items=tuple(items), total=len(items), limit=filters.limit, offset=0)
|
||||
|
||||
async def get_document_detail(
|
||||
self, principal: Principal, document_id: UUID, *, version_id: UUID | None = None
|
||||
):
|
||||
if document_id == self.document.id:
|
||||
chunk = ChunkRecord(
|
||||
id=uuid4(),
|
||||
document_version_id=self.version.id,
|
||||
structure_path="doc/section[1]/p[2]",
|
||||
content_hash="d" * 64,
|
||||
chunk_profile_version="prose-v1",
|
||||
content="Run the nightly snapshot SNAP-22 before midnight.",
|
||||
token_count=9,
|
||||
metadata={
|
||||
"role": "child",
|
||||
"section": "Nightly snapshot",
|
||||
"line_start": 12,
|
||||
"line_end": 14,
|
||||
"content_type": "text/markdown",
|
||||
"language": "en",
|
||||
"trust_level": "trusted_internal",
|
||||
},
|
||||
)
|
||||
return DocumentDetail(
|
||||
document=self.document,
|
||||
versions=(self.version,),
|
||||
shown_version=self.version,
|
||||
chunks=(chunk,),
|
||||
artifacts=(
|
||||
ArtifactRecord(
|
||||
id=uuid4(),
|
||||
document_version_id=self.version.id,
|
||||
kind="canonical",
|
||||
content_hash="e" * 64,
|
||||
storage_key="parsed/ee",
|
||||
size_bytes=1234,
|
||||
),
|
||||
),
|
||||
)
|
||||
if document_id == self.failed_document.id:
|
||||
return DocumentDetail(
|
||||
document=self.failed_document,
|
||||
versions=(self.failed_version,),
|
||||
shown_version=self.failed_version,
|
||||
chunks=(),
|
||||
artifacts=(),
|
||||
)
|
||||
raise LookupError("document not found")
|
||||
|
||||
async def get_document_by_source(
|
||||
self, workspace_id: UUID, source_key: str, external_id: str
|
||||
) -> DocumentRecord | None:
|
||||
if (
|
||||
workspace_id == WORKSPACE_ID
|
||||
and source_key == str(SOURCE_ID)
|
||||
and external_id == self.document.external_id
|
||||
):
|
||||
return self.document
|
||||
return None
|
||||
|
||||
|
||||
class FakeJobRepository:
|
||||
def __init__(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
self.jobs: dict[UUID, Job] = {
|
||||
JOB_ID: Job(
|
||||
id=JOB_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
job_key="k1",
|
||||
operation="ingest_upload",
|
||||
target_key="digest",
|
||||
profile_version="upload-v1",
|
||||
payload={
|
||||
"original_name": "backup.md",
|
||||
"external_id": "runbooks/backup.md",
|
||||
"source_id": str(SOURCE_ID),
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"space_id": str(SPACE_ID),
|
||||
"media_type": "text/markdown",
|
||||
},
|
||||
status=JobStatus.TERMINAL,
|
||||
available_at=now - timedelta(minutes=5),
|
||||
attempt_count=3,
|
||||
last_error_code="retryable_error",
|
||||
last_error_details={"failure_code": "embedding_failed"},
|
||||
)
|
||||
}
|
||||
self.requeued: list[UUID] = []
|
||||
self.scopes: list[UUID | None] = []
|
||||
self.fail_requeue_race = False
|
||||
|
||||
async def list_jobs(
|
||||
self, *, workspace_id: UUID | None, filters: JobListFilter
|
||||
) -> JobListPage:
|
||||
self.scopes.append(workspace_id)
|
||||
matching = [
|
||||
job
|
||||
for job in self.jobs.values()
|
||||
if (workspace_id is None or job.workspace_id == workspace_id)
|
||||
and (filters.status is None or job.status is filters.status)
|
||||
and (filters.operation is None or job.operation == filters.operation)
|
||||
]
|
||||
return JobListPage(
|
||||
items=tuple(matching[filters.offset : filters.offset + filters.limit]),
|
||||
total=len(matching),
|
||||
limit=filters.limit,
|
||||
offset=filters.offset,
|
||||
)
|
||||
|
||||
async def status_counts(self, *, workspace_id: UUID | None) -> dict[str, int]:
|
||||
self.scopes.append(workspace_id)
|
||||
return {"terminal": 1}
|
||||
|
||||
async def get(self, job_id: UUID) -> Job | None:
|
||||
return self.jobs.get(job_id)
|
||||
|
||||
async def get_owned(self, job_id: UUID, *, workspace_id: UUID | None) -> Job | None:
|
||||
self.scopes.append(workspace_id)
|
||||
job = self.jobs.get(job_id)
|
||||
if job is None or (workspace_id is not None and job.workspace_id != workspace_id):
|
||||
return None
|
||||
return job
|
||||
|
||||
async def list_attempts(self, job_id: UUID) -> tuple[JobAttempt, ...]:
|
||||
started = datetime(2026, 8, 20, 9, 0, tzinfo=UTC)
|
||||
return (
|
||||
JobAttempt(
|
||||
attempt_number=1,
|
||||
worker_id="worker-1",
|
||||
status="retry_wait",
|
||||
leased_at=started,
|
||||
heartbeat_at=started,
|
||||
finished_at=started + timedelta(seconds=12),
|
||||
error_code="retryable_error",
|
||||
),
|
||||
)
|
||||
|
||||
async def requeue(
|
||||
self, job_id: UUID, *, workspace_id: UUID | None, now: datetime
|
||||
) -> Job | None:
|
||||
self.scopes.append(workspace_id)
|
||||
if self.fail_requeue_race:
|
||||
return None
|
||||
job = self.jobs.get(job_id)
|
||||
if job is None or job.status is not JobStatus.TERMINAL:
|
||||
return None
|
||||
if workspace_id is not None and job.workspace_id != workspace_id:
|
||||
return None
|
||||
self.requeued.append(job_id)
|
||||
return job
|
||||
|
||||
|
||||
def _client(
|
||||
principal: Principal | None = None,
|
||||
*,
|
||||
registry: object | None = None,
|
||||
document_repository: object | None = None,
|
||||
) -> tuple[TestClient, FakeJobRepository, FakeControl]:
|
||||
app = create_app()
|
||||
control = FakeControl()
|
||||
jobs = FakeJobRepository()
|
||||
app.dependency_overrides[get_principal] = lambda: principal or _principal()
|
||||
app.dependency_overrides[get_control_application] = lambda: control
|
||||
app.dependency_overrides[get_source_registry] = lambda: registry or FakeRegistry()
|
||||
app.state.job_repository = jobs
|
||||
app.state.document_repository = document_repository or control
|
||||
app.state.session_factory = None # no real DB: audit/source-name lookups must degrade quietly
|
||||
return TestClient(app), jobs, control
|
||||
|
||||
|
||||
def test_documents_list_shows_state_source_and_filters() -> None:
|
||||
client, _, control = _client()
|
||||
response = client.get("/admin/documents")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "Backup runbook" in body and "Broken scan" in body
|
||||
assert "Infra runbooks" in body # source name, not a UUID
|
||||
assert "Runbooks space" in body
|
||||
assert "Searchable" in body and "Failed" in body
|
||||
assert "security_quarantined" in body
|
||||
assert f'href="/admin/sources/{SOURCE_ID}"' in body
|
||||
assert 'data-operations-journey="source-to-search"' in body
|
||||
assert 'data-mobile-records="documents"' in body
|
||||
assert 'data-status-tone="ok"' in body and 'status-badge__icon' in body
|
||||
failed_only = client.get("/admin/documents?state=failed")
|
||||
assert "Broken scan" in failed_only.text and "Backup runbook" not in failed_only.text
|
||||
assert client.get(f"/admin/documents/{control.document.id}").status_code == 200
|
||||
|
||||
|
||||
def test_document_pagination_preserves_the_source_and_state_filters() -> None:
|
||||
client, _, _ = _client()
|
||||
|
||||
response = client.get(
|
||||
f"/admin/documents?source_id={SOURCE_ID}&state=published&limit=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
f"source_id={SOURCE_ID}&state=published&offset=1&limit=1"
|
||||
in response.text
|
||||
)
|
||||
assert "Filtered to Infra runbooks" in response.text
|
||||
|
||||
|
||||
def test_filtered_document_empty_state_does_not_suggest_initial_setup() -> None:
|
||||
client, _, _ = _client()
|
||||
|
||||
response = client.get("/admin/documents?q=no-such-document")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No documents match these filters" in response.text
|
||||
assert "Clear filters" in response.text
|
||||
assert "Upload a document into a knowledge space" not in response.text
|
||||
|
||||
|
||||
def test_document_detail_shows_chunks_provenance_versions_and_jobs() -> None:
|
||||
client, _, control = _client()
|
||||
response = client.get(f"/admin/documents/{control.document.id}")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "Run the nightly snapshot SNAP-22" in body
|
||||
assert "Nightly snapshot" in body and "lines 12" in body
|
||||
assert "doc/section[1]/p[2]" in body
|
||||
assert "3fc821a9bd" in body # source revision
|
||||
assert "text/markdown" in body and "trusted_internal" in body
|
||||
assert f'href="/admin/jobs/{JOB_ID}"' in body
|
||||
assert "Infra runbooks" in body
|
||||
|
||||
|
||||
def test_failed_document_detail_explains_failure_and_links_job() -> None:
|
||||
client, _, control = _client()
|
||||
response = client.get(f"/admin/documents/{control.failed_document.id}")
|
||||
assert response.status_code == 200
|
||||
assert 'role="alert"' in response.text
|
||||
assert "security gate quarantined" in response.text
|
||||
assert "not retryable" in response.text
|
||||
assert "failed before any chunk" in response.text
|
||||
|
||||
|
||||
def test_unknown_document_is_404() -> None:
|
||||
client, _, _ = _client()
|
||||
response = client.get(f"/admin/documents/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
assert "Document not found" in response.text
|
||||
|
||||
|
||||
def test_jobs_list_and_detail_show_real_fields_and_retry_for_retryable_terminal_jobs() -> None:
|
||||
client, jobs, control = _client()
|
||||
listing = client.get("/admin/jobs")
|
||||
assert listing.status_code == 200
|
||||
assert "Ingest document" in listing.text
|
||||
assert "backup.md" in listing.text
|
||||
assert "embedding_failed" in listing.text
|
||||
detail = client.get(f"/admin/jobs/{JOB_ID}")
|
||||
assert detail.status_code == 200
|
||||
assert "worker-1" in detail.text
|
||||
assert "Retry" in detail.text
|
||||
assert "12.0 s" in detail.text
|
||||
assert f'href="/admin/documents/{control.document.id}"' in detail.text
|
||||
assert 'data-operations-journey="source-to-search"' in detail.text
|
||||
retried = client.post(f"/admin/jobs/{JOB_ID}/retry", follow_redirects=False)
|
||||
assert retried.status_code == 303
|
||||
assert jobs.requeued == [JOB_ID]
|
||||
|
||||
|
||||
def test_job_detail_never_links_a_document_from_another_workspace() -> None:
|
||||
foreign_document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=SPACE_ID,
|
||||
source_key=str(SOURCE_ID),
|
||||
external_id="runbooks/backup.md",
|
||||
title="Foreign runbook",
|
||||
source_id=SOURCE_ID,
|
||||
)
|
||||
|
||||
class ForeignDocumentRepository:
|
||||
async def get_document_by_source(
|
||||
self, workspace_id: UUID, source_key: str, external_id: str
|
||||
) -> DocumentRecord | None:
|
||||
return foreign_document
|
||||
|
||||
client, _, _ = _client(document_repository=ForeignDocumentRepository())
|
||||
|
||||
response = client.get(f"/admin/jobs/{JOB_ID}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'/admin/documents/{foreign_document.id}' not in response.text
|
||||
|
||||
|
||||
def test_job_links_are_suppressed_when_the_target_route_is_not_authorized() -> None:
|
||||
auditor, _, control = _client(_principal(Role.AUDITOR))
|
||||
|
||||
auditor_list = auditor.get("/admin/jobs")
|
||||
auditor_detail = auditor.get(f"/admin/jobs/{JOB_ID}")
|
||||
|
||||
assert auditor_list.status_code == 200
|
||||
assert '<span title="Sources"' in auditor_list.text
|
||||
assert '<span title="Documents"' in auditor_list.text
|
||||
assert auditor_detail.status_code == 200
|
||||
assert f'href="/admin/sources/{SOURCE_ID}"' not in auditor_detail.text
|
||||
assert f'href="/admin/documents/{control.document.id}"' not in auditor_detail.text
|
||||
assert "Document unavailable" in auditor_detail.text
|
||||
|
||||
foreign_workspace = uuid4()
|
||||
platform = Principal(
|
||||
uuid4(), foreign_workspace, PrincipalType.HUMAN, frozenset({Role.PLATFORM_ADMIN})
|
||||
)
|
||||
platform_client, _, _ = _client(platform)
|
||||
platform_detail = platform_client.get(f"/admin/jobs/{JOB_ID}")
|
||||
|
||||
assert platform_detail.status_code == 200
|
||||
assert f'href="/admin/sources/{SOURCE_ID}"' not in platform_detail.text
|
||||
assert f'href="/admin/documents/{control.document.id}"' not in platform_detail.text
|
||||
|
||||
|
||||
def test_document_links_are_suppressed_when_related_resources_are_unavailable() -> None:
|
||||
viewer, _, control = _client(_principal(Role.VIEWER))
|
||||
|
||||
listing = viewer.get("/admin/documents")
|
||||
detail = viewer.get(f"/admin/documents/{control.document.id}")
|
||||
failed = viewer.get(f"/admin/documents/{control.failed_document.id}")
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert '<span title="Sources"' in listing.text
|
||||
assert '<span title="Processing"' in listing.text
|
||||
assert f'href="/admin/sources/{SOURCE_ID}"' not in listing.text
|
||||
assert detail.status_code == 200
|
||||
assert f'href="/admin/sources/{SOURCE_ID}"' not in detail.text
|
||||
assert f'href="/admin/jobs/{JOB_ID}"' not in detail.text
|
||||
assert "Source unavailable" in detail.text
|
||||
assert failed.status_code == 200
|
||||
assert f'href="/admin/jobs/{control.failed_version.job_id}"' not in failed.text
|
||||
assert "Job: <span" in failed.text
|
||||
|
||||
|
||||
def test_retry_reports_a_concurrent_state_change_instead_of_claiming_success() -> None:
|
||||
client, jobs, _ = _client()
|
||||
jobs.fail_requeue_race = True
|
||||
|
||||
response = client.post(f"/admin/jobs/{JOB_ID}/retry", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "changed before it could be requeued" in response.text
|
||||
assert "notice=retried" not in response.text
|
||||
assert jobs.requeued == []
|
||||
|
||||
|
||||
def test_operational_tables_have_an_intentional_compact_mobile_contract() -> None:
|
||||
client, _, _ = _client()
|
||||
|
||||
jobs = client.get("/admin/jobs")
|
||||
styles = client.get("/static/admin.css")
|
||||
|
||||
assert 'data-mobile-records="jobs"' in jobs.text
|
||||
assert 'data-label="Status"' in jobs.text
|
||||
assert "[data-mobile-records]" in styles.text
|
||||
assert "content: attr(data-label)" in styles.text
|
||||
assert "min-height: 2.75rem" in styles.text
|
||||
|
||||
|
||||
def test_auditor_sees_jobs_but_cannot_retry_and_viewer_is_forbidden() -> None:
|
||||
client, jobs, _ = _client(_principal(Role.AUDITOR))
|
||||
assert client.get("/admin/jobs").status_code == 200
|
||||
detail = client.get(f"/admin/jobs/{JOB_ID}")
|
||||
assert detail.status_code == 200
|
||||
assert 'action="/admin/jobs/' not in detail.text
|
||||
assert client.post(f"/admin/jobs/{JOB_ID}/retry").status_code == 403
|
||||
assert jobs.requeued == []
|
||||
viewer, _, _ = _client(_principal(Role.VIEWER))
|
||||
assert viewer.get("/admin/jobs").status_code == 403
|
||||
assert viewer.get("/admin/system").status_code == 403
|
||||
|
||||
|
||||
def test_system_page_renders_runtime_facts() -> None:
|
||||
client, _, _ = _client()
|
||||
response = client.get("/admin/system")
|
||||
assert response.status_code == 200
|
||||
assert "System Health" in response.text
|
||||
assert "RAGCore version" in response.text
|
||||
assert "Ingestion queue" in response.text
|
||||
@@ -0,0 +1,124 @@
|
||||
"""'/' root redirect and the '/login' landing page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.application.auth import SessionService, SignedTokenCodec
|
||||
from ragcore.application.auth.ports import AuthorizationChallenge
|
||||
from ragcore.domain.auth import OidcClaims
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.main import create_app
|
||||
from ragcore.web.auth.dependencies import get_session_codec, get_session_service
|
||||
|
||||
SECRET = b"0" * 32
|
||||
|
||||
|
||||
class FakeOidcProvider:
|
||||
def __init__(self, claims: OidcClaims) -> None:
|
||||
self.claims = claims
|
||||
|
||||
async def build_authorization_challenge(self, *, redirect_uri: str) -> AuthorizationChallenge:
|
||||
return AuthorizationChallenge(
|
||||
redirect_url="https://idp.example.test/authorize?mock=1",
|
||||
state="fixed-state-for-tests",
|
||||
nonce="fixed-nonce-for-tests",
|
||||
code_verifier="fixed-verifier-for-tests",
|
||||
)
|
||||
|
||||
async def complete_login(
|
||||
self, *, code: str, code_verifier: str, nonce: str, redirect_uri: str
|
||||
) -> OidcClaims:
|
||||
return self.claims
|
||||
|
||||
|
||||
def _signed_in_client() -> TestClient:
|
||||
provider = FakeOidcProvider(
|
||||
OidcClaims(subject="alice", email="alice@example.test", groups=("ragcore-editors",))
|
||||
)
|
||||
service = SessionService(
|
||||
provider=provider, group_roles={"ragcore-editors": Role.EDITOR}, workspace_id=uuid4()
|
||||
)
|
||||
codec = SignedTokenCodec(secret=SECRET)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_session_service] = lambda: service
|
||||
app.dependency_overrides[get_session_codec] = lambda: codec
|
||||
# SessionAuthenticationMiddleware reads the codec straight off app.state
|
||||
# (it runs outside FastAPI's dependency-injection system), so the
|
||||
# dependency override above -- which only affects the /auth/* routes --
|
||||
# is not enough to make it recognize the cookie those routes just set.
|
||||
app.state.auth_session_codec = codec
|
||||
client = TestClient(app, follow_redirects=False)
|
||||
client.get("/auth/login")
|
||||
client.get("/auth/callback", params={"code": "auth-code", "state": "fixed-state-for-tests"})
|
||||
return client
|
||||
|
||||
|
||||
def test_root_redirects_unauthenticated_visitors_to_login() -> None:
|
||||
client = TestClient(create_app(), follow_redirects=False)
|
||||
response = client.get("/")
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_root_redirects_authenticated_visitors_to_dashboard() -> None:
|
||||
client = _signed_in_client()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/admin/dashboard"
|
||||
|
||||
|
||||
def test_login_page_shows_not_configured_when_oidc_is_absent() -> None:
|
||||
client = TestClient(create_app(), follow_redirects=False)
|
||||
response = client.get("/login")
|
||||
assert response.status_code == 200
|
||||
assert "No identity provider is configured" in response.text
|
||||
assert "Sign in with SSO" not in response.text
|
||||
assert "OIDC secured" not in response.text
|
||||
assert "Setup required" in response.text
|
||||
|
||||
|
||||
def test_login_page_offers_sso_when_oidc_is_configured() -> None:
|
||||
app = create_app()
|
||||
app.state.session_service = object()
|
||||
client = TestClient(app, follow_redirects=False)
|
||||
response = client.get("/login")
|
||||
assert response.status_code == 200
|
||||
assert "Sign in with SSO" in response.text
|
||||
assert 'href="/auth/login"' in response.text
|
||||
|
||||
|
||||
def test_login_page_introduces_the_real_ragcore_value_flow() -> None:
|
||||
app = create_app()
|
||||
app.state.session_service = object()
|
||||
response = TestClient(app, follow_redirects=False).get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-auth-stage="ragcore"' in response.text
|
||||
assert "Private knowledge. Verifiable answers." in response.text
|
||||
assert 'data-rag-flow="sources-retrieval-answers"' in response.text
|
||||
assert "Dense + sparse" in response.text
|
||||
assert "Cited answers" in response.text
|
||||
assert 'aria-describedby="sign-in-description"' in response.text
|
||||
assert "https://" not in response.text
|
||||
|
||||
|
||||
def test_login_page_declares_a_loadable_local_favicon() -> None:
|
||||
client = TestClient(create_app(), follow_redirects=False)
|
||||
|
||||
response = client.get("/login")
|
||||
favicon = client.get("/static/favicon.svg")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">' in response.text
|
||||
assert favicon.status_code == 200
|
||||
assert favicon.headers["content-type"].startswith("image/svg+xml")
|
||||
|
||||
|
||||
def test_login_page_redirects_an_already_authenticated_visitor() -> None:
|
||||
client = _signed_in_client()
|
||||
response = client.get("/login")
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"] == "/admin/dashboard"
|
||||
@@ -0,0 +1,146 @@
|
||||
"""What the jobs screen asks for, and what its links carry forward.
|
||||
|
||||
The repository-level proof that filtering happens in SQL lives in
|
||||
``tests/integration/jobs/test_job_listing_filters.py``. This is the other
|
||||
half: that the *route* pushes the operator's filter down instead of narrowing
|
||||
rows it already fetched, and that navigating a page does not silently drop the
|
||||
filter the operator is looking at.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_principal
|
||||
from ragcore.application.jobs import Job, JobListFilter, JobListPage, JobStatus
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.main import create_app
|
||||
|
||||
WORKSPACE = uuid4()
|
||||
NOW = datetime(2026, 8, 20, 9, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _job(index: int, *, status: JobStatus, operation: str) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE,
|
||||
job_key=f"job-{index}",
|
||||
operation=operation,
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
payload={"original_name": f"document-{index}.md"},
|
||||
status=status,
|
||||
available_at=NOW - timedelta(minutes=index),
|
||||
trace_id=f"trace-{index}",
|
||||
)
|
||||
|
||||
|
||||
class RecordingJobRepository:
|
||||
"""A store big enough that a page is not the whole result set."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.jobs = [
|
||||
_job(
|
||||
index,
|
||||
status=JobStatus.TERMINAL if index % 2 else JobStatus.READY,
|
||||
operation="sync_source" if index % 3 == 0 else "ingest_upload",
|
||||
)
|
||||
for index in range(120)
|
||||
]
|
||||
self.filters: list[JobListFilter] = []
|
||||
|
||||
async def list_jobs(
|
||||
self, *, workspace_id: UUID | None, filters: JobListFilter
|
||||
) -> JobListPage:
|
||||
self.filters.append(filters)
|
||||
matching = [
|
||||
job
|
||||
for job in self.jobs
|
||||
if (workspace_id is None or job.workspace_id == workspace_id)
|
||||
and (filters.status is None or job.status is filters.status)
|
||||
and (filters.operation is None or job.operation == filters.operation)
|
||||
]
|
||||
return JobListPage(
|
||||
items=tuple(matching[filters.offset : filters.offset + filters.limit]),
|
||||
total=len(matching),
|
||||
limit=filters.limit,
|
||||
offset=filters.offset,
|
||||
)
|
||||
|
||||
async def status_counts(self, *, workspace_id: UUID | None) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for job in self.jobs:
|
||||
counts[job.status.value] = counts.get(job.status.value, 0) + 1
|
||||
return counts
|
||||
|
||||
async def get_owned(self, job_id: UUID, *, workspace_id: UUID | None) -> Job | None:
|
||||
return next((job for job in self.jobs if job.id == job_id), None)
|
||||
|
||||
|
||||
def _client() -> tuple[TestClient, RecordingJobRepository]:
|
||||
app = create_app()
|
||||
repository = RecordingJobRepository()
|
||||
app.dependency_overrides[get_principal] = lambda: Principal(
|
||||
uuid4(), WORKSPACE, PrincipalType.HUMAN, frozenset({Role.WORKSPACE_ADMIN})
|
||||
)
|
||||
app.state.job_repository = repository
|
||||
app.state.session_factory = None
|
||||
return TestClient(app), repository
|
||||
|
||||
|
||||
def test_the_filter_reaches_the_query_rather_than_the_rendered_page() -> None:
|
||||
client, repository = _client()
|
||||
|
||||
response = client.get("/admin/jobs?status=terminal&operation=sync_source")
|
||||
|
||||
assert response.status_code == 200
|
||||
applied = repository.filters[-1]
|
||||
assert applied.status is JobStatus.TERMINAL
|
||||
assert applied.operation == "sync_source"
|
||||
# Every rendered row really is a match: nothing was fetched and hidden.
|
||||
assert "No jobs match" not in response.text
|
||||
assert 'data-operations-journey="source-to-search"' in response.text
|
||||
assert 'data-mobile-records="jobs"' in response.text
|
||||
|
||||
|
||||
def test_a_page_beyond_the_first_is_a_different_query_not_a_longer_one() -> None:
|
||||
client, repository = _client()
|
||||
|
||||
response = client.get("/admin/jobs?status=terminal&offset=50&limit=50")
|
||||
|
||||
assert response.status_code == 200
|
||||
applied = repository.filters[-1]
|
||||
assert (applied.offset, applied.limit) == (50, 50)
|
||||
assert applied.status is JobStatus.TERMINAL
|
||||
# Sixty terminal jobs, fifty per page: page two exists and says so.
|
||||
assert "Showing 51\u201360 of 60" in response.text
|
||||
|
||||
|
||||
def test_pagination_links_keep_the_active_filter() -> None:
|
||||
client, _ = _client()
|
||||
|
||||
response = client.get("/admin/jobs?status=terminal&operation=sync_source&limit=10")
|
||||
|
||||
# The "next page" link must carry both filters, or paging silently widens
|
||||
# the result set the operator is reading. Every separator in that href is
|
||||
# an escaped entity: a bare "&" in an attribute is invalid HTML, and the
|
||||
# only reason it survives today is that no browser happens to read
|
||||
# "&limit" as a character reference.
|
||||
assert (
|
||||
"status=terminal&operation=sync_source&offset=10&limit=10" in response.text
|
||||
)
|
||||
assert "Showing 1\u201310 of 20" in response.text
|
||||
|
||||
|
||||
def test_an_unusable_filter_value_is_reported_not_silently_dropped() -> None:
|
||||
client, repository = _client()
|
||||
|
||||
response = client.get("/admin/jobs?status=failed")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert repository.filters[-1].status is None
|
||||
assert "Unrecognised status filter ignored" in response.text
|
||||
@@ -0,0 +1,595 @@
|
||||
"""Sources screens: list/detail/form render real registry data, mutations go
|
||||
through the management service, and unauthorized principals see nothing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_principal
|
||||
from ragcore.application.documents import (
|
||||
DocumentListFilter,
|
||||
DocumentListPage,
|
||||
DocumentRecord,
|
||||
DocumentStatusRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.jobs import Job
|
||||
from ragcore.application.sources import (
|
||||
SourceItemRecord,
|
||||
SourceListFilter,
|
||||
SourceListPage,
|
||||
SourceOverview,
|
||||
SourceRecord,
|
||||
SourceRegistryError,
|
||||
SourceStatus,
|
||||
SourceSyncOutcome,
|
||||
SourceSyncRunRecord,
|
||||
)
|
||||
from ragcore.domain.authorization import (
|
||||
AuthorizationError,
|
||||
PermissionDenied,
|
||||
Principal,
|
||||
PrincipalType,
|
||||
ResourceNotFound,
|
||||
)
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.domain.sources import ConnectorKind, SourceSyncState
|
||||
from ragcore.main import create_app
|
||||
from ragcore.web.sources import get_source_management
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeSourceManagement:
|
||||
sources: dict[UUID, SourceRecord] = field(default_factory=dict)
|
||||
runs: dict[UUID, list[SourceSyncRunRecord]] = field(default_factory=dict)
|
||||
documents: dict[UUID, list[DocumentRecord]] = field(default_factory=dict)
|
||||
failed_items: dict[UUID, list[SourceItemRecord]] = field(default_factory=dict)
|
||||
manage: bool = True
|
||||
manage_filesystem: bool = True
|
||||
actions: list[tuple[str, UUID]] = field(default_factory=list)
|
||||
created: list[dict[str, object]] = field(default_factory=list)
|
||||
|
||||
def can_manage(self, principal: Principal) -> bool:
|
||||
return self.manage
|
||||
|
||||
def can_manage_filesystem_sources(self, principal: Principal) -> bool:
|
||||
return self.manage and self.manage_filesystem
|
||||
|
||||
def _check(self) -> None:
|
||||
if not self.manage:
|
||||
raise PermissionDenied("no")
|
||||
|
||||
async def list_sources(self, principal: Principal, filters: SourceListFilter):
|
||||
items = [
|
||||
source
|
||||
for source in self.sources.values()
|
||||
if (filters.kind is None or source.kind is filters.kind)
|
||||
and (filters.search is None or filters.search.lower() in source.name.lower())
|
||||
and source.status is not SourceStatus.DELETED
|
||||
]
|
||||
page = SourceListPage(items=tuple(items), total=len(items), limit=filters.limit, offset=0)
|
||||
counts = {source.id: len(self.documents.get(source.id, [])) for source in items}
|
||||
return page, counts, {SPACE_ID: "Runbooks space"}
|
||||
|
||||
async def get_source(self, principal: Principal, source_id: UUID) -> SourceOverview:
|
||||
source = self.sources.get(source_id)
|
||||
if source is None:
|
||||
raise ResourceNotFound("source not found")
|
||||
return SourceOverview(
|
||||
source=source,
|
||||
document_count=len(self.documents.get(source_id, [])),
|
||||
space_name="Runbooks space",
|
||||
failed_items=tuple(self.failed_items.get(source_id, [])),
|
||||
)
|
||||
|
||||
async def sync_history(self, principal: Principal, source_id: UUID, *, limit: int = 20):
|
||||
return tuple(self.runs.get(source_id, []))
|
||||
|
||||
async def documents_of(
|
||||
self, principal: Principal, source_id: UUID, filters: DocumentListFilter
|
||||
):
|
||||
items = tuple(
|
||||
DocumentStatusRecord(
|
||||
document=document,
|
||||
latest_state=DocumentVersionState.PUBLISHED,
|
||||
parser_version="text-parser-1",
|
||||
chunk_profile_version="prose-v1",
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
for document in self.documents.get(source_id, [])
|
||||
)
|
||||
return DocumentListPage(items=items, total=len(items), limit=filters.limit, offset=0)
|
||||
|
||||
async def create_source(
|
||||
self, principal, *, name, kind, space_id, config_values, trace_id,
|
||||
sync_interval_seconds=None,
|
||||
):
|
||||
self._check()
|
||||
if kind is ConnectorKind.FOLDER and not config_values.get("root", "").startswith("/"):
|
||||
raise SourceRegistryError("root must be an absolute non-traversing path")
|
||||
record = SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
kind=kind,
|
||||
config={"root": config_values.get("root")},
|
||||
)
|
||||
self.sources[record.id] = record
|
||||
self.created.append(
|
||||
{
|
||||
"name": name,
|
||||
"kind": kind,
|
||||
"config": dict(config_values),
|
||||
"sync_interval_seconds": sync_interval_seconds,
|
||||
}
|
||||
)
|
||||
return record
|
||||
|
||||
async def update_source(
|
||||
self, principal, source_id, *, name, config_values, trace_id,
|
||||
sync_interval_seconds=None,
|
||||
):
|
||||
self._check()
|
||||
self.actions.append(("update", source_id))
|
||||
self.updated_intervals.append(sync_interval_seconds)
|
||||
return self.sources[source_id]
|
||||
|
||||
async def set_status(self, principal, source_id, status, *, trace_id):
|
||||
self._check()
|
||||
if source_id not in self.sources:
|
||||
raise ResourceNotFound("source not found")
|
||||
self.actions.append((status.value, source_id))
|
||||
return self.sources[source_id]
|
||||
|
||||
async def request_sync(self, principal, source_id, *, trace_id):
|
||||
self._check()
|
||||
source = self.sources.get(source_id)
|
||||
if source is None:
|
||||
raise ResourceNotFound("source not found")
|
||||
if not source.syncable:
|
||||
raise SourceRegistryError("this source kind is fed by its callers, not synced")
|
||||
self.actions.append(("sync", source_id))
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
job_key="k",
|
||||
operation="sync_source",
|
||||
target_key=str(source_id),
|
||||
profile_version="sync-v1",
|
||||
)
|
||||
|
||||
|
||||
def _principal(role: Role = Role.WORKSPACE_ADMIN) -> Principal:
|
||||
return Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset({role}))
|
||||
|
||||
|
||||
def _fixture(
|
||||
service: FakeSourceManagement,
|
||||
principal: Principal | None = None,
|
||||
*,
|
||||
resolve_failed_jobs: bool = True,
|
||||
) -> TestClient:
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_principal] = lambda: principal or _principal()
|
||||
app.dependency_overrides[get_source_management] = lambda: service
|
||||
|
||||
class OwnedJobs:
|
||||
async def get_owned(
|
||||
self, job_id: UUID, *, workspace_id: UUID | None
|
||||
) -> Job | None:
|
||||
known = any(
|
||||
item.last_failure_job_id == job_id
|
||||
for items in service.failed_items.values()
|
||||
for item in items
|
||||
)
|
||||
if not resolve_failed_jobs or not known or workspace_id != WORKSPACE_ID:
|
||||
return None
|
||||
return Job(
|
||||
id=job_id,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
job_key=f"failed-{job_id}",
|
||||
operation="ingest_upload",
|
||||
target_key="failed-item",
|
||||
profile_version="upload-v1",
|
||||
)
|
||||
|
||||
app.state.job_repository = OwnedJobs()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _folder_source(name: str = "Infra runbooks") -> SourceRecord:
|
||||
return SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
name=name,
|
||||
kind=ConnectorKind.FOLDER,
|
||||
config={"root": "/mnt/runbooks", "include": ["*.md"], "trust_level": "trusted_internal"},
|
||||
last_sync_at=datetime(2026, 8, 20, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def test_sources_list_shows_empty_state_with_action_and_grouped_navigation() -> None:
|
||||
client = _fixture(FakeSourceManagement())
|
||||
response = client.get("/admin/sources")
|
||||
assert response.status_code == 200
|
||||
assert "No sources configured yet" in response.text
|
||||
assert 'href="/admin/sources/new"' in response.text
|
||||
# The grouped information architecture is rendered for every screen.
|
||||
for label in ("Knowledge", "Access", "Quality", "Operations", "Sources", "Documents"):
|
||||
assert label in response.text
|
||||
assert 'aria-current="page"' in response.text
|
||||
|
||||
|
||||
def test_sources_list_renders_registry_rows_with_counts_and_filters() -> None:
|
||||
service = FakeSourceManagement()
|
||||
source = _folder_source()
|
||||
service.sources[source.id] = source
|
||||
service.documents[source.id] = [
|
||||
DocumentRecord(
|
||||
uuid4(), WORKSPACE_ID, SPACE_ID, str(source.id), "a.md", "A", source_id=source.id
|
||||
)
|
||||
]
|
||||
client = _fixture(service)
|
||||
response = client.get("/admin/sources?q=infra&kind=folder")
|
||||
assert response.status_code == 200
|
||||
assert "Infra runbooks" in response.text
|
||||
assert "Runbooks space" in response.text
|
||||
assert "2026-08-20 10:00" in response.text
|
||||
assert f'href="/admin/sources/{source.id}"' in response.text
|
||||
assert f'href="/admin/documents?source_id={source.id}"' in response.text
|
||||
assert 'role="search"' in response.text
|
||||
assert 'data-operations-journey="source-to-search"' in response.text
|
||||
assert 'data-mobile-records="sources"' in response.text
|
||||
|
||||
|
||||
def test_invalid_source_filter_is_reported_without_dropping_valid_filters() -> None:
|
||||
service = FakeSourceManagement()
|
||||
matching = _folder_source("Infra runbooks")
|
||||
unrelated = _folder_source("Employee handbook")
|
||||
service.sources[matching.id] = matching
|
||||
service.sources[unrelated.id] = unrelated
|
||||
|
||||
response = _fixture(service).get("/admin/sources?q=infra&kind=not-a-kind")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Infra runbooks" in response.text
|
||||
assert "Employee handbook" not in response.text
|
||||
assert "Unrecognised type filter ignored" in response.text
|
||||
assert 'value="infra"' in response.text
|
||||
assert 'value="not-a-kind"' not in response.text
|
||||
|
||||
|
||||
def test_filtered_source_empty_state_does_not_claim_the_registry_is_empty() -> None:
|
||||
service = FakeSourceManagement()
|
||||
source = _folder_source()
|
||||
service.sources[source.id] = source
|
||||
|
||||
response = _fixture(service).get("/admin/sources?q=no-such-source")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No sources match these filters" in response.text
|
||||
assert "No sources configured yet" not in response.text
|
||||
assert 'href="/admin/sources"' in response.text
|
||||
|
||||
|
||||
def test_source_detail_shows_configuration_documents_and_sync_history() -> None:
|
||||
service = FakeSourceManagement()
|
||||
source = _folder_source()
|
||||
service.sources[source.id] = source
|
||||
service.runs[source.id] = [
|
||||
SourceSyncRunRecord(
|
||||
id=uuid4(),
|
||||
source_id=source.id,
|
||||
started_at=datetime(2026, 8, 20, 10, 0, tzinfo=UTC),
|
||||
finished_at=datetime(2026, 8, 20, 10, 1, tzinfo=UTC),
|
||||
outcome=SourceSyncOutcome.COMPLETED,
|
||||
discovered=12,
|
||||
new=3,
|
||||
changed=1,
|
||||
unchanged=8,
|
||||
removed=0,
|
||||
)
|
||||
]
|
||||
service.documents[source.id] = [
|
||||
DocumentRecord(
|
||||
uuid4(),
|
||||
WORKSPACE_ID,
|
||||
SPACE_ID,
|
||||
str(source.id),
|
||||
"runbooks/backup.md",
|
||||
"Backup",
|
||||
source_id=source.id,
|
||||
)
|
||||
]
|
||||
client = _fixture(service)
|
||||
response = client.get(f"/admin/sources/{source.id}")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "/mnt/runbooks" in body
|
||||
assert "12 discovered" in body and "3 new" in body
|
||||
assert "runbooks/backup.md" in body
|
||||
assert "Sync now" in body
|
||||
assert "data-confirm" in body # destructive actions confirm
|
||||
assert 'action="/admin/sources/' + str(source.id) + '/delete"' in body
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("outcome", "failed", "expected"),
|
||||
[
|
||||
(SourceSyncOutcome.RUNNING, 0, "Syncing"),
|
||||
(SourceSyncOutcome.AWAITING_INGEST, 0, "Indexing child jobs"),
|
||||
(SourceSyncOutcome.COMPLETED_WITH_FAILURES, 1, "Completed with failures"),
|
||||
],
|
||||
)
|
||||
def test_source_sync_history_names_unfinished_and_incomplete_outcomes_truthfully(
|
||||
outcome: SourceSyncOutcome, failed: int, expected: str
|
||||
) -> None:
|
||||
service = FakeSourceManagement()
|
||||
source = _folder_source()
|
||||
service.sources[source.id] = source
|
||||
service.runs[source.id] = [
|
||||
SourceSyncRunRecord(
|
||||
id=uuid4(),
|
||||
source_id=source.id,
|
||||
started_at=datetime(2026, 8, 20, 10, 0, tzinfo=UTC),
|
||||
finished_at=datetime(2026, 8, 20, 10, 1, tzinfo=UTC),
|
||||
outcome=outcome,
|
||||
discovered=3,
|
||||
new=1,
|
||||
changed=0,
|
||||
unchanged=2,
|
||||
removed=0,
|
||||
failed=failed,
|
||||
)
|
||||
]
|
||||
|
||||
response = _fixture(service).get(f"/admin/sources/{source.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert expected in response.text
|
||||
|
||||
|
||||
def test_unknown_source_is_a_404_with_reference() -> None:
|
||||
client = _fixture(FakeSourceManagement())
|
||||
response = client.get(f"/admin/sources/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
assert "Source not found" in response.text
|
||||
assert "Reference:" in response.text
|
||||
|
||||
|
||||
def test_create_source_validates_and_redirects_to_detail() -> None:
|
||||
service = FakeSourceManagement()
|
||||
client = _fixture(service)
|
||||
invalid = client.post(
|
||||
"/admin/sources",
|
||||
data={"name": "Bad", "kind": "folder", "space_id": str(SPACE_ID), "root": "relative/path"},
|
||||
)
|
||||
assert invalid.status_code == 400
|
||||
assert "absolute" in invalid.text
|
||||
assert 'role="alert"' in invalid.text
|
||||
|
||||
created = client.post(
|
||||
"/admin/sources",
|
||||
data={
|
||||
"name": "Infra runbooks",
|
||||
"kind": "folder",
|
||||
"space_id": str(SPACE_ID),
|
||||
"root": "/mnt/runbooks",
|
||||
"include": "*.md",
|
||||
"trust_level": "trusted_internal",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert created.status_code == 303
|
||||
assert created.headers["location"].startswith("/admin/sources/")
|
||||
assert service.created[0]["name"] == "Infra runbooks"
|
||||
detail = client.get(created.headers["location"])
|
||||
assert detail.status_code == 200
|
||||
assert "Source created." in detail.text
|
||||
|
||||
|
||||
def test_actions_call_the_service_and_upload_sources_cannot_sync() -> None:
|
||||
service = FakeSourceManagement()
|
||||
folder = _folder_source()
|
||||
upload = SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
name="Uploads",
|
||||
kind=ConnectorKind.UPLOAD,
|
||||
)
|
||||
service.sources[folder.id] = folder
|
||||
service.sources[upload.id] = upload
|
||||
client = _fixture(service)
|
||||
sync = client.post(f"/admin/sources/{folder.id}/sync", follow_redirects=False)
|
||||
assert sync.status_code == 303 and "notice=sync_requested" in sync.headers["location"]
|
||||
pause = client.post(f"/admin/sources/{folder.id}/pause", follow_redirects=False)
|
||||
assert pause.status_code == 303
|
||||
delete = client.post(f"/admin/sources/{folder.id}/delete", follow_redirects=False)
|
||||
assert (
|
||||
delete.status_code == 303 and delete.headers["location"] == "/admin/sources?notice=deleted"
|
||||
)
|
||||
assert [action for action, _ in service.actions] == ["sync", "paused", "deleted"]
|
||||
refused = client.post(f"/admin/sources/{upload.id}/sync")
|
||||
assert refused.status_code == 409
|
||||
assert "fed by its callers" in refused.text
|
||||
# Upload sources show no Sync button and no delete.
|
||||
page = client.get(f"/admin/sources/{upload.id}")
|
||||
assert "Sync now" not in page.text
|
||||
|
||||
|
||||
def test_viewer_cannot_see_the_form_or_mutate_and_leaks_nothing() -> None:
|
||||
service = FakeSourceManagement(manage=False)
|
||||
source = _folder_source("Secret source")
|
||||
service.sources[source.id] = source
|
||||
client = _fixture(service, _principal(Role.VIEWER))
|
||||
form = client.get("/admin/sources/new")
|
||||
assert form.status_code == 403
|
||||
assert "Secret source" not in form.text
|
||||
mutate = client.post(f"/admin/sources/{source.id}/sync")
|
||||
assert mutate.status_code == 403
|
||||
listing = client.get("/admin/sources")
|
||||
assert listing.status_code == 200
|
||||
assert 'href="/admin/sources/new"' not in listing.text
|
||||
|
||||
|
||||
def test_authorization_errors_render_forbidden_state() -> None:
|
||||
class Denying(FakeSourceManagement):
|
||||
async def list_sources(self, principal, filters):
|
||||
raise AuthorizationError("denied")
|
||||
|
||||
client = _fixture(Denying())
|
||||
response = client.get("/admin/sources")
|
||||
assert response.status_code == 403
|
||||
assert "Access denied" in response.text
|
||||
|
||||
|
||||
# -- the Control Plane must not render "nothing to see" for unfinished work --
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "expected"),
|
||||
[
|
||||
(SourceSyncState.AWAITING_INGEST, "Indexing"),
|
||||
(SourceSyncState.COMPLETED_WITH_FAILURES, "Last sync incomplete"),
|
||||
(SourceSyncState.COMPLETED, "Last sync ok"),
|
||||
(SourceSyncState.FAILED, "Last sync failed"),
|
||||
(SourceSyncState.SCANNING, "Syncing"),
|
||||
],
|
||||
)
|
||||
def test_every_sync_state_renders_a_badge_on_both_screens(
|
||||
state: SourceSyncState, expected: str
|
||||
) -> None:
|
||||
"""A state with no badge shows as an unqualified "Active" source, which an
|
||||
operator reads as finished -- exactly the lie convergence removes."""
|
||||
|
||||
service = FakeSourceManagement()
|
||||
source = replace(_folder_source(), sync_state=state)
|
||||
service.sources[source.id] = source
|
||||
|
||||
client = _fixture(service)
|
||||
|
||||
assert expected in client.get("/admin/sources").text
|
||||
assert expected in client.get(f"/admin/sources/{source.id}").text
|
||||
|
||||
|
||||
def test_an_unconverged_item_says_what_to_retry_instead_of_hiding() -> None:
|
||||
"""A terminally failed item whose content has not changed is deliberately
|
||||
never re-enqueued by the next sync, so the screen has to name the recovery
|
||||
path. Reporting only discovery counts would leave it reading as
|
||||
"unchanged" forever while nothing was ever indexed."""
|
||||
|
||||
service = FakeSourceManagement()
|
||||
source = _folder_source()
|
||||
service.sources[source.id] = source
|
||||
failed_job_id = uuid4()
|
||||
service.failed_items[source.id] = [
|
||||
SourceItemRecord(
|
||||
source_id=source.id,
|
||||
external_id="runbooks/broken.pdf",
|
||||
fingerprint="a" * 64,
|
||||
locator="/mnt/rag-sources/runbooks/broken.pdf",
|
||||
last_seen_at=datetime(2026, 8, 20, 10, 0, tzinfo=UTC),
|
||||
last_failure_code="embedding_failed",
|
||||
last_failure_retryable=True,
|
||||
last_failure_job_id=failed_job_id,
|
||||
)
|
||||
]
|
||||
client = _fixture(service)
|
||||
|
||||
response = client.get(f"/admin/sources/{source.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "Ingest failed" in body
|
||||
assert "runbooks/broken.pdf" in body
|
||||
assert "embedding_failed" in body
|
||||
assert "retry the failed ingest job" in body
|
||||
assert f'href="/admin/jobs/{failed_job_id}"' in body
|
||||
|
||||
|
||||
def test_failed_source_item_does_not_link_an_unreachable_recorded_job() -> None:
|
||||
service = FakeSourceManagement()
|
||||
source = _folder_source()
|
||||
failed_job_id = uuid4()
|
||||
service.sources[source.id] = source
|
||||
service.failed_items[source.id] = [
|
||||
SourceItemRecord(
|
||||
source_id=source.id,
|
||||
external_id="runbooks/missing.pdf",
|
||||
fingerprint="b" * 64,
|
||||
locator="/mnt/rag-sources/runbooks/missing.pdf",
|
||||
last_seen_at=datetime(2026, 8, 20, 10, 0, tzinfo=UTC),
|
||||
last_failure_code="parse_failed",
|
||||
last_failure_retryable=True,
|
||||
last_failure_job_id=failed_job_id,
|
||||
)
|
||||
]
|
||||
|
||||
response = _fixture(service, resolve_failed_jobs=False).get(
|
||||
f"/admin/sources/{source.id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert f'href="/admin/jobs/{failed_job_id}"' not in response.text
|
||||
assert "recorded job is unavailable" in response.text
|
||||
|
||||
|
||||
def test_a_sync_interval_can_be_set_on_the_form_and_is_bounded() -> None:
|
||||
"""Automatic syncing is opt-in per source, and the form is where an
|
||||
operator opts in. Leaving it blank has to keep meaning "only when I ask"
|
||||
-- that is the behaviour every source had before there was a scheduler."""
|
||||
|
||||
service = FakeSourceManagement()
|
||||
client = _fixture(service)
|
||||
|
||||
without = client.post(
|
||||
"/admin/sources",
|
||||
data={
|
||||
"name": "Manual only",
|
||||
"kind": "folder",
|
||||
"space_id": str(SPACE_ID),
|
||||
"root": "/mnt/runbooks",
|
||||
"sync_interval_minutes": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert without.status_code == 303
|
||||
assert service.created[-1]["sync_interval_seconds"] is None
|
||||
|
||||
scheduled = client.post(
|
||||
"/admin/sources",
|
||||
data={
|
||||
"name": "Hourly",
|
||||
"kind": "folder",
|
||||
"space_id": str(SPACE_ID),
|
||||
"root": "/mnt/runbooks",
|
||||
"sync_interval_minutes": "60",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert scheduled.status_code == 303
|
||||
assert service.created[-1]["sync_interval_seconds"] == 3600
|
||||
|
||||
# One minute would spend the worker rediscovering unchanged content.
|
||||
too_often = client.post(
|
||||
"/admin/sources",
|
||||
data={
|
||||
"name": "Too often",
|
||||
"kind": "folder",
|
||||
"space_id": str(SPACE_ID),
|
||||
"root": "/mnt/runbooks",
|
||||
"sync_interval_minutes": "1",
|
||||
},
|
||||
)
|
||||
assert too_often.status_code == 400
|
||||
assert 'role="alert"' in too_often.text
|
||||
Reference in new issue
Block a user