mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 20:35:22 +08:00
145 lines
5.0 KiB
Python
145 lines
5.0 KiB
Python
"""Dify deletion safety: fail-closed fence reads, dispatch fencing, target binding.
|
|
|
|
Host storage and the HTTP transport are explicit local fixtures; no live service
|
|
is contacted. The plugin is loaded through the shared certification loader so the
|
|
bundled ``components`` package is exercised as shipped.
|
|
"""
|
|
import asyncio
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from test_shared_state import StorageFixture, bind, configuration, load_plugin
|
|
|
|
|
|
def make_engine(storage):
|
|
pc, ec = load_plugin('DifyDatasetsConnector')
|
|
engine = ec()
|
|
engine.plugin = bind(pc, storage)
|
|
return engine
|
|
|
|
|
|
def patch_http(monkeypatch, handler):
|
|
calls = []
|
|
|
|
async def service(request):
|
|
calls.append(request)
|
|
result = handler(request)
|
|
if asyncio.iscoroutine(result):
|
|
result = await result
|
|
return result
|
|
|
|
client = httpx.AsyncClient
|
|
monkeypatch.setattr(
|
|
httpx, 'AsyncClient',
|
|
lambda **kw: client(transport=httpx.MockTransport(service), **kw),
|
|
)
|
|
return calls
|
|
|
|
|
|
async def seed(engine, mapping_target='https://fixture.invalid', dataset_id='A'):
|
|
await engine._save_config('kb', configuration('A'))
|
|
await engine._save_document('kb', 'doc', {
|
|
'upstream_id': 'upstream-A', 'dataset_id': dataset_id, 'status': 'created',
|
|
'api_base_url': mapping_target,
|
|
})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fence_read_failure_refuses_without_remote_call_and_recovers(monkeypatch):
|
|
calls = patch_http(monkeypatch, lambda request: httpx.Response(204))
|
|
store = StorageFixture()
|
|
engine = make_engine(store)
|
|
await seed(engine)
|
|
from components import shared_state
|
|
binding = engine._state.binding(engine.plugin)
|
|
|
|
store.fail = True
|
|
with pytest.raises(shared_state.FenceStateUnavailableError, match='fence state'):
|
|
await engine.delete_document('kb', 'doc')
|
|
# Unknown fence state fails closed before any provider request.
|
|
assert calls == []
|
|
# The failure is not cached as a loaded binding, so the read is retried.
|
|
assert binding not in engine._state._fence_loaded
|
|
|
|
store.fail = False
|
|
assert await engine.delete_document('kb', 'doc') is True
|
|
assert [request.method for request in calls] == ['DELETE']
|
|
|
|
|
|
class BlockingDelete:
|
|
def __init__(self):
|
|
self.entered = asyncio.Event()
|
|
self.gate = asyncio.Event()
|
|
|
|
async def __call__(self, request):
|
|
self.entered.set()
|
|
await self.gate.wait()
|
|
return httpx.Response(204)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_delete_dispatch_keeps_fence_and_mapping(monkeypatch):
|
|
transport = BlockingDelete()
|
|
calls = patch_http(monkeypatch, transport)
|
|
store = StorageFixture()
|
|
engine = make_engine(store)
|
|
await seed(engine)
|
|
binding = engine._state.binding(engine.plugin)
|
|
|
|
task = asyncio.create_task(engine.delete_document('kb', 'doc'))
|
|
await asyncio.wait_for(transport.entered.wait(), 1.0)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert len(calls) == 1
|
|
# Dispatched with an unknown outcome: the fence is kept.
|
|
assert engine._state.is_fenced(binding, 'kb')
|
|
# The remote delete was never acknowledged, so the mapping stays actionable.
|
|
assert (await engine._load_document('kb', 'doc'))['status'] == 'created'
|
|
|
|
restarted = make_engine(store)
|
|
with pytest.raises(RuntimeError, match='fenced'):
|
|
await restarted.delete_document('kb', 'doc')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deterministic_delete_rejection_leaves_no_fence(monkeypatch):
|
|
calls = patch_http(monkeypatch, lambda request: httpx.Response(404, text='missing'))
|
|
store = StorageFixture()
|
|
engine = make_engine(store)
|
|
await seed(engine)
|
|
binding = engine._state.binding(engine.plugin)
|
|
|
|
assert await engine.delete_document('kb', 'doc') is False
|
|
assert len(calls) == 1
|
|
assert not engine._state.is_fenced(binding, 'kb')
|
|
|
|
# A restarted worker still dispatches (rather than being fenced).
|
|
restarted = make_engine(store)
|
|
assert await restarted.delete_document('kb', 'doc') is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_rejects_changed_upstream_before_any_remote_call(monkeypatch):
|
|
calls = patch_http(monkeypatch, lambda request: httpx.Response(204))
|
|
store = StorageFixture()
|
|
engine = make_engine(store)
|
|
await seed(engine, mapping_target='https://old.invalid')
|
|
|
|
# Current configuration points at a different upstream instance.
|
|
with pytest.raises(RuntimeError, match='Upstream target changed'):
|
|
await engine.delete_document('kb', 'doc')
|
|
assert calls == []
|
|
|
|
# Legacy mappings lacking a bound target are equally rejected, before dispatch.
|
|
legacy_store = StorageFixture()
|
|
legacy = make_engine(legacy_store)
|
|
await legacy._save_config('kb', configuration('A'))
|
|
await legacy._save_document('kb', 'doc', {
|
|
'upstream_id': 'upstream-A', 'dataset_id': 'A', 'status': 'created',
|
|
})
|
|
with pytest.raises(RuntimeError, match='Upstream target changed'):
|
|
await legacy.delete_document('kb', 'doc')
|
|
assert calls == []
|