From a40ffd2df8c83ce42aaa234c4f212b0286a74030 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 15:42:56 +0800 Subject: [PATCH] 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()