thth 2.13.2__tar.gz → 2.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thth-2.13.2 → thth-2.14.0}/PKG-INFO +1 -1
- {thth-2.13.2 → thth-2.14.0}/mcp/server.py +2 -2
- thth-2.14.0/tests/helpers/fake_x.py +138 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_adapter_boundary.py +4 -2
- {thth-2.13.2 → thth-2.14.0}/tests/test_selfupdate.py +11 -4
- {thth-2.13.2 → thth-2.14.0}/tests/test_v211_auth_integration.py +2 -2
- {thth-2.13.2 → thth-2.14.0}/tests/test_v211_x_auth.py +7 -5
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_webm_named.py +1 -1
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_capabilities.py +6 -1
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_foundation.py +4 -3
- thth-2.14.0/tests/test_v214_release_signing.py +157 -0
- thth-2.14.0/tests/test_v214_x_adapter.py +220 -0
- thth-2.14.0/tests/test_v214_x_auth.py +103 -0
- thth-2.14.0/tests/test_v214_x_media.py +324 -0
- thth-2.14.0/tests/test_v214_x_posts_budget.py +86 -0
- thth-2.14.0/thth/VERSION +1 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/__init__.py +3 -1
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/auth_x.py +55 -11
- thth-2.14.0/thth/adapters/x.py +359 -0
- thth-2.14.0/thth/adapters/x_media.py +318 -0
- {thth-2.13.2 → thth-2.14.0}/thth/appconfig.py +6 -1
- {thth-2.13.2 → thth-2.14.0}/thth/budget_x.py +31 -3
- thth-2.14.0/thth/budget_x_posts.py +358 -0
- {thth-2.13.2 → thth-2.14.0}/thth/cli.py +6 -1
- {thth-2.13.2 → thth-2.14.0}/thth/doctor.py +1 -1
- {thth-2.13.2 → thth-2.14.0}/thth/leave.py +4 -3
- {thth-2.13.2 → thth-2.14.0}/thth/leave_gate.py +2 -2
- {thth-2.13.2 → thth-2.14.0}/thth/media.py +11 -3
- {thth-2.13.2 → thth-2.14.0}/thth/media_capabilities.py +42 -1
- {thth-2.13.2 → thth-2.14.0}/thth/media_delivery.py +11 -4
- {thth-2.13.2 → thth-2.14.0}/thth/queuefile.py +4 -1
- {thth-2.13.2 → thth-2.14.0}/thth/report.py +11 -2
- {thth-2.13.2 → thth-2.14.0}/thth/report_service.py +11 -2
- {thth-2.13.2 → thth-2.14.0}/thth/selfupdate.py +77 -16
- thth-2.13.2/thth/VERSION +0 -1
- {thth-2.13.2 → thth-2.14.0}/.gitignore +0 -0
- {thth-2.13.2 → thth-2.14.0}/LICENSE +0 -0
- {thth-2.13.2 → thth-2.14.0}/README.md +0 -0
- {thth-2.13.2 → thth-2.14.0}/accounts.example/bluesky.json +0 -0
- {thth-2.13.2 → thth-2.14.0}/accounts.example/mastodon.json +0 -0
- {thth-2.13.2 → thth-2.14.0}/accounts.example/threads.json +0 -0
- {thth-2.13.2 → thth-2.14.0}/accounts.example/x.json +0 -0
- {thth-2.13.2 → thth-2.14.0}/bin/thth +0 -0
- {thth-2.13.2 → thth-2.14.0}/bin/thth-remote +0 -0
- {thth-2.13.2 → thth-2.14.0}/bin/thth-run +0 -0
- {thth-2.13.2 → thth-2.14.0}/pyproject.toml +0 -0
- {thth-2.13.2 → thth-2.14.0}/skills/thth/SKILL.md +0 -0
- {thth-2.13.2 → thth-2.14.0}/skills/thth-admin/SKILL.md +0 -0
- {thth-2.13.2 → thth-2.14.0}/systemd/thth@.service +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/__init__.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/conftest.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/coordination_snapshot.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/fixtures/GraphemeBreakTest-17.0.0.txt +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/fixtures/build_injection_eval.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/fixtures/callback/worker_responses.json +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/fixtures/injection_eval.json +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/fixtures/umami-bile.md +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/__init__.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_oauth_server.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_push_adapter.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_redirect_server.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_search_adapter.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_threads_reader.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/fake_threads_writes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/hold_lock.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/hold_repo_lock.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/helpers/worker_probe.mjs +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_27_acceptance_fixes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_account_name_check.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_account_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_accounts_dir.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_admin_http_mcp.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_admin_log.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_admin_notifications.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_admin_repairs.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_admin_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_after_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_analytics_comparison.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_analytics_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_api_diagnostic.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_api_error_body.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_app_env_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_approval.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_approve_cmd.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_ask.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_fifth_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_fourth_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_second_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_atlas_third_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit1_fixes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit2_fixes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_counterexamples.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_m1_semantics.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_n1_depth.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit_b_r1_closure.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit_v201.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_audit_v23_details.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_auth.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_auth_identity_required.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_behind_remote.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_bluesky_adapter.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_bluesky_secret_scrub.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_board_needs_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_board_running.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_bundle.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_charcount.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_cli_entrypoint.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_cli_help.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_cli_vm_path.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_collect.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_collect_engagements.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_collect_runs.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_collect_sent.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_comparability.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_control_chars_refused.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_conversation_paging.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_count_for.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_docs_links.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_doctor.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_doctor_all_permissions.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_doctor_next_step.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_duplicate_front_matter_keys.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_editorial_audit.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_editorial_n3_n4.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_engagements.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_external_acceptance.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_fake_api.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_form_spec.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_form_spec_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_forms.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_fresh_install.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_front_matter_injection.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_healthcheck_notifications.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_hypotheses.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_hypothesis_boundaries.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_hypothesis_units.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_impact_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_inbox_granted_scopes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_incident_notifications.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_independent_thread_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_independent_topic_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_independent_topic_review_r2.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_inflight.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_inflight_tag_body_hash.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_jst.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_lint.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_list_projections.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_llm_trial.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_llm_trial_engage.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_lock.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_m2_independent.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_maintain.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_mastodon_adapter.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_mastodon_visibility.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_max_per_run.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_mcp.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_mcp_robustness.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_measured.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_media_accounts.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_media_next_steps.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_media_roundtrip.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_mismatch_diagnostics.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_n5_reply_null_element.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_n7_doctor_error_body.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_no_limits.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_observation_provenance.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_operations_handoff.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_packaging.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_preview.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_production_mode.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_pull_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_queue.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_quiet_hours.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_r2_r4_r5.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_reason_adoption.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_redact.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_redirect_token_leak.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_refresh.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_refresh_destination.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_rejudge_new.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_rejudge_r2_r3.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_replies.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_replies_refresh.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_repo_lock.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_repo_sync.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_report_checkout_import.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_report_http.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_report_isolation.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_report_service.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_reverse_audit.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_review_case_id_cluster_m2.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_review_case_id_cluster_r2.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_review_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_review_evidence_account.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_review_form_spec_and_case_id.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_review_provenance.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_review_records.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_revoke.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_roundtrip.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_run_missing_env.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_run_record_failclosed.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_runs_collected.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_runs_read_rows.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_schedule_multiple.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_schedule_view.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_scopes_source_requested.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_search_engage_targets.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_search_media_routes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_secret_value_redact.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_secrets_grep.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_security_small_fixes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_select.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_send.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_send_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_send_control_chars.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_send_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_send_visibility.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_sent_integrity.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_server_json.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_site.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_skill_doc.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_study_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_systemd.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_thread_publish.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_thread_read.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_threadrun.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_threads_read_permissions.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_threadshape.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_thth_root.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_token_exists.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_token_scopes_record.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_token_set.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topic.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topic_advice.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topic_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topic_models.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topic_review_fixes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topic_shelf_observers.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topic_store.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topics_record_concurrent.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_topics_tool.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_trial_frictions.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_admin_transaction.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_approve_account.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_json_contract.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_length.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_lock_wait.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_mastodon_scopes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_mentions.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_post_urls.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_repo_paths.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_reposts.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_study_add.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_tool_version.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_unanswered.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_unanswered_scope.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_where_arguments.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_where_filters.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v210_who_arguments.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v211_authflow.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v211_bluesky_stdin.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v211_doctor_binding.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v211_mastodon_auth.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_approval_relay.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_ask_coordination.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_budget.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_budget_auth.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_budget_mcp.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_deletion.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_job_completion_fault.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave_gate.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave_providers.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_leave_token_safety.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_mastodon_guide.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_mcp_compat.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_missing_token.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_read_coordination.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_server_writes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_token_parent.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v212_write_storage.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v2131_send_dry_run.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v2131_threads_publish_wait.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v2132_send_inflight_hint.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v2132_threads_create_timeout.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v2132_threads_create_unknown.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_acceptance_fixes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_adts_asf.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_approval_attachments.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_attachment_kinds.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_external.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_metadata.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_metadata_uri.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_metadata_video.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_record.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_record_video.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bluesky_video.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_bmff_typed_metadata.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_budget_lock.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_carryover_timeout.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_graphemes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_auth_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_bmff_audio.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_flac.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_gif.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_link.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_mp3.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_ogg_opus.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_ogg_vorbis.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_poll.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_primary_size_boundary.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_quote.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_thumbnail.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_wav.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_webm_audio.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_mastodon_webm_video.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_cleanup.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_formats.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_prepared.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_relay.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_relay_refusals.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_media_upload.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_receipt_deadline.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_review_followups.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_stage810_audit.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_static_diagnostics.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_stop_observation.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_systemd_worker.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_ghost.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_gif.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_link.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_media_spoiler.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_poll.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_quote.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_quote_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_replies.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_text.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_text_spoiler.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_threads_video.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_transport_review.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_video_typed_metadata.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_vorbis_comment_boundary.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v213_webm_schema_boundary.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v21b_approved_writes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v28_tags.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v31_analysis.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_v31_collection.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_value_domains_shown.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_version.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_where_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_who_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_writeback_rebase.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_writeback_validate.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/tests/test_x_metrics.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/__init__.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/__main__.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/_grapheme_data.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/account_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/account_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/accounts.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/auth_mastodon.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/auth_threads.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/base.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/bluesky.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/bluesky_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/bluesky_video.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/mastodon.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/mastodon_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/threads.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/adapters/threads_media.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/admin_log.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/admin_notifications.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/admin_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/after_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/analytics_comparison.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/analytics_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/analytics_shapes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/analytics_threads.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/api_diagnostic.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/appenv.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/approval.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/approval_jobs.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/approval_relay.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/ask.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/ask_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/audioformats.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/authclients.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/authflow.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/authpassword.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/bluesky_metadata.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/bundle.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/collect.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/collection_context.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/collection_status.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/core.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/deletion.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/ebmlaudio.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/ebmlvideo.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/engagements.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/flacformats.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/forms.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/graphemes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/handoff_cursor.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/healthcheck.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/httpsafe.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/incident.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/inflight.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/jst.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/lint.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/lock.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/maintain.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/managed_repo.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/measured.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/media_cleanup.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/media_relay.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/media_uploads.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/mediaformats.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/mpeg_audio.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/oauth.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/oggformats.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/operations_handoff.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/postid.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/read_coordination.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/read_window.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/redact.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/replies.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/report_details.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/report_http.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/report_isolation.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/retract_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/runs.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/scopes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/secrets_fs.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/select.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/sent.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/server_files.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/server_writes.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/stop_observation.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/study_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/study_report.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/systemd_gen.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/tags.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/thread_read.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/threadrun.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/threads_read_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/threadshape.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/threadthrow.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/tool_version.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/topic_advice.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/topic_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/topic_models.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/topic_store.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/topics.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/unanswered.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/vorbisformats.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/where_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/who_cli.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/writeback.py +0 -0
- {thth-2.13.2 → thth-2.14.0}/thth/x_metrics.py +0 -0
|
@@ -505,8 +505,8 @@ ADMIN_TOOLS = [
|
|
|
505
505
|
for name in ("inventory", "account", "log", "tokens", "release", "diff")]
|
|
506
506
|
|
|
507
507
|
|
|
508
|
-
ADMIN_TOOLS.append({'name':'thth_admin_budget_set','description':'Set the X monthly read estimate cap; administrator only, by required; no provider call',
|
|
509
|
-
'inputSchema':{'type':'object','properties':{key:{'type':'string'} for key in ('monthly','currency','rate','rate_source','by')},
|
|
508
|
+
ADMIN_TOOLS.append({'name':'thth_admin_budget_set','description':'Set the X monthly read estimate cap (kind x_read, default) or the monthly post count cap (kind x_posts); administrator only, by required; no provider call',
|
|
509
|
+
'inputSchema':{'type':'object','properties':{key:{'type':'string'} for key in ('monthly','currency','rate','rate_source','by','kind')},
|
|
510
510
|
'required':['monthly','by'],'additionalProperties':False}})
|
|
511
511
|
|
|
512
512
|
SERVER_TOOLS = [
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""偽の X(loopback HTTP)。**本物の api.x.com は 1 度も叩かない。**
|
|
2
|
+
|
|
3
|
+
`initialize` → `append`(multipart・`segment_index`)→ `finalize` → STATUS →
|
|
4
|
+
`metadata` → `POST /2/tweets` の順序と本体をそのまま記録して、試験が wire を
|
|
5
|
+
見られるようにする。応答は `state` の欄で差し替える。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import urllib.parse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_multipart(raw, content_type):
|
|
17
|
+
"""`segment_index` と `media` の中身だけを取り出す(試験用の最小の実装)。"""
|
|
18
|
+
boundary = content_type.partition('boundary=')[2].encode('ascii')
|
|
19
|
+
fields = {}
|
|
20
|
+
for part in raw.split(b'--' + boundary):
|
|
21
|
+
head, separator, body = part.partition(b'\r\n\r\n')
|
|
22
|
+
if not separator:
|
|
23
|
+
continue
|
|
24
|
+
name = re.search(rb'name="([^"]+)"', head)
|
|
25
|
+
if not name:
|
|
26
|
+
continue
|
|
27
|
+
fields[name.group(1).decode()] = body[:-2] if body.endswith(b'\r\n') else body
|
|
28
|
+
return fields
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def new_state():
|
|
32
|
+
return {'calls': [], 'uploads': {}, 'segments': {}, 'status': {}, 'next_media': 100,
|
|
33
|
+
'next_post': '1900000000000000001', 'reply': {}, 'on_tweet': None,
|
|
34
|
+
'on_append': None, 'on_status': None, 'metadata_status': {}, 'tweets': [],
|
|
35
|
+
'me': {'data': {'id': '123', 'username': 'demo'}}, 'posts': None}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
39
|
+
state: dict = {}
|
|
40
|
+
|
|
41
|
+
def log_message(self, *a):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def reply(self, code, body):
|
|
45
|
+
raw = b'' if body is None else json.dumps(body).encode()
|
|
46
|
+
self.send_response(code)
|
|
47
|
+
for key, value in self.state['reply'].get(self.path, {}).items():
|
|
48
|
+
self.send_header(key, value)
|
|
49
|
+
self.send_header('Content-Length', str(len(raw)))
|
|
50
|
+
self.end_headers()
|
|
51
|
+
self.wfile.write(raw)
|
|
52
|
+
|
|
53
|
+
def _body(self):
|
|
54
|
+
length = int(self.headers.get('Content-Length') or 0)
|
|
55
|
+
return self.rfile.read(length) if length else b''
|
|
56
|
+
|
|
57
|
+
def do_GET(self):
|
|
58
|
+
path, _, query = self.path.partition('?')
|
|
59
|
+
values = urllib.parse.parse_qs(query)
|
|
60
|
+
self.state['calls'].append(('GET', path, values))
|
|
61
|
+
if path == '/2/media/upload':
|
|
62
|
+
identifier = values.get('media_id', [''])[0]
|
|
63
|
+
if self.state['on_status']:
|
|
64
|
+
self.state['on_status']()
|
|
65
|
+
queue = self.state['status'].get(identifier) or []
|
|
66
|
+
info = queue.pop(0) if queue else {'state': 'succeeded'}
|
|
67
|
+
if isinstance(info, tuple):
|
|
68
|
+
return self.reply(info[0], info[1])
|
|
69
|
+
return self.reply(200, {'data': {'id': identifier, 'processing_info': info}})
|
|
70
|
+
if path == '/2/users/me':
|
|
71
|
+
return self.reply(200, self.state['me'])
|
|
72
|
+
if re.fullmatch(r'/2/users/[0-9]+/tweets', path):
|
|
73
|
+
return self.reply(200, self.state['posts'] if self.state['posts'] is not None
|
|
74
|
+
else {'data': [], 'meta': {'result_count': 0}})
|
|
75
|
+
return self.reply(404, {})
|
|
76
|
+
|
|
77
|
+
def do_DELETE(self):
|
|
78
|
+
self.state['calls'].append(('DELETE', self.path, {}))
|
|
79
|
+
return self.reply(200, {'data': {'deleted': True}})
|
|
80
|
+
|
|
81
|
+
def do_POST(self):
|
|
82
|
+
raw = self._body()
|
|
83
|
+
content = self.headers.get('Content-Type') or ''
|
|
84
|
+
if content.startswith('multipart/form-data'):
|
|
85
|
+
fields = parse_multipart(raw, content)
|
|
86
|
+
payload = fields.get('media', b'')
|
|
87
|
+
body = {'segment_index': fields.get('segment_index', b'').decode(),
|
|
88
|
+
'bytes': len(payload)}
|
|
89
|
+
identifier = self.path.split('/')[-2]
|
|
90
|
+
self.state['segments'].setdefault(identifier, []).append(body['segment_index'])
|
|
91
|
+
self.state['uploads'][identifier] = self.state['uploads'].get(identifier, b'') + payload
|
|
92
|
+
self.state['calls'].append(('POST', self.path, body))
|
|
93
|
+
if self.state['on_append']:
|
|
94
|
+
self.state['on_append']()
|
|
95
|
+
return self.reply(200, {'data': {'id': identifier, 'expires_after_secs': 3600}})
|
|
96
|
+
body = json.loads(raw) if raw else {}
|
|
97
|
+
self.state['calls'].append(('POST', self.path, body))
|
|
98
|
+
if self.path == '/2/media/upload/initialize':
|
|
99
|
+
self.state['next_media'] += 1
|
|
100
|
+
identifier = str(self.state['next_media'])
|
|
101
|
+
return self.reply(200, {'data': {'id': identifier, 'expires_after_secs': 3600}})
|
|
102
|
+
if self.path.endswith('/finalize'):
|
|
103
|
+
identifier = self.path.split('/')[-2]
|
|
104
|
+
queue = self.state['status'].get(identifier)
|
|
105
|
+
info = {'state': 'pending', 'check_after_secs': 0} if queue else None
|
|
106
|
+
data = {'id': identifier}
|
|
107
|
+
if info is not None:
|
|
108
|
+
data['processing_info'] = info
|
|
109
|
+
return self.reply(200, {'data': data})
|
|
110
|
+
if self.path == '/2/media/metadata':
|
|
111
|
+
status = self.state['metadata_status'].get(body.get('id'), 200)
|
|
112
|
+
return self.reply(status, {} if status == 200 else {'title': 'refused'})
|
|
113
|
+
if self.path == '/2/tweets':
|
|
114
|
+
if self.state['on_tweet']:
|
|
115
|
+
self.state['on_tweet']()
|
|
116
|
+
self.state['tweets'].append(body)
|
|
117
|
+
return self.reply(201, {'data': {'id': self.state['next_post'], 'text': body.get('text', '')}})
|
|
118
|
+
return self.reply(404, {})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def serve(monkeypatch=None):
|
|
122
|
+
"""試験のための server を 1 本立てて `(state, url, shutdown)` を返す。"""
|
|
123
|
+
state = new_state()
|
|
124
|
+
handler = type('Handler', (_Handler,), {'state': state})
|
|
125
|
+
server = ThreadingHTTPServer(('127.0.0.1', 0), handler)
|
|
126
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
127
|
+
thread.start()
|
|
128
|
+
url = 'http://127.0.0.1:' + str(server.server_port)
|
|
129
|
+
|
|
130
|
+
def shutdown():
|
|
131
|
+
server.shutdown()
|
|
132
|
+
server.server_close()
|
|
133
|
+
thread.join(3)
|
|
134
|
+
return state, url, shutdown
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def calls(state, method, suffix):
|
|
138
|
+
return [row for row in state['calls'] if row[0] == method and row[1].endswith(suffix)]
|
|
@@ -706,7 +706,7 @@ def test_TB5_知らない媒体のdoctorは黙って異常なしにしない(tmp
|
|
|
706
706
|
|
|
707
707
|
# --- T3 の配線(2026-09-13・Bluesky と Mastodon を REGISTRY に載せる) ---------
|
|
708
708
|
|
|
709
|
-
def test_T3_REGISTRYに
|
|
709
|
+
def test_T3_REGISTRYに4媒体が載っている():
|
|
710
710
|
"""**足すのは 1 行だけ**(設計 v2 §4.2「台帳と登録」)。
|
|
711
711
|
|
|
712
712
|
ここが落ちると、台帳に `media: bluesky` と書いても
|
|
@@ -714,9 +714,11 @@ def test_T3_REGISTRYに3媒体が載っている():
|
|
|
714
714
|
"""
|
|
715
715
|
from thth.adapters import bluesky as bluesky_mod
|
|
716
716
|
from thth.adapters import mastodon as mastodon_mod
|
|
717
|
-
|
|
717
|
+
from thth.adapters import x as x_mod
|
|
718
|
+
assert adapters_mod.known_media() == ["bluesky", "mastodon", "threads", "x"]
|
|
718
719
|
assert adapters_mod.REGISTRY["bluesky"] is bluesky_mod.BlueskyAdapter
|
|
719
720
|
assert adapters_mod.REGISTRY["mastodon"] is mastodon_mod.MastodonAdapter
|
|
721
|
+
assert adapters_mod.REGISTRY["x"] is x_mod.XAdapter
|
|
720
722
|
|
|
721
723
|
|
|
722
724
|
def test_T3_全媒体のCAPABILITIESが境界の語彙に収まる():
|
|
@@ -870,10 +870,12 @@ def test_D11_署名検証後にrefが動いても検証したOIDだけをmerge
|
|
|
870
870
|
"+refs/heads/release:refs/remotes/origin/release"],
|
|
871
871
|
check=True,
|
|
872
872
|
)
|
|
873
|
-
return
|
|
873
|
+
return None
|
|
874
874
|
|
|
875
875
|
monkeypatch.setenv(selfupdate.REQUIRE_SIGNED_ENV, "1")
|
|
876
|
-
monkeypatch.setattr(selfupdate, "
|
|
876
|
+
monkeypatch.setattr(selfupdate, "release_signature",
|
|
877
|
+
lambda app_dir, oid: {"state": "verified",
|
|
878
|
+
"key_id": 検証後にrefを進める(app_dir, oid)})
|
|
877
879
|
|
|
878
880
|
message, moved = selfupdate._pull_locked(pair["work"])
|
|
879
881
|
merged = selfupdate.head(pair["work"])
|
|
@@ -902,9 +904,14 @@ def test_D11_verify_commitの実git引数はrefでなく固定OID(tmp_path, monk
|
|
|
902
904
|
|
|
903
905
|
monkeypatch.setattr(selfupdate, "_git", 記録して実行)
|
|
904
906
|
# fixture の commit は未署名なので結果は False。ここで確かめるのは、実際の
|
|
905
|
-
# `git verify-commit` が可変 ref でなく固定 OID
|
|
907
|
+
# `git verify-commit` が可変 ref でなく固定 OID を受け取ること(2.14 から
|
|
908
|
+
# allowed_signers を明示し、落ちたら「無い」と「合わない」を言い分ける)。
|
|
906
909
|
assert selfupdate.verify_release_signature(pair["work"], oid) is False
|
|
907
|
-
|
|
910
|
+
signers = selfupdate.allowed_signers_path()
|
|
911
|
+
assert calls == [["-c", "gpg.format=ssh", "-c", "gpg.ssh.program=ssh-keygen",
|
|
912
|
+
"-c", "gpg.ssh.allowedSignersFile=" + signers, "verify-commit", oid],
|
|
913
|
+
["cat-file", "commit", oid]]
|
|
914
|
+
assert selfupdate.release_signature(pair["work"], oid)["state"] == selfupdate.SIGNATURE_MISSING
|
|
908
915
|
|
|
909
916
|
|
|
910
917
|
def test_P2_5_boardは署名を確かめているかを1語出す(tmp_path, monkeypatch, capsys):
|
|
@@ -42,7 +42,7 @@ def test_app_json_cli_reads_same_profile_store_presence_only(env,monkeypatch,cap
|
|
|
42
42
|
if media=='x':values.update(client_type='confidential',redirect_uri=auth_x.CALLBACK)
|
|
43
43
|
monkeypatch.setattr('sys.stdin',io.StringIO(json.dumps(values)))
|
|
44
44
|
assert cli.main(['app','set',media,'--stdin','--by','operator'])==0
|
|
45
|
-
path=env['app'] if media=='threads' else
|
|
45
|
+
path=env['app'] if media=='threads' else auth_x.client_path({})
|
|
46
46
|
if media=='threads':assert appenv.load_app_env()==(values['client_id'],values['client_secret'])
|
|
47
47
|
else:
|
|
48
48
|
cfg=cfg_media(env,'x');profile=auth_x.XAuthProfile.prepare(cfg)
|
|
@@ -69,7 +69,7 @@ def test_app_by_before_any_read_input_or_write(env,legacy):
|
|
|
69
69
|
def test_app_save_fault_retains_append_contract(env,monkeypatch,media,fault):
|
|
70
70
|
values={'client_id':opaque(),'client_secret':opaque()}
|
|
71
71
|
if media=='x':values.update(client_type='confidential',redirect_uri=auth_x.CALLBACK)
|
|
72
|
-
path=env['app'] if media=='threads' else
|
|
72
|
+
path=env['app'] if media=='threads' else auth_x.client_path({})
|
|
73
73
|
if media=='x':authclients.write(path,values)
|
|
74
74
|
old=path.read_bytes();values['client_secret']=opaque();lines=[]
|
|
75
75
|
emit_fault(monkeypatch,fault)
|
|
@@ -25,9 +25,7 @@ def env(tmp_path,monkeypatch):
|
|
|
25
25
|
cfg.update(account='alpha',project='demo',handle='demo',repo_dir=str(root/'repos/_none'),token=str(root/'alpha.token'),env=str(root/'alpha.env'))
|
|
26
26
|
ledger=ledgers/'alpha.json';ledger.write_text(json.dumps(cfg));cfg=accounts.load_account('alpha')
|
|
27
27
|
data=dict(root=root,home=home,cfg=cfg,ledger=ledger,client_id=opaque(),client_secret=opaque(),code=opaque(),token=opaque(),refresh=opaque(),calls=[],behavior={},queries=[],lines=[],hook=None)
|
|
28
|
-
path=authclients.path_for('x',None,cfg);data['client_path']=path
|
|
29
28
|
data['client']={'client_id':data['client_id'],'client_secret':data['client_secret'],'client_type':'confidential','redirect_uri':x.CALLBACK}
|
|
30
|
-
authclients.write(path,data['client'])
|
|
31
29
|
class Handler(BaseHTTPRequestHandler):
|
|
32
30
|
def log_message(self,*a):pass
|
|
33
31
|
def do_GET(self):self.answer(None)
|
|
@@ -44,6 +42,9 @@ def env(tmp_path,monkeypatch):
|
|
|
44
42
|
self.send_response(status);self.end_headers();self.wfile.write(json.dumps(body).encode())
|
|
45
43
|
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
|
|
46
44
|
monkeypatch.setenv('THTH_X_BASE_URL',f'http://127.0.0.1:{server.server_port}')
|
|
45
|
+
# 2.14: client は scope 集合ごとの世代に置く(origin は上書き後に決まる)。
|
|
46
|
+
path=x.client_path(cfg);data['client_path']=path
|
|
47
|
+
authclients.write(path,data['client'])
|
|
47
48
|
# This auth fixture now explicitly provisions a synthetic read cap.
|
|
48
49
|
# Budget default-zero and setter/audit contracts have separate 2.12 tests.
|
|
49
50
|
from thth import budget_x,server_files
|
|
@@ -88,8 +89,9 @@ def test_success_pkce_basic_observed_expiry_and_auth_only(env,capsys):
|
|
|
88
89
|
assert (jst.parse(token['expires_at'])-jst.parse(token['obtained_at'])).total_seconds()==7200
|
|
89
90
|
assert token['scopes']==x.SCOPES and token['scopes_source']=='response' and token['auth_via']=='paste'
|
|
90
91
|
assert stat.S_IMODE(Path(env['cfg']['token']).stat().st_mode)==0o600
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
# 2.14: X は投稿 adapter を持つ(2.11 の auth-only ではなくなった)。
|
|
93
|
+
assert adapters.capabilities_for('x')=={'recent_posts'}
|
|
94
|
+
assert adapters.make_adapter(env['cfg'],token).user_id=='123'
|
|
93
95
|
output='\n'.join(env['lines'])+json.dumps(admin_log.read())+capsys.readouterr().out
|
|
94
96
|
for value in [env['token'],env['refresh'],env['client_secret'],env['code'],session['state'],session['read_key'],session['code_verifier']]:assert value not in output
|
|
95
97
|
|
|
@@ -212,7 +214,7 @@ def test_cli_account_add_then_auth_reaches_x_profile(env,monkeypatch):
|
|
|
212
214
|
monkeypatch.setattr('builtins.input',lambda:x.CALLBACK+'?'+urllib.parse.urlencode({'code':env['code'],'state':authflow._read_session('newx')['state']}))
|
|
213
215
|
assert cli.main(['auth','newx','--by','operator','--paste'])==0
|
|
214
216
|
cfg=accounts.load_account('newx');assert cfg['production'] is False and cfg['scheduled'] is False
|
|
215
|
-
assert accounts.load_token(cfg)['user_id']=='123' and adapters.capabilities_for('x')==
|
|
217
|
+
assert accounts.load_token(cfg)['user_id']=='123' and adapters.capabilities_for('x')=={'recent_posts'}
|
|
216
218
|
|
|
217
219
|
|
|
218
220
|
@pytest.mark.parametrize('fault',['zero','partial','fsync','rollback'])
|
|
@@ -39,7 +39,7 @@ def test_mastodon_still_accepts_the_same_webm(tmp_path):
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def test_policy_table_names_every_medium():
|
|
42
|
-
assert set(media.MEDIUM_POLICY) == {'threads', 'bluesky', 'mastodon'}
|
|
42
|
+
assert set(media.MEDIUM_POLICY) == {'threads', 'bluesky', 'mastodon', 'x'}
|
|
43
43
|
assert media.MEDIUM_POLICY['mastodon']['allow_webm'] is True
|
|
44
44
|
# An edit list is no longer a per-medium policy: every medium takes one.
|
|
45
45
|
assert all(set(p) <= {'allow_webm'} for p in media.MEDIUM_POLICY.values())
|
|
@@ -17,7 +17,7 @@ from thth import media, media_capabilities as caps, mediaformats, tool_version
|
|
|
17
17
|
|
|
18
18
|
SOURCE = {path: path.read_text(encoding='utf-8')
|
|
19
19
|
for path in sorted(Path(thth.__file__).resolve().parent.rglob('*.py'))}
|
|
20
|
-
LITERAL = re.compile(r"unsupported_attachment: (mastodon|bluesky|threads)/([a-z0-9_]*)")
|
|
20
|
+
LITERAL = re.compile(r"unsupported_attachment: (mastodon|bluesky|threads|x)/([a-z0-9_]*)")
|
|
21
21
|
BARE = re.compile(r"unsupported_attachment: ([a-z0-9_][a-z0-9_ ]*)['\"]")
|
|
22
22
|
INSPECTION = re.compile(r"Inspection\(\s*'([a-z0-9_]+)'")
|
|
23
23
|
REQUIRED = ('image', 'video', 'audio', 'carousel', 'poll', 'quote', 'link', 'gif', 'text',
|
|
@@ -91,6 +91,11 @@ def test_the_rulings_are_written_down_as_given():
|
|
|
91
91
|
assert caps.CAPABILITIES['mastodon']['visibility_private'] == 'unsupported: ledger_public_only'
|
|
92
92
|
assert caps.CAPABILITIES['mastodon']['link'] == 'body_only'
|
|
93
93
|
assert caps.CAPABILITIES['bluesky']['webm'] == 'unsupported: provider_format'
|
|
94
|
+
# 2.14: 引用は「API に無い」のではなく tier で使えない(設計 §0・§7)。
|
|
95
|
+
assert caps.CAPABILITIES['x']['quote'] == 'unsupported: provider_tier_enterprise_only'
|
|
96
|
+
assert caps.CAPABILITIES['x']['carousel_quote'] == 'unsupported: provider_tier_enterprise_only'
|
|
97
|
+
assert caps.CAPABILITIES['x']['link'] == 'body_only'
|
|
98
|
+
assert caps.CAPABILITIES['x']['reply_control'] == 'supported'
|
|
94
99
|
for medium in caps.MEDIA:
|
|
95
100
|
assert caps.CAPABILITIES[medium]['aac_adts'] == 'unsupported: privacy_inspection_boundary_unverified'
|
|
96
101
|
assert caps.CAPABILITIES[medium]['asf'] == 'unsupported: deferred_by_ruling_c14'
|
|
@@ -181,8 +181,9 @@ def test_prepared_approved_threads_image_is_selected_until_source_changes(repo,
|
|
|
181
181
|
|
|
182
182
|
def test_approved_attachment_for_unsupported_provider_is_rejected(repo):
|
|
183
183
|
from thth import select
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
# 2.14 で X は載ったので、**まだ載っていない媒体**でこの筋を守る。
|
|
185
|
+
cfg = {'account': 'demo', 'media': 'whatsapp', 'repo_dir': str(repo)}
|
|
186
|
+
original = v1().replace('## threads', '## whatsapp')
|
|
186
187
|
draft = queuefile.parse_text(original, 'q.md')
|
|
187
188
|
manifest = media.manifest_for(draft.front_matter, cfg)
|
|
188
189
|
approved_sha = approval.compute_approved_sha(**args(), media_manifest=manifest)
|
|
@@ -213,7 +214,7 @@ def test_legacy_nondefault_posts_indent_writeback():
|
|
|
213
214
|
|
|
214
215
|
|
|
215
216
|
@pytest.mark.parametrize('medium,expected', [
|
|
216
|
-
('
|
|
217
|
+
('whatsapp', 'media_provider_unavailable'),
|
|
217
218
|
('threads', 'approval_stale'),
|
|
218
219
|
])
|
|
219
220
|
def test_direct_core_guard_never_constructs_adapter(medium, expected):
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""2.14.0 §5: release の SSH 署名。鍵はこの試験の中で作って、外に出さない。"""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from thth import cli as cli_mod, report as report_mod, selfupdate
|
|
10
|
+
from tests.test_selfupdate import _advance_main, _app_pair, _release
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run(*args, **kwargs):
|
|
14
|
+
return subprocess.run(list(args), capture_output=True, text=True, **kwargs)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def keys(tmp_path):
|
|
19
|
+
"""使い捨ての ed25519 鍵を 2 本(信じる鍵・信じない鍵)。"""
|
|
20
|
+
if shutil.which('ssh-keygen') is None:
|
|
21
|
+
pytest.skip('ssh-keygen がありません(SSH 署名の検証は測れません)')
|
|
22
|
+
made = {}
|
|
23
|
+
for name in ('trusted', 'other'):
|
|
24
|
+
path = tmp_path / name
|
|
25
|
+
result = _run('ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', name, '-f', str(path))
|
|
26
|
+
if result.returncode != 0:
|
|
27
|
+
pytest.skip('ssh-keygen が鍵を作れません')
|
|
28
|
+
fingerprint = _run('ssh-keygen', '-lf', str(path) + '.pub').stdout.split()[1]
|
|
29
|
+
made[name] = {'private': str(path), 'public': str(path) + '.pub',
|
|
30
|
+
'line': (tmp_path / (name + '.pub')).read_text().strip(),
|
|
31
|
+
'fingerprint': fingerprint}
|
|
32
|
+
allowed = tmp_path / 'allowed_signers'
|
|
33
|
+
allowed.write_text('thth-release ' + made['trusted']['line'] + '\n')
|
|
34
|
+
allowed.chmod(0o600)
|
|
35
|
+
made['allowed'] = str(allowed)
|
|
36
|
+
return made
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sign_with(repo, key):
|
|
40
|
+
_run('git', '-C', repo, 'config', 'gpg.format', 'ssh')
|
|
41
|
+
_run('git', '-C', repo, 'config', 'user.signingkey', key['public'])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def commit(repo, text, *, signed):
|
|
45
|
+
path = os.path.join(repo, 'docs', 'sns', 'queue', 'version.txt')
|
|
46
|
+
with open(path, 'w', encoding='utf-8') as stream:
|
|
47
|
+
stream.write(text)
|
|
48
|
+
args = ['git', '-C', repo, 'commit', '-aq'] + (['-S'] if signed else []) + ['-m', 'advance']
|
|
49
|
+
return _run(*args)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def signed_pair(tmp_path, keys, monkeypatch):
|
|
54
|
+
pair = _app_pair(tmp_path)
|
|
55
|
+
monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
|
|
56
|
+
sign_with(pair['seed'], keys['trusted'])
|
|
57
|
+
if commit(pair['seed'], 'v2\n', signed=True).returncode != 0:
|
|
58
|
+
pytest.skip('この git は SSH 署名を作れません')
|
|
59
|
+
_release(pair)
|
|
60
|
+
return pair
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_a_commit_signed_by_the_trusted_key_verifies_and_names_the_key(signed_pair, keys):
|
|
64
|
+
subprocess.run(['git', '-C', signed_pair['work'], 'fetch', '-q', 'origin',
|
|
65
|
+
'+refs/heads/release:refs/remotes/origin/release'], check=True)
|
|
66
|
+
oid = _run('git', '-C', signed_pair['work'], 'rev-parse', 'origin/release').stdout.strip()
|
|
67
|
+
found = selfupdate.release_signature(signed_pair['work'], oid)
|
|
68
|
+
assert found['state'] == 'verified'
|
|
69
|
+
assert found['key_id'] == keys['trusted']['fingerprint']
|
|
70
|
+
assert selfupdate.verify_release_signature(signed_pair['work'], oid) is True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_an_unsigned_commit_is_missing_not_invalid(tmp_path, keys, monkeypatch):
|
|
74
|
+
pair = _app_pair(tmp_path)
|
|
75
|
+
monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
|
|
76
|
+
_advance_main(pair)
|
|
77
|
+
_release(pair)
|
|
78
|
+
subprocess.run(['git', '-C', pair['work'], 'fetch', '-q', 'origin',
|
|
79
|
+
'+refs/heads/release:refs/remotes/origin/release'], check=True)
|
|
80
|
+
oid = _run('git', '-C', pair['work'], 'rev-parse', 'origin/release').stdout.strip()
|
|
81
|
+
found = selfupdate.release_signature(pair['work'], oid)
|
|
82
|
+
assert found == {'state': selfupdate.SIGNATURE_MISSING, 'key_id': None}
|
|
83
|
+
assert selfupdate.verify_release_signature(pair['work'], oid) is False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_commit_signed_by_an_untrusted_key_is_invalid(tmp_path, keys, monkeypatch):
|
|
87
|
+
pair = _app_pair(tmp_path)
|
|
88
|
+
monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
|
|
89
|
+
sign_with(pair['seed'], keys['other'])
|
|
90
|
+
if commit(pair['seed'], 'v2\n', signed=True).returncode != 0:
|
|
91
|
+
pytest.skip('この git は SSH 署名を作れません')
|
|
92
|
+
_release(pair)
|
|
93
|
+
subprocess.run(['git', '-C', pair['work'], 'fetch', '-q', 'origin',
|
|
94
|
+
'+refs/heads/release:refs/remotes/origin/release'], check=True)
|
|
95
|
+
oid = _run('git', '-C', pair['work'], 'rev-parse', 'origin/release').stdout.strip()
|
|
96
|
+
found = selfupdate.release_signature(pair['work'], oid)
|
|
97
|
+
assert found == {'state': selfupdate.SIGNATURE_INVALID, 'key_id': None}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_the_signed_release_is_taken_in_and_the_board_names_the_key(signed_pair, keys,
|
|
101
|
+
monkeypatch, capsys):
|
|
102
|
+
before = selfupdate.head(signed_pair['work'])
|
|
103
|
+
monkeypatch.setenv(selfupdate.REQUIRE_SIGNED_ENV, '1')
|
|
104
|
+
monkeypatch.setattr(os, 'execve', lambda *a: None)
|
|
105
|
+
selfupdate.pull_and_reexec(['thth'], app_dir=signed_pair['work'], log=lambda _l: None)
|
|
106
|
+
assert selfupdate.head(signed_pair['work']) != before
|
|
107
|
+
record = selfupdate.release_check(signed_pair['work'])
|
|
108
|
+
assert record['ok'] is True and record['signature_key'] == keys['trusted']['fingerprint']
|
|
109
|
+
monkeypatch.setattr(report_mod.selfupdate_mod, 'release_check', lambda *a, **k: dict(record))
|
|
110
|
+
cli_mod.cmd_board(argparse.Namespace(json=False, account=None))
|
|
111
|
+
assert '署名: 確認済み(' + keys['trusted']['fingerprint'] + ')' in capsys.readouterr().out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize('flavour,expected,line', [
|
|
115
|
+
('unsigned', selfupdate.SIGNATURE_MISSING, '署名: 未確認(取り込んでいません)'),
|
|
116
|
+
('untrusted', selfupdate.SIGNATURE_INVALID, '署名: 不正(取り込んでいません)'),
|
|
117
|
+
])
|
|
118
|
+
def test_an_unverifiable_release_is_not_taken_in_and_the_board_says_which(
|
|
119
|
+
tmp_path, keys, monkeypatch, capsys, flavour, expected, line):
|
|
120
|
+
pair = _app_pair(tmp_path)
|
|
121
|
+
monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
|
|
122
|
+
if flavour == 'untrusted':
|
|
123
|
+
sign_with(pair['seed'], keys['other'])
|
|
124
|
+
if commit(pair['seed'], 'v2\n', signed=True).returncode != 0:
|
|
125
|
+
pytest.skip('この git は SSH 署名を作れません')
|
|
126
|
+
else:
|
|
127
|
+
_advance_main(pair)
|
|
128
|
+
_release(pair)
|
|
129
|
+
before = selfupdate.head(pair['work'])
|
|
130
|
+
monkeypatch.setenv(selfupdate.REQUIRE_SIGNED_ENV, '1')
|
|
131
|
+
execs = []
|
|
132
|
+
monkeypatch.setattr(os, 'execve', lambda *a: execs.append(a))
|
|
133
|
+
message = selfupdate.pull_and_reexec(['thth'], app_dir=pair['work'], log=lambda _l: None)
|
|
134
|
+
assert selfupdate.head(pair['work']) == before and execs == []
|
|
135
|
+
assert message and selfupdate.SIGNATURE_ERROR in message and expected in message
|
|
136
|
+
record = selfupdate.release_check(pair['work'])
|
|
137
|
+
assert record['ok'] is False and record['error'].endswith(expected)
|
|
138
|
+
monkeypatch.setattr(report_mod.selfupdate_mod, 'release_check', lambda *a, **k: dict(record))
|
|
139
|
+
cli_mod.cmd_board(argparse.Namespace(json=False, account=None))
|
|
140
|
+
assert line in capsys.readouterr().out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_the_default_still_takes_an_unsigned_release(tmp_path, keys, monkeypatch):
|
|
144
|
+
pair = _app_pair(tmp_path)
|
|
145
|
+
monkeypatch.delenv(selfupdate.REQUIRE_SIGNED_ENV, raising=False)
|
|
146
|
+
monkeypatch.setenv(selfupdate.ALLOWED_SIGNERS_ENV, keys['allowed'])
|
|
147
|
+
before = selfupdate.head(pair['work'])
|
|
148
|
+
_advance_main(pair)
|
|
149
|
+
_release(pair)
|
|
150
|
+
monkeypatch.setattr(os, 'execve', lambda *a: None)
|
|
151
|
+
selfupdate.pull_and_reexec(['thth'], app_dir=pair['work'], log=lambda _l: None)
|
|
152
|
+
assert selfupdate.head(pair['work']) != before
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_the_allowed_signers_path_defaults_outside_the_project(monkeypatch):
|
|
156
|
+
monkeypatch.delenv(selfupdate.ALLOWED_SIGNERS_ENV, raising=False)
|
|
157
|
+
assert selfupdate.allowed_signers_path().endswith('/.config/thth/allowed_signers')
|