@@ -2638,6 +2638,51 @@ printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","n
26382638out=" $( cd " $V " && PATH=" $V /bin:$PATH " ./pithead apply -y 2>&1 ) "
26392639assert_rc " malformed tari view key rejected" " $? " " 1"
26402640assert_contains " malformed tari view-key message" " $out " " 64-character hex"
2641+ # (5) A spend key PRESENT but malformed (not 64 hex) is rejected — the require-both check keys off
2642+ # the same 64-hex shape as the view key, so a garbage spend key fails just like a missing one (#523).
2643+ seed_env
2644+ printf ' { "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T","view_key":"%s","spend_public_key":"deadbeef"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' " $WALLET " " $TVIEW " > " $V /config.json"
2645+ out=" $( cd " $V " && PATH=" $V /bin:$PATH " ./pithead apply -y 2>&1 ) "
2646+ assert_rc " malformed tari spend key rejected" " $? " " 1"
2647+ assert_contains " malformed spend-key message names spend_public_key" " $out " " tari.spend_public_key"
2648+
2649+ echo " == black-box: tari.payout_scan_birthday validation (#523) =="
2650+ # The restore-point birthday is validated only on the view-key path (it feeds the tari-wallet). It
2651+ # is "auto" or a u16 days-since-epoch (0–65535) — a block height or an out-of-range value is a
2652+ # common mistake that must fail at apply, not silently mis-restore the wallet. Keys are valid so
2653+ # only the birthday is under test.
2654+ # (1) A non-integer birthday (a block height, say) is refused.
2655+ seed_env
2656+ printf ' { "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T","view_key":"%s","spend_public_key":"%s","payout_scan_birthday":"height-3200000"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' " $WALLET " " $TVIEW " " $TSPEND " > " $V /config.json"
2657+ out=" $( cd " $V " && PATH=" $V /bin:$PATH " ./pithead apply -y 2>&1 ) "
2658+ assert_rc " non-integer birthday rejected" " $? " " 1"
2659+ assert_contains " non-integer birthday message names the field" " $out " " tari.payout_scan_birthday"
2660+ # (2) An in-range-looking but too-large birthday (> 65535, e.g. a block height) is refused.
2661+ seed_env
2662+ printf ' { "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T","view_key":"%s","spend_public_key":"%s","payout_scan_birthday":"99999"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' " $WALLET " " $TVIEW " " $TSPEND " > " $V /config.json"
2663+ out=" $( cd " $V " && PATH=" $V /bin:$PATH " ./pithead apply -y 2>&1 ) "
2664+ assert_rc " out-of-range birthday rejected" " $? " " 1"
2665+ assert_contains " out-of-range birthday message names the u16 ceiling" " $out " " 65535"
2666+ # (3) A valid u16 birthday applies and reflects verbatim into .env.
2667+ seed_env
2668+ printf ' { "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T","view_key":"%s","spend_public_key":"%s","payout_scan_birthday":"1000"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' " $WALLET " " $TVIEW " " $TSPEND " > " $V /config.json"
2669+ out=" $( cd " $V " && PATH=" $V /bin:$PATH " ./pithead apply -y 2>&1 ) "
2670+ assert_rc " valid birthday accepted" " $? " " 0"
2671+ assert_eq " valid birthday reflected into .env" " $( run_sourced " $V " env_get_file " $V /.env" TARI_WALLET_BIRTHDAY) " " 1000"
2672+
2673+ echo " == black-box: monero.rpc_lan_access + prep_blocks_threads reflect into .env (#523) =="
2674+ # The rendered .env must match the config input. rpc_lan_access gates the monerod RPC bind: default
2675+ # (unset) keeps it localhost-only; true opens it to the LAN. prep_blocks_threads overrides the
2676+ # host-core-derived block-verification thread count verbatim when it is an integer.
2677+ seed_env
2678+ printf ' { "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' " $WALLET " > " $V /config.json"
2679+ out=" $( cd " $V " && PATH=" $V /bin:$PATH " ./pithead apply -y 2>&1 ) "
2680+ assert_eq " rpc_lan_access default binds monerod RPC to localhost" " $( run_sourced " $V " env_get_file " $V /.env" MONERO_RPC_BIND) " " 127.0.0.1"
2681+ seed_env
2682+ printf ' { "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p","rpc_lan_access":true,"prep_blocks_threads":6}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan"} }\n' " $WALLET " > " $V /config.json"
2683+ out=" $( cd " $V " && PATH=" $V /bin:$PATH " ./pithead apply -y 2>&1 ) "
2684+ assert_eq " rpc_lan_access true binds monerod RPC to all interfaces" " $( run_sourced " $V " env_get_file " $V /.env" MONERO_RPC_BIND) " " 0.0.0.0"
2685+ assert_eq " prep_blocks_threads override reflected verbatim" " $( run_sourced " $V " env_get_file " $V /.env" MONERO_PREP_THREADS) " " 6"
26412686
26422687echo " == black-box: tor.auto_heal renders to .env (#424) =="
26432688# The dashboard's healer reads TOR_AUTO_HEAL from .env. Key absent -> off (the stack never
@@ -4030,6 +4075,44 @@ control_config main
40304075out=" $( cd " $C " && DOCKER_LOG=" $CTRL_LOG " PATH=" $C /bin:$PATH " ./pithead apply --dry-run --porcelain 2>&1 ) "
40314076assert_rc " control without an onion is allowed" " $? " " 0"
40324077
4078+ echo " == black-box: telegram.control fails closed on each leg (#521) =="
4079+ # telegram.control (the #338 remote /restart /apply surface) is a remotely-reachable host-control
4080+ # channel, so it refuses to enable unless the whole chain is present: dashboard.control on (the #33
4081+ # spool it rides), telegram.commands on (the bot that answers it), and at least one allow-listed
4082+ # operator id (or every command is refused and the feature is inert). Each leg must fail closed.
4083+ tg_control_config () { # <dashboard.control.enabled> <telegram.commands.enabled> <allowed_ids-json>
4084+ jq -n --arg w " $WALLET " --argjson ctl " $1 " --argjson cmds " $2 " --argjson ids " $3 " \
4085+ ' {monero:{mode:"local",wallet_address:$w,node_username:"u",node_password:"p"},
4086+ tari:{wallet_address:"T"}, p2pool:{pool:"main"},
4087+ telegram:{enabled:true,bot_token:"123456:legit-ABC_def",chat_id:"1111",
4088+ commands:{enabled:$cmds}, control:{enabled:true,allowed_ids:$ids}},
4089+ dashboard:{secure:true,host:"box.lan",auth:{username:"admin",password:"a control passphrase"},
4090+ control:{enabled:$ctl}}}' > " $C /config.json"
4091+ }
4092+ # Leg 1: dashboard.control off — the spool the commands ride does not exist.
4093+ tg_control_config false true ' [123456]'
4094+ out=" $( cd " $C " && DOCKER_LOG=" $CTRL_LOG " PATH=" $C /bin:$PATH " ./pithead apply --dry-run --porcelain 2>&1 ) "
4095+ assert_rc " telegram.control without dashboard.control is refused" " $? " " 1"
4096+ assert_contains " refusal names dashboard.control.enabled" " $out " " dashboard.control.enabled is false"
4097+ # Leg 2: telegram.commands off — no bot is polling for the commands.
4098+ tg_control_config true false ' [123456]'
4099+ out=" $( cd " $C " && DOCKER_LOG=" $CTRL_LOG " PATH=" $C /bin:$PATH " ./pithead apply --dry-run --porcelain 2>&1 ) "
4100+ assert_rc " telegram.control without telegram.commands is refused" " $? " " 1"
4101+ assert_contains " refusal names telegram.commands.enabled" " $out " " telegram.commands.enabled is false"
4102+ # Leg 3: allowed_ids empty — no operator could ever confirm, the feature is inert.
4103+ tg_control_config true true ' []'
4104+ out=" $( cd " $C " && DOCKER_LOG=" $CTRL_LOG " PATH=" $C /bin:$PATH " ./pithead apply --dry-run --porcelain 2>&1 ) "
4105+ assert_rc " telegram.control with an empty allowed_ids is refused" " $? " " 1"
4106+ assert_contains " refusal names allowed_ids" " $out " " telegram.control.allowed_ids is empty"
4107+ # All three legs present — validates.
4108+ tg_control_config true true ' [123456]'
4109+ out=" $( cd " $C " && DOCKER_LOG=" $CTRL_LOG " PATH=" $C /bin:$PATH " ./pithead apply --dry-run --porcelain 2>&1 ) "
4110+ assert_rc " fully-configured telegram.control validates" " $? " " 0"
4111+ assert_not_contains " fully-configured control raises no telegram.control error" " $out " " telegram.control.enabled is true but"
4112+ # Restore the section baseline (control on, no telegram) for the tests that follow.
4113+ control_config main
4114+ (cd " $C " && DOCKER_LOG=" $CTRL_LOG " PATH=" $C /bin:$PATH " ./pithead apply -y > /dev/null 2>&1 )
4115+
40334116echo " == black-box: approval gate fails closed on a destructive commit (#33 / #338) =="
40344117UUID3=" 33333333-3333-4333-8333-333333333333"
40354118# Clean baseline: pool mini, clearnet off, applied.
@@ -4185,6 +4268,47 @@ gate_try "$C/cand.json"
41854268assert_eq " non-security change on a security-laden config still applies" " $( jq -r ' .status' " $RESULTS /$UUID5 .json" 2> /dev/null) " " applied"
41864269assert_eq " pool tier change landed in config.json" " $( jq -r ' .p2pool.pool' " $C /config.json" ) " " mini"
41874270
4271+ echo " == black-box: editable-allowlist commit round-trip, every key (#522) =="
4272+ # Every key on CONTROL_DASHBOARD_EDITABLE_KEYS must actually round-trip a real preview->commit
4273+ # through the approval gate and land in config.json — not just pass a describe_change unit check.
4274+ # Fresh baseline with each tunable at a known value so every row below is a genuine single-key
4275+ # env diff (pool flips P2POOL_FLAGS + P2POOL_PORT, both allowlisted).
4276+ jq -n --arg w " $WALLET " ' {
4277+ monero:{mode:"local",wallet_address:$w,node_username:"u",node_password:"p",mem_limit:"4g",prep_blocks_threads:4},
4278+ tari:{wallet_address:"T",mem_limit:"3g"}, p2pool:{pool:"main"},
4279+ xvb:{enabled:true,donation_level:"donor"}, telegram:{daily_summary_time:"08:00"},
4280+ dashboard:{secure:true,host:"box.lan",tari_required:true,check_for_updates:true,timezone:"UTC",
4281+ hashrate_drop_threshold:50,hashrate_drop_minutes:10,
4282+ auth:{username:"admin",password:"a control passphrase"},control:{enabled:true}}}' > " $C /config.json"
4283+ (cd " $C " && DOCKER_LOG=" $CTRL_LOG " PATH=" $C /bin:$PATH " ./pithead apply -y > /dev/null 2>&1 )
4284+ roundtrip_key () { # <label> <jq-set> <jq-read> <expected>
4285+ jq " $2 " " $C /config.json" > " $C /cand.json"
4286+ gate_try " $C /cand.json"
4287+ assert_eq " $1 commit applies through the gate" " $( jq -r ' .status' " $RESULTS /$UUID5 .json" 2> /dev/null) " " applied"
4288+ assert_eq " $1 landed in config.json" " $( jq -r " $3 " " $C /config.json" ) " " $4 "
4289+ }
4290+ roundtrip_key " XVB_ENABLED" ' .xvb.enabled=false' ' .xvb.enabled' " false"
4291+ roundtrip_key " XVB_DONATION_LEVEL" ' .xvb.donation_level="whale"' ' .xvb.donation_level' " whale"
4292+ roundtrip_key " TARI_REQUIRED" ' .dashboard.tari_required=false' ' .dashboard.tari_required' " false"
4293+ roundtrip_key " DASHBOARD_CHECK_UPDATES" ' .dashboard.check_for_updates=false' ' .dashboard.check_for_updates' " false"
4294+ roundtrip_key " DASHBOARD_TZ" ' .dashboard.timezone="Europe/Paris"' ' .dashboard.timezone' " Europe/Paris"
4295+ roundtrip_key " MONERO_MEM_LIMIT" ' .monero.mem_limit="5g"' ' .monero.mem_limit' " 5g"
4296+ roundtrip_key " TARI_MEM_LIMIT" ' .tari.mem_limit="2g"' ' .tari.mem_limit' " 2g"
4297+ roundtrip_key " MONERO_PREP_THREADS" ' .monero.prep_blocks_threads=8' ' .monero.prep_blocks_threads' " 8"
4298+ roundtrip_key " HASHRATE_DROP_THRESHOLD_PCT" ' .dashboard.hashrate_drop_threshold=40' ' .dashboard.hashrate_drop_threshold' " 40"
4299+ roundtrip_key " HASHRATE_DROP_MINUTES" ' .dashboard.hashrate_drop_minutes=15' ' .dashboard.hashrate_drop_minutes' " 15"
4300+ roundtrip_key " TELEGRAM_DAILY_SUMMARY_TIME" ' .telegram.daily_summary_time="09:30"' ' .telegram.daily_summary_time' " 09:30"
4301+ roundtrip_key " P2POOL_FLAGS/P2POOL_PORT" ' .p2pool.pool="mini"' ' .p2pool.pool' " mini"
4302+ # The 24 allowlisted TELEGRAM_EVENT_* toggles. wallet_changed + clearnet_exposed are deliberately
4303+ # NOT on the allowlist (tamper-evidence alarms; their refusal is asserted above), so they are
4304+ # excluded here. Each flips true->false as a single-key diff.
4305+ for ev in node_down node_recovered worker_offline worker_recovered worker_joined worker_left \
4306+ sync_finished disk_space db_unhealthy db_reset xvb_no_share xvb_registration new_release \
4307+ stack_online daily_summary hashrate_low hashrate_loss hugepages low_ram high_reject_rate \
4308+ block_found payout_found payout_confirmed container_unhealthy; do
4309+ roundtrip_key " TELEGRAM_EVENT ${ev} " " .telegram.events.${ev} =false" " .telegram.events.${ev} " " false"
4310+ done
4311+
41884312echo " == black-box: per-worker token mask + host-side restore (#172) =="
41894313# dashboard.workers[].token is a per-rig credential living in a VARIABLE-LENGTH array — out of the
41904314# fixed CONTROL_SECRET_PATHS walk. The masked prefill copy must sentinel each set token (extends
0 commit comments