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
|
|
@ -1,4 +1,5 @@
|
|||
import copy
|
||||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -75,3 +76,24 @@ def test_policy_revision_changes_immutable_configuration_name():
|
|||
first=inputs();second=copy.deepcopy(first);second['policy']['version']='v2'
|
||||
names=lambda data:[o['metadata']['name'] for o in module.render(data)['items'] if o['kind']=='ConfigMap']
|
||||
assert names(first)!=names(second)
|
||||
|
||||
|
||||
def test_optional_workspace_requires_distinct_pins_and_exact_policy_network_peer():
|
||||
data = inputs()
|
||||
data['lifecycle_policy'] = {**data['policy'], 'package': 'informed-decision.lifecycle',
|
||||
'origin': 'http://flex-auth-lifecycle.flex-auth.svc.cluster.local:8080',
|
||||
'pod_name': 'flex-auth-lifecycle'}
|
||||
with pytest.raises(ValueError):
|
||||
module.render(data)
|
||||
data['lifecycle_delivery'] = True
|
||||
objects = module.render(data)['items']
|
||||
config = json.loads(next(row for row in objects if row['kind']=='ConfigMap')['data']['runtime.json'])
|
||||
assert config['lifecycle_policy']['package']=='informed-decision.lifecycle'
|
||||
assert config['lifecycle_delivery'] is True
|
||||
review_network = next(row for row in objects if row['kind']=='NetworkPolicy' and row['metadata']['namespace']=='informed-decision')
|
||||
assert any(peer.get('podSelector', {}).get('matchLabels', {}).get('app.kubernetes.io/name')=='flex-auth-lifecycle'
|
||||
for rule in review_network['spec']['egress'] for peer in rule.get('to', []))
|
||||
assert any(row['metadata']['name']=='informed-decision-lifecycle-ingress' for row in objects)
|
||||
data['lifecycle_policy']['package'] = data['policy']['package']
|
||||
with pytest.raises(ValueError):
|
||||
module.render(data)
|
||||
|
|
|
|||
80
tests/test_lifecycle_audit.py
Normal file
80
tests/test_lifecycle_audit.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from dataclasses import replace
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.audit import OutboxWorker
|
||||
from informed_decision.evidence import EventClass
|
||||
from informed_decision.lifecycle import LifecycleRepository
|
||||
from informed_decision.runtime import AuditPump
|
||||
from informed_decision.store import Conflict, Store
|
||||
from test_durable_component import receiver
|
||||
from test_lifecycle import AGENT, workspace
|
||||
|
||||
|
||||
def register_platform(workspace):
|
||||
repo, memo = workspace
|
||||
actor = replace(AGENT, tenant="tenant:platform")
|
||||
event_id = repo.register(actor, memo, key="register")
|
||||
return repo.store, memo, event_id
|
||||
|
||||
|
||||
def test_lifecycle_commitment_is_content_free_stable_and_verifiable(workspace):
|
||||
store, memo, event_id = register_platform(workspace)
|
||||
assert store.stage_lifecycle_audit() == 1
|
||||
assert store.stage_lifecycle_audit() == 0
|
||||
evidence = store.evidence()[0]
|
||||
envelope = json.loads(evidence["envelope"])
|
||||
assert envelope["id"] == event_id and envelope["type"] == EventClass.LIFECYCLE.value
|
||||
assert memo.brief not in evidence["envelope"] and memo.question not in evidence["envelope"]
|
||||
assert envelope["data"]["content_sha256"] == "sha256:" + hashlib.sha256(evidence["content"].encode()).hexdigest()
|
||||
assert store.settle_lifecycle_audit() == 0
|
||||
assert store.lifecycle_counts()["pending"] == 1
|
||||
assert Store(store.path).evidence() == store.evidence()
|
||||
|
||||
|
||||
def test_lifecycle_delivery_uses_actual_receiver_and_recovers_lost_reply(workspace, receiver):
|
||||
store, _, event_id = register_platform(workspace)
|
||||
sink, transport, _ = receiver
|
||||
store.stage_lifecycle_audit()
|
||||
transport.lose_reply = True
|
||||
worker = OutboxWorker(store, sink)
|
||||
assert worker.run_once()["retrying"] == 1
|
||||
assert store.settle_lifecycle_audit() == 0
|
||||
with store._connection() as db:
|
||||
db.execute("UPDATE outbox SET next_attempt=0 WHERE id=?", (event_id,))
|
||||
reopened = Store(store.path)
|
||||
assert OutboxWorker(reopened, sink).run_once()["delivered"] == 1
|
||||
assert reopened.settle_lifecycle_audit() == 1
|
||||
assert reopened.lifecycle_counts() == {"records": 1, "events": 1, "pending": 0}
|
||||
assert worker.reconcile("2020-01-01T00:00:00Z", "2100-01-01T00:00:00Z")["counts"][EventClass.LIFECYCLE.value] == {"source": 1, "receiver": 1}
|
||||
|
||||
|
||||
def test_runtime_delivery_is_explicit_and_restores_readiness_only_after_receipts(workspace, receiver):
|
||||
store, _, _ = register_platform(workspace)
|
||||
sink, _, _ = receiver
|
||||
disabled = AuditPump(store, sink)
|
||||
disabled.tick()
|
||||
assert not disabled.ready() and store.lifecycle_counts()["pending"] == 1
|
||||
enabled = AuditPump(store, sink, lifecycle_delivery=True)
|
||||
enabled.tick()
|
||||
assert enabled.ready() and store.lifecycle_counts()["pending"] == 0
|
||||
assert store.evidence()[0]["class"] != EventClass.LIFECYCLE.value # Legacy heartbeats predate opt-in.
|
||||
|
||||
|
||||
def test_unadmitted_tenant_remains_pending_without_audit_relabelling(workspace):
|
||||
repo, memo = workspace
|
||||
repo.register(AGENT, memo, key="register")
|
||||
with pytest.raises(Conflict, match="tenant not admitted"):
|
||||
repo.store.stage_lifecycle_audit()
|
||||
assert repo.store.evidence() == []
|
||||
assert repo.store.lifecycle_counts()["pending"] == 1
|
||||
|
||||
|
||||
def test_pending_events_do_not_get_a_nothing_to_report_heartbeat(workspace):
|
||||
store, _, _ = register_platform(workspace)
|
||||
store.stage_lifecycle_audit()
|
||||
store.queue_heartbeats(include_lifecycle=True)
|
||||
assert not any(json.loads(row["envelope"])["data"].get("class") == EventClass.LIFECYCLE.value
|
||||
for row in store.evidence() if row["class"] == EventClass.HEARTBEAT.value)
|
||||
74
tests/test_lifecycle_flow.py
Normal file
74
tests/test_lifecycle_flow.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import json
|
||||
from urllib.parse import urlencode
|
||||
import uuid
|
||||
|
||||
from informed_decision.audit import OutboxWorker
|
||||
from informed_decision.lifecycle_http import LifecycleAPI
|
||||
from informed_decision.lifecycle_service import Caller
|
||||
from informed_decision.runtime import Runtime
|
||||
from informed_decision.web import App, SESSION_COOKIE
|
||||
from informed_decision.oidc import ORIGIN
|
||||
from informed_decision.policy import PolicyClient
|
||||
from informed_decision.records import dumps
|
||||
from test_browser_auth import login, signing_key, finish, call
|
||||
from test_deployment_candidate import inputs, module
|
||||
from test_durable_component import receiver
|
||||
from test_lifecycle_http import call as api_call
|
||||
from test_policy_client import PolicyTransport
|
||||
from test_review_runtime import config
|
||||
from test_skeleton import make_memo
|
||||
|
||||
|
||||
def test_agent_to_human_collaboration_and_audit_without_configuration_changes(tmp_path, login, receiver):
|
||||
path, configuration = config(tmp_path)
|
||||
configuration['lifecycle_policy'] = {**configuration['policy'], 'package': 'informed-decision.lifecycle'}
|
||||
configuration['lifecycle_delivery'] = True
|
||||
path.write_text(json.dumps(configuration))
|
||||
original_configuration = path.read_bytes()
|
||||
runtime = Runtime.from_file(path)
|
||||
transport = PolicyTransport()
|
||||
transport.change = lambda data: data['provenance'].update(policy_package='informed-decision.lifecycle')
|
||||
lifecycle = runtime.lifecycle
|
||||
lifecycle.policy = PolicyClient('https://policy.test', lambda: 'synthetic-caller',
|
||||
package='informed-decision.lifecycle', version='v1', package_digest=configuration['policy']['package_digest'],
|
||||
transport=transport)
|
||||
# Match the immutable configured digest in the simulated PDP return.
|
||||
transport.change = lambda data: data['provenance'].update(
|
||||
policy_package='informed-decision.lifecycle', policy_package_digest=configuration['policy']['package_digest'])
|
||||
sid = finish(login)
|
||||
session = login[0].session(sid)
|
||||
from informed_decision.lifecycle import Attribution
|
||||
from informed_decision.disposition import Actor, ActorKind
|
||||
agent = Caller(Attribution(session.tenant.value, Actor('synthetic-agent', ActorKind.AGENT)),
|
||||
session.expires_at, '{"principal_type_source":"authentication-derived"}')
|
||||
def authenticate(token):
|
||||
if token != 'fixture':
|
||||
raise ValueError()
|
||||
return agent
|
||||
api = LifecycleAPI(lifecycle, authenticate)
|
||||
app = App(login[0], runtime.controller, readiness=runtime.pump.ready, lifecycle=lifecycle)
|
||||
cookie = f'{SESSION_COOKIE}={sid}'
|
||||
memo = make_memo(id='agent-posed', approval_id=None, packet=(), highlights=())
|
||||
status, receipt, _ = api_call(api, '/v1/decisions', method='POST', body={'memo': json.loads(dumps(memo))})
|
||||
assert status == 200 and receipt['workspace_url'] == ORIGIN + '/decisions/agent-posed?version=1'
|
||||
assert call(app, '/decisions/agent-posed', cookie=cookie)['status'] == 200
|
||||
def post(action, **values):
|
||||
values.update(csrf=session.csrf, key=str(uuid.uuid4()))
|
||||
return call(app, '/decisions/agent-posed/' + action, 'POST', cookie=cookie, origin=ORIGIN,
|
||||
body=urlencode(values).encode())
|
||||
assert post('publish', version='1', subjects=session.subject, groups='')['status'] == 303
|
||||
assert post('comment', version='1', text='Please add the alternative.')['status'] == 303
|
||||
assert post('revise', version='1', question=memo.question, brief='Alternative added.', summary='Responded to feedback')['status'] == 303
|
||||
assert api_call(api, '/v1/decisions/agent-posed')[1]['version'] == 2
|
||||
from dataclasses import replace
|
||||
second = replace(memo, id='second-agent-decision')
|
||||
assert api_call(api, '/v1/decisions', method='POST', body={'memo': json.loads(dumps(second))},
|
||||
HTTP_IDEMPOTENCY_KEY='second')[0] == 200
|
||||
assert not runtime.pump.ready()
|
||||
runtime.pump.worker = OutboxWorker(runtime.controller.store, receiver[0])
|
||||
runtime.pump.tick()
|
||||
assert runtime.pump.ready()
|
||||
assert runtime.controller.store.lifecycle_counts() == {'records': 2, 'events': 5, 'pending': 0}
|
||||
assert path.read_bytes() == original_configuration
|
||||
assert runtime.controller.store.memos_for(memo.binding.principal.id) == []
|
||||
assert api_call(api, '/v1/decisions/agent-posed/accept', method='POST', body={})[0] == 404
|
||||
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())
|
||||
92
tests/test_lifecycle_ui.py
Normal file
92
tests/test_lifecycle_ui.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import re
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
|
||||
from informed_decision.lifecycle_service import Caller
|
||||
from informed_decision.oidc import ORIGIN
|
||||
from informed_decision.web import App, SESSION_COOKIE
|
||||
from test_browser_auth import login, signing_key, finish, call
|
||||
from test_lifecycle import workspace
|
||||
from test_lifecycle_service import service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser(workspace, login):
|
||||
lifecycle, _ = service(workspace)
|
||||
sid = finish(login)
|
||||
session = login[0].session(sid)
|
||||
return App(login[0], lifecycle=lifecycle), lifecycle, session, f"{SESSION_COOKIE}={sid}"
|
||||
|
||||
|
||||
def post(browser, path, **values):
|
||||
app, _, session, cookie = browser
|
||||
values.setdefault("csrf", session.csrf)
|
||||
values.setdefault("key", str(uuid.uuid4()))
|
||||
return call(app, path, "POST", cookie=cookie, origin=ORIGIN, body=urlencode(values).encode())
|
||||
|
||||
|
||||
def create(browser):
|
||||
response = post(browser, "/decisions/new/prepare", question="Which storage option should we adopt?",
|
||||
brief="Compare cost, recovery and maintenance.", scope_id="storage", scope_label="Storage strategy")
|
||||
assert response["status"] == 303
|
||||
return dict(response["headers"])["Location"]
|
||||
|
||||
|
||||
def test_human_workspace_create_refine_publish_discuss_and_retrieve(browser):
|
||||
app, lifecycle, session, cookie = browser
|
||||
assert 'Open the decision workspace' in call(app, cookie=cookie)["body"]
|
||||
path = create(browser)
|
||||
page = call(app, path, cookie=cookie)
|
||||
assert page["status"] == 200 and "Storage strategy" in page["body"]
|
||||
assert "Human acceptance is not available" in page["body"]
|
||||
assert post(browser, path + "/comment", version="1", text="Please include restore time.")["status"] == 303
|
||||
assert post(browser, path + "/publish", version="1", subjects=session.subject, groups="")["status"] == 303
|
||||
assert "published" in call(app, path, cookie=cookie)["body"]
|
||||
assert post(browser, path + "/revise", version="1", question="Which storage option should we adopt?",
|
||||
brief="Option A restores in one hour.", summary="Added recovery estimate")["status"] == 303
|
||||
assert "Revision 2" in call(app, path, cookie=cookie)["body"]
|
||||
assert "Compare cost" in call(app, path, cookie=cookie, query="version=1")["body"]
|
||||
listing = call(app, "/decisions", cookie=cookie, query="q=storage")
|
||||
assert listing["status"] == 200 and path in listing["body"]
|
||||
memo_id = path.rsplit("/", 1)[1]
|
||||
assert lifecycle.repository.policy_context(session.tenant.value, memo_id)["published_audience"] is None
|
||||
assert lifecycle.repository.store.lifecycle_counts()["pending"] == 4
|
||||
|
||||
|
||||
def test_same_create_form_replay_reuses_record(browser):
|
||||
fields = {"question": "Which option?", "brief": "Consider alternatives", "scope_id": "target",
|
||||
"scope_label": "Target", "key": str(uuid.uuid4())}
|
||||
first = post(browser, "/decisions/new/prepare", **fields)
|
||||
second = post(browser, "/decisions/new/prepare", **fields)
|
||||
assert dict(first["headers"])["Location"] == dict(second["headers"])["Location"]
|
||||
assert browser[1].repository.store.lifecycle_counts()["records"] == 1
|
||||
|
||||
|
||||
def test_csrf_and_origin_are_enforced_before_any_mutation(browser):
|
||||
app, lifecycle, session, cookie = browser
|
||||
values = {"question": "Which option?", "brief": "Consider alternatives", "scope_id": "target",
|
||||
"scope_label": "Target", "key": str(uuid.uuid4()), "csrf": session.csrf}
|
||||
for origin, csrf in [(None, session.csrf), ("https://other.test", session.csrf), (ORIGIN, "wrong")]:
|
||||
response = call(app, "/decisions/new/prepare", "POST", cookie=cookie, origin=origin,
|
||||
body=urlencode({**values, "csrf": csrf}).encode())
|
||||
assert response["status"] == 403
|
||||
assert lifecycle.repository.store.lifecycle_counts()["records"] == 0
|
||||
|
||||
|
||||
def test_workspace_is_opt_in_and_requires_human_session(browser):
|
||||
app, _, _, cookie = browser
|
||||
assert call(app, "/decisions")["status"] == 401
|
||||
assert call(App(app.login), "/decisions", cookie=cookie)["status"] == 404
|
||||
|
||||
|
||||
def test_workspace_escapes_content_and_policy_failure_returns_no_memo(browser):
|
||||
app, lifecycle, _, cookie = browser
|
||||
path = create(browser)
|
||||
post(browser, path + "/comment", version="1", text='<script>alert("x")</script>')
|
||||
page = call(app, path, cookie=cookie)["body"]
|
||||
assert "<script>" not in page and "<script>" in page
|
||||
lifecycle.policy.transport.change = lambda data: data.update(effect="deny")
|
||||
response = call(app, path, cookie=cookie)
|
||||
assert response["status"] == 403 and "Compare cost" not in response["body"]
|
||||
|
|
@ -63,6 +63,21 @@ def test_config_load_does_not_provision_or_read_credentials(tmp_path):
|
|||
assert all(r['state']=='blocked' for r in runtime.controller.store.outbox())
|
||||
|
||||
|
||||
def test_lifecycle_workspace_requires_separate_explicit_policy_pin(tmp_path):
|
||||
path, data = config(tmp_path)
|
||||
assert Runtime.from_file(path).lifecycle is None
|
||||
data['lifecycle_policy'] = dict(data['policy'])
|
||||
path.write_text(json.dumps(data))
|
||||
with pytest.raises(ValueError, match='separate admission'):
|
||||
Runtime.from_file(path)
|
||||
data['lifecycle_policy']['package'] = 'informed-decision.lifecycle'
|
||||
path.write_text(json.dumps(data))
|
||||
runtime = Runtime.from_file(path)
|
||||
assert runtime.lifecycle.repository.store is runtime.controller.store
|
||||
assert runtime.lifecycle.policy is not runtime.controller.policy
|
||||
assert not runtime.pump.ready()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('fault',['permissions','symlink','unknown-key','relative-store','missing-pin','unsafe-origin'])
|
||||
def test_unsafe_or_incomplete_config_refused(tmp_path,fault):
|
||||
path,data=config(tmp_path)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue