From 6500fdb188a2424fc84abdeced86f060520868d8 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 15:38:59 +0800 Subject: [PATCH 1/2] Fix TUI mailbox navigation to referenced resources --- src/msg/tui.py | 20 ++++- tests/test_tui_mailbox_selection.py | 133 ++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/test_tui_mailbox_selection.py diff --git a/src/msg/tui.py b/src/msg/tui.py index ed087c3a..a20e0b14 100644 --- a/src/msg/tui.py +++ b/src/msg/tui.py @@ -177,7 +177,12 @@ def _read_error(self, code): self._write('retry 重新读取;不自动提交 ACK 或其他写入。') @staticmethod - def _item_id(item): + def _item_id(item, *, mailbox=False): + if mailbox: + # Delivery IDs identify Inbox/Outbox records, not readable resources. + ref = item.get('resource') + rid = ref.get('id') if isinstance(ref, Mapping) else None + return rid if isinstance(rid, str) and rid else None ref = item.get('ref') or item.get('resource') for rid in ( item.get('id'), @@ -188,9 +193,18 @@ def _item_id(item): return rid return None + def _page_item_id(self, item): + return self._item_id( + item, + mailbox=bool( + self.page + and self.page['operation'] in {'communication.inbox', 'communication.outbox'} + ), + ) + def _show_items(self, items): for index, item in enumerate(items, 1): - rid = self._item_id(item) or '' + rid = self._page_item_id(item) or '' name = ( item.get('title') or item.get('name') @@ -404,7 +418,7 @@ async def command(self, raw): await self._document(operation, {'name': name}, name) else: self._write('该条目没有可读取的标识。') - elif rid := self._item_id(item): + elif rid := self._page_item_id(item): await self.read(rid) else: self._write('该条目没有可读取的标识。') diff --git a/tests/test_tui_mailbox_selection.py b/tests/test_tui_mailbox_selection.py new file mode 100644 index 00000000..ad8d658c --- /dev/null +++ b/tests/test_tui_mailbox_selection.py @@ -0,0 +1,133 @@ +"""Mailbox navigation resolves content references, never delivery-record IDs.""" + +from io import StringIO +from types import MappingProxyType, SimpleNamespace + +import pytest + +from msg.tui import TerminalUI + + +class MailboxClient: + def __init__(self, item, *, denied=False): + self.state = SimpleNamespace(subject='u_reader', server='https://msg.example') + self.item, self.denied = item, denied + self.calls = [] + + async def call(self, operation, arguments): + self.calls.append((operation, arguments)) + if operation == 'discovery.get': + if self.denied: + return SimpleNamespace( + status='error', error=SimpleNamespace(code='permission_denied') + ) + return SimpleNamespace(status='ok', data={'content': 'Referenced body'}) + return SimpleNamespace(status='ok', data={'items': [self.item]}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('command', ['inbox', 'outbox']) +@pytest.mark.parametrize('frozen', [False, True]) +async def test_mailbox_selection_uses_resource_not_message_id(command, frozen): + ref = {'id': 'p_content'} + item = {'id': 'message_delivery', 'resource': ref, 'state': 'delivered'} + if frozen: + item = MappingProxyType({**item, 'resource': MappingProxyType(ref)}) + client = MailboxClient(item) + output = StringIO() + ui = TerminalUI(client, stdout=output) + await ui.command(command) + await ui.command('1') + assert client.calls == [ + ('communication.' + command, {'limit': 20}), + ('discovery.get', {'id': 'p_content'}), + ] + assert 'Referenced body' in output.getvalue() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('command', ['inbox', 'outbox']) +@pytest.mark.parametrize('ref', [None, {}, {'id': ''}, {'id': 7}, 'p_content']) +async def test_mailbox_missing_reference_cannot_open_delivery_record(command, ref): + client = MailboxClient({'id': 'message_delivery', 'resource': ref}) + ui = TerminalUI(client, stdout=StringIO()) + await ui.command(command) + await ui.command('1') + assert client.calls == [('communication.' + command, {'limit': 20})] + + +@pytest.mark.asyncio +async def test_mailbox_selection_rechecks_access_and_retry_stays_read_only(): + client = MailboxClient({'id': 'message_delivery', 'resource': {'id': 'p_content'}}, denied=True) + ui = TerminalUI(client, stdout=StringIO()) + await ui.command('inbox') + await ui.command('1') + assert ui.page is None + await ui.command('1') + await ui.command('retry') + assert client.calls == [ + ('communication.inbox', {'limit': 20}), + ('discovery.get', {'id': 'p_content'}), + ('discovery.get', {'id': 'p_content'}), + ] + + +@pytest.mark.asyncio +async def test_ordinary_resource_list_keeps_its_own_id(): + client = MailboxClient({'id': 'p_content', 'resource': {'id': 'p_other'}}) + ui = TerminalUI(client, stdout=StringIO()) + await ui.command('home') + await ui.command('1') + assert client.calls[-1] == ('discovery.get', {'id': 'p_content'}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('command', ['inbox', 'outbox']) +async def test_real_signed_mailbox_selection_reads_without_ack( + installed, tmp_path, monkeypatch, command +): + import httpx + from read_only_evidence import readonly_evidence + from test_service import NOW + + from msg.client import ClientState, MsgClient + from msg.transports.client import HTTPTransport + from msg.transports.http import create_app + + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + clients = [ + MsgClient( + ClientState(tmp_path / name, server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + for name in ('mailbox-author', 'mailbox-recipient') + ] + author, recipient = clients + for client, name in zip(clients, ('mailbox-author', 'mailbox-recipient'), strict=True): + assert (await client.register(name)).status == 'ok' + posted = await author.call( + 'content.post_create', {'parent': '/main', 'body': 'Mailbox referenced content'} + ) + assert posted.status == 'ok' + sent = await author.call( + 'communication.send', + {'recipient': recipient.state.subject, 'resource': {'id': posted.resources[0].id}}, + ) + assert sent.status == 'ok' + output = StringIO() + ui = TerminalUI(recipient if command == 'inbox' else author, stdout=output) + async with readonly_evidence(app, monkeypatch): + await ui.command(command) + item = next( + item + for item in ui.page['items'] + if item['resource']['id'] == posted.resources[0].id + ) + assert item['id'] != item['resource']['id'] + await ui.command(str(ui.page['items'].index(item) + 1)) + assert 'Mailbox referenced content' in output.getvalue() + assert '读取失败' not in output.getvalue() From a40ffd2df8c83ce42aaa234c4f212b0286a74030 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 15:42:56 +0800 Subject: [PATCH 2/2] test(bounty): verify public lifecycle and CLI retry after restart --- PROGRESS.md | 32 ++++--- docs/BOUNTY_ACCEPTANCE.md | 53 +++++++++++ tests/test_bounty_public_lifecycle.py | 126 ++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 docs/BOUNTY_ACCEPTANCE.md create mode 100644 tests/test_bounty_public_lifecycle.py diff --git a/PROGRESS.md b/PROGRESS.md index 10aa1508..4ab2643d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -5,29 +5,37 @@ ## 本轮基线与完成事项 -- 已核对 16 个开放 issue(#64、#65、#68、#69、#70、#72、#76–#85)与唯一开放 PR #186。 +- 已核对 16 个开放 issue(#64、#65、#68、#69、#70、#72、#76–#85); + #186、#187、#188 已审查并按 expected head 依次合并。 - 已读取当前权威设计及有效归档;现有功能和历史协议保持原义。 -- #186 已审查并合并为 `dfb89fe989eaef66c99f14ae6a953b07204b7170`。 - 主线 tree `e1d65b624266527bef5f66d2843fa1a40c4ddc5f` 与实际通过测试的 tree 相同。 -- [核心验收 36632579472](https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36632579472): - 八份 ZIP 摘要、manifest 和 JUnit 真实 node-ID 已核验,2541 core + 8 conformance, +- 当前基线 main `c8ed2610ab774dba3085972683c1d2c8a9232222`, + tree `89f8e4119e625cb421b75be468392b2d7b187930` 与 #188 实际验收树一致。 +- [核心验收 36644965337](https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36644965337): + 八份 ZIP 摘要、manifest 和 JUnit 真实 node-ID 已核验,2569 core + 8 conformance, 无失败、错误、跳过、重复或遗漏。 -- [质量 36632578929](https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36632578929): - Ruff JSON 零诊断、stderr 为空、585 文件格式、Python 3.15.0rc2 编译通过。 -- [部署演练 36632578807](https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36632578807): +- [质量 36644965239](https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36644965239): + Ruff JSON 零诊断、stderr 为空、588 文件格式、Python 3.15.0rc2 编译通过。 +- [部署演练 36644965142](https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36644965142): 同一 wheel 的锁定 server/client 安装、真实启动/重启、四传输、只读 doctor、 禁外发 worker、真实 OpenSSH/Git 撤权及正式隔离 selftest 42 项均通过。 - wheel/sdist 与核心构建产物逐字节一致;精确摘要见 PR #186。 + wheel/sdist 与核心构建产物逐字节一致;全部十份 ZIP、摘要与测量精确证据见 + [PR #188](https://github.com/TokenNotIncluded/msg.lmm.best/pull/188#issuecomment-5906443148)。 + 合并后独立主线部署/质量/恢复专项已 success,发布四文件与已验 #188 逐字节一致; + 主线 Rewrite 完整门槛仍排队,不能提前算通过。 ## 当前推进 -- 增加安装包的有界并发写入/读取测量、真实 SIGKILL 重启与全表/sequence 不变量检查。 -- 增加安装包的真实 PostgreSQL/Git/CAS 备份恢复计时和全表值比较;只允许恢复流程 +- 已完成安装包的有界并发写入/读取测量、真实 SIGKILL 重启与全表/sequence 不变量检查。 +- 已完成安装包的真实 PostgreSQL/Git/CAS 备份恢复计时和全表值比较;只允许恢复流程 明确规定的 runtime_config/quarantine 变化,验证删 marker 后仍隔离业务与幂等重放。 - 首次新演练 [36643217304](https://github.com/TokenNotIncluded/msg.lmm.best/actions/runs/36643217304) 在真实恢复时失败:私有 Git 打包引用后,ZIP 丢失空 refs 目录,Git 无法识别仓库。 本轮修复私有/用户仓库统一结构目录重建,保持只读 proof 校验不修改磁盘, - 并新增 packed-ref 恢复、只读拒绝和符号链接拒绝回归。新最终 tree 仍须重新完整验收。 + 并新增 packed-ref 恢复、只读拒绝和符号链接拒绝回归;#187 修复后的完整验收已通过。 +- #188 新增真实 COMMIT 故障回滚、主体奖励限额/版本拒绝,以及 R1–R5 各轮 + nonce/question/单轮 TTL/答案、跨主体与并发授勋回归,均已在完整门槛执行。 +- 当前补核对 #72:六种公开搜索/真实 CLI/历史签署内容状态、CLI PoP 真实重启重试; + `docs/BOUNTY_ACCEPTANCE.md` 给出原清单逐断言映射,新回归须云端实际通过后收口。 - 逐条核对 issue 中尚未刷新到当前源码的描述,保留原验收范围和历史证据。 ## issue 交接 diff --git a/docs/BOUNTY_ACCEPTANCE.md b/docs/BOUNTY_ACCEPTANCE.md new file mode 100644 index 00000000..1e02fe33 --- /dev/null +++ b/docs/BOUNTY_ACCEPTANCE.md @@ -0,0 +1,53 @@ +# Bounty / issue #72 acceptance + +Scope: the prefunded PoP market contract and its isolated official fixture. +The source base is main `c8ed2610ab774dba3085972683c1d2c8a9232222`. +CI provenance belongs to the exact tested tree; final results are recorded in +the PR and issue, rather than inferred from test names or this document. + +## Requirement to assertion map + +All node IDs below are relative to `tests/`. Parameterized nodes must all run. +The tests use actual PostgreSQL and the registered executor unless a row +explicitly describes a CLI parsing or pure-policy test. + +| Requirement | Test node or installed check | Actual assertion | +| --- | --- | --- | +| Integer, protected prefunding; publisher can be offline | `test_bounty.py::test_prefunded_bounty_pays_offline_publisher_and_stops_at_budget` | BountyEscrow is a LedgerAccount, has no Subject or credential; one funding leg, one payout, exact recipient/escrow balances and total supply; no publisher call needed to claim | +| Current catalog and immutable historical terms | `test_bounty.py::test_bounty_catalog_current_projection_and_historical_revision` | Underfunded, top-up, paid, closed projections; historical budget/state and terms Revision remain original while current fields report live values | +| Search and real CLI across all lifecycle states | `test_bounty_public_lifecycle.py::test_search_and_real_cli_preserve_terms_and_current_accounting` | Six transitions: underfunded/top-up/publisher pause/claims exhausted/inclusive expiry/close. Both public search APIs locate the exact Revision; real CLI get/store get agree on state, reason, budget, escrow and claims; historical signed Revision and content bytes are unchanged; all public table values and effect entrypoints prove reads cause no writes | +| Search semantics | Same six-transition test | Search returns Resource metadata/references, not accounting balances. Resource publication state is distinct from Bounty payout state; dereferencing through bounty.get/store.listing_get yields the current accounting projection. Existing published schemas are unchanged | +| Pause/resume/top-up/close conservation | `test_market_72.py::test_pause_preserves_budget_topup_does_not_resume_and_close_returns_remainder` | Publisher pause survives top-up; pending claim denied while paused, succeeds after resume; close refunds exact remainder; balances sum to supply | +| Claim cap cannot reopen through top-up | `test_market_72.py::test_exhausted_claim_limit_is_consistent_in_both_views_and_topup_cannot_reopen` | Current views remain claims_exhausted with positive escrow; resume denied; claimant sees own claim and outsider sees none | +| Expiry does not write during GET | `test_market_72.py::test_expired_listing_projection_does_not_mutate_state_or_emit_events` | Both views report expired; stored state and Event count remain unchanged; six-transition test additionally checks exact deadline and every table value | +| Current key/revocation and subject binding | `test_bounty.py::test_bad_proof_wrong_subject_expiry_and_top_up_do_not_spend`; `test_market_contracts.py::test_revoked_or_replaced_identity_key_cannot_spend_pending_challenge` | Wrong proof key/foreign subject/expired nonce denied; old revoked key and replacement non-primary key denied; no payout or consumed nonce | +| Canonical nonce fields and verifier version | `test_market_72.py::test_stored_challenge_fields_must_match_canonical_signed_payload`; `test_bounty_commit_boundary.py::test_unsupported_challenge_verifier_cannot_consume_budget_or_nonce` | Tampered stored nonce or unsupported version cannot spend; full row values unchanged in the version case | +| Eligibility and per-subject limit | `test_bounty.py::test_bad_proof_wrong_subject_expiry_and_top_up_do_not_spend`; `test_bounty.py::test_unknown_eligibility_constraint_fails_closed`; `test_bounty_commit_boundary.py::test_subject_limit_rejects_fresh_challenge_while_budget_remains` | Allowlist excludes other subject; unknown constraint rejected before funding; fresh valid challenge denied at subject cap even with remaining budget | +| Last reward concurrency and close race | `test_bounty.py::test_concurrent_last_reward_only_one_paid_claim`; `test_market_contracts.py::test_claim_close_race_and_event_failure_never_lose_or_duplicate_budget` | Exactly one payout at last reward; close always succeeds and escrow ends zero; all remaining funds belong to publisher/claimant, no lost supply | +| Claim/ledger/receipt/Event/Inbox/results transaction | `test_bounty_commit_boundary.py::test_deferred_claim_commit_failure_preserves_all_rows_and_retry`; event-failure node above | Real deferred PostgreSQL trigger fails at COMMIT after handler preparation; every public row value rolls back, then same request succeeds exactly once and replay changes no rows; injected Event failure also rolls back payout/nonce/minimal notice | +| CLI proof retries and actual restart | `test_bounty_public_lifecycle.py::test_real_cli_prove_replays_after_restart_without_second_nonce_or_reward` | Real signer/Registry/executor/PostgreSQL through CLI; new Application returns identical payout/receipt; one challenge/claim, every row value unchanged on retry | +| Mixed mutable/immutable terms fail closed | All nodes in `test_bounty_snapshot_integrity.py` | Reward/cap/subject cap/eligibility mismatch rejects claim and reads; doctor does not repair; management cannot move ledger funds | +| Real backup and persistent restore quarantine | `test_market_72.py::test_backup_restores_claim_nonce_inbox_receipt_and_replay_together` | Real backup/empty PostgreSQL restore retains exact claim, nonce, result, minimal Inbox and ledger rows; saved-result replay stays writes_paused, including after marker deletion; private reads remain quarantined | +| Official mint/fund entrypoints | `test_market_71.py::test_bank_fund_is_a_formal_command_without_unattended_flags`; `test_market_71.py::test_bank_fund_two_console_approvals_before_pin_or_writes`; installed `market_e2e` | Real daemon parser and MoneyAdmin through isolated TestRoot/TestConsoleIO; mint20 and bank fund20 require explicit approvals. Refusal precedes PIN/writes; no unattended flag | +| Official 20→10→5 fixture | `msg.admin.market_check::check_market_e2e`, run by installed official selftest | Bank prefunds10; current buyer IdentityKey PoP earns10; fixed text `msg.lmm.best store selftest` and hello.txt `delivery-ok\n` cost5; V4 Delivery waits for buyer's signed exact digest, then buyer5/bank15/all escrow0/supply20 | +| Published V3 automatic settlement preserved | `test_market_lifecycle.py::test_instant_delivery_settlement_and_claim_are_distinct`; `test_market_cli_bindings.py::test_explicit_buy_version_preserves_old_inputs_and_settlement_semantics` | Original published policies remain automatic; settled does not manufacture claimed or accepted. Explicit old versions 1/2/3 retain signed input and behavior | +| Current default V4 explicit acceptance | All nodes in `test_market_explicit_acceptance.py` | CLI selects V4; current buyer and exact delivered digest required; wrong/revoked signatures cannot settle or replay; one protected posting; restart preserves delivered escrow | +| Disabled/failed/unverified mail does not gate trade | `test_market_integration.py::test_explicitly_disabled_mail_never_calls_sender`; `test_market_delivery.py::test_verified_checkout_mail_is_minimal_and_not_claimed`; `test_market_delivery.py::test_unverified_checkout_has_no_order_information_until_owner_verifies`; installed `market_e2e` | Disabled sender never called; failed sender leaves settled business fact intact; unverified receives only minimal verification; verified notice has order reference/name snapshot/authenticated pickup, no product secret; local sink only | +| Send-time endpoint/current authorization | `test_market_delivery.py::test_send_time_revalidation_prevents_stale_or_cross_subject_delivery` | Disabled, unverified and foreign-subject endpoint variants cannot leak order facts | +| Bank has no special business privileges | `test_market_redemption.py::test_clearing_policy_is_pure_and_has_no_bank_or_role_exceptions`; `test_market_71.py::test_bank_fund_role_and_payment_rollback_together`; `test_market_cli_bindings.py::test_package_shortcut_binds_actual_id_and_preserves_seller_privacy` | Pure policy without bank/role override; failed bank funding preserves role/payment; another subject cannot inspect private package | +| Default/doctor/bootstrap/selftest guard | `test_market_contracts.py::test_default_doctor_is_read_only_and_selftest_cannot_seed_an_installed_server`; `test_market_recovery.py::test_market_doctor_cannot_mutate_or_synthesize_a_repair`; installed selftest | Default publishes no fake budget; doctor retains all table values; production selftest refuses seeding an installed server; official selftest is isolated | + +## Version and field boundary + +The original issue's automatic-settlement wording belongs to published V3. +The authoritative current design and default V4 require explicit signed buyer +acceptance. Both are tested; reverting V4 to automatic settlement would alter +the current contract. Delivery, claimed, accepted and settled are separate facts. + +PoP proves possession of the current IdentityKey for one subject/challenge. +It does not prove a human, trustworthiness or resistance to Sybil identities. +The bank remains an ordinary Subject for catalog, verification and permissions. + +This code acceptance does not authorize production mint/funding, Root/PIN, +SMTP/Webhook delivery or deployment. Real target-host/old-snapshot/current-pin +and cutover evidence remain separately tracked in #64–70 and #84. diff --git a/tests/test_bounty_public_lifecycle.py b/tests/test_bounty_public_lifecycle.py new file mode 100644 index 00000000..153a02c7 --- /dev/null +++ b/tests/test_bounty_public_lifecycle.py @@ -0,0 +1,126 @@ +"""Public discovery locates immutable terms; market reads report live accounting.""" + +from datetime import timedelta +from types import SimpleNamespace + +import pytest +from read_only_evidence import business_snapshot, readonly_evidence +from test_market_72 import fixture, proof +from test_market_cli_bindings import ExecutorClient, command +from test_service import NOW, call + +from msg.application import Application +from msg.core.codec import canonical, wire + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'transition,state,reason,budget,balance,claims', + [ + ('underfunded', 'paused', 'out_of_budget', 5, 5, 0), + ('top_up', 'active', None, 20, 20, 0), + ('pause', 'paused', 'publisher_paused', 20, 20, 0), + ('exhausted', 'paused', 'claims_exhausted', 20, 10, 1), + ('expire', 'paused', 'expired', 20, 20, 0), + ('close', 'closed', None, 20, 0, 0), + ], +) +async def test_search_and_real_cli_preserve_terms_and_current_accounting( + installed, monkeypatch, transition, state, reason, budget, balance, claims +): + app, root = installed + changes = {'budget_minor': 5} if transition in {'underfunded', 'top_up'} else {} + if transition == 'expire': + changes['expires_at'] = wire(NOW + timedelta(seconds=1)) + (key, bank), identity, created = await fixture(app, root, **changes) + lid = created.data['bounty']['listing_id'] + ref = created.resources[0] + original = dict(created.data['bounty']) + async with app.metadata.transaction(write=False) as tx: + revision_before = wire(await tx.revision(ref)) + content_before = await app.contents.read_bytes((await tx.revision(ref)).content) + publisher = ExecutorClient(app, key, bank) + if transition == 'top_up': + result = await command( + publisher, + 'bounty', + 'top-up', + canonical({'listing_id': lid, 'amount_minor': 15}).decode(), + ) + assert result.status == 'ok', wire(result) + elif transition in {'pause', 'close'}: + result = await command(publisher, 'bounty', transition, lid) + assert result.status == 'ok', wire(result) + elif transition == 'exhausted': + claimant_key, claimant = identity + result = await call( + app, + 'bounty.claim', + await proof(app, identity, lid), + key=claimant_key, + subject=claimant, + ) + assert result.status == 'ok', wire(result) + elif transition == 'expire': + app.clock = lambda: NOW + timedelta(seconds=1) + await app.load() + + anonymous = ExecutorClient(app, None, None) + async with readonly_evidence(app, monkeypatch): + for operation, parameters in ( + ('discovery.search', {'query': 'pop', 'type': 'listing'}), + ('discovery.lexical_search', {'scope': '/store', 'terms': 'pop', 'field': 'name'}), + ): + found = await call(app, operation, parameters) + assert found.status == 'ok', wire(found) + assert [item['id'] for item in found.data['items']] == [lid] + # Resource.state describes publication, not Bounty accounting. + # Search returns a reference, never fabricated budget/award facts. + assert found.data['items'][0]['revision'] == ref.revision + assert 'budget_minor' not in found.data['items'][0] + bounty = await command(anonymous, 'bounty', 'get', lid) + catalog = await command(anonymous, 'store', 'get', lid) + assert bounty.status == catalog.status == 'ok', (wire(bounty), wire(catalog)) + for shown in (bounty.data['bounty'], catalog.data['listing']): + assert (shown['state'], shown['pause_reason']) == (state, reason) + assert shown['budget_minor'] == budget + assert shown['escrow_balance_minor'] == balance + assert shown['paid_claims'] == claims + history = await call(app, 'store.listing_get', {'id': lid, 'revision': ref.revision}) + assert history.status == 'ok', wire(history) + shown = history.data['listing'] + assert shown['state'] == original['state'] + assert shown['budget_minor'] == original['budget_minor'] + assert shown['current_state'] == state and shown['current_budget_minor'] == budget + assert shown['terms_revision'] == ref.revision + async with app.metadata.transaction(write=False) as tx: + revision = await tx.revision(ref) + assert wire(revision) == revision_before + assert await app.contents.read_bytes(revision.content) == content_before + + +@pytest.mark.asyncio +async def test_real_cli_prove_replays_after_restart_without_second_nonce_or_reward(installed): + app, root = installed + _, (key, claimant), created = await fixture(app, root) + lid = created.data['bounty']['listing_id'] + client = ExecutorClient(app, key, claimant) + client.state = SimpleNamespace(signer=key, subject=claimant) + argv = ('bounty', 'prove', lid, '--request-id', 'cli-pop-restart') + paid = await command(client, *argv) + assert paid.status == 'ok' and not paid.replayed, wire(paid) + before = await business_snapshot(app) + restarted = Application(app.settings, clock=lambda: NOW) + await restarted.load() + try: + client = ExecutorClient(restarted, key, claimant) + client.state = SimpleNamespace(signer=key, subject=claimant) + replay = await command(client, *argv) + assert replay.status == 'ok' and replay.replayed, wire(replay) + assert replay.data == paid.data and replay.receipt == paid.receipt + assert await business_snapshot(restarted) == before + async with restarted.metadata.transaction(write=False) as tx: + assert tx.one('SELECT COUNT(*) FROM bounty_challenges')[0] == 1 + assert tx.one('SELECT COUNT(*) FROM bounty_claims')[0] == 1 + finally: + await restarted.close()