[verified] Handle delivery acknowledgements and read-only recovery
This commit is contained in:
@@ -26,6 +26,12 @@ Baseline/current inventory를 생성·전달·채택할 때 `check-tests 이전`
|
|||||||
|
|
||||||
실제 소비 함수에서도 [runtime consumption 정본](../../../../operations/repository-cache.md#delegation-adoption-runtime-consumption)의 `require_adoption`을 통과한다. main은 위임 근거 비사용을 `{"mode":"none"}`, 사용을 `mode=delegated` exact 선언으로 명시하며 누락이나 이전 PASS receipt를 허용하지 않는다. 판정/render, delivery artifact, native publication마다 새 검증을 수행하고 소비 source/stage에 결속한다. 최초 구현 delivery는 `gitea-issue-implementation/scripts/implementation_delivery.py`의 `deliver_implementation(adapter, head=final_head, declaration=main_declaration)`에서 main-owned push/PR adapter를 호출한다. Adapter는 push/read_remote_head/create_or_reuse_pr/read_pr를 제공하고 기존 인증·repository/Issue scope·non-force explicit refspec·remote readback 계약을 지킨다. 새 진입점을 쓰지 않는 직접 push/PR 생성은 이 workflow를 우회하는 것으로 금지한다. Gate 밖 직접 Git/API 실행을 sandbox로 차단한다고 주장하지 않는다. 기존 semantic/source/identity/lease 검증과 외부 readback은 그대로 유지한다.
|
실제 소비 함수에서도 [runtime consumption 정본](../../../../operations/repository-cache.md#delegation-adoption-runtime-consumption)의 `require_adoption`을 통과한다. main은 위임 근거 비사용을 `{"mode":"none"}`, 사용을 `mode=delegated` exact 선언으로 명시하며 누락이나 이전 PASS receipt를 허용하지 않는다. 판정/render, delivery artifact, native publication마다 새 검증을 수행하고 소비 source/stage에 결속한다. 최초 구현 delivery는 `gitea-issue-implementation/scripts/implementation_delivery.py`의 `deliver_implementation(adapter, head=final_head, declaration=main_declaration)`에서 main-owned push/PR adapter를 호출한다. Adapter는 push/read_remote_head/create_or_reuse_pr/read_pr를 제공하고 기존 인증·repository/Issue scope·non-force explicit refspec·remote readback 계약을 지킨다. 새 진입점을 쓰지 않는 직접 push/PR 생성은 이 workflow를 우회하는 것으로 금지한다. Gate 밖 직접 Git/API 실행을 sandbox로 차단한다고 주장하지 않는다. 기존 semantic/source/identity/lease 검증과 외부 readback은 그대로 유지한다.
|
||||||
|
|
||||||
|
## Delivery adapter 응답과 read-only reconciliation
|
||||||
|
|
||||||
|
Main-owned REST adapter는 `scripts/delivery_response.py`의 응답 검증을 사용하고, 확인된 endpoint별 성공 status를 명시한다. `POST이면 201, 나머지는 200`이나 모든 2xx 수용으로 계약을 추측하지 않는다. 이 helper는 인증·origin/TLS·권한·payload 계약을 대신하지 않으며 mutation을 자동 재시도하지 않는다. Credential mechanism은 `git_credential_helper`, 검증 script는 `evidence_script`처럼 별도 변수로 유지한다.
|
||||||
|
|
||||||
|
응답 status·JSON shape 검사 또는 연결 오류가 write 이후 발생하면 acknowledgement가 불확실할 수 있다. 인증 거부·redirect를 성공으로 완화하지 않는다. 이미 승인된 의도 상태가 반영됐는지 fresh readback으로 확인한 뒤에만 `scripts/implementation_delivery.py`의 `reconcile_implementation(adapter, head=final_head, number=known_pr_number, declaration=main_declaration)`를 명시적으로 호출한다. 이 경로는 push/create/patch를 호출하지 않는다. Main-owned read-only adapter의 `verify_reconciliation(head, number)`는 fresh identity·repository/base/head/state·exact intended body·보호된 Issue snapshot과 전체 changed-file/commit collection을 검증하고 literal `True`만 반환한다. 불일치·미완료 pagination·확인 불가이면 완료하지 않으며 별도 승인 없이 재write하지 않는다. Trusted callback 계약이지 transport sandbox나 stale receipt의 자동 채택 경로가 아니다.
|
||||||
|
|
||||||
## Managed-lock broker gate
|
## Managed-lock broker gate
|
||||||
|
|
||||||
Issue workflow는 bootstrap 이후 반드시 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 하나의 role-neutral managed-lock broker를 사용한다. `serve`에 canonical Issue identity, role, workflow run-id와 TTL을 전달해 broker-generated runtime을 만들고, ready JSON의 `handle`/`socket` 경로만 사용한다. 경로는 `<issue>/runtime/<workflow-run-id>/broker.handle`과 `broker.sock`이며 verification stage run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Repository lock은 같은 handle의 `acquire_repository`/`release_repository` request로만 중첩하고, TTL 전에 `renew`한다. 정상·오류·cancel 모두 `release_repository`(보유 시) → `release` → broker wait/reap → `probe`의 `free=true` 확인 순서를 완료하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다.
|
Issue workflow는 bootstrap 이후 반드시 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 하나의 role-neutral managed-lock broker를 사용한다. `serve`에 canonical Issue identity, role, workflow run-id와 TTL을 전달해 broker-generated runtime을 만들고, ready JSON의 `handle`/`socket` 경로만 사용한다. 경로는 `<issue>/runtime/<workflow-run-id>/broker.handle`과 `broker.sock`이며 verification stage run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Repository lock은 같은 handle의 `acquire_repository`/`release_repository` request로만 중첩하고, TTL 전에 `renew`한다. 정상·오류·cancel 모두 `release_repository`(보유 시) → `release` → broker wait/reap → `probe`의 `free=true` 확인 순서를 완료하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다.
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Fail-closed decoding for one Gitea API delivery attempt.
|
||||||
|
|
||||||
|
Unexpected/malformed acknowledgements can follow a committed mutation: callers
|
||||||
|
must not blindly retry. They may perform caller-initiated read-only reconciliation.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
MAX_RESPONSE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryResponseError(Exception):
|
||||||
|
"""Sanitized response failure; message never includes server content."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidExpectationError(DeliveryResponseError, ValueError):
|
||||||
|
"""The caller supplied an invalid endpoint-specific status expectation."""
|
||||||
|
|
||||||
|
|
||||||
|
class RejectedResponseError(DeliveryResponseError):
|
||||||
|
"""Known auth/redirect or other non-success rejection; do not retry blindly."""
|
||||||
|
|
||||||
|
|
||||||
|
class AcknowledgementUncertainError(DeliveryResponseError):
|
||||||
|
"""Mutation may have committed; do not retry; reconcile read-only if needed."""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_expected_status(expected_status):
|
||||||
|
if (isinstance(expected_status, bool) or not isinstance(expected_status, int)
|
||||||
|
or expected_status < 200 or expected_status > 299):
|
||||||
|
raise InvalidExpectationError("expected_status must be an explicit success status integer")
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_constant(_value):
|
||||||
|
raise ValueError("non-standard JSON constant")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_response(status, body, *, expected_status):
|
||||||
|
"""Require the exact expected status and a bounded JSON object body."""
|
||||||
|
_validate_expected_status(expected_status)
|
||||||
|
if isinstance(status, bool) or not isinstance(status, int):
|
||||||
|
raise AcknowledgementUncertainError("invalid response status; do not retry; reconcile read-only")
|
||||||
|
if 300 <= status <= 399 or status in (401, 403):
|
||||||
|
raise RejectedResponseError("request rejected or redirected; do not retry blindly")
|
||||||
|
if status != expected_status:
|
||||||
|
raise AcknowledgementUncertainError("unexpected response status; do not retry; reconcile read-only")
|
||||||
|
if not isinstance(body, (bytes, bytearray)) or len(body) > MAX_RESPONSE_BYTES:
|
||||||
|
raise AcknowledgementUncertainError("invalid or oversized response; do not retry; reconcile read-only")
|
||||||
|
try:
|
||||||
|
value = json.loads(bytes(body).decode("utf-8"), parse_constant=_reject_constant)
|
||||||
|
except (UnicodeError, ValueError, RecursionError):
|
||||||
|
raise AcknowledgementUncertainError("invalid JSON response; do not retry; reconcile read-only") from None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise AcknowledgementUncertainError("response JSON must be an object; do not retry; reconcile read-only")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def deliver_once(send, *, expected_status):
|
||||||
|
"""Validate expectation, invoke injected sender exactly once, decode its (status, bytes)."""
|
||||||
|
_validate_expected_status(expected_status)
|
||||||
|
try:
|
||||||
|
result = send()
|
||||||
|
except (RejectedResponseError, ssl.SSLCertVerificationError):
|
||||||
|
raise RejectedResponseError("transport security rejected; do not retry blindly") from None
|
||||||
|
except HTTPError as error:
|
||||||
|
if 300 <= error.code <= 399 or error.code in (401, 403):
|
||||||
|
raise RejectedResponseError("request rejected or redirected; do not retry blindly") from None
|
||||||
|
raise AcknowledgementUncertainError("HTTP acknowledgement uncertain; do not retry; reconcile read-only") from None
|
||||||
|
except URLError as error:
|
||||||
|
if isinstance(error.reason, ssl.SSLCertVerificationError):
|
||||||
|
raise RejectedResponseError("TLS verification rejected; do not retry blindly") from None
|
||||||
|
raise AcknowledgementUncertainError("transport failure; do not retry; reconcile read-only") from None
|
||||||
|
except Exception:
|
||||||
|
raise AcknowledgementUncertainError("transport failure; do not retry; reconcile read-only") from None
|
||||||
|
try:
|
||||||
|
status, body = result
|
||||||
|
except Exception:
|
||||||
|
raise AcknowledgementUncertainError("invalid transport response; do not retry; reconcile read-only") from None
|
||||||
|
return decode_response(status, body, expected_status=expected_status)
|
||||||
+39
@@ -88,3 +88,42 @@ def deliver_implementation(adapter: Any, *, head: str, declaration: Any) -> dict
|
|||||||
readback = adapter.read_pr(pr_number)
|
readback = adapter.read_pr(pr_number)
|
||||||
bounded_pr = _validate_readback(readback, pr_number, head)
|
bounded_pr = _validate_readback(readback, pr_number, head)
|
||||||
return {"remote_head": remote_head, "pr": bounded_pr}
|
return {"remote_head": remote_head, "pr": bounded_pr}
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_implementation(adapter: Any, *, head: str, number: int,
|
||||||
|
declaration: Any) -> dict[str, Any]:
|
||||||
|
"""Explicit read-only recovery after uncertain write acknowledgement.
|
||||||
|
|
||||||
|
The trusted main-owned adapter must freshly verify identity/scope, exact
|
||||||
|
intended body, protected Issue snapshot, and complete files/commits via
|
||||||
|
verify_reconciliation(head, number). Only literal True accepts that proof.
|
||||||
|
These callbacks MUST be read-only. This is not a transport sandbox, an
|
||||||
|
automatic exception handler, or permission to retry a write.
|
||||||
|
"""
|
||||||
|
_validate_head(head)
|
||||||
|
_validate_pr_number(number)
|
||||||
|
for name in ("read_remote_head", "read_pr", "verify_reconciliation"):
|
||||||
|
callback = None
|
||||||
|
try:
|
||||||
|
callback = getattr(adapter, name)
|
||||||
|
except Exception:
|
||||||
|
_reject("trusted reconciliation adapter required")
|
||||||
|
if not callable(callback):
|
||||||
|
_reject("trusted reconciliation adapter required")
|
||||||
|
|
||||||
|
remote_head = _delivery_adoption.deliver(
|
||||||
|
adapter.read_remote_head, head=head, declaration=declaration
|
||||||
|
)
|
||||||
|
if type(remote_head) is not str or remote_head != head:
|
||||||
|
_reject("remote head mismatch")
|
||||||
|
readback = _delivery_adoption.deliver(
|
||||||
|
lambda: adapter.read_pr(number), head=head, declaration=declaration
|
||||||
|
)
|
||||||
|
bounded_pr = _validate_readback(readback, number, head)
|
||||||
|
verified = _delivery_adoption.deliver(
|
||||||
|
lambda: adapter.verify_reconciliation(head, number),
|
||||||
|
head=head, declaration=declaration
|
||||||
|
)
|
||||||
|
if verified is not True:
|
||||||
|
_reject("reconciliation intent or protected state mismatch")
|
||||||
|
return {"remote_head": remote_head, "pr": bounded_pr}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Read-only recovery must never repeat an uncertain delivery mutation."""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
PATH = Path(__file__).resolve().parents[3] / 'harness/agents/impl/skills/gitea-issue-implementation/scripts/implementation_delivery.py'
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlyAdapter:
|
||||||
|
def __init__(self, head):
|
||||||
|
self.head = head
|
||||||
|
self.calls = []
|
||||||
|
self.proof = True
|
||||||
|
|
||||||
|
def push(self, head):
|
||||||
|
raise AssertionError('reconciliation must not push')
|
||||||
|
|
||||||
|
def create_or_reuse_pr(self, head):
|
||||||
|
raise AssertionError('reconciliation must not create or patch')
|
||||||
|
|
||||||
|
def read_remote_head(self):
|
||||||
|
self.calls.append('branch')
|
||||||
|
return self.head
|
||||||
|
|
||||||
|
def read_pr(self, number):
|
||||||
|
self.calls.append('pr')
|
||||||
|
return {'number': number, 'head': self.head, 'state': 'open'}
|
||||||
|
|
||||||
|
def verify_reconciliation(self, head, number):
|
||||||
|
self.calls.append('complete_intent')
|
||||||
|
return self.proof
|
||||||
|
|
||||||
|
|
||||||
|
class ReconciliationTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
spec = importlib.util.spec_from_file_location('delivery_reconciliation_under_test', PATH)
|
||||||
|
cls.module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(cls.module)
|
||||||
|
|
||||||
|
def test_read_only_recovery_returns_exact_state_without_repeated_mutation(self):
|
||||||
|
adapter = ReadOnlyAdapter('a' * 40)
|
||||||
|
result = self.module.reconcile_implementation(adapter, head=adapter.head, number=19, declaration={'mode': 'none'})
|
||||||
|
self.assertEqual(result, {'remote_head': adapter.head, 'pr': {'number': 19, 'head': adapter.head, 'state': 'open'}})
|
||||||
|
self.assertEqual(adapter.calls, ['branch', 'pr', 'complete_intent'])
|
||||||
|
|
||||||
|
def test_missing_declaration_blocks_all_reads(self):
|
||||||
|
adapter = ReadOnlyAdapter('a' * 40)
|
||||||
|
with self.assertRaises(self.module.AdoptionError):
|
||||||
|
self.module.reconcile_implementation(adapter, head=adapter.head, number=19, declaration=None)
|
||||||
|
self.assertEqual(adapter.calls, [])
|
||||||
|
|
||||||
|
def test_invalid_identifiers_block_all_reads(self):
|
||||||
|
for head, number in [('bad', 19), ('a' * 40, True), ('a' * 40, 0)]:
|
||||||
|
adapter = ReadOnlyAdapter('a' * 40)
|
||||||
|
with self.subTest(head=head, number=number), self.assertRaises(self.module.AdoptionError):
|
||||||
|
self.module.reconcile_implementation(adapter, head=head, number=number, declaration={'mode': 'none'})
|
||||||
|
self.assertEqual(adapter.calls, [])
|
||||||
|
|
||||||
|
def test_branch_mismatch_blocks_pr_and_proof_reads(self):
|
||||||
|
adapter = ReadOnlyAdapter('b' * 40)
|
||||||
|
with self.assertRaises(self.module.AdoptionError):
|
||||||
|
self.module.reconcile_implementation(adapter, head='a' * 40, number=19, declaration={'mode': 'none'})
|
||||||
|
self.assertEqual(adapter.calls, ['branch'])
|
||||||
|
|
||||||
|
def test_body_snapshot_scope_or_collection_failure_cannot_complete(self):
|
||||||
|
for proof in [False, None, 1, {}, 'yes']:
|
||||||
|
adapter = ReadOnlyAdapter('a' * 40)
|
||||||
|
adapter.proof = proof
|
||||||
|
with self.subTest(proof=proof), self.assertRaises(self.module.AdoptionError):
|
||||||
|
self.module.reconcile_implementation(adapter, head=adapter.head, number=19, declaration={'mode': 'none'})
|
||||||
|
self.assertEqual(adapter.calls, ['branch', 'pr', 'complete_intent'])
|
||||||
|
|
||||||
|
def test_loopback_committed_write_with_bad_ack_recovers_read_only(self):
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
spec = importlib.util.spec_from_file_location('response_reconciliation_integration', PATH.with_name('delivery_response.py'))
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
response = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(response)
|
||||||
|
head = 'a' * 40
|
||||||
|
state = {'writes': 0, 'body': '', 'head': head, 'number': 19, 'state': 'open',
|
||||||
|
'base': 'main', 'repository': 'owner/repo', 'author': 6,
|
||||||
|
'issue': {'body': 'approved', 'updated_at': 'pinned', 'comments': []},
|
||||||
|
'files': ['one.py'], 'commits': [head], 'collections_complete': True}
|
||||||
|
expected_issue = dict(state['issue'])
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_PATCH(self):
|
||||||
|
state['writes'] += 1
|
||||||
|
state['body'] = self.rfile.read(int(self.headers['Content-Length'])).decode()
|
||||||
|
self.send_response(201)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{') # Committed, but malformed acknowledgement.
|
||||||
|
def do_GET(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(state).encode())
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
server = HTTPServer(('127.0.0.1', 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
url = 'http://127.0.0.1:' + str(server.server_port)
|
||||||
|
def send(method='GET'):
|
||||||
|
request = Request(url, data=b'intended' if method == 'PATCH' else None, method=method)
|
||||||
|
with urlopen(request, timeout=2) as reply:
|
||||||
|
return reply.status, reply.read(response.MAX_RESPONSE_BYTES + 1)
|
||||||
|
def read():
|
||||||
|
return response.deliver_once(send, expected_status=200)
|
||||||
|
class Adapter(ReadOnlyAdapter):
|
||||||
|
def read_remote_head(self):
|
||||||
|
return read()['head']
|
||||||
|
def read_pr(self, number):
|
||||||
|
return read()
|
||||||
|
def verify_reconciliation(self, head, number):
|
||||||
|
actual = read()
|
||||||
|
return (actual['body'] == 'intended' and actual['head'] == head
|
||||||
|
and actual['number'] == number and actual['state'] == 'open'
|
||||||
|
and actual['base'] == 'main' and actual['repository'] == 'owner/repo'
|
||||||
|
and actual['author'] == 6 and actual['issue'] == expected_issue
|
||||||
|
and actual['files'] == ['one.py'] and actual['commits'] == [head]
|
||||||
|
and actual['collections_complete'] is True)
|
||||||
|
try:
|
||||||
|
with self.assertRaises(response.AcknowledgementUncertainError):
|
||||||
|
response.deliver_once(lambda: send('PATCH'), expected_status=201)
|
||||||
|
result = self.module.reconcile_implementation(Adapter(head), head=head, number=19, declaration={'mode': 'none'})
|
||||||
|
self.assertEqual(result['remote_head'], head)
|
||||||
|
for key, bad in [('body', 'competing change'), ('issue', {}), ('collections_complete', False)]:
|
||||||
|
saved = state[key]
|
||||||
|
state[key] = bad
|
||||||
|
with self.subTest(key=key), self.assertRaises(self.module.AdoptionError):
|
||||||
|
self.module.reconcile_implementation(Adapter(head), head=head, number=19, declaration={'mode': 'none'})
|
||||||
|
state[key] = saved
|
||||||
|
self.assertEqual(state['writes'], 1)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
self.assertFalse(thread.is_alive())
|
||||||
|
|
||||||
|
def test_closed_pr_cannot_complete(self):
|
||||||
|
adapter = ReadOnlyAdapter('a' * 40)
|
||||||
|
adapter.read_pr = lambda number: {'number': number, 'head': adapter.head, 'state': 'closed'}
|
||||||
|
with self.assertRaises(self.module.AdoptionError):
|
||||||
|
self.module.reconcile_implementation(adapter, head=adapter.head, number=19, declaration={'mode': 'none'})
|
||||||
|
self.assertNotIn('complete_intent', adapter.calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'agents/impl/skills/gitea-issue-implementation/scripts'))
|
||||||
|
import delivery_response as dr
|
||||||
|
|
||||||
|
|
||||||
|
def make_handler():
|
||||||
|
class FakeHandler(BaseHTTPRequestHandler):
|
||||||
|
status = 200
|
||||||
|
body = b'{"ok":true}'
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def do_GET(self): self.respond()
|
||||||
|
def do_POST(self): self.respond()
|
||||||
|
def do_PATCH(self): self.respond()
|
||||||
|
|
||||||
|
def respond(self):
|
||||||
|
type(self).calls += 1
|
||||||
|
self.send_response(type(self).status)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(type(self).body)
|
||||||
|
|
||||||
|
def log_message(self, format, *args): pass
|
||||||
|
return FakeHandler
|
||||||
|
|
||||||
|
|
||||||
|
FakeHandler = make_handler()
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryResponseTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.server = HTTPServer(('127.0.0.1', 0), FakeHandler)
|
||||||
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||||
|
cls.thread.start()
|
||||||
|
cls.url = 'http://127.0.0.1:%d/' % cls.server.server_port
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.server.shutdown()
|
||||||
|
cls.server.server_close()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
FakeHandler.calls = 0
|
||||||
|
FakeHandler.body = b'{"ok":true}'
|
||||||
|
FakeHandler.status = 200
|
||||||
|
|
||||||
|
def request(self, method, expected):
|
||||||
|
def send():
|
||||||
|
req = Request(self.url, method=method)
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=2) as response:
|
||||||
|
return response.status, response.read(1024 * 1024 + 1)
|
||||||
|
except HTTPError as e:
|
||||||
|
return e.code, e.read(1024 * 1024 + 1)
|
||||||
|
return dr.deliver_once(send, expected_status=expected)
|
||||||
|
|
||||||
|
def test_endpoint_specific_success_statuses(self):
|
||||||
|
for method, status in [('GET', 200), ('POST', 201), ('PATCH', 201)]:
|
||||||
|
FakeHandler.status = status
|
||||||
|
self.assertEqual(self.request(method, status), {'ok': True})
|
||||||
|
|
||||||
|
def test_rejects_invalid_expected_status_before_send(self):
|
||||||
|
calls = []
|
||||||
|
for invalid in (None, True, 0, 700, '201'):
|
||||||
|
with self.subTest(invalid=invalid):
|
||||||
|
with self.assertRaises(dr.InvalidExpectationError):
|
||||||
|
dr.deliver_once(lambda: calls.append(1), expected_status=invalid)
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
def test_unexpected_success_status_uncertain_not_heuristic(self):
|
||||||
|
FakeHandler.status = 202
|
||||||
|
with self.assertRaises(dr.AcknowledgementUncertainError):
|
||||||
|
self.request('POST', 201)
|
||||||
|
self.assertEqual(FakeHandler.calls, 1)
|
||||||
|
|
||||||
|
def test_reconstructed_method_only_heuristic_rejects_valid_patch_ack(self):
|
||||||
|
# Reconstructed historical predicate, NOT a prior runtime RED run.
|
||||||
|
old_expected = 201 if 'PATCH' == 'POST' else 200
|
||||||
|
self.assertNotEqual(201, old_expected)
|
||||||
|
FakeHandler.status = 201
|
||||||
|
self.assertEqual(self.request('PATCH', 201), {'ok': True})
|
||||||
|
self.assertEqual(FakeHandler.calls, 1)
|
||||||
|
|
||||||
|
def test_failure_status_cannot_be_configured_as_success(self):
|
||||||
|
calls = []
|
||||||
|
for status in (100, 199, 300, 400, 500, 599):
|
||||||
|
with self.subTest(status=status), self.assertRaises(dr.InvalidExpectationError):
|
||||||
|
dr.deliver_once(lambda: calls.append(1) or (status, b'{}'), expected_status=status)
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
def test_raised_auth_or_redirect_error_is_rejected_not_uncertain(self):
|
||||||
|
for status in (300, 302, 304, 399, 401, 403):
|
||||||
|
calls = []
|
||||||
|
def send():
|
||||||
|
calls.append(1)
|
||||||
|
raise HTTPError('http://example.invalid/private', status, 'sensitive detail', {}, None)
|
||||||
|
with self.subTest(status=status):
|
||||||
|
with self.assertRaises(dr.RejectedResponseError) as caught:
|
||||||
|
dr.deliver_once(send, expected_status=201)
|
||||||
|
self.assertEqual(calls, [1])
|
||||||
|
self.assertNotIn('sensitive detail', str(caught.exception))
|
||||||
|
|
||||||
|
def test_sender_explicit_rejection_is_not_reclassified_as_uncertain(self):
|
||||||
|
def send():
|
||||||
|
raise dr.RejectedResponseError('sensitive detail')
|
||||||
|
with self.assertRaises(dr.RejectedResponseError) as caught:
|
||||||
|
dr.deliver_once(send, expected_status=201)
|
||||||
|
self.assertNotIn('sensitive detail', str(caught.exception))
|
||||||
|
|
||||||
|
def test_tls_certificate_rejection_is_not_reclassified_as_uncertain(self):
|
||||||
|
import ssl
|
||||||
|
from urllib.error import URLError
|
||||||
|
for error in (ssl.SSLCertVerificationError('sensitive detail'),
|
||||||
|
URLError(ssl.SSLCertVerificationError('sensitive detail'))):
|
||||||
|
def send():
|
||||||
|
raise error
|
||||||
|
with self.subTest(error=type(error).__name__), self.assertRaises(dr.RejectedResponseError):
|
||||||
|
dr.deliver_once(send, expected_status=201)
|
||||||
|
|
||||||
|
def test_auth_and_redirect_rejected(self):
|
||||||
|
for status in (401, 403, 302):
|
||||||
|
with self.subTest(status=status):
|
||||||
|
FakeHandler.status = status
|
||||||
|
with self.assertRaises(dr.RejectedResponseError):
|
||||||
|
self.request('POST', 201)
|
||||||
|
|
||||||
|
def test_bad_json_shape_and_size_are_uncertain(self):
|
||||||
|
for body in (b'{', b'[]', b'null', b'{"x":NaN}', b'"x"', b' ' * (1024 * 1024 + 1)):
|
||||||
|
with self.subTest(body=body[:12]):
|
||||||
|
FakeHandler.body = body
|
||||||
|
FakeHandler.status = 201
|
||||||
|
with self.assertRaises(dr.AcknowledgementUncertainError):
|
||||||
|
self.request('POST', 201)
|
||||||
|
|
||||||
|
def test_transport_error_once_and_sanitized_uncertainty(self):
|
||||||
|
calls = []
|
||||||
|
def send():
|
||||||
|
calls.append(1)
|
||||||
|
raise OSError('secret token should not leak')
|
||||||
|
with self.assertRaises(dr.AcknowledgementUncertainError) as caught:
|
||||||
|
dr.deliver_once(send, expected_status=201)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertNotIn('secret token', str(caught.exception))
|
||||||
|
self.assertIn('reconcil', str(caught.exception).lower())
|
||||||
|
|
||||||
|
def test_control_exceptions_propagate(self):
|
||||||
|
for exc in (KeyboardInterrupt(), SystemExit()):
|
||||||
|
with self.subTest(exc=type(exc).__name__):
|
||||||
|
with self.assertRaises(type(exc)):
|
||||||
|
dr.deliver_once(lambda: (_ for _ in ()).throw(exc), expected_status=200)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user