test(integration): add coverage for repositories, auth resolution, and the two-phase upload
This commit is contained in:
65
tests/integration/postgres/test_auth_service.py
Normal file
65
tests/integration/postgres/test_auth_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.application.auth.errors import InvalidApiKeyError, TenantInactiveError
|
||||
from src.application.auth.service import resolve_auth_context
|
||||
from tests.support.factories import create_api_key, create_tenant
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.postgres,
|
||||
pytest.mark.asyncio(loop_scope="session"),
|
||||
]
|
||||
|
||||
|
||||
async def test_resolve_auth_context_accepts_valid_key(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
_, full_key = await create_api_key(db_session, tenant=tenant, scopes=["files:write"])
|
||||
await db_session.commit()
|
||||
|
||||
auth = await resolve_auth_context(db_sessionmaker, full_key)
|
||||
|
||||
assert auth.tenant_id == tenant.id
|
||||
assert auth.has_scope("files:write")
|
||||
|
||||
|
||||
async def test_resolve_auth_context_rejects_wrong_secret(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
api_key, _ = await create_api_key(db_session, tenant=tenant)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(InvalidApiKeyError):
|
||||
await resolve_auth_context(db_sessionmaker, f"sk_{api_key.key_prefix}_wrong-secret")
|
||||
|
||||
|
||||
async def test_resolve_auth_context_rejects_unknown_prefix(
|
||||
db_sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
with pytest.raises(InvalidApiKeyError):
|
||||
await resolve_auth_context(db_sessionmaker, "sk_doesnotexist_secret")
|
||||
|
||||
|
||||
async def test_resolve_auth_context_rejects_revoked_key(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
_, full_key = await create_api_key(db_session, tenant=tenant, status="revoked")
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(InvalidApiKeyError):
|
||||
await resolve_auth_context(db_sessionmaker, full_key)
|
||||
|
||||
|
||||
async def test_resolve_auth_context_rejects_suspended_tenant(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session, status="suspended")
|
||||
_, full_key = await create_api_key(db_session, tenant=tenant)
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(TenantInactiveError):
|
||||
await resolve_auth_context(db_sessionmaker, full_key)
|
||||
113
tests/integration/postgres/test_repositories.py
Normal file
113
tests/integration/postgres/test_repositories.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.infrastructure.postgres.repositories import ingestion_jobs as jobs_repo
|
||||
from src.infrastructure.postgres.repositories import source_files as source_files_repo
|
||||
from tests.support.factories import create_tenant
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.postgres,
|
||||
pytest.mark.asyncio(loop_scope="session"),
|
||||
]
|
||||
|
||||
|
||||
async def test_source_files_get_by_id_is_tenant_scoped(db_session: AsyncSession) -> None:
|
||||
owner = await create_tenant(db_session)
|
||||
other = await create_tenant(db_session)
|
||||
source_file_id = uuid.uuid4()
|
||||
source_files_repo.create(
|
||||
db_session,
|
||||
source_file_id=source_file_id,
|
||||
tenant_id=owner.id,
|
||||
domain="general",
|
||||
source_filename="report.csv",
|
||||
source_type="csv",
|
||||
content_sha256="a" * 64,
|
||||
byte_size=10,
|
||||
storage_uri="tenants/x/source-files/y/original",
|
||||
created_by_api_key_id=None,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
found_for_owner = await source_files_repo.get_by_id(
|
||||
db_session, tenant_id=owner.id, source_file_id=source_file_id
|
||||
)
|
||||
found_for_other = await source_files_repo.get_by_id(
|
||||
db_session, tenant_id=other.id, source_file_id=source_file_id
|
||||
)
|
||||
|
||||
assert found_for_owner is not None
|
||||
assert found_for_other is None
|
||||
|
||||
|
||||
async def test_source_files_find_active_by_content_hash_matches_tenant_domain_hash(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
tenant = await create_tenant(db_session)
|
||||
source_files_repo.create(
|
||||
db_session,
|
||||
source_file_id=uuid.uuid4(),
|
||||
tenant_id=tenant.id,
|
||||
domain="general",
|
||||
source_filename="report.csv",
|
||||
source_type="csv",
|
||||
content_sha256="b" * 64,
|
||||
byte_size=10,
|
||||
storage_uri="tenants/x/source-files/y/original",
|
||||
created_by_api_key_id=None,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
found = await source_files_repo.find_active_by_content_hash(
|
||||
db_session, tenant_id=tenant.id, domain="general", content_sha256="b" * 64
|
||||
)
|
||||
not_found_other_domain = await source_files_repo.find_active_by_content_hash(
|
||||
db_session, tenant_id=tenant.id, domain="other", content_sha256="b" * 64
|
||||
)
|
||||
|
||||
assert found is not None
|
||||
assert not_found_other_domain is None
|
||||
|
||||
|
||||
async def test_ingestion_jobs_mark_terminal_rejects_non_running_job(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""A job already in a terminal state must not be re-marked (ADR-0017)."""
|
||||
tenant = await create_tenant(db_session)
|
||||
source_file_id = uuid.uuid4()
|
||||
source_files_repo.create(
|
||||
db_session,
|
||||
source_file_id=source_file_id,
|
||||
tenant_id=tenant.id,
|
||||
domain="general",
|
||||
source_filename="report.csv",
|
||||
source_type="csv",
|
||||
content_sha256="c" * 64,
|
||||
byte_size=10,
|
||||
storage_uri="tenants/x/source-files/y/original",
|
||||
created_by_api_key_id=None,
|
||||
)
|
||||
await db_session.flush()
|
||||
job = jobs_repo.create_running(
|
||||
db_session,
|
||||
tenant_id=tenant.id,
|
||||
source_file_id=source_file_id,
|
||||
requested_by_api_key_id=None,
|
||||
chunking_strategy="fixed_size",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
first = await jobs_repo.mark_terminal(
|
||||
db_session, tenant_id=tenant.id, ingestion_job_id=job.id, status="succeeded"
|
||||
)
|
||||
await db_session.commit()
|
||||
second = await jobs_repo.mark_terminal(
|
||||
db_session, tenant_id=tenant.id, ingestion_job_id=job.id, status="failed"
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.status == "succeeded"
|
||||
assert second is None
|
||||
194
tests/integration/postgres/test_upload_service.py
Normal file
194
tests/integration/postgres/test_upload_service.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""ADR-0017's two-transaction upload shape, exercised against real Postgres.
|
||||
|
||||
Object storage is faked (`FakeObjectStorage`) -- it's a port, not the thing
|
||||
under test here. `MinioObjectStorage` itself is covered in
|
||||
`tests/integration/minio/`.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from anyio import CapacityLimiter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from src.application.auth.context import AuthContext
|
||||
from src.application.files.upload import upload_source_file
|
||||
from src.infrastructure.postgres.models.ingestion_job import IngestionJob
|
||||
from tests.fakes import FakeObjectStorage
|
||||
from tests.support.factories import create_api_key, create_tenant
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.postgres,
|
||||
pytest.mark.asyncio(loop_scope="session"),
|
||||
]
|
||||
|
||||
_CSV_BYTES = b"name,value\nfirst,1\n"
|
||||
|
||||
|
||||
async def _auth_for(db_session: AsyncSession) -> AuthContext:
|
||||
tenant = await create_tenant(db_session)
|
||||
api_key, _ = await create_api_key(db_session, tenant=tenant)
|
||||
await db_session.commit()
|
||||
return AuthContext(
|
||||
tenant_id=tenant.id,
|
||||
tenant_slug=tenant.slug,
|
||||
api_key_id=api_key.id,
|
||||
scopes=frozenset({"files:write"}),
|
||||
actor_type="backend",
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_source_file_commits_running_job_before_storage_write(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
"""After phase 1 commits, a *separate* session must already see the
|
||||
running job -- proving txn A committed and released before phase 2 work,
|
||||
per ADR-0017.
|
||||
"""
|
||||
auth = await _auth_for(db_session)
|
||||
storage = FakeObjectStorage()
|
||||
|
||||
result = await upload_source_file(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=storage,
|
||||
auth=auth,
|
||||
domain="general",
|
||||
filename="report.csv",
|
||||
data=_CSV_BYTES,
|
||||
max_upload_size_bytes=1_000_000,
|
||||
chunking_strategy="fixed_size",
|
||||
validation_limiter=CapacityLimiter(2),
|
||||
)
|
||||
|
||||
assert result.status == "succeeded"
|
||||
assert result.chunks_indexed == 0
|
||||
assert result.is_new_attempt
|
||||
|
||||
async with db_sessionmaker() as verify_session:
|
||||
job = await verify_session.get(IngestionJob, result.ingestion_job_id)
|
||||
assert job is not None
|
||||
assert job.status == "succeeded"
|
||||
assert storage.objects # bytes were actually written
|
||||
|
||||
|
||||
async def test_upload_source_file_duplicate_hash_returns_existing_without_reingesting(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
auth = await _auth_for(db_session)
|
||||
storage = FakeObjectStorage()
|
||||
|
||||
first = await upload_source_file(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=storage,
|
||||
auth=auth,
|
||||
domain="general",
|
||||
filename="report.csv",
|
||||
data=_CSV_BYTES,
|
||||
max_upload_size_bytes=1_000_000,
|
||||
chunking_strategy="fixed_size",
|
||||
validation_limiter=CapacityLimiter(2),
|
||||
)
|
||||
second = await upload_source_file(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=storage,
|
||||
auth=auth,
|
||||
domain="general",
|
||||
filename="report.csv",
|
||||
data=_CSV_BYTES,
|
||||
max_upload_size_bytes=1_000_000,
|
||||
chunking_strategy="fixed_size",
|
||||
validation_limiter=CapacityLimiter(2),
|
||||
)
|
||||
|
||||
assert second.file_id == first.file_id
|
||||
assert second.ingestion_job_id == first.ingestion_job_id
|
||||
assert not second.is_new_attempt
|
||||
assert len(storage.objects) == 1 # no second write
|
||||
|
||||
|
||||
async def test_upload_source_file_retries_after_failed_job(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
"""A duplicate upload whose last attempt failed must get a fresh job,
|
||||
not be treated as already-succeeded (ADR-0017: safe to run more than
|
||||
once).
|
||||
"""
|
||||
auth = await _auth_for(db_session)
|
||||
failing_storage = FakeObjectStorage(fail_next=True)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
await upload_source_file(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=failing_storage,
|
||||
auth=auth,
|
||||
domain="general",
|
||||
filename="report.csv",
|
||||
data=_CSV_BYTES,
|
||||
max_upload_size_bytes=1_000_000,
|
||||
chunking_strategy="fixed_size",
|
||||
validation_limiter=CapacityLimiter(2),
|
||||
)
|
||||
|
||||
async with db_sessionmaker() as verify_session:
|
||||
jobs = (
|
||||
(
|
||||
await verify_session.execute(
|
||||
select(IngestionJob).where(IngestionJob.tenant_id == auth.tenant_id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].status == "failed"
|
||||
|
||||
retry = await upload_source_file(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=FakeObjectStorage(),
|
||||
auth=auth,
|
||||
domain="general",
|
||||
filename="report.csv",
|
||||
data=_CSV_BYTES,
|
||||
max_upload_size_bytes=1_000_000,
|
||||
chunking_strategy="fixed_size",
|
||||
validation_limiter=CapacityLimiter(2),
|
||||
)
|
||||
|
||||
assert retry.status == "succeeded"
|
||||
assert retry.is_new_attempt
|
||||
assert retry.file_id == jobs[0].source_file_id
|
||||
|
||||
|
||||
async def test_upload_source_file_is_tenant_isolated_for_identical_content(
|
||||
db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession]
|
||||
) -> None:
|
||||
"""Two different tenants uploading byte-identical files must not collide
|
||||
on the (tenant_id, domain, content_sha256) idempotency key.
|
||||
"""
|
||||
auth_a = await _auth_for(db_session)
|
||||
auth_b = await _auth_for(db_session)
|
||||
|
||||
result_a = await upload_source_file(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=FakeObjectStorage(),
|
||||
auth=auth_a,
|
||||
domain="general",
|
||||
filename="report.csv",
|
||||
data=_CSV_BYTES,
|
||||
max_upload_size_bytes=1_000_000,
|
||||
chunking_strategy="fixed_size",
|
||||
validation_limiter=CapacityLimiter(2),
|
||||
)
|
||||
result_b = await upload_source_file(
|
||||
sessionmaker=db_sessionmaker,
|
||||
storage=FakeObjectStorage(),
|
||||
auth=auth_b,
|
||||
domain="general",
|
||||
filename="report.csv",
|
||||
data=_CSV_BYTES,
|
||||
max_upload_size_bytes=1_000_000,
|
||||
chunking_strategy="fixed_size",
|
||||
validation_limiter=CapacityLimiter(2),
|
||||
)
|
||||
|
||||
assert result_a.file_id != result_b.file_id
|
||||
Reference in New Issue
Block a user