2026-05-05 03:19:43 +02:00
|
|
|
"""ProviderService coverage — list/get/create/update/delete/decrypt.
|
|
|
|
|
|
|
|
|
|
Drives a real `db_session` via the project's Postgres-backed conftest.
|
|
|
|
|
Provider rows are encrypted at rest with Fernet; tests round-trip
|
|
|
|
|
plaintext → ciphertext → plaintext through `get_decrypted_token` and
|
|
|
|
|
exercise the tri-state semantics of ``ProviderUpdate.auth_token``.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-14 13:43:46 +02:00
|
|
|
import uuid
|
|
|
|
|
from typing import TYPE_CHECKING, cast
|
2026-05-05 03:19:43 +02:00
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from roboco.db.tables import ModelAssignmentTable
|
|
|
|
|
from roboco.models.base import ModelProvider
|
2026-05-06 21:02:31 +02:00
|
|
|
from roboco.services import provider as provider_module
|
2026-05-05 03:19:43 +02:00
|
|
|
from roboco.services.base import ConflictError, NotFoundError
|
|
|
|
|
from roboco.services.provider import (
|
|
|
|
|
ProviderCreate,
|
|
|
|
|
ProviderService,
|
|
|
|
|
ProviderUpdate,
|
2026-05-06 21:02:31 +02:00
|
|
|
get_provider_service,
|
2026-05-05 03:19:43 +02:00
|
|
|
)
|
2026-05-06 21:02:31 +02:00
|
|
|
from roboco.utils.crypto import EncryptionError
|
2026-05-05 03:19:43 +02:00
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def provider_svc(db_session: AsyncSession) -> AsyncIterator[ProviderService]:
|
|
|
|
|
yield ProviderService(db_session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_provider_with_token_encrypts(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"anthropic-{uuid4().hex[:6]}",
|
|
|
|
|
type=ModelProvider.ANTHROPIC,
|
|
|
|
|
auth_token="sk-test-secret",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert row.auth_token_encrypted is not None
|
|
|
|
|
assert row.auth_token_encrypted != "sk-test-secret"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_provider_without_token_leaves_null(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"p-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
|
|
|
|
|
)
|
|
|
|
|
assert row.auth_token_encrypted is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_provider_duplicate_name_raises(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
name = f"dup-{uuid4().hex[:6]}"
|
|
|
|
|
await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(ConflictError):
|
|
|
|
|
await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=name, type=ModelProvider.OPENAI)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_provider_returns_row(provider_svc: ProviderService) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"g-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
|
|
|
|
|
)
|
2026-06-14 13:43:46 +02:00
|
|
|
fetched = await provider_svc.get_provider(cast("uuid.UUID", row.id))
|
2026-05-05 03:19:43 +02:00
|
|
|
assert fetched is not None
|
|
|
|
|
assert fetched.id == row.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_provider_returns_none_when_missing(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
assert await provider_svc.get_provider(uuid4()) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_provider_or_raise_raises(provider_svc: ProviderService) -> None:
|
|
|
|
|
with pytest.raises(NotFoundError):
|
|
|
|
|
await provider_svc.get_provider_or_raise(uuid4())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_by_name(provider_svc: ProviderService) -> None:
|
|
|
|
|
name = f"by-name-{uuid4().hex[:6]}"
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
|
|
|
|
|
)
|
|
|
|
|
found = await provider_svc.get_by_name(name)
|
|
|
|
|
assert found is not None
|
|
|
|
|
assert found.id == row.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_list_providers_excludes_disabled_by_default(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
enabled = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"on-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC, enabled=True
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
disabled = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"off-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
visible = await provider_svc.list_providers()
|
|
|
|
|
visible_ids = {p.id for p in visible}
|
|
|
|
|
assert enabled.id in visible_ids
|
|
|
|
|
assert disabled.id not in visible_ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_list_providers_include_disabled(provider_svc: ProviderService) -> None:
|
|
|
|
|
disabled = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"x-{uuid4().hex[:6]}", type=ModelProvider.LOCAL, enabled=False
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
every = await provider_svc.list_providers(include_disabled=True)
|
|
|
|
|
assert disabled.id in {p.id for p in every}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_changes_name(provider_svc: ProviderService) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"old-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
|
|
|
|
|
)
|
|
|
|
|
new_name = f"new-{uuid4().hex[:6]}"
|
2026-06-14 13:43:46 +02:00
|
|
|
updated = await provider_svc.update_provider(
|
|
|
|
|
cast("uuid.UUID", row.id), ProviderUpdate(name=new_name)
|
|
|
|
|
)
|
2026-05-05 03:19:43 +02:00
|
|
|
assert updated is not None
|
|
|
|
|
assert updated.name == new_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_duplicate_name_raises(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
a = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"a-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
|
|
|
|
|
)
|
|
|
|
|
b = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"b-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(ConflictError):
|
2026-06-14 13:43:46 +02:00
|
|
|
await provider_svc.update_provider(
|
|
|
|
|
cast("uuid.UUID", b.id), ProviderUpdate(name=a.name)
|
|
|
|
|
)
|
2026-05-05 03:19:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_clears_base_url_with_empty_string(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"url-{uuid4().hex[:6]}",
|
|
|
|
|
type=ModelProvider.OLLAMA_CLOUD,
|
|
|
|
|
base_url="https://example.com",
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-06-14 13:43:46 +02:00
|
|
|
updated = await provider_svc.update_provider(
|
|
|
|
|
cast("uuid.UUID", row.id), ProviderUpdate(base_url="")
|
|
|
|
|
)
|
2026-05-05 03:19:43 +02:00
|
|
|
assert updated is not None
|
|
|
|
|
assert updated.base_url is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_token_tristate_clear(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"t-{uuid4().hex[:6]}",
|
|
|
|
|
type=ModelProvider.OLLAMA_CLOUD,
|
|
|
|
|
auth_token="initial",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
updated = await provider_svc.update_provider(
|
2026-06-14 13:43:46 +02:00
|
|
|
cast("uuid.UUID", row.id), ProviderUpdate(clear_auth_token=True)
|
2026-05-05 03:19:43 +02:00
|
|
|
)
|
|
|
|
|
assert updated is not None
|
|
|
|
|
assert updated.auth_token_encrypted is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_token_tristate_set(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"s-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
|
|
|
|
|
)
|
|
|
|
|
updated = await provider_svc.update_provider(
|
2026-06-14 13:43:46 +02:00
|
|
|
cast("uuid.UUID", row.id), ProviderUpdate(auth_token="new-secret")
|
2026-05-05 03:19:43 +02:00
|
|
|
)
|
|
|
|
|
assert updated is not None
|
|
|
|
|
assert updated.auth_token_encrypted is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_token_tristate_unchanged(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"u-{uuid4().hex[:6]}",
|
|
|
|
|
type=ModelProvider.ANTHROPIC,
|
|
|
|
|
auth_token="initial",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
original_token = row.auth_token_encrypted
|
|
|
|
|
updated = await provider_svc.update_provider(
|
2026-06-14 13:43:46 +02:00
|
|
|
cast("uuid.UUID", row.id),
|
2026-05-05 03:19:43 +02:00
|
|
|
ProviderUpdate(enabled=False), # no auth_token field
|
|
|
|
|
)
|
|
|
|
|
assert updated is not None
|
|
|
|
|
assert updated.auth_token_encrypted == original_token # unchanged
|
|
|
|
|
assert updated.enabled is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_returns_none_for_missing(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
assert (
|
|
|
|
|
await provider_svc.update_provider(uuid4(), ProviderUpdate(enabled=False))
|
|
|
|
|
is None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_delete_provider(provider_svc: ProviderService) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"d-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
|
|
|
|
|
)
|
2026-06-14 13:43:46 +02:00
|
|
|
await provider_svc.delete_provider(cast("uuid.UUID", row.id))
|
|
|
|
|
assert await provider_svc.get_provider(cast("uuid.UUID", row.id)) is None
|
2026-05-05 03:19:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_delete_provider_raises_when_referenced(
|
|
|
|
|
db_session: AsyncSession, provider_svc: ProviderService
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"r-{uuid4().hex[:6]}", type=ModelProvider.OPENAI)
|
|
|
|
|
)
|
|
|
|
|
# Insert a model assignment that references this provider.
|
|
|
|
|
assignment = ModelAssignmentTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
scope="role",
|
|
|
|
|
scope_value="developer",
|
|
|
|
|
provider_config_id=row.id,
|
|
|
|
|
model_name="claude-haiku-4-5",
|
|
|
|
|
)
|
|
|
|
|
db_session.add(assignment)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ConflictError):
|
2026-06-14 13:43:46 +02:00
|
|
|
await provider_svc.delete_provider(cast("uuid.UUID", row.id))
|
2026-05-05 03:19:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_decrypted_token_round_trip(provider_svc: ProviderService) -> None:
|
|
|
|
|
plaintext = "sk-roundtrip-secret"
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"rt-{uuid4().hex[:6]}",
|
|
|
|
|
type=ModelProvider.ANTHROPIC,
|
|
|
|
|
auth_token=plaintext,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-06-14 13:43:46 +02:00
|
|
|
decrypted = await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id))
|
2026-05-05 03:19:43 +02:00
|
|
|
assert decrypted == plaintext
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_decrypted_token_returns_none_when_unset(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"nt-{uuid4().hex[:6]}", type=ModelProvider.LOCAL)
|
|
|
|
|
)
|
2026-06-14 13:43:46 +02:00
|
|
|
assert await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id)) is None
|
2026-05-05 03:19:43 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_decrypted_token_returns_none_for_missing_provider(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
assert await provider_svc.get_decrypted_token(uuid4()) is None
|
2026-05-06 21:02:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Encryption error paths (lines 115-117, 162-164)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_create_provider_encrypt_failure_propagates(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""If encrypt_token fails, error is logged + raised."""
|
|
|
|
|
|
|
|
|
|
def _boom(_token: str) -> str:
|
|
|
|
|
raise EncryptionError("bad key")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(provider_module, "encrypt_token", _boom)
|
|
|
|
|
with pytest.raises(EncryptionError):
|
|
|
|
|
await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"e-{uuid4().hex[:6]}",
|
|
|
|
|
type=ModelProvider.ANTHROPIC,
|
|
|
|
|
auth_token="some-token",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_encrypt_failure_propagates(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""If encrypt_token fails on update, error propagates."""
|
|
|
|
|
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=f"eu-{uuid4().hex[:6]}", type=ModelProvider.ANTHROPIC)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _boom(_token: str) -> str:
|
|
|
|
|
raise EncryptionError("bad key")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(provider_module, "encrypt_token", _boom)
|
|
|
|
|
with pytest.raises(EncryptionError):
|
|
|
|
|
await provider_svc.update_provider(
|
2026-06-14 13:43:46 +02:00
|
|
|
cast("uuid.UUID", row.id), ProviderUpdate(auth_token="new-secret")
|
2026-05-06 21:02:31 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Decryption error path (lines 218-224)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_decrypted_token_decrypt_failure_propagates(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""If decrypt_token raises, error propagates."""
|
|
|
|
|
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(
|
|
|
|
|
name=f"dec-{uuid4().hex[:6]}",
|
|
|
|
|
type=ModelProvider.ANTHROPIC,
|
|
|
|
|
auth_token="initial",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _boom(_blob: str) -> str:
|
|
|
|
|
raise EncryptionError("decrypt failed")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(provider_module, "decrypt_token", _boom)
|
|
|
|
|
with pytest.raises(EncryptionError):
|
2026-06-14 13:43:46 +02:00
|
|
|
await provider_svc.get_decrypted_token(cast("uuid.UUID", row.id))
|
2026-05-06 21:02:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _apply_name_change identity branch (line 141)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_update_provider_same_name_is_noop(
|
|
|
|
|
provider_svc: ProviderService,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Updating to the same name short-circuits in _apply_name_change (line 141)."""
|
|
|
|
|
name = f"same-{uuid4().hex[:6]}"
|
|
|
|
|
row = await provider_svc.create_provider(
|
|
|
|
|
ProviderCreate(name=name, type=ModelProvider.ANTHROPIC)
|
|
|
|
|
)
|
2026-06-14 13:43:46 +02:00
|
|
|
updated = await provider_svc.update_provider(
|
|
|
|
|
cast("uuid.UUID", row.id), ProviderUpdate(name=name)
|
|
|
|
|
)
|
2026-05-06 21:02:31 +02:00
|
|
|
assert updated is not None
|
|
|
|
|
assert updated.name == name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Factory (line 229)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_provider_service_factory(db_session: AsyncSession) -> None:
|
|
|
|
|
"""Factory returns a configured ProviderService."""
|
|
|
|
|
|
|
|
|
|
svc = get_provider_service(db_session)
|
|
|
|
|
assert isinstance(svc, ProviderService)
|