Files
Octop/tests/unit/api/test_provider_patch_rehydrate.py
seed30 6f19e9a268 fix(providers): clear the API key when authorization is revoked
Revoke sends api_key null, and the patch treated null as leave unchanged, so the provider stayed authorized. Explicit null now clears only that field.
2026-10-12 20:44:18 +08:00

136 lines
4.9 KiB
Python

"""Unit tests: skip harness rehydrate when provider patch is note-only."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from octop.api.routers.providers import (
ProviderPatchBody,
_patch_requires_provider_rehydrate,
_provider_patch_updates,
admin_patch_provider,
)
def test_revoke_api_key_is_an_explicit_clear() -> None:
body = ProviderPatchBody.model_validate({"api_key": None})
assert _provider_patch_updates(body, current_extra_json=None) == {"api_key": None}
def test_blank_api_key_clears_without_touching_other_fields() -> None:
body = ProviderPatchBody.model_validate({"api_key": " "})
assert _provider_patch_updates(body, current_extra_json=None) == {"api_key": None}
def test_note_only_patch_does_not_include_api_key() -> None:
body = ProviderPatchBody.model_validate({"note": "ops memo"})
assert _provider_patch_updates(body, current_extra_json='{"headers": {}}') == {
"note": "ops memo"
}
def test_stream_usage_patch_rewrites_extra_without_clearing_api_key() -> None:
body = ProviderPatchBody.model_validate({"stream_usage": False})
assert _provider_patch_updates(body, current_extra_json='{"headers": {"X-A": "1"}}') == {
"extra_json": '{"headers": {"X-A": "1"}, "stream_usage": false}'
}
def test_patch_note_only_does_not_require_rehydrate() -> None:
body = ProviderPatchBody.model_validate({"note": "ops memo"})
assert _patch_requires_provider_rehydrate(body) is False
def test_patch_models_requires_rehydrate() -> None:
body = ProviderPatchBody.model_validate(
{"models": [{"id": "m1", "name": "m1", "enabled": True}]}
)
assert _patch_requires_provider_rehydrate(body) is True
def test_patch_note_and_api_key_requires_rehydrate() -> None:
body = ProviderPatchBody.model_validate({"note": "x", "api_key": "sk-new"})
assert _patch_requires_provider_rehydrate(body) is True
@pytest.mark.asyncio
async def test_admin_patch_note_only_skips_on_provider_changed() -> None:
on_changed = AsyncMock()
row = MagicMock()
row.id = 1
row.name = "test-openai"
server = MagicMock()
server.services.provider_repo.get.side_effect = [row, row]
server.services.provider_repo.update = MagicMock()
server.app_runtime.agent_registry.on_provider_changed = on_changed
body = ProviderPatchBody.model_validate({"note": "only note"})
await admin_patch_provider(provider_id=1, body=body, _=None, server=server)
server.services.provider_repo.update.assert_called_once_with(1, note="only note")
on_changed.assert_not_awaited()
@pytest.mark.asyncio
async def test_admin_patch_api_key_calls_on_provider_changed() -> None:
on_changed = AsyncMock()
row = MagicMock()
row.id = 1
row.name = "test-openai"
server = MagicMock()
server.services.provider_repo.get.side_effect = [row, row]
server.services.provider_repo.update = MagicMock()
server.app_runtime.agent_registry.on_provider_changed = on_changed
body = ProviderPatchBody.model_validate({"api_key": "sk-new"})
await admin_patch_provider(provider_id=1, body=body, _=None, server=server)
server.services.provider_repo.update.assert_called_once_with(1, api_key="sk-new")
on_changed.assert_awaited_once_with(provider_name="test-openai")
@pytest.mark.asyncio
async def test_admin_patch_null_api_key_clears_stored_key() -> None:
on_changed = AsyncMock()
row = MagicMock()
row.id = 1
row.name = "DeepSeek"
server = MagicMock()
server.services.provider_repo.get.side_effect = [row, row]
server.services.provider_repo.update = MagicMock()
server.app_runtime.agent_registry.on_provider_changed = on_changed
body = ProviderPatchBody.model_validate({"api_key": None})
await admin_patch_provider(provider_id=1, body=body, _=None, server=server)
server.services.provider_repo.update.assert_called_once_with(1, api_key=None)
on_changed.assert_awaited_once_with(provider_name="DeepSeek")
@pytest.mark.asyncio
async def test_admin_patch_models_clears_stale_agent_default_and_active() -> None:
on_changed = AsyncMock()
row = MagicMock()
row.id = 1
row.name = "hai"
server = MagicMock()
server.services.provider_repo.get.side_effect = [row, row]
server.services.provider_repo.update = MagicMock()
server.app_runtime.agent_registry.on_provider_changed = on_changed
body = ProviderPatchBody.model_validate(
{"models": [{"id": "keep", "name": "keep", "enabled": True}]}
)
with patch("octop.api.routers.providers.clear_stale_pins_for_provider") as clear_pins:
await admin_patch_provider(provider_id=1, body=body, _=None, server=server)
clear_pins.assert_called_once_with(
agent_repo=server.services.agent_repo,
settings_repo=server.services.settings_repo,
provider_name="hai",
models=body.models,
)
on_changed.assert_awaited_once_with(provider_name="hai")