Implement lifecycle workspace publication and audit delivery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
This commit is contained in:
parent
ee472acc87
commit
5fec7de4cb
21 changed files with 1022 additions and 78 deletions
111
tests/test_lifecycle_publication.py
Normal file
111
tests/test_lifecycle_publication.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
from dataclasses import replace
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.lifecycle_service import LifecycleError, LifecycleService
|
||||
from informed_decision.store import Conflict, EvidenceUnavailable, Store
|
||||
from test_lifecycle import AGENT, workspace
|
||||
from test_lifecycle_service import caller, service
|
||||
from test_policy_client import policy
|
||||
|
||||
|
||||
AUDIENCE = {"subjects": ["reviewer"], "groups": []}
|
||||
|
||||
|
||||
def test_publication_is_revision_pinned_durable_and_replayable(workspace):
|
||||
app, memo = service(workspace)
|
||||
actor = caller()
|
||||
app.register(actor, memo, key="register")
|
||||
receipt = app.publish(actor, memo.id, 1, audience=AUDIENCE, key="publish")
|
||||
assert app.publish(actor, memo.id, 1, audience=AUDIENCE, key="publish") == receipt
|
||||
state = app.repository.policy_context(AGENT.tenant, memo.id)
|
||||
assert state["published_audience"] == AUDIENCE
|
||||
reopened = type(app.repository)(Store(app.repository.store.path))
|
||||
assert reopened.policy_context(AGENT.tenant, memo.id) == state
|
||||
assert reopened.events(AGENT.tenant, memo.id)[-1]["kind"] == "published"
|
||||
assert app.policy.transport.calls[1][3]["context"]["publication_audience"] == AUDIENCE
|
||||
app.revise(actor, memo.next_version(brief="revised"), expected_version=1, summary="Changed", key="revise")
|
||||
assert app.repository.policy_context(AGENT.tenant, memo.id)["published_audience"] is None
|
||||
assert app.repository.policy_context(AGENT.tenant, memo.id, 1)["published_audience"] == AUDIENCE
|
||||
with pytest.raises(Conflict):
|
||||
app.publish(actor, memo.id, 1, audience=AUDIENCE, key="new-publish")
|
||||
with app.repository.store._connection() as db:
|
||||
assert db.execute("SELECT count(*) FROM dispositions").fetchone()[0] == 0
|
||||
assert db.execute("SELECT count(*) FROM submissions").fetchone()[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("audience", [{}, {"public": True}, {"subjects": [], "groups": []},
|
||||
{"subjects": ["*"], "groups": []},
|
||||
{"subjects": "reviewer", "groups": []}])
|
||||
def test_publication_requires_explicit_bounded_audience(workspace, audience):
|
||||
app, memo = service(workspace)
|
||||
app.register(caller(), memo, key="register")
|
||||
with pytest.raises(ValueError):
|
||||
app.publish(caller(), memo.id, 1, audience=audience, key="publish")
|
||||
assert len(app.repository.events(AGENT.tenant, memo.id)) == 1
|
||||
|
||||
|
||||
def test_audience_is_not_local_permission_and_denial_prevents_publication(workspace):
|
||||
app, memo = service(workspace)
|
||||
app.register(caller(), memo, key="register")
|
||||
app.policy.transport.change = lambda data: data.update(effect="deny")
|
||||
with pytest.raises(LifecycleError) as error:
|
||||
app.publish(caller(), memo.id, 1, audience=AUDIENCE, key="publish")
|
||||
assert error.value.status == 403
|
||||
assert app.repository.policy_context(AGENT.tenant, memo.id)["published_audience"] is None
|
||||
|
||||
|
||||
def test_audience_change_during_authorization_blocks_read_and_write(workspace, monkeypatch):
|
||||
app, memo = service(workspace)
|
||||
app.register(caller(), memo, key="register")
|
||||
original = app.repository.annotate
|
||||
def changed(*args, **kwargs):
|
||||
app.repository.publish(AGENT, memo.id, 1, audience=AUDIENCE, key="concurrent-publish")
|
||||
return original(*args, **kwargs)
|
||||
monkeypatch.setattr(app.repository, "annotate", changed)
|
||||
with pytest.raises(LifecycleError, match="record_changed"):
|
||||
app.annotate(caller(), memo.id, 1, kind="comment", body={"text": "pending"}, key="comment")
|
||||
original_read = app.repository.retrieve
|
||||
def changed_read(*args, **kwargs):
|
||||
result = original_read(*args, **kwargs)
|
||||
app.repository.publish(AGENT, memo.id, 1, audience={"subjects": ["another"], "groups": []}, key="replace")
|
||||
return result
|
||||
monkeypatch.setattr(app.repository, "retrieve", changed_read)
|
||||
with pytest.raises(LifecycleError, match="record_changed"):
|
||||
app.retrieve(caller(), memo.id)
|
||||
|
||||
|
||||
def test_search_filters_denied_records_and_opaque_cursor_is_caller_scoped(workspace):
|
||||
app, memo = service(workspace)
|
||||
for index in range(51):
|
||||
app.repository.register(AGENT, replace(memo, id=f"record-{index:03}"), key=f"register-{index}")
|
||||
# Actual PolicyClient still validates these synthetic PDP responses.
|
||||
def decide(data):
|
||||
if data["binding"]["resource"]["id"] == "memo:record-001":
|
||||
data["effect"] = "deny"
|
||||
app.policy.transport.change = decide
|
||||
first = app.search(caller())
|
||||
assert len(first["decisions"]) == 49
|
||||
assert "record-001" not in json.dumps(first)
|
||||
assert first["next_cursor"] and "record-049" not in first["next_cursor"]
|
||||
assert app.search(caller(), cursor=first["next_cursor"])["decisions"][0]["memo_id"] == "record-050"
|
||||
with pytest.raises(ValueError):
|
||||
app.search(caller(), query="different", cursor=first["next_cursor"])
|
||||
foreign = replace(caller(), attribution=replace(AGENT, tenant="tenant:other"))
|
||||
with pytest.raises(ValueError):
|
||||
app.search(foreign, cursor=first["next_cursor"])
|
||||
with pytest.raises(ValueError):
|
||||
LifecycleService(app.repository, policy()).search(caller(), cursor=first["next_cursor"])
|
||||
app.policy.transport.change = lambda data: data.update(effect="deny")
|
||||
with pytest.raises(LifecycleError):
|
||||
app.search(caller(), cursor=first["next_cursor"])
|
||||
|
||||
|
||||
def test_search_does_not_turn_policy_failure_into_empty_success(workspace):
|
||||
app, memo = service(workspace)
|
||||
app.repository.register(AGENT, memo, key="register")
|
||||
app.policy.transport.status = 503
|
||||
with pytest.raises(LifecycleError):
|
||||
app.search(caller())
|
||||
Loading…
Add table
Add a link
Reference in a new issue