540 lines
19 KiB
Python
540 lines
19 KiB
Python
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
|