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,248 @@
|
||||
"""System gate: the operations console against real PostgreSQL, two tenants.
|
||||
|
||||
Everything below the HTTP client is the deployed thing: the real FastAPI
|
||||
application (`create_app`, its middleware stack and routes), the real
|
||||
`PostgresJobRepository`, the real `PostgresSourceRegistry` for job source-name
|
||||
enrichment, and a real PostgreSQL database. The one substitution is
|
||||
authentication -- `get_principal` is overridden so the test can be two
|
||||
different operators without standing up OIDC -- which is exactly the boundary
|
||||
under test: given a correctly authenticated principal, what may they see and
|
||||
change?
|
||||
|
||||
The composed proof, in one run:
|
||||
|
||||
* workspace A's admin lists jobs and sees only A's, with A's counters;
|
||||
* A's admin opening B's job is denied, and the response contains none of B's
|
||||
metadata (filename, source id, source name, failure code);
|
||||
* A's admin retrying B's job is denied, and B's row is afterwards unchanged in
|
||||
the database -- status, attempt count, availability and lease all identical;
|
||||
* B's own admin can still retry B's job, so the refusal above was scoping and
|
||||
not a broken feature;
|
||||
* a legacy job with no owner is invisible and immutable for both of them;
|
||||
* the platform administrator sees all three and may act on them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ragcore.api.v1.control.dependencies import get_principal
|
||||
from ragcore.application.jobs import Job, JobStatus
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
from ragcore.infrastructure.database import create_database_engine, create_session_factory
|
||||
from ragcore.infrastructure.database.jobs import PostgresJobRepository
|
||||
from ragcore.main import create_app
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.security]
|
||||
|
||||
WORKSPACE_A = uuid4()
|
||||
WORKSPACE_B = uuid4()
|
||||
SPACE_A = uuid4()
|
||||
SPACE_B = uuid4()
|
||||
SOURCE_A = uuid4()
|
||||
SOURCE_B = uuid4()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
async def _empty_the_queue(factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""Leave ``jobs.jobs`` as this test found it --- before *and* after.
|
||||
|
||||
The queue is one shared table, and the ingestion and system gates later
|
||||
in a full run drive a real ``JobWorker``, which claims the oldest
|
||||
*available* job in it. A leftover fixture job is therefore claimed instead
|
||||
of theirs, and they fail for a reason that has nothing to do with what
|
||||
they test.
|
||||
"""
|
||||
|
||||
async with factory() as session:
|
||||
await session.execute(text("TRUNCATE jobs.events, jobs.attempts, jobs.jobs CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def factory() -> async_sessionmaker[AsyncSession]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
session_factory = create_session_factory(engine)
|
||||
await _empty_the_queue(session_factory)
|
||||
try:
|
||||
yield session_factory
|
||||
finally:
|
||||
await _empty_the_queue(session_factory)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _job(workspace_id: UUID, space_id: UUID, source_id: UUID, name: str, key: str) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
job_key=key,
|
||||
operation="ingest_upload",
|
||||
target_key=f"blob:{key}",
|
||||
profile_version="upload-v1",
|
||||
payload={
|
||||
"original_name": name,
|
||||
"source_id": str(source_id),
|
||||
"space_id": str(space_id),
|
||||
"workspace_id": str(workspace_id),
|
||||
"media_type": "text/markdown",
|
||||
},
|
||||
available_at=datetime.now(UTC),
|
||||
trace_id=f"trace-{key}",
|
||||
)
|
||||
|
||||
|
||||
async def _make_terminal(factory: async_sessionmaker[AsyncSession], job_id: UUID) -> None:
|
||||
"""Put a job in the one state the Control Plane offers Retry for."""
|
||||
|
||||
async with factory() as session, session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE jobs.jobs SET status = 'terminal', attempt_count = 3, "
|
||||
"last_error_code = 'retryable_error', "
|
||||
"last_error_details = CAST(:details AS json) WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"id": job_id,
|
||||
"details": f'{{"failure_code": "embedding_failed_{job_id.hex[:8]}"}}',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _plant_legacy(factory: async_sessionmaker[AsyncSession]) -> UUID:
|
||||
job_id = uuid4()
|
||||
now = datetime.now(UTC)
|
||||
async with factory() as session, session.begin():
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO jobs.jobs (id, job_key, operation, target_key, profile_version,
|
||||
payload, status, available_at, max_attempts,
|
||||
attempt_count, trace_id, last_error_code,
|
||||
last_error_details, created_at, updated_at)
|
||||
VALUES (:id, 'legacy-gate', 'ingest_upload', 'blob:legacy', 'upload-v1',
|
||||
CAST(:payload AS json), 'terminal', :now, 3, 3, 'trace-legacy',
|
||||
'retryable_error', CAST('{}' AS json), :now, :now)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": job_id,
|
||||
"payload": f'{{"original_name": "legacy-unowned.md", '
|
||||
f'"workspace_id": "{WORKSPACE_A}"}}',
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
return job_id
|
||||
|
||||
|
||||
async def _row(factory: async_sessionmaker[AsyncSession], job_id: UUID) -> tuple[object, ...]:
|
||||
async with factory() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT status, attempt_count, available_at, updated_at, leased_by, "
|
||||
"lease_until FROM jobs.jobs WHERE id = :id"
|
||||
),
|
||||
{"id": job_id},
|
||||
)
|
||||
).one()
|
||||
return tuple(row)
|
||||
|
||||
|
||||
def _client(
|
||||
factory: async_sessionmaker[AsyncSession], workspace_id: UUID, role: Role
|
||||
) -> TestClient:
|
||||
app = create_app()
|
||||
principal = Principal(uuid4(), workspace_id, PrincipalType.HUMAN, frozenset({role}))
|
||||
app.dependency_overrides[get_principal] = lambda: principal
|
||||
app.state.job_repository = PostgresJobRepository(factory)
|
||||
app.state.session_factory = factory
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
async def test_cross_workspace_operations_gate(
|
||||
factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
repository = PostgresJobRepository(factory)
|
||||
a1 = await repository.enqueue(_job(WORKSPACE_A, SPACE_A, SOURCE_A, "alpha-one.md", "a1"))
|
||||
a2 = await repository.enqueue(_job(WORKSPACE_A, SPACE_A, SOURCE_A, "alpha-two.md", "a2"))
|
||||
b1 = await repository.enqueue(_job(WORKSPACE_B, SPACE_B, SOURCE_B, "bravo-secret.md", "b1"))
|
||||
await _make_terminal(factory, a1.id)
|
||||
await _make_terminal(factory, b1.id)
|
||||
legacy_id = await _plant_legacy(factory)
|
||||
|
||||
admin_a = _client(factory, WORKSPACE_A, Role.WORKSPACE_ADMIN)
|
||||
admin_b = _client(factory, WORKSPACE_B, Role.WORKSPACE_ADMIN)
|
||||
auditor_a = _client(factory, WORKSPACE_A, Role.AUDITOR)
|
||||
platform = _client(factory, WORKSPACE_A, Role.PLATFORM_ADMIN)
|
||||
|
||||
# -- listing -----------------------------------------------------------
|
||||
listing = admin_a.get("/admin/jobs")
|
||||
assert listing.status_code == 200
|
||||
assert "alpha-one.md" in listing.text and "alpha-two.md" in listing.text
|
||||
for secret in ("bravo-secret.md", str(b1.id), str(SOURCE_B), "legacy-unowned.md"):
|
||||
assert secret not in listing.text
|
||||
assert "This workspace" in listing.text
|
||||
|
||||
# -- detail ------------------------------------------------------------
|
||||
assert admin_a.get(f"/admin/jobs/{a1.id}").status_code == 200
|
||||
denied = admin_a.get(f"/admin/jobs/{b1.id}")
|
||||
assert denied.status_code == 404
|
||||
for secret in ("bravo-secret.md", str(SOURCE_B), "embedding_failed"):
|
||||
assert secret not in denied.text
|
||||
|
||||
# -- mutation ----------------------------------------------------------
|
||||
before = await _row(factory, b1.id)
|
||||
refused = admin_a.post(f"/admin/jobs/{b1.id}/retry", follow_redirects=False)
|
||||
assert refused.status_code == 404
|
||||
assert await _row(factory, b1.id) == before, "a refused retry must not touch the job"
|
||||
|
||||
# ...and B's own admin can still recover it.
|
||||
allowed = admin_b.post(f"/admin/jobs/{b1.id}/retry", follow_redirects=False)
|
||||
assert allowed.status_code == 303
|
||||
async with factory() as session:
|
||||
status = (
|
||||
await session.execute(
|
||||
text("SELECT status FROM jobs.jobs WHERE id = :id"), {"id": b1.id}
|
||||
)
|
||||
).scalar_one()
|
||||
assert status == JobStatus.READY.value
|
||||
|
||||
# -- auditor is read-only ---------------------------------------------
|
||||
assert auditor_a.get("/admin/jobs").status_code == 200
|
||||
assert auditor_a.post(f"/admin/jobs/{a1.id}/retry").status_code == 403
|
||||
|
||||
# -- legacy job fails closed for both tenants -------------------------
|
||||
legacy_before = await _row(factory, legacy_id)
|
||||
for client in (admin_a, admin_b):
|
||||
assert client.get(f"/admin/jobs/{legacy_id}").status_code == 404
|
||||
retry = client.post(f"/admin/jobs/{legacy_id}/retry", follow_redirects=False)
|
||||
assert retry.status_code == 404
|
||||
assert await _row(factory, legacy_id) == legacy_before
|
||||
|
||||
# -- platform admin keeps global operations ---------------------------
|
||||
global_listing = platform.get("/admin/jobs")
|
||||
assert global_listing.status_code == 200
|
||||
for expected in ("alpha-one.md", "bravo-secret.md", "legacy-unowned.md", "All workspaces"):
|
||||
assert expected in global_listing.text
|
||||
assert platform.get(f"/admin/jobs/{legacy_id}").status_code == 200
|
||||
assert (
|
||||
platform.post(f"/admin/jobs/{legacy_id}/retry", follow_redirects=False).status_code == 303
|
||||
)
|
||||
assert a2.workspace_id == WORKSPACE_A
|
||||
@@ -0,0 +1,502 @@
|
||||
"""System gate: HTTP upload -> PostgreSQL job -> worker lease -> production
|
||||
ingest handler -> security gate -> parser -> chunker -> dense + sparse ->
|
||||
Qdrant -> published -> HTTP search, with the caller's source identity intact.
|
||||
|
||||
Everything below the HTTP client is real: the real FastAPI application
|
||||
(``create_app``, with its middleware stack, bearer authentication, rate
|
||||
limiting and routes), real PostgreSQL (control plane, credentials, jobs,
|
||||
content, source registry, audit), the real ``PostgresJobRepository`` lease
|
||||
protocol driven by the real ``JobWorker``, the real
|
||||
``build_ingest_upload_handler`` production wiring, and a real Qdrant server.
|
||||
HTTP is exercised end to end through the ASGI interface of the real app (an
|
||||
in-process transport, not a TCP socket -- the only ingredient that is not the
|
||||
deployed process boundary itself). The one deliberate substitution is the
|
||||
embedding *model*: a deterministic hash-based HTTP transport stands in for
|
||||
Ollama, for both the worker's document embedding and the app's query
|
||||
embedding, because the gate proves orchestration and correctness, not
|
||||
embedding quality.
|
||||
|
||||
Proven, in order:
|
||||
|
||||
* the upload is accepted over HTTP with a bearer token (202 + job url);
|
||||
* the job really exists in PostgreSQL and is READY;
|
||||
* the worker leases and executes it; the job ends SUCCEEDED with the attempt
|
||||
recorded against this worker;
|
||||
* the document is PUBLISHED (searchable) with its canonical source id and
|
||||
the job id on its version;
|
||||
* every stored Qdrant point has a dense *and* a sparse vector and carries the
|
||||
caller's source id;
|
||||
* ``POST /v1/search`` over HTTP finds the document; provenance/citation name
|
||||
the same source; ``source_ids=[A]`` returns it and a foreign source does not;
|
||||
* ``GET /v1/jobs/{id}`` reports ``succeeded``;
|
||||
* failure path: content the security gate quarantines ends with the job
|
||||
terminal AND the document version FAILED with a content-free failure code
|
||||
-- never "processing" forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from ragcore.application.audit import AuditEventService
|
||||
from ragcore.application.documents import DocumentFailureCode, DocumentVersionState
|
||||
from ragcore.application.identity.credentials import ServiceAccountCredentialService
|
||||
from ragcore.application.jobs import JobStatus, JobWorker
|
||||
from ragcore.config import Settings, get_settings
|
||||
from ragcore.domain.control import DataPolicy, Role, Scope, ServiceAccount, Workspace
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider
|
||||
from ragcore.infrastructure.database import (
|
||||
create_database_engine,
|
||||
create_session_factory,
|
||||
session_scope,
|
||||
)
|
||||
from ragcore.infrastructure.database.audit import SessionScopedAuditEventRepository
|
||||
from ragcore.infrastructure.database.content import PostgresDocumentRepository
|
||||
from ragcore.infrastructure.database.control import ControlPlaneRepository
|
||||
from ragcore.infrastructure.database.identity import PostgresCredentialRepository
|
||||
from ragcore.infrastructure.database.jobs import PostgresJobRepository
|
||||
from ragcore.infrastructure.database.sources import PostgresSourceRegistry
|
||||
from ragcore.infrastructure.ingestion import build_ingest_upload_handler
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
DIMENSIONS = 8
|
||||
DOCUMENT_ONE = (
|
||||
"Gateway Failover Runbook\n\n"
|
||||
"When the payment gateway loses power, cycle breaker BRK-4417 in rack two.\n"
|
||||
"Wait ten seconds before restoring the feed and confirm the status light.\n"
|
||||
)
|
||||
DOCUMENT_TWO = (
|
||||
"Certificate Rotation Procedure\n\n"
|
||||
"Rotate the edge certificate CERT-8820 every ninety days using the vault job.\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop_policy() -> asyncio.AbstractEventLoopPolicy:
|
||||
if sys.platform == "win32":
|
||||
return asyncio.WindowsSelectorEventLoopPolicy()
|
||||
return asyncio.get_event_loop_policy()
|
||||
|
||||
|
||||
def _deterministic_vector(text: str) -> list[float]:
|
||||
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
||||
raw = [digest[index] / 255.0 - 0.5 for index in range(DIMENSIONS)]
|
||||
norm = math.sqrt(sum(value * value for value in raw)) or 1.0
|
||||
return [value / norm for value in raw]
|
||||
|
||||
|
||||
def _embedding_transport() -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200, json={"embeddings": [_deterministic_vector(text) for text in body["input"]]}
|
||||
)
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine() -> AsyncIterator[AsyncEngine]:
|
||||
engine = create_database_engine(Settings(env="test"))
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def uninitialized_embedding_selection(db_engine: AsyncEngine) -> AsyncIterator[None]:
|
||||
"""Exercise first-start selection bootstrap, then restore shared test state."""
|
||||
|
||||
async with db_engine.begin() as connection:
|
||||
result = await connection.execute(
|
||||
text(
|
||||
"SELECT generation, initialized, active_selection, previous_selection, updated_at "
|
||||
"FROM profiles.embedding_selection_state WHERE singleton_id=1"
|
||||
)
|
||||
)
|
||||
row = result.mappings().one_or_none()
|
||||
if row is None:
|
||||
pytest.fail("migration sentinel missing; apply Alembic head before system tests")
|
||||
previous_state = dict(row)
|
||||
reset = await connection.execute(
|
||||
text(
|
||||
"UPDATE profiles.embedding_selection_state "
|
||||
"SET generation=0, initialized=false, active_selection=NULL, "
|
||||
"previous_selection=NULL, updated_at=now() WHERE singleton_id=1"
|
||||
)
|
||||
)
|
||||
if reset.rowcount != 1:
|
||||
pytest.fail("migration sentinel missing; apply Alembic head before system tests")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
async with db_engine.begin() as connection:
|
||||
restored = await connection.execute(
|
||||
text(
|
||||
"UPDATE profiles.embedding_selection_state SET generation=:generation, "
|
||||
"initialized=:initialized, "
|
||||
"active_selection=CAST(:active_selection AS json), "
|
||||
"previous_selection=CAST(:previous_selection AS json), "
|
||||
"updated_at=:updated_at WHERE singleton_id=1"
|
||||
),
|
||||
{
|
||||
"generation": previous_state["generation"],
|
||||
"initialized": previous_state["initialized"],
|
||||
"active_selection": (
|
||||
json.dumps(previous_state["active_selection"])
|
||||
if previous_state["active_selection"] is not None
|
||||
else None
|
||||
),
|
||||
"previous_selection": (
|
||||
json.dumps(previous_state["previous_selection"])
|
||||
if previous_state["previous_selection"] is not None
|
||||
else None
|
||||
),
|
||||
"updated_at": previous_state["updated_at"],
|
||||
},
|
||||
)
|
||||
if restored.rowcount != 1:
|
||||
pytest.fail("embedding selection state row disappeared during system test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _activate_profile(settings: Settings, name: str) -> EmbeddingProfile:
|
||||
store = JsonEmbeddingProfileStore(settings.embedding_profiles_path)
|
||||
profile = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile=name,
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="system-gate:v1",
|
||||
model_digest="c" * 64,
|
||||
dimensions=DIMENSIONS,
|
||||
context_limit=2048,
|
||||
tokenizer="gate",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return profile
|
||||
|
||||
|
||||
async def _provision_caller(db_engine: AsyncEngine, settings: Settings) -> tuple[UUID, UUID, str]:
|
||||
"""Real workspace, space, application, service account, grant and bearer token."""
|
||||
|
||||
session_factory = create_session_factory(db_engine)
|
||||
workspace_id = uuid4()
|
||||
workspace = Workspace(
|
||||
workspace_id, f"System gate {workspace_id.hex[:6]}", f"sg-{workspace_id.hex[:8]}"
|
||||
)
|
||||
application = workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="System gate client",
|
||||
slug=f"sg-client-{uuid4().hex[:8]}",
|
||||
scopes=frozenset({Scope.SEARCH, Scope.CONTEXT, Scope.SOURCES_SYNC, Scope.DOCUMENTS_READ}),
|
||||
)
|
||||
space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="System gate space", slug=f"sg-space-{uuid4().hex[:8]}"
|
||||
)
|
||||
service_account = ServiceAccount(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
application_id=application.id,
|
||||
name="System gate service account",
|
||||
scopes=application.scopes,
|
||||
)
|
||||
async with session_scope(session_factory) as session:
|
||||
repository = ControlPlaneRepository(session)
|
||||
await repository.create_workspace(workspace)
|
||||
await repository.create_space(space)
|
||||
await repository.create_application(application)
|
||||
await repository.create_service_account(service_account)
|
||||
await repository.create_grant(
|
||||
workspace.grant(
|
||||
id=uuid4(), application=application, knowledge_space=space, role=Role.EDITOR
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def load_service_account(account_id: UUID) -> ServiceAccount | None:
|
||||
async with session_scope(session_factory) as session:
|
||||
return await ControlPlaneRepository(session).get_service_account_by_id(account_id)
|
||||
|
||||
async with session_scope(session_factory) as session:
|
||||
credentials = ServiceAccountCredentialService(
|
||||
PostgresCredentialRepository(session),
|
||||
settings.api_token_pepper.get_secret_value(),
|
||||
service_account_loader=load_service_account,
|
||||
)
|
||||
issued = await credentials.issue(service_account, scopes=service_account.scopes)
|
||||
await session.commit()
|
||||
return workspace_id, space.id, issued.token
|
||||
|
||||
|
||||
async def _upload(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
token: str,
|
||||
space_id: UUID,
|
||||
source_id: UUID | None,
|
||||
external_id: str | None,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
) -> UUID:
|
||||
data: dict[str, str] = {"space_id": str(space_id)}
|
||||
if source_id is not None:
|
||||
data["source_id"] = str(source_id)
|
||||
if external_id is not None:
|
||||
data["external_id"] = external_id
|
||||
response = await client.post(
|
||||
"/v1/uploads",
|
||||
data=data,
|
||||
files={"file": (filename, content, "text/plain")},
|
||||
headers={"Authorization": f"Bearer {token}", "Idempotency-Key": uuid4().hex},
|
||||
)
|
||||
assert response.status_code == 202, response.text
|
||||
body = response.json()
|
||||
assert body["status_url"] == f"/v1/jobs/{body['job_id']}"
|
||||
return UUID(body["job_id"])
|
||||
|
||||
|
||||
async def test_http_upload_to_worker_to_index_to_http_search(
|
||||
tmp_path: Path,
|
||||
db_engine: AsyncEngine,
|
||||
qdrant: AsyncQdrantClient,
|
||||
uninitialized_embedding_selection: None,
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
profile_name = f"sysgate-{uuid4().hex[:10]}"
|
||||
await _activate_profile(settings, profile_name)
|
||||
collection_name = f"rag_dense_{profile_name}_v1"
|
||||
alias_name = f"rag_dense_{profile_name}_active"
|
||||
workspace_id, space_id, token = await _provision_caller(db_engine, settings)
|
||||
session_factory = create_session_factory(db_engine)
|
||||
source_a = uuid4()
|
||||
|
||||
from ragcore.main import create_app
|
||||
|
||||
model_client = httpx.AsyncClient(transport=_embedding_transport(), follow_redirects=False)
|
||||
app = create_app(model_http_client=model_client)
|
||||
jobs = PostgresJobRepository(session_factory)
|
||||
documents = PostgresDocumentRepository(session_factory)
|
||||
worker = JobWorker(
|
||||
jobs,
|
||||
{
|
||||
"ingest_upload": build_ingest_upload_handler(
|
||||
settings=settings,
|
||||
document_repository=documents,
|
||||
source_registry=PostgresSourceRegistry(session_factory),
|
||||
security_audit=AuditEventService(
|
||||
SessionScopedAuditEventRepository(session_factory)
|
||||
),
|
||||
embedding_http_client=model_client,
|
||||
qdrant_store=QdrantRebuildStore(qdrant),
|
||||
)
|
||||
},
|
||||
worker_id="system-gate-worker",
|
||||
lease_duration=timedelta(seconds=60),
|
||||
poll_interval=0.05,
|
||||
)
|
||||
lifespan_started = False
|
||||
try:
|
||||
async with contextlib.AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(app.router.lifespan_context(app))
|
||||
lifespan_started = True
|
||||
http = await stack.enter_async_context(
|
||||
httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://ragcore.test"
|
||||
)
|
||||
)
|
||||
# --- 1. HTTP upload accepted ------------------------------------
|
||||
job_one = await _upload(
|
||||
http,
|
||||
token=token,
|
||||
space_id=space_id,
|
||||
source_id=source_a,
|
||||
external_id="runbooks/gateway",
|
||||
filename="gateway.txt",
|
||||
content=DOCUMENT_ONE.encode(),
|
||||
)
|
||||
job_two = await _upload(
|
||||
http,
|
||||
token=token,
|
||||
space_id=space_id,
|
||||
source_id=source_a,
|
||||
external_id="procedures/certificates",
|
||||
filename="certificates.txt",
|
||||
content=DOCUMENT_TWO.encode(),
|
||||
)
|
||||
|
||||
# --- 2. real job persisted ---------------------------------------
|
||||
persisted = await jobs.get(job_one)
|
||||
assert persisted is not None
|
||||
assert persisted.status is JobStatus.READY
|
||||
assert persisted.payload["source_id"] == str(source_a)
|
||||
status_before = await http.get(
|
||||
f"/v1/jobs/{job_one}", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert status_before.status_code == 200
|
||||
assert status_before.json()["status"] == "ready"
|
||||
|
||||
# --- 3. the worker leases and executes both jobs -----------------
|
||||
assert await worker.run_once() is True
|
||||
assert await worker.run_once() is True
|
||||
for job_id in (job_one, job_two):
|
||||
done = await jobs.get(job_id)
|
||||
assert done is not None
|
||||
assert done.status is JobStatus.SUCCEEDED, done.last_error_code
|
||||
assert done.attempt_count == 1
|
||||
status_after = await http.get(
|
||||
f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert status_after.json()["status"] == "succeeded"
|
||||
|
||||
# --- 4. documents are published with canonical source identity ---
|
||||
owned = await documents.list_documents_by_source(workspace_id, source_a)
|
||||
assert {document.external_id for document in owned} == {
|
||||
"runbooks/gateway",
|
||||
"procedures/certificates",
|
||||
}
|
||||
assert all(document.source_id == source_a for document in owned)
|
||||
for document in owned:
|
||||
[version] = await documents.list_searchable_versions(document.id)
|
||||
assert version.state is DocumentVersionState.PUBLISHED
|
||||
assert version.job_id in {job_one, job_two}
|
||||
|
||||
# --- 5. Qdrant points: dense + sparse, caller's source id --------
|
||||
assert (await qdrant.get_collection(alias_name)).status is not None
|
||||
records, _ = await qdrant.scroll(
|
||||
collection_name=collection_name, limit=100, with_payload=True, with_vectors=True
|
||||
)
|
||||
assert records
|
||||
for record in records:
|
||||
vectors = record.vector
|
||||
assert isinstance(vectors, dict)
|
||||
assert len(vectors["dense"]) == DIMENSIONS
|
||||
sparse = vectors["sparse"]
|
||||
assert len(getattr(sparse, "indices", None) or sparse["indices"]) > 0
|
||||
assert record.payload is not None
|
||||
assert record.payload["source_id"] == str(source_a)
|
||||
assert record.payload["status"] == "published"
|
||||
|
||||
# --- 6. HTTP search finds it; source filter honors input identity
|
||||
search = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "cycle breaker BRK-4417",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"max_results": 5,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert search.status_code == 200, search.text
|
||||
body = search.json()
|
||||
assert body["effective_space_ids"] == [str(space_id)]
|
||||
assert body["degraded"] is False, body["degradation_codes"]
|
||||
assert body["results"], body
|
||||
top = body["results"][0]
|
||||
assert "BRK-4417" in top["text"]
|
||||
assert top["scores"]["sparse"] is not None
|
||||
assert top["scores"]["dense"] is not None
|
||||
assert top["citation"]["source_uri"] == f"ragcore://source/{source_a}"
|
||||
assert top["citation"]["title"] == "gateway.txt"
|
||||
assert body["timings_ms"]["dense_search"] >= 0
|
||||
|
||||
filtered = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "runbook breaker certificate rotation",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"max_results": 10,
|
||||
"filters": {"source_ids": [str(source_a)]},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert filtered.status_code == 200, filtered.text
|
||||
returned = {result["citation"]["document_id"] for result in filtered.json()["results"]}
|
||||
assert returned == {str(document.id) for document in owned}
|
||||
|
||||
foreign = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "runbook breaker certificate rotation",
|
||||
"requested_space_ids": [str(space_id)],
|
||||
"filters": {"source_ids": [str(uuid4())]},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert foreign.status_code == 200
|
||||
assert foreign.json()["results"] == []
|
||||
|
||||
# --- 7. failure path: terminal job <-> FAILED document ------------
|
||||
poisoned = await _upload(
|
||||
http,
|
||||
token=token,
|
||||
space_id=space_id,
|
||||
source_id=source_a,
|
||||
external_id="poison/pdf-as-text",
|
||||
filename="innocent.txt",
|
||||
content=b"%PDF-1.7 this is really a PDF pretending to be text",
|
||||
)
|
||||
assert await worker.run_once() is True
|
||||
failed_job = await jobs.get(poisoned)
|
||||
assert failed_job is not None
|
||||
assert failed_job.status is JobStatus.TERMINAL
|
||||
assert failed_job.last_error_details.get("failure_code") == (
|
||||
DocumentFailureCode.SECURITY_QUARANTINED.value
|
||||
)
|
||||
failed_status = await http.get(
|
||||
f"/v1/jobs/{poisoned}", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert failed_status.json()["status"] == "terminal"
|
||||
poisoned_document = await documents.get_document_by_source(
|
||||
workspace_id, str(source_a), "poison/pdf-as-text"
|
||||
)
|
||||
assert poisoned_document is not None
|
||||
[failed_version] = await documents.list_versions(poisoned_document.id)
|
||||
assert failed_version.state is DocumentVersionState.FAILED
|
||||
assert failed_version.failure_code == DocumentFailureCode.SECURITY_QUARANTINED.value
|
||||
assert failed_version.failure_retryable is False
|
||||
assert failed_version.job_id == poisoned
|
||||
assert failed_version.failed_at is not None
|
||||
assert await documents.list_searchable_versions(poisoned_document.id) == ()
|
||||
assert datetime.now(UTC) - failed_version.failed_at < timedelta(minutes=5)
|
||||
finally:
|
||||
if not lifespan_started:
|
||||
app.state.telemetry.shutdown()
|
||||
await app.state.database_engine.dispose()
|
||||
await app.state.qdrant_client.close()
|
||||
await model_client.aclose()
|
||||
with contextlib.suppress(Exception):
|
||||
await qdrant.delete_collection(collection_name)
|
||||
Reference in new issue
Block a user