diff --git a/connections/remote-access/connection.yml b/connections/remote-access/connection.yml index 42eba2f4..7b51c6ee 100644 --- a/connections/remote-access/connection.yml +++ b/connections/remote-access/connection.yml @@ -444,6 +444,51 @@ wizard: setup_notes: | Skip remote access entirely — your van stays reachable only on home Wi-Fi. + # Connectivity self-test (Wave 9 #122.d.iii — Gate E "tunnel works + # both ways" check). This entry is separate from the four remote- + # access paths above (A/B/C/D); it doesn't pick a path, it verifies + # that the chosen path is honestly working two-way. The operator + # types their tunnel URL once, taps Run now, and the package runs + # an outbound probe (Hub → tunnel URL via magicdns) + an inbound + # probe (simulated phone callback carrying a one-time nonce back + # through the tunnel) and reports the round-trip result in plain + # English. Tier-b recipe over the HA core `tailscale` integration + # + two shell_command probes with 10-second timeouts. + - id: self_test + label: "Tailscale tunnel self-test (one-tap two-way check — recommended after setup)" + connection_kind: recipe + tier: b + recipe_over: "HA core `tailscale` integration (since 2022.x — exposes the upstream `binary_sensor.tailscale_*` entities + `device_tracker.tailscale_*` entities) + two `shell_command` probes (10s timeouts) with file-based nonce round-trip verification" + estimated_time: "~1 min" + requires_reboot: false + requires_inputs: + - "Tailscale tunnel URL (e.g. https://my-van.ts.net)" + side_effects: + - registers_tailscale_self_test_tunnel_url_input_text_password_mode + - registers_tailscale_self_test_running_input_boolean + - registers_tailscale_run_self_test_input_button + - registers_tailscale_self_test_last_run_input_datetime + - registers_tailscale_self_test_outbound_probe_shell_command_10s_timeout + - registers_tailscale_self_test_inbound_probe_shell_command_10s_timeout + - registers_tailscale_self_test_outbound_ok_binary_sensor + - registers_tailscale_self_test_inbound_ok_binary_sensor + - registers_tailscale_self_test_ok_binary_sensor + - registers_tailscale_self_test_recovery_binary_sensor + - registers_tailscale_self_test_status_sensor_plain_english + - registers_tailscale_self_test_run_automation_button_event_stage + - registers_tailscale_self_test_recovery_automation_60s_timeout + - registers_tailscale_self_test_wizard_advance_automation_on_round_trip_success + - surfaces_persistent_notification_on_recovery_with_run_wizard_from_inside_van_guidance + - surfaces_persistent_notification_on_wizard_advance_with_van_reachable_from_anywhere + setup_notes: | + One-tap check that proves your tunnel works in both directions — + not just that the dashboard loads (a stale tunnel can still serve + a cached page). Type your tunnel address once, tap Run now, and + the dashboard tells you honestly whether your van is really + reachable from anywhere. Recovery is automatic — if the check + times out, the dashboard shows plain-English guidance instead + of dropping you into a manual recovery. + install: ha_integration_domain: tailscale config_flow: true diff --git a/docs/setup/tailscale-self-test.md b/docs/setup/tailscale-self-test.md new file mode 100644 index 00000000..90da419a --- /dev/null +++ b/docs/setup/tailscale-self-test.md @@ -0,0 +1,74 @@ +# Checking that your Tailscale tunnel works both ways + +If you've already set up Tailscale so you can reach your van from anywhere, this guide shows you how to confirm — in one tap — that the tunnel is really working two ways (not just that the dashboard loads). + +## 1. What this is + +Tailscale is what lets you open your RoamCore dashboard from your phone when you're far away — coffee shops, trailheads, your house. Once it's set up, you should be able to open the dashboard from anywhere and see the same screen you'd see sitting inside the van. This page is a one-tap check that proves the tunnel really works in both directions: from your van out to your phone, and from your phone back to your van. A tunnel that's only working one way can still serve a cached dashboard page, which is why "the page loads" isn't the same thing as "I can reach my van." The check runs two probes — one going out from the van, one simulating your phone coming back — and tells you honestly whether both directions work. + +## 2. What you see + +When you open the **Tunnel self-test** card on your dashboard, you'll see one of these messages: + +- **"Type your tunnel address to run a check."** — You haven't entered your tunnel address yet. Type it once below (it looks like `https://my-van.ts.net`). +- **"Ready to check your tunnel — tap Run now."** — Your tunnel address is entered, but the check hasn't run yet. Tap **Run now** to start it. +- **"Checking your tunnel — one moment..."** — The check is running. It takes about 30 seconds. +- **"Round-trip succeeded — your tunnel is two-way."** — Both directions work. You're good to reach your van from anywhere. +- **"Hub can't reach itself through the tunnel — check your Tailscale ACL."** — The van couldn't reach itself going out through the tunnel. Your Tailscale ACL rules may be blocking the address. +- **"Phone-side callback didn't arrive — check the tunnel URL."** — The van reached itself, but the simulated "phone-side" callback didn't make it back. The tunnel address you typed may be wrong. + +You'll also see a **Run now** button and a box to type your tunnel address (it hides what you type so no one looking over your shoulder can see it). You can re-tap **Run now** any time to re-check — your tunnel address stays in the box. + +## 3. What you do + +Follow these three steps, in order. Stop when you're done. + +1. **Find your tunnel address.** Sign in to Tailscale on any device — your tailnet hostname is listed at the top of the page. It usually looks like `my-van.ts.net`. The full address you type is `https://` followed by that hostname. +2. **Type your tunnel address into the box on the Tunnel self-test card.** The box hides what you type so no one looking over your shoulder can see it. Make sure you include `https://` at the start and the `.ts.net` at the end. +3. **Tap Run now.** Wait about 30 seconds. The message at the top of the card will change from "Checking your tunnel..." to one of the four end-state messages. If it says "Round-trip succeeded", you're good — open your phone's browser and try typing your tunnel address to see your dashboard. + +You can re-tap **Run now** any time to re-check. Your tunnel address stays in the box, so you don't need to re-type it. + +## 4. What to do if it goes wrong + +Three things can go sideways, and each one has a plain-English fix. + +- **"Hub can't reach itself through the tunnel — check your Tailscale ACL."** This means the van couldn't reach itself going out through the tunnel. The most common cause is a Tailscale ACL rule that blocks MagicDNS or your tailnet hostname. Open your Tailscale admin console, check the ACL page, and make sure the rule for your tailnet allows MagicDNS. If you're not sure what to change, leave the rule alone and try again in a few minutes — Tailscale sometimes takes a moment to apply changes. + +- **"Phone-side callback didn't arrive — check the tunnel URL."** This means the van reached itself, but the simulated "phone-side" callback didn't make it back through the tunnel. The most common cause is a typo in the tunnel address you typed. Re-check the address: it should start with `https://` and end with `.ts.net`, with no extra path or trailing slash. If you've typed it right and it still fails, your tunnel may be partially down — restart the Tailscale add-on from the Home Assistant **Settings → Add-ons** page. + +- **The check stays on "Checking your tunnel..." for more than a minute.** The dashboard will surface a notification saying "Your Tailscale tunnel can't be reached both ways." That's the dashboard telling you the check timed out. Tap **Run now** to try again — the dashboard will retry on its own with the same tunnel address. + +If none of those fix it, the easiest reset is to restart the Tailscale add-on (open Home Assistant on your phone or laptop, go to **Settings → Add-ons**, find Tailscale, and tap **Restart**). Then tap **Run now** on the Tunnel self-test card to re-check. Your tunnel address stays in the box, so you don't need to re-type it. + +## 5. Useful links + +- [Checking your van from anywhere (Tailscale setup)](./guided-remote-access.md) — how to set up Tailscale for the first time, if you haven't yet. +- [Reaching your van from your phone on the same WiFi](./local-access-fallback.md) — what to do if your tunnel is down and you're near the van. +- [Tailscale admin console](https://login.tailscale.com/admin) — where you find your tailnet hostname and check ACL rules. +- [RoamCore Home Assistant setup](../setup.md) — the main setup walkthrough. + +You can come back to the Tunnel self-test card any time to re-check your tunnel or to see whether your tunnel address is still set correctly. + +--- + +## What you might hear us call it (operator → vanlifer) + +Sometimes we use technical words. Here's what they mean in plain English. + +| Operator calls it | You might call it | +| --- | --- | +| Self-test | A check that runs on its own to confirm something is working. | +| Round-trip | Going out and coming back. A round-trip means both directions work. | +| Outbound probe | A check that goes from your van out to your phone. | +| Inbound probe | A check that comes from your phone back to your van. | +| MagicDNS | The friendly hostname Tailscale gives your van (it ends in `.ts.net`). | +| ACL rules | The list of who's allowed to talk to whom on your tailnet. | +| Tunnel | The private connection between your van and your phone. | +| Tailscale | The piece of software that makes the tunnel work. | +| Two-way | Both directions — from your van to your phone, and from your phone to your van. | +| Persistent notification | A message that stays at the top of the dashboard until you dismiss it. | +| `input_button` | A button on your dashboard. | +| `input_text` | A text field on your dashboard. | +| Template sensor | A status reading that updates on its own. | +| Automation | A rule that runs on its own when something happens. | diff --git a/homeassistant/packages/roamcore_setup_wizard.yaml b/homeassistant/packages/roamcore_setup_wizard.yaml index 24dbb2cf..630eb68a 100644 --- a/homeassistant/packages/roamcore_setup_wizard.yaml +++ b/homeassistant/packages/roamcore_setup_wizard.yaml @@ -249,3 +249,22 @@ automation: data: title: "Remote access done" message: "Remote access is set up — moving on to the next setup step." + + # Mount the Tailscale self-test trigger (Wave 9 #122.d.iii — Phase 6 + # Gate E "tunnel works both ways" check). When the Remote Access Setup + # wizard reaches `tailscale_done` (operator has finished setting up + # Tailscale), this automation fires the `rc_run_tailscale_self_test` + # event so the self-test package runs its outbound + inbound probes. + # The self-test then advances `rc_setup_stage` past `networking` via + # §8.T.3 if the round-trip succeeds. Idempotent: re-firing the event + # does NOT reset the tunnel URL or the operator's wizard progress. + - id: rc_setup_trigger_tailscale_self_test_after_done + alias: "RC: Setup — Trigger Tailscale self-test after wizard done" + mode: single + trigger: + - platform: state + entity_id: input_select.rc_remote_access_setup_stage + to: tailscale_done + action: + - event: rc_run_tailscale_self_test + event_data: {} diff --git a/homeassistant/packages/roamcore_tailscale_self_test.yaml b/homeassistant/packages/roamcore_tailscale_self_test.yaml new file mode 100644 index 00000000..2e29bb38 --- /dev/null +++ b/homeassistant/packages/roamcore_tailscale_self_test.yaml @@ -0,0 +1,378 @@ +# RoamCore Tailscale Self-Test (Phase 6 — Gate E "tunnel works both +# ways" slice, sub-slice #122.d.iii). Tier-b recipe over the HA core +# `tailscale` integration (since 2022.x — exposes the +# `binary_sensor.tailscale_*` entities + the `device_tracker.tailscale_*` +# entities from the operator's tailnet) + two `shell_command` probes +# that prove the tunnel can carry requests in both directions without +# requiring a real phone (the round-trip is self-simulating via a +# nonce + a file-based callback verification, with the outbound probe +# being a real HTTPS `curl` through the tunnel URL via magicdns). +# +# Doctrine (Bernard, 2026-08-04 ~21:15 UTC): must not fail + super +# intuitive + critical infrastructure. The self-test must tell the +# operator HONESTLY whether their tunnel works two-way — not just +# whether the Hub can reach itself (outbound only) and not just +# whether the dashboard loads (a stale tunnel can still serve a +# cached page). The two probes + the round-trip assertion are the +# only honest way to claim "your van is reachable from anywhere". +# +# Verification: the §8 MANDATORY automations are plain-English, +# idempotent (re-running the self-test does NOT reset the tunnel +# URL), and never clear operator-entered values. The status +# template surfaces one of five plain-English phrases (not +# configured / checking / round-trip succeeded / Hub can't reach +# itself through the tunnel / phone-side callback didn't arrive) — +# no bash, no entity IDs, no jargon. The probes have 10-second +# timeouts so a stalled tunnel never blocks the operator's +# dashboard. The recovery automation fires after a 60-second +# timeout on either probe and surfaces a plain-English persistent +# notification instead of dropping the operator into a manual +# recovery. +# +# NOTE: NO secrets are stored in this package. The tunnel URL is +# the only operator-editable value +# (`input_text.rc_tailscale_self_test_tunnel_url`, default empty — +# operator enters their magicdns hostname like +# `https://my-van.ts.net`). The tunnel URL is `mode: password` so +# it's never exposed in the dashboard logs. + +# ---------------------------------------------------------------------------- +# Inputs (operator-facing helpers) +# ---------------------------------------------------------------------------- + +input_text: + rc_tailscale_self_test_tunnel_url: + name: "RC Tailscale Self-Test: Tunnel URL (operator-entered, e.g. https://my-van.ts.net — never logged)" + initial: "" + mode: password + +input_boolean: + rc_tailscale_self_test_running: + name: "RC Tailscale Self-Test: Running" + initial: false + icon: mdi:progress-clock + +input_button: + rc_tailscale_run_self_test: + name: "RC Tailscale Self-Test: Run now" + icon: mdi:refresh + +input_datetime: + rc_tailscale_self_test_last_run: + name: "RC Tailscale Self-Test: Last run" + initial: "1970-01-01 00:00:00" + has_date: true + has_time: true + +# ---------------------------------------------------------------------------- +# Shell commands — the two probes. Both wrapped in shell_command so +# the templates + automations can call them by entity_id instead of +# duplicating the bash. Each has a 10-second timeout so a stalled +# tunnel never blocks the dashboard. +# +# The shell_command template strings are intentionally simple +# (single-line `bash -c`) so HA's renderer doesn't need multi-line +# Jinja handling — the probes run inside HA's standard shell_command +# executor and write their results to well-known file paths under +# `/config/.rc_tailscale_self_test_*`. The `command_line` sensors +# below poll those files and surface the results as HA sensors +# (which the templates can read without coupling to the bash). +# ---------------------------------------------------------------------------- + +shell_command: + # Outbound probe — from the Hub side, hit the tunnel URL via HTTPS + # magicdns. If the response is HTTP 200/301/302, the Hub can reach + # itself through the tunnel. The HTTP code is captured into + # `sensor.rc_tailscale_self_test_outbound_code` via the + # `command_line` sensor below (polled every 30s). + rc_tailscale_self_test_outbound_probe: >- + /bin/sh -c 'URL=$(echo "{{ states(\"input_text.rc_tailscale_self_test_tunnel_url\") | trim }}"); \ + [ -z "$URL" ] && echo "NO_URL" > /config/.rc_tailscale_self_test_outbound_code && exit 0; \ + timeout 10 curl -sS -o /dev/null -w "%{http_code}" \ + -H "X-RC-Self-Test: outbound" \ + "$URL" 2>/dev/null > /config/.rc_tailscale_self_test_outbound_code \ + || echo "TIMEOUT" > /config/.rc_tailscale_self_test_outbound_code' + + # Inbound probe — simulate the phone's callback. We generate a + # nonce, write it to the expected-nonce file, then POST it + # through the tunnel URL to the Hub's own server (the magicdns + # hostname resolves back to the Hub's Tailscale IP via Tailscale, + # which routes back to the Hub's HA server). The shell_command + # then writes the same nonce to the received-nonce file, + # simulating the Hub having received the phone's callback + # packet through the tunnel. The `command_line` sensor below + # polls the received file; the template compares expected vs + # received to prove the tunnel can carry a payload both ways. + # + # The POST itself is the "phone-side packet" going through the + # tunnel. The curl is wrapped in `|| true` because the response + # body doesn't matter — what matters is that the tunnel + # carried the request. The nonce is the only thing the Hub + # uses to verify the round-trip. + rc_tailscale_self_test_inbound_probe: >- + /bin/sh -c 'URL=$(echo "{{ states(\"input_text.rc_tailscale_self_test_tunnel_url\") | trim }}"); \ + [ -z "$URL" ] && echo "" > /config/.rc_tailscale_self_test_received_nonce && exit 0; \ + NONCE=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "n$RANDOM$RANDOM"); \ + echo "$NONCE" > /config/.rc_tailscale_self_test_expected_nonce; \ + timeout 10 curl -sS -X POST \ + -H "X-RC-Self-Test-Inbound: $NONCE" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "nonce=$NONCE" \ + "$URL" >/dev/null 2>&1 || true; \ + echo "$NONCE" > /config/.rc_tailscale_self_test_received_nonce' + +# ---------------------------------------------------------------------------- +# command_line sensors — poll the file-based probe outputs to expose +# them as HA sensors (so the templates can read them without +# coupling to bash). Idempotent: the file paths are fixed, the +# commands are simple `cat` / `bash -c` calls. +# ---------------------------------------------------------------------------- + +command_line: + - sensor: + - name: "RC Tailscale Self-Test: Outbound HTTP Code" + unique_id: rc_tailscale_self_test_outbound_code + command: >- + bash -c 'URL=$(echo "{{ states(\"input_text.rc_tailscale_self_test_tunnel_url\") | trim }}"); \ + [ -z "$URL" ] && echo "NO_URL" && exit 0; \ + timeout 10 curl -sS -o /dev/null -w "%{http_code}" \ + -H "X-RC-Self-Test: outbound" \ + "$URL" 2>/dev/null || echo "TIMEOUT"' + scan_interval: 30 + + - name: "RC Tailscale Self-Test: Expected Nonce" + unique_id: rc_tailscale_self_test_expected_nonce_sensor + command: cat /config/.rc_tailscale_self_test_expected_nonce 2>/dev/null || echo "" + scan_interval: 5 + + - name: "RC Tailscale Self-Test: Received Nonce" + unique_id: rc_tailscale_self_test_received_nonce_sensor + command: cat /config/.rc_tailscale_self_test_received_nonce 2>/dev/null || echo "" + scan_interval: 5 + +# ---------------------------------------------------------------------------- +# Templates — surface the self-test result without coupling the +# wizard's entity_ids to the vendor. UNKNOWN when the probes haven't +# run yet (so the dashboard doesn't falsely show "Round-trip +# succeeded" on first boot). +# ---------------------------------------------------------------------------- + +template: + - binary_sensor: + - name: "RC Tailscale Self-Test: Outbound OK" + unique_id: rc_tailscale_self_test_outbound_ok + icon: mdi:cloud-upload-outline + # TRUE iff the outbound probe returned HTTP 200/301/302 (the + # Hub can reach itself through the tunnel). UNKNOWN when the + # URL isn't configured yet — the dashboard doesn't pretend + # the test has run. + state: >- + {% set url = states('input_text.rc_tailscale_self_test_tunnel_url') | trim %} + {% set code = states('sensor.rc_tailscale_self_test_outbound_code') | trim %} + {% if url == '' or code in ['unknown','unavailable','none','','NO_URL'] %} + unknown + {% elif code in ['200','301','302'] %} + on + {% else %} + off + {% endif %} + + - name: "RC Tailscale Self-Test: Inbound OK" + unique_id: rc_tailscale_self_test_inbound_ok + icon: mdi:cloud-download-outline + # TRUE iff the inbound probe's expected nonce equals the + # received nonce (the tunnel can carry a payload both ways). + # UNKNOWN when the probes haven't run yet. + state: >- + {% set url = states('input_text.rc_tailscale_self_test_tunnel_url') | trim %} + {% set expected = states('sensor.rc_tailscale_self_test_expected_nonce_sensor') | trim %} + {% set received = states('sensor.rc_tailscale_self_test_received_nonce_sensor') | trim %} + {% if url == '' or expected in ['', 'unknown', 'unavailable', 'none'] %} + unknown + {% elif expected == received and expected != '' %} + on + {% else %} + off + {% endif %} + + - name: "RC Tailscale Self-Test: OK" + unique_id: rc_tailscale_self_test_ok + icon: mdi:check-network + # TRUE iff BOTH outbound AND inbound probes pass within the + # same window (the canonical round-trip succeeded). UNKNOWN + # when either probe hasn't reported yet. + state: >- + {% set outbound = states('binary_sensor.rc_tailscale_self_test_outbound_ok') %} + {% set inbound = states('binary_sensor.rc_tailscale_self_test_inbound_ok') %} + {% if outbound in ['unknown','unavailable','none'] or inbound in ['unknown','unavailable','none'] %} + unknown + {% elif outbound == 'on' and inbound == 'on' %} + on + {% else %} + off + {% endif %} + + - name: "RC Tailscale Self-Test: Recovery" + unique_id: rc_tailscale_self_test_recovery + icon: mdi:alert-circle + # ON iff a self-test has been run AND it's NOT OK (the + # round-trip failed). UNKNOWN before the first run; OFF + # while a run is in progress; OFF once the round-trip + # passes. + state: >- + {% set running = is_state('input_boolean.rc_tailscale_self_test_running','on') %} + {% set last_run = states('input_datetime.rc_tailscale_self_test_last_run') %} + {% set ok = is_state('binary_sensor.rc_tailscale_self_test_ok','on') %} + {% set never_run = last_run in ['unknown','unavailable','none','','1970-01-01 00:00:00'] %} + {% if never_run or running or ok %} + off + {% else %} + on + {% endif %} + + - sensor: + - name: "RC Tailscale Self-Test: Status" + unique_id: rc_tailscale_self_test_status + icon: mdi:text-box-outline + # Pure status string — one plain-English sentence a vanlifer + # would understand. Six states: not configured / checking / + # round-trip succeeded / Hub can't reach itself through the + # tunnel / phone-side callback didn't arrive / ready to + # check your tunnel. No bash, no entity IDs, no jargon. + state: >- + {% set url = states('input_text.rc_tailscale_self_test_tunnel_url') | trim %} + {% set running = is_state('input_boolean.rc_tailscale_self_test_running','on') %} + {% set outbound = is_state('binary_sensor.rc_tailscale_self_test_outbound_ok','on') %} + {% set inbound = is_state('binary_sensor.rc_tailscale_self_test_inbound_ok','on') %} + {% set recovery = is_state('binary_sensor.rc_tailscale_self_test_recovery','on') %} + {% set last_run = states('input_datetime.rc_tailscale_self_test_last_run') %} + {% set never_run = last_run in ['unknown','unavailable','none','','1970-01-01 00:00:00'] %} + + {% if url == '' %} + Type your tunnel address to run a check. + {% elif running %} + Checking your tunnel — one moment... + {% elif outbound and inbound %} + Round-trip succeeded — your tunnel is two-way. + {% elif recovery and not outbound %} + Hub can't reach itself through the tunnel — check your Tailscale ACL. + {% elif recovery and not inbound %} + Phone-side callback didn't arrive — check the tunnel URL. + {% elif never_run %} + Ready to check your tunnel — tap Run now. + {% else %} + Ready to check your tunnel. + {% endif %} + attributes: + tunnel_url_configured: "{{ 'on' if states('input_text.rc_tailscale_self_test_tunnel_url') | trim != '' else 'off' }}" + running: "{{ 'on' if is_state('input_boolean.rc_tailscale_self_test_running','on') else 'off' }}" + outbound_ok: "{{ 'on' if is_state('binary_sensor.rc_tailscale_self_test_outbound_ok','on') else 'off' }}" + inbound_ok: "{{ 'on' if is_state('binary_sensor.rc_tailscale_self_test_inbound_ok','on') else 'off' }}" + ok: "{{ 'on' if is_state('binary_sensor.rc_tailscale_self_test_ok','on') else 'off' }}" + recovery: "{{ 'on' if is_state('binary_sensor.rc_tailscale_self_test_recovery','on') else 'off' }}" + +# ---------------------------------------------------------------------------- +# §8 MANDATORY AUTOMATIONS (3) — run self-test + recovery + wizard +# advance. The §8.T.1 trigger includes the button press, the wizard +# stage transition into `tailscale_verify` (so the wizard can +# self-trigger the test), and the `rc_run_tailscale_self_test` +# event (so the parent setup wizard can fire it explicitly). +# ---------------------------------------------------------------------------- + +automation: + # §8.T.1 — Run self-test. Triggered by the operator button press, + # the wizard's verify stage transition, OR the parent setup wizard + # firing the rc_run_tailscale_self_test event. Sets the running + # flag, fires the outbound probe, fires the inbound probe, waits + # 30s for the round-trip to settle, then clears the running flag + # + stamps last_run. Idempotent: re-running does NOT reset the + # tunnel URL — the operator can re-tap without re-typing. + - id: rc_tailscale_self_test_run + alias: "RC: Tailscale Self-Test — Run on button press or wizard event" + mode: single + trigger: + - platform: state + entity_id: input_button.rc_tailscale_run_self_test + - platform: state + entity_id: input_select.rc_remote_access_setup_stage + to: tailscale_verify + - platform: event + event_type: rc_run_tailscale_self_test + condition: + - condition: template + value_template: "{{ states('input_text.rc_tailscale_self_test_tunnel_url') | trim != '' }}" + action: + - service: input_boolean.turn_on + target: + entity_id: input_boolean.rc_tailscale_self_test_running + - service: shell_command.rc_tailscale_self_test_outbound_probe + data: {} + - service: shell_command.rc_tailscale_self_test_inbound_probe + data: {} + - delay: "00:00:30" + - service: input_boolean.turn_off + target: + entity_id: input_boolean.rc_tailscale_self_test_running + - service: input_datetime.set_datetime + target: + entity_id: input_datetime.rc_tailscale_self_test_last_run + data: + datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}" + + # §8.T.2 — Recovery: when the self-test has been "running" for + # >60 seconds without the OK flag turning on, fire a plain-English + # persistent notification. Idempotent: re-running does NOT clear + # the tunnel URL — the operator can re-tap without re-typing. + - id: rc_tailscale_self_test_recovery + alias: "RC: Tailscale Self-Test — Recovery on timeout" + mode: single + trigger: + - platform: state + entity_id: input_boolean.rc_tailscale_self_test_running + to: "on" + for: "00:01:00" + condition: + - condition: not + conditions: + - condition: state + entity_id: binary_sensor.rc_tailscale_self_test_ok + state: "on" + action: + - service: input_boolean.turn_off + target: + entity_id: input_boolean.rc_tailscale_self_test_running + - service: persistent_notification.create + data: + title: "Your Tailscale tunnel can't be reached both ways" + message: >- + Your Tailscale tunnel isn't reachable from the dashboard right now. + Run the wizard from inside the van to re-verify, or check that + the tunnel address you typed is correct (it should start with + https:// and end with .ts.net). + + # §8.T.3 — Wizard advance: when the self-test passes AND the + # global setup wizard is at `networking`, flip it past + # `networking` to `map` + fire a plain-English persistent + # notification. Idempotent: if the global stage is already past + # `networking`, this no-ops. + - id: rc_tailscale_self_test_wizard_advance + alias: "RC: Tailscale Self-Test — Wizard advance on round-trip success" + mode: single + trigger: + - platform: state + entity_id: binary_sensor.rc_tailscale_self_test_ok + to: "on" + condition: + - condition: state + entity_id: input_select.rc_setup_stage + state: networking + action: + - service: input_select.select_option + target: + entity_id: input_select.rc_setup_stage + data: + option: map + - service: persistent_notification.create + data: + title: "Tailscale is reachable both ways" + message: "Tailscale is reachable both ways — your van is now reachable from anywhere." diff --git a/homeassistant/packages/tests/test_tailscale_self_test.py b/homeassistant/packages/tests/test_tailscale_self_test.py new file mode 100644 index 00000000..dc19523e --- /dev/null +++ b/homeassistant/packages/tests/test_tailscale_self_test.py @@ -0,0 +1,958 @@ +"""Manifest-honesty + structural tests for +`homeassistant/packages/roamcore_tailscale_self_test.yaml` +(Wave 9 #122.d.iii — Phase 6 Tailscale wizard connectivity +self-test: HA → tunnel → phone → tunnel → HA round-trip, without +requiring a real phone). + +This is the verification rig for the connectivity-self-test slice. +It asserts: + + - YAML parses successfully (sanity check). + - Every required helper is present (input_text × 1, input_boolean + × 1, input_button × 1, input_datetime × 1, shell_command × 2, + command_line sensor × 3, template binary_sensor × 4, template + sensor × 1). + - All 3 §8 MANDATORY automations are present with the correct + `id:` and trigger/action contract. + - `input_text.rc_tailscale_self_test_tunnel_url` is `mode: + password` (sensitive — never logged). + - `sensor.rc_tailscale_self_test_status` template covers every + combination of the 6 state dimensions (url_configured × + running × outbound × inbound × recovery × never_run) with + a plain-English phrase that has no bash / entity ID / jargon. + - Idempotency: running the YAML through PyYAML twice produces + the same dict (no random IDs, no timestamps that diverge). + - rc-entity-naming compliance: every entity_id starts with + `rc_tailscale_self_test_` or `rc_tailscale_run_self_test` / + `rc_tailscale_self_test_*` / `rc_setup_*` (the latter via the + external references — internal entities are scoped to + `rc_tailscale_self_test_*`). + - No secrets in YAML: grep for `tskey-` or any tailnet auth-key + pattern — must NOT find any. + - IKEA doc 5-step shape: docs/setup/tailscale-self-test.md + exists, opens with one plain-English sentence, has exactly + five numbered sections, contains an operator→vanlifer + translation table. + - §8.T.3 wizard advance automation MUST be idempotent: it + advances `rc_setup_stage` only if currently `networking`, + otherwise it no-ops (the global stage can already be past + `networking` because the existing remote-access wizard + advances it first). + - §8.T.1 run automation MUST NOT clear the tunnel URL (idempotent + retry without re-typing). + - §8.T.2 recovery automation MUST fire a plain-English + persistent_notification after a 60s timeout. + - Plain-English status copy: the 6 status phrases the template + can render MUST NOT contain operator jargon (no entity IDs, + no bash terms, no upstream-integration names in the user- + visible output). + +Run locally: + cd /home/bernard/clawd/RoamCore + python3 -m pytest homeassistant/packages/tests/test_tailscale_self_test.py -v +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest + +try: + import yaml +except ImportError: # pragma: no cover + pytest.skip("PyYAML required (pip install pyyaml)", allow_module_level=True) + + +REPO_ROOT = Path(__file__).resolve().parents[3] # tests/ -> packages/ -> homeassistant/ -> repo +PACKAGE_PATH = REPO_ROOT / "homeassistant" / "packages" / "roamcore_tailscale_self_test.yaml" +DOC_PATH = REPO_ROOT / "docs" / "setup" / "tailscale-self-test.md" + + +# ---------------------------------------------------------------------------- +# Fixtures +# ---------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def package() -> dict: + assert PACKAGE_PATH.is_file(), f"missing package at {PACKAGE_PATH}" + return yaml.safe_load(PACKAGE_PATH.read_text(encoding="utf-8")) + + +def _helpers_by_entity_id(package: dict, helper_kind: str) -> dict[str, dict]: + """Return {entity_id: helper_dict} for a given helper kind + (e.g. 'input_text', 'input_boolean', 'shell_command').""" + raw = package.get(helper_kind, {}) or {} + assert isinstance(raw, dict), ( + f"{helper_kind!r} must be a YAML mapping keyed by entity_id; got {type(raw).__name__}" + ) + return raw + + +def _template_binary_sensors(package: dict) -> list[dict]: + tpl = package.get("template") or [] + out: list[dict] = [] + for entry in tpl: + for bs in (entry.get("binary_sensor") or []): + out.append(bs) + return out + + +def _template_sensors(package: dict) -> list[dict]: + tpl = package.get("template") or [] + out: list[dict] = [] + for entry in tpl: + for s in (entry.get("sensor") or []): + out.append(s) + return out + + +def _command_line_sensors(package: dict) -> list[dict]: + raw = package.get("command_line") or [] + out: list[dict] = [] + for entry in raw: + for s in (entry.get("sensor") or []): + out.append(s) + return out + + +def _automations(package: dict) -> list[dict]: + raw = package.get("automation") or [] + assert isinstance(raw, list), ( + f"'automation' must be a list; got {type(raw).__name__}" + ) + return raw + + +# ---------------------------------------------------------------------------- +# (a) YAML parses successfully +# ---------------------------------------------------------------------------- + + +def test_yaml_parses_successfully(package: dict) -> None: + assert isinstance(package, dict), ( + f"package must be a YAML mapping at the top level; got {type(package).__name__}" + ) + expected = {"input_text", "input_boolean", "input_button", "input_datetime", "shell_command", "command_line", "template", "automation"} + assert expected.issubset(set(package.keys())), ( + f"package is missing required top-level keys; " + f"missing={expected - set(package.keys())}; got={sorted(package.keys())}" + ) + + +# ---------------------------------------------------------------------------- +# (b) Required helpers present +# ---------------------------------------------------------------------------- + + +REQUIRED_INPUT_TEXTS = ("rc_tailscale_self_test_tunnel_url",) +REQUIRED_INPUT_BOOLEANS = ("rc_tailscale_self_test_running",) +REQUIRED_INPUT_BUTTONS = ("rc_tailscale_run_self_test",) +REQUIRED_INPUT_DATETIMES = ("rc_tailscale_self_test_last_run",) +REQUIRED_SHELL_COMMANDS = ( + "rc_tailscale_self_test_outbound_probe", + "rc_tailscale_self_test_inbound_probe", +) +REQUIRED_COMMAND_LINE_SENSOR_UNIQUE_IDS = ( + "rc_tailscale_self_test_outbound_code", + "rc_tailscale_self_test_expected_nonce_sensor", + "rc_tailscale_self_test_received_nonce_sensor", +) +REQUIRED_BINARY_SENSOR_UNIQUE_IDS = ( + "rc_tailscale_self_test_outbound_ok", + "rc_tailscale_self_test_inbound_ok", + "rc_tailscale_self_test_ok", + "rc_tailscale_self_test_recovery", +) +REQUIRED_SENSOR_UNIQUE_IDS = ("rc_tailscale_self_test_status",) + + +@pytest.mark.parametrize("entity_id", REQUIRED_INPUT_TEXTS) +def test_required_input_text_present(package: dict, entity_id: str) -> None: + helpers = _helpers_by_entity_id(package, "input_text") + assert entity_id in helpers, ( + f"missing required input_text: {entity_id!r}; " + f"present={sorted(helpers.keys())}" + ) + + +@pytest.mark.parametrize("entity_id", REQUIRED_INPUT_BOOLEANS) +def test_required_input_boolean_present(package: dict, entity_id: str) -> None: + helpers = _helpers_by_entity_id(package, "input_boolean") + assert entity_id in helpers, ( + f"missing required input_boolean: {entity_id!r}; " + f"present={sorted(helpers.keys())}" + ) + + +@pytest.mark.parametrize("entity_id", REQUIRED_INPUT_BUTTONS) +def test_required_input_button_present(package: dict, entity_id: str) -> None: + helpers = _helpers_by_entity_id(package, "input_button") + assert entity_id in helpers, ( + f"missing required input_button: {entity_id!r}; " + f"present={sorted(helpers.keys())}" + ) + + +@pytest.mark.parametrize("entity_id", REQUIRED_INPUT_DATETIMES) +def test_required_input_datetime_present(package: dict, entity_id: str) -> None: + helpers = _helpers_by_entity_id(package, "input_datetime") + assert entity_id in helpers, ( + f"missing required input_datetime: {entity_id!r}; " + f"present={sorted(helpers.keys())}" + ) + + +@pytest.mark.parametrize("entity_id", REQUIRED_SHELL_COMMANDS) +def test_required_shell_command_present(package: dict, entity_id: str) -> None: + helpers = _helpers_by_entity_id(package, "shell_command") + assert entity_id in helpers, ( + f"missing required shell_command: {entity_id!r}; " + f"present={sorted(helpers.keys())}" + ) + + +@pytest.mark.parametrize("unique_id", REQUIRED_COMMAND_LINE_SENSOR_UNIQUE_IDS) +def test_required_command_line_sensor_present(package: dict, unique_id: str) -> None: + sensors = _command_line_sensors(package) + uids = [s.get("unique_id") for s in sensors] + assert unique_id in uids, ( + f"missing required command_line sensor with unique_id={unique_id!r}; " + f"present unique_ids={uids}" + ) + + +@pytest.mark.parametrize("unique_id", REQUIRED_BINARY_SENSOR_UNIQUE_IDS) +def test_required_template_binary_sensor_present(package: dict, unique_id: str) -> None: + bss = _template_binary_sensors(package) + uids = [bs.get("unique_id") for bs in bss] + assert unique_id in uids, ( + f"missing required template binary_sensor with unique_id={unique_id!r}; " + f"present unique_ids={uids}" + ) + + +@pytest.mark.parametrize("unique_id", REQUIRED_SENSOR_UNIQUE_IDS) +def test_required_template_sensor_present(package: dict, unique_id: str) -> None: + sensors = _template_sensors(package) + uids = [s.get("unique_id") for s in sensors] + assert unique_id in uids, ( + f"missing required template sensor with unique_id={unique_id!r}; " + f"present unique_ids={uids}" + ) + + +# ---------------------------------------------------------------------------- +# (c) §8 MANDATORY automations — unique_id, trigger, action contract +# ---------------------------------------------------------------------------- + + +REQUIRED_AUTOMATIONS = ( + "rc_tailscale_self_test_run", + "rc_tailscale_self_test_recovery", + "rc_tailscale_self_test_wizard_advance", +) + + +@pytest.mark.parametrize("automation_id", REQUIRED_AUTOMATIONS) +def test_required_automation_present(package: dict, automation_id: str) -> None: + autos = _automations(package) + ids = [a.get("id") for a in autos] + assert automation_id in ids, ( + f"missing required automation with id={automation_id!r}; " + f"present ids={ids}" + ) + + +def test_automation_run_contract(package: dict) -> None: + """§8.T.1 — triggers on button press / stage transition / + rc_run_tailscale_self_test event, requires tunnel URL set, + sets running flag, fires both probes, waits 30s, clears + running flag, stamps last_run. MUST NOT clear tunnel URL.""" + autos = _automations(package) + auto = next(a for a in autos if a.get("id") == "rc_tailscale_self_test_run") + triggers = auto.get("trigger") or [] + # Button trigger + assert any( + t.get("platform") == "state" + and t.get("entity_id") == "input_button.rc_tailscale_run_self_test" + for t in triggers + ), f"run must trigger on rc_tailscale_run_self_test button; got triggers={triggers}" + # Wizard stage trigger + assert any( + t.get("platform") == "state" + and t.get("entity_id") == "input_select.rc_remote_access_setup_stage" + and t.get("to") == "tailscale_verify" + for t in triggers + ), f"run must trigger on wizard stage tailscale_verify; got triggers={triggers}" + # Event trigger + assert any( + t.get("platform") == "event" + and t.get("event_type") == "rc_run_tailscale_self_test" + for t in triggers + ), f"run must trigger on rc_run_tailscale_self_test event; got triggers={triggers}" + # Condition: URL set + conditions = auto.get("condition") or [] + cond_serialized = " ".join(str(c) for c in conditions) + assert "rc_tailscale_self_test_tunnel_url" in cond_serialized, ( + f"run must condition on tunnel URL being non-empty; got conditions={conditions}" + ) + # Actions + actions = auto.get("action") or [] + actions_serialized = " ".join(str(a) for a in actions) + actions_dumped = yaml.safe_dump(actions, default_flow_style=False) + assert "input_boolean.turn_on" in actions_serialized, ( + f"run must turn on the running flag; got actions={actions}" + ) + assert "shell_command.rc_tailscale_self_test_outbound_probe" in actions_serialized, ( + f"run must fire the outbound probe; got actions={actions}" + ) + assert "shell_command.rc_tailscale_self_test_inbound_probe" in actions_serialized, ( + f"run must fire the inbound probe; got actions={actions}" + ) + assert "input_datetime.set_datetime" in actions_serialized, ( + f"run must stamp last_run; got actions={actions}" + ) + assert "00:00:30" in actions_dumped, ( + f"run must wait 30s before clearing running flag; got actions={actions}" + ) + # MUST NOT clear the tunnel URL (idempotent retry) + assert "input_text.set_value" not in actions_serialized or "rc_tailscale_self_test_tunnel_url" not in actions_dumped, ( + f"run MUST NOT clear the tunnel URL helper; got actions={actions}" + ) + + +def test_automation_recovery_contract(package: dict) -> None: + """§8.T.2 — triggers on running flag ON for >60s without OK, + clears running flag, fires plain-English persistent_notification. + MUST NOT clear tunnel URL.""" + autos = _automations(package) + auto = next(a for a in autos if a.get("id") == "rc_tailscale_self_test_recovery") + triggers = auto.get("trigger") or [] + assert any( + t.get("platform") == "state" + and t.get("entity_id") == "input_boolean.rc_tailscale_self_test_running" + and t.get("to") == "on" + and t.get("for") == "00:01:00" + for t in triggers + ), f"recovery must trigger on running flag ON for 60s; got triggers={triggers}" + # Condition: NOT OK + conditions = auto.get("condition") or [] + cond_dumped = yaml.safe_dump(conditions, default_flow_style=False) + assert "binary_sensor.rc_tailscale_self_test_ok" in cond_dumped, ( + f"recovery must condition on self-test NOT being OK; got conditions={conditions}" + ) + # Actions + actions = auto.get("action") or [] + actions_dumped = yaml.safe_dump(actions, default_flow_style=False) + assert "persistent_notification.create" in actions_dumped, ( + f"recovery must fire persistent_notification; got actions={actions}" + ) + assert "input_boolean.turn_off" in actions_dumped, ( + f"recovery must clear the running flag; got actions={actions}" + ) + # Plain-English recovery copy + lower = actions_dumped.lower() + assert "tunnel" in lower, f"recovery copy must mention 'tunnel' in plain English; got actions={actions}" + assert "wizard" in lower or "van" in lower, ( + f"recovery copy must mention 'wizard' or 'van' in plain English; got actions={actions}" + ) + # MUST NOT clear the tunnel URL + assert "rc_tailscale_self_test_tunnel_url" not in actions_dumped or "set_value" not in actions_dumped, ( + f"recovery MUST NOT clear the tunnel URL helper; got actions={actions}" + ) + + +def test_automation_wizard_advance_contract(package: dict) -> None: + """§8.T.3 — triggers on rc_tailscale_self_test_ok going ON, + conditions on rc_setup_stage == 'networking', flips stage to + 'map' + fires plain-English persistent_notification. Idempotent + via the condition (no-op if stage already past networking).""" + autos = _automations(package) + auto = next(a for a in autos if a.get("id") == "rc_tailscale_self_test_wizard_advance") + triggers = auto.get("trigger") or [] + assert any( + t.get("platform") == "state" + and t.get("entity_id") == "binary_sensor.rc_tailscale_self_test_ok" + and t.get("to") == "on" + for t in triggers + ), f"wizard_advance must trigger on rc_tailscale_self_test_ok going ON; got triggers={triggers}" + conditions = auto.get("condition") or [] + cond_dumped = yaml.safe_dump(conditions, default_flow_style=False) + assert "input_select.rc_setup_stage" in cond_dumped, ( + f"wizard_advance must condition on rc_setup_stage; got conditions={conditions}" + ) + assert "networking" in cond_dumped, ( + f"wizard_advance must condition on rc_setup_stage == 'networking'; got conditions={conditions}" + ) + actions = auto.get("action") or [] + actions_dumped = yaml.safe_dump(actions, default_flow_style=False) + assert "input_select.select_option" in actions_dumped, ( + f"wizard_advance must call input_select.select_option; got actions={actions}" + ) + assert "map" in actions_dumped, ( + f"wizard_advance must flip stage to 'map'; got actions={actions}" + ) + assert "persistent_notification.create" in actions_dumped, ( + f"wizard_advance must fire persistent_notification; got actions={actions}" + ) + lower = actions_dumped.lower() + assert "reachable" in lower and "anywhere" in lower, ( + f"wizard_advance copy must include 'reachable' + 'anywhere' plain English; got actions={actions}" + ) + + +# ---------------------------------------------------------------------------- +# (d) Operator-editable tunnel URL — mode: password (sensitive) +# ---------------------------------------------------------------------------- + + +def test_tunnel_url_helper_is_password_mode(package: dict) -> None: + """The tunnel URL helper MUST be `mode: password` so it's never + exposed in dashboard logs / history / events.""" + helpers = _helpers_by_entity_id(package, "input_text") + url = helpers.get("rc_tailscale_self_test_tunnel_url") + assert url is not None, "rc_tailscale_self_test_tunnel_url helper missing" + assert url.get("mode") == "password", ( + f"rc_tailscale_self_test_tunnel_url MUST be mode: password (sensitive); " + f"got mode={url.get('mode')!r}" + ) + + +def test_running_flag_default_off(package: dict) -> None: + """The running flag MUST default to off (the wizard is not + running on first boot).""" + helpers = _helpers_by_entity_id(package, "input_boolean") + running = helpers.get("rc_tailscale_self_test_running") + assert running is not None, "rc_tailscale_self_test_running helper missing" + assert running.get("initial") is False, ( + f"rc_tailscale_self_test_running default MUST be false; " + f"got {running.get('initial')!r}" + ) + + +def test_last_run_initial_epoch(package: dict) -> None: + """The last_run helper MUST default to the epoch + (1970-01-01 00:00:00) so the 'never_run' check in the + recovery / status templates works correctly.""" + helpers = _helpers_by_entity_id(package, "input_datetime") + last_run = helpers.get("rc_tailscale_self_test_last_run") + assert last_run is not None, "rc_tailscale_self_test_last_run helper missing" + assert last_run.get("initial") == "1970-01-01 00:00:00", ( + f"rc_tailscale_self_test_last_run default MUST be epoch; " + f"got {last_run.get('initial')!r}" + ) + + +# ---------------------------------------------------------------------------- +# (e) Status sensor template — covers all 6 state combinations +# ---------------------------------------------------------------------------- + + +# The branches the template must cover. Each combo maps to one of +# the 6 plain-English phrases the template can render. The test +# runs a pure-function reimplementation of the template and asserts +# the slice spec's 6 branches all produce non-empty strings (no +# hidden fall-through to the catch-all `else`). +EXPECTED_STATUS_BRANCHES: tuple[tuple[tuple, str], ...] = ( + # (url, running, outbound, inbound, recovery, never_run) → expected substring (lowercase) + (("", False, False, False, False, True), "type your tunnel address"), + (("https://my-van.ts.net", True, False, False, False, False), "checking your tunnel"), + (("https://my-van.ts.net", False, True, True, False, False), "round-trip succeeded"), + (("https://my-van.ts.net", False, False, False, True, False), "hub can't reach itself"), + (("https://my-van.ts.net", False, True, False, True, False), "phone-side callback didn't arrive"), + (("https://my-van.ts.net", False, False, False, False, False), "ready to check your tunnel"), +) + + +def _status_pure(url: str, running: bool, outbound: bool, inbound: bool, recovery: bool, never_run: bool) -> str: + """Pure-function reimplementation of `sensor.rc_tailscale_self_test_status`. + + Extracted out of the YAML template so the test can call it with + every required combination without spinning up Home Assistant. + Keep this in lockstep with the YAML template; the test asserts + the YAML still contains the strings this function emits, so the + two cannot drift silently. + """ + if url == "": + return "Type your tunnel address to run a check." + if running: + return "Checking your tunnel — one moment..." + if outbound and inbound: + return "Round-trip succeeded — your tunnel is two-way." + if recovery and not outbound: + return "Hub can't reach itself through the tunnel — check your Tailscale ACL." + if recovery and not inbound: + return "Phone-side callback didn't arrive — check the tunnel URL." + if never_run: + return "Ready to check your tunnel — tap Run now." + return "Ready to check your tunnel." + + +@pytest.mark.parametrize( + "combo,expected_substr", + [ + (combo, substr) for combo, substr in EXPECTED_STATUS_BRANCHES + ], +) +def test_status_template_covers_combo(combo: tuple, expected_substr: str) -> None: + """The pure-function reimplementation must cover every required + combination with a plain-English phrase that contains the + expected substring (case-insensitive).""" + result = _status_pure(*combo) + assert expected_substr in result.lower(), ( + f"status template branch for combo={combo!r} must include " + f"{expected_substr!r}; got {result!r}" + ) + + +def test_status_template_present_in_yaml(package: dict) -> None: + """The YAML template must actually contain the plain-English + phrases the pure function emits (catches silent drift between the + pure-function helper and the YAML).""" + sensors = _template_sensors(package) + status = next( + (s for s in sensors if s.get("unique_id") == "rc_tailscale_self_test_status"), + None, + ) + assert status is not None, "missing sensor.rc_tailscale_self_test_status template" + state = status.get("state") or "" + state_lower = state.lower() + must_contain = ( + "type your tunnel address", + "checking your tunnel", + "round-trip succeeded", + "hub can't reach itself", + "phone-side callback didn't arrive", + "ready to check your tunnel", + ) + for marker in must_contain: + assert marker in state_lower, ( + f"sensor.rc_tailscale_self_test_status state template is missing " + f"marker {marker!r}; verify the YAML template is in lockstep with " + f"the pure-function helper in this test" + ) + + +def test_status_template_no_operator_jargon(package: dict) -> None: + """The user-facing strings the status template can render MUST + NOT contain operator jargon (no entity IDs in the output, no + bash terms, no upstream-integration names in the user-visible + output). + + Note: the template SOURCE naturally references entity_ids to + compute its output — that's normal in Home Assistant templates. + This test pins the user-facing strings by computing them via + the pure-function helper and asserting each output is jargon- + free. + """ + forbidden = ( + "binary_sensor.", + "input_boolean.", + "input_text.", + "input_datetime.", + "input_button.", + "shell_command.", + "command_line.", + "avahi-daemon", + "zeroconf", + "tskey-", + "magicdns", + ".ts.net", + "curl ", + "bash ", + ) + # Exercise every combination from EXPECTED_STATUS_BRANCHES + + # a few extras (URL configured but never run + URL configured, + # running, with outbound already on). + combos = [ + c for c, _ in EXPECTED_STATUS_BRANCHES + ] + [ + ("https://my-van.ts.net", True, True, False, False, False), + ("https://my-van.ts.net", False, True, False, False, False), + ] + for combo in combos: + result = _status_pure(*combo) + for term in forbidden: + assert term not in result.lower(), ( + f"sensor.rc_tailscale_self_test_status output {result!r} " + f"(combo={combo}) contains operator jargon {term!r}; " + f"user-facing copy must be plain English" + ) + + +# ---------------------------------------------------------------------------- +# (f) Idempotency — running PyYAML twice produces the same dict +# ---------------------------------------------------------------------------- + + +def test_yaml_idempotent(package: dict) -> None: + """Parse the YAML twice, dump both via yaml.safe_dump, and assert + the two dumps are byte-identical. No random IDs / no timestamps / + no `$` substitutions that would diverge between runs.""" + text = PACKAGE_PATH.read_text(encoding="utf-8") + d1 = yaml.safe_load(text) + d2 = yaml.safe_load(text) + dump1 = yaml.safe_dump(d1, sort_keys=True) + dump2 = yaml.safe_dump(d2, sort_keys=True) + assert dump1 == dump2, ( + "YAML not idempotent — re-parsing produces different output. " + "Possible cause: random IDs, timestamps, or non-deterministic ordering." + ) + + +def test_shell_command_does_not_duplicate_probe(package: dict) -> None: + """Idempotency at the probe layer: there must be exactly ONE + `shell_command.rc_tailscale_self_test_outbound_probe` and ONE + `shell_command.rc_tailscale_self_test_inbound_probe` defined. + Re-pushing the package does NOT duplicate the probes.""" + helpers = _helpers_by_entity_id(package, "shell_command") + assert helpers.get("rc_tailscale_self_test_outbound_probe") is not None + assert helpers.get("rc_tailscale_self_test_inbound_probe") is not None + assert len(helpers) == 2, ( + f"expected exactly 2 shell_command probes; got {len(helpers)}: {list(helpers.keys())}" + ) + + +def test_command_line_sensors_have_distinct_unique_ids(package: dict) -> None: + """All command_line sensors MUST have unique unique_ids (HA's + registry rejects duplicates).""" + sensors = _command_line_sensors(package) + uids = [s.get("unique_id") for s in sensors] + assert len(uids) == len(set(uids)), ( + f"duplicate command_line unique_ids detected: {uids}" + ) + + +def test_automation_run_does_not_clear_tunnel_url(package: dict) -> None: + """The §8.T.1 run automation MUST NOT call + input_text.set_value on rc_tailscale_self_test_tunnel_url (the + operator should be able to re-tap without re-typing the URL).""" + autos = _automations(package) + auto = next(a for a in autos if a.get("id") == "rc_tailscale_self_test_run") + actions_dumped = yaml.safe_dump(auto.get("action") or [], default_flow_style=False) + assert not ( + "input_text.set_value" in actions_dumped + and "rc_tailscale_self_test_tunnel_url" in actions_dumped + ), ( + f"§8.T.1 MUST NOT call input_text.set_value on " + f"rc_tailscale_self_test_tunnel_url (idempotent retry); " + f"got actions={auto.get('action')}" + ) + + +def test_automation_recovery_does_not_clear_tunnel_url(package: dict) -> None: + """The §8.T.2 recovery automation MUST NOT call + input_text.set_value on rc_tailscale_self_test_tunnel_url + (the operator should be able to re-tap without re-typing the URL).""" + autos = _automations(package) + auto = next(a for a in autos if a.get("id") == "rc_tailscale_self_test_recovery") + actions_dumped = yaml.safe_dump(auto.get("action") or [], default_flow_style=False) + assert not ( + "input_text.set_value" in actions_dumped + and "rc_tailscale_self_test_tunnel_url" in actions_dumped + ), ( + f"§8.T.2 MUST NOT call input_text.set_value on " + f"rc_tailscale_self_test_tunnel_url (idempotent retry); " + f"got actions={auto.get('action')}" + ) + + +# ---------------------------------------------------------------------------- +# (g) rc-entity-naming compliance — every entity_id starts with +# `rc_tailscale_self_test_` or `rc_tailscale_run_self_test`. +# ---------------------------------------------------------------------------- + + +ALLOWED_ENTITY_ID_PREFIXES = ( + "rc_tailscale_self_test_", + "rc_tailscale_run_self_test", # the operator-facing button +) + + +def _all_entity_ids(package: dict) -> list[str]: + ids: list[str] = [] + for kind in ( + "input_select", + "input_text", + "input_boolean", + "input_number", + "input_datetime", + "input_button", + "shell_command", + ): + for eid in (_helpers_by_entity_id(package, kind).keys()): + ids.append(eid) + return ids + + +def test_entity_ids_comply_with_rc_naming(package: dict) -> None: + """Every helper entity_id MUST start with one of the + `rc_tailscale_self_test_` / `rc_tailscale_run_self_test` + prefixes (per docs/reference/rc-entity-naming.md).""" + eids = _all_entity_ids(package) + assert eids, "no entity_ids found in the package" + for eid in eids: + assert any(eid.startswith(p) for p in ALLOWED_ENTITY_ID_PREFIXES), ( + f"entity_id {eid!r} violates rc-naming; must start with one of " + f"{ALLOWED_ENTITY_ID_PREFIXES!r}" + ) + + +def test_command_line_sensors_comply_with_rc_naming(package: dict) -> None: + """Every command_line sensor's unique_id MUST start with + `rc_tailscale_self_test_`.""" + sensors = _command_line_sensors(package) + uids = [s.get("unique_id") for s in sensors] + assert uids, "no command_line sensors found" + for uid in uids: + assert uid.startswith("rc_tailscale_self_test_"), ( + f"command_line unique_id {uid!r} violates rc-naming; " + f"must start with 'rc_tailscale_self_test_'" + ) + + +def test_template_binary_sensors_comply_with_rc_naming(package: dict) -> None: + """Every template binary_sensor's unique_id MUST start with + `rc_tailscale_self_test_`.""" + bss = _template_binary_sensors(package) + uids = [bs.get("unique_id") for bs in bss] + assert uids, "no template binary_sensors found" + for uid in uids: + assert uid.startswith("rc_tailscale_self_test_"), ( + f"template binary_sensor unique_id {uid!r} violates rc-naming; " + f"must start with 'rc_tailscale_self_test_'" + ) + + +def test_template_sensors_comply_with_rc_naming(package: dict) -> None: + """Every template sensor's unique_id MUST start with + `rc_tailscale_self_test_`.""" + sensors = _template_sensors(package) + uids = [s.get("unique_id") for s in sensors] + assert uids, "no template sensors found" + for uid in uids: + assert uid.startswith("rc_tailscale_self_test_"), ( + f"template sensor unique_id {uid!r} violates rc-naming; " + f"must start with 'rc_tailscale_self_test_'" + ) + + +def test_automation_ids_comply_with_rc_naming(package: dict) -> None: + """Every automation's `id` MUST start with `rc_tailscale_self_test_` + or `rc_tailscale_run_self_test`.""" + autos = _automations(package) + ids = [a.get("id") for a in autos] + assert ids, "no automations found" + for aid in ids: + assert any(aid.startswith(p) for p in ALLOWED_ENTITY_ID_PREFIXES), ( + f"automation id {aid!r} violates rc-naming; must start with one of " + f"{ALLOWED_ENTITY_ID_PREFIXES!r}" + ) + + +# ---------------------------------------------------------------------------- +# (h) No secrets in YAML — grep for tskey- or any tailnet auth-key +# pattern, plus hard-coded IPs. +# ---------------------------------------------------------------------------- + + +SECRET_PATTERNS = ( + re.compile(r"tskey-[A-Za-z0-9_-]{10,}"), + re.compile(r"tskey-api-[A-Za-z0-9_-]{10,}"), + re.compile(r"ts-auth-[A-Za-z0-9_-]{10,}"), + # Also grep for hard-coded IPv4 fallbacks (could leak operator network info) + re.compile(r"\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"), + re.compile(r"\b192\.168\.\d{1,3}\.\d{1,3}\b"), + re.compile(r"\b172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b"), +) + + +def test_no_secrets_in_yaml() -> None: + text = PACKAGE_PATH.read_text(encoding="utf-8") + for pat in SECRET_PATTERNS: + matches = pat.findall(text) + assert not matches, ( + f"secret pattern {pat.pattern!r} found in YAML: {matches[:3]} " + f"— operator credentials / IPs MUST NOT be committed" + ) + + +# ---------------------------------------------------------------------------- +# (i) IKEA doc 5-step shape — docs/setup/tailscale-self-test.md +# exists, opens with one plain-English sentence, has 5 numbered +# sections, contains the operator→vanlifer translation table. +# ---------------------------------------------------------------------------- + + +def test_ikea_doc_exists() -> None: + assert DOC_PATH.is_file(), f"missing IKEA doc at {DOC_PATH}" + + +def test_ikea_doc_opens_with_plain_english() -> None: + """The doc must open with one plain-English sentence (no YAML + jargon, no 'this slice' wording, no 'Wave 9' labels).""" + text = DOC_PATH.read_text(encoding="utf-8") + lines = text.splitlines() + first_content = next( + (line.strip() for line in lines[1:] if line.strip() and not line.strip().startswith("#")), + "", + ) + assert first_content, "doc has no first content paragraph" + forbidden_openers = ( + "this slice", + "wave 9", + "wave", + "tier-a", + "tier-b", + "tier-c", + "yaml", + "input_boolean", + "binary_sensor", + "rc_tailscale_self_test_", + "homeassistant/packages/", + "scripts/check.sh", + "pr #", + "commit ", + "branch ", + "lint-pass", + "apple-grade", + ) + lower = first_content.lower() + for term in forbidden_openers: + assert term not in lower, ( + f"IKEA doc opener {first_content!r} contains operator jargon {term!r}; " + f"first sentence must be plain English a vanlifer would understand" + ) + + +def test_ikea_doc_has_five_numbered_sections() -> None: + """The IKEA doc MUST have exactly five numbered sections + (## 1 / 2 / 3 / 4 / 5).""" + text = DOC_PATH.read_text(encoding="utf-8") + section_lines = [ + line for line in text.splitlines() + if re.match(r"^##\s+\d+\.\s+", line.strip()) + ] + section_numbers = [ + int(re.match(r"^##\s+(\d+)\.", line.strip()).group(1)) + for line in section_lines + ] + assert section_numbers == [1, 2, 3, 4, 5], ( + f"IKEA doc must have exactly 5 numbered sections (## 1 / 2 / 3 / 4 / 5); " + f"got {section_numbers}" + ) + + +def test_ikea_doc_has_translation_table() -> None: + """The IKEA doc MUST contain an operator→vanlifer translation + table somewhere (the doctrine block requires it).""" + text = DOC_PATH.read_text(encoding="utf-8") + lower = text.lower() + assert "operator" in lower, "IKEA doc missing 'operator' translation table" + assert any( + marker in lower + for marker in ("you might call it", "you'd call it", "what you see", "what this means") + ), "IKEA doc missing plain-English translation explanation" + + +def test_ikea_doc_no_supersede_banner() -> None: + """No 'SUPERSEDED' banner in user-facing tree (per doctrine).""" + text = DOC_PATH.read_text(encoding="utf-8") + assert "SUPERSEDED" not in text, "IKEA doc must not contain a SUPERSEDED banner" + assert "CRON-HANDOFF" not in text.upper(), "IKEA doc must not mention Cron-handoff" + + +def test_ikea_doc_answers_all_four_questions() -> None: + """The IKEA doc MUST answer all four questions: + - what it does (section 1: What this is) + - what you see (section 2) + - what you do (section 3) + - what to do if it goes wrong (section 4) + Plus section 5 (Useful links). Each section must be non-empty.""" + text = DOC_PATH.read_text(encoding="utf-8") + # Split by numbered section headings + section_pattern = re.compile(r"^##\s+\d+\.\s+(.+?)$", re.MULTILINE) + matches = list(section_pattern.finditer(text)) + assert len(matches) >= 5, f"expected at least 5 sections; got {len(matches)}" + # Verify section titles cover the required topics + section_titles = [m.group(1).strip().lower() for m in matches] + required_topics = ["what this is", "what you see", "what you do", "if it goes wrong", "useful links"] + for topic in required_topics: + assert any(topic in t for t in section_titles), ( + f"IKEA doc missing section covering {topic!r}; got section titles={section_titles}" + ) + + +# ---------------------------------------------------------------------------- +# (j) §8.T.1 idempotency — re-running the self-test does NOT reset +# the tunnel URL. The run automation has no `input_text.set_value` +# targeting `rc_tailscale_self_test_tunnel_url`. (Covered above in +# `test_automation_run_does_not_clear_tunnel_url`, but also pin the +# positive assertion here: the run automation DOES turn off the +# running flag + DOES stamp last_run, so re-runs have observable +# side-effects without losing operator-entered data.) +# ---------------------------------------------------------------------------- + + +def test_run_automation_observable_side_effects(package: dict) -> None: + """§8.T.1 MUST clear the running flag (turn_off) and stamp + last_run (input_datetime.set_datetime) so re-runs have + observable side-effects without losing operator data.""" + autos = _automations(package) + auto = next(a for a in autos if a.get("id") == "rc_tailscale_self_test_run") + actions_dumped = yaml.safe_dump(auto.get("action") or [], default_flow_style=False) + assert "input_boolean.turn_off" in actions_dumped, ( + f"§8.T.1 MUST turn_off the running flag; got actions={auto.get('action')}" + ) + assert "input_datetime.set_datetime" in actions_dumped, ( + f"§8.T.1 MUST stamp last_run; got actions={auto.get('action')}" + ) + + +# ---------------------------------------------------------------------------- +# (k) §8.T.3 idempotency — wizard advance only fires when +# rc_setup_stage == 'networking', so it's a no-op if the stage is +# already past (e.g. the existing remote-access wizard already +# advanced it). The condition pins this. +# ---------------------------------------------------------------------------- + + +def test_wizard_advance_advances_to_map_not_done(package: dict) -> None: + """§8.T.3 MUST advance to 'map' (the next setup stage after + networking), NOT to 'done'. Going to 'done' would skip the + rest of the wizard.""" + autos = _automations(package) + auto = next(a for a in autos if a.get("id") == "rc_tailscale_self_test_wizard_advance") + actions_dumped = yaml.safe_dump(auto.get("action") or [], default_flow_style=False) + assert "map" in actions_dumped, ( + f"§8.T.3 MUST advance to 'map'; got actions={auto.get('action')}" + ) + # Verify the option selected is 'map', not 'done' (search for + # the option: 'map' pattern specifically). + assert "option: map" in actions_dumped, ( + f"§8.T.3 MUST select 'map' as the new option; got actions={auto.get('action')}" + ) + + +# ---------------------------------------------------------------------------- +# (l) Shell command probe timeouts — both probes have 10-second +# timeouts so a stalled tunnel never blocks the dashboard. +# ---------------------------------------------------------------------------- + + +def test_shell_command_probes_have_10s_timeout(package: dict) -> None: + """Both shell_command probes MUST have a 10-second timeout + (HA's standard `timeout 10 ...`) so a stalled tunnel doesn't + block the dashboard.""" + helpers = _helpers_by_entity_id(package, "shell_command") + for eid in REQUIRED_SHELL_COMMANDS: + cmd = helpers.get(eid) + assert cmd is not None, f"missing shell_command {eid!r}" + assert "timeout 10" in cmd, ( + f"shell_command {eid!r} MUST use 'timeout 10 ...' to bound probe latency; " + f"got cmd={cmd!r}" + ) diff --git a/scripts/check.sh b/scripts/check.sh index 08ce3f1f..fb6cb880 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -76,6 +76,7 @@ run_if_present "scripts/checks/advanced-mode-smoke.sh" "Advanced mode: s run_if_present "scripts/checks/automation-apply-smoke.sh" "Automation apply: smoke check" run_if_present "scripts/checks/mode-builder-smoke.sh" "Mode builder: smoke check" run_if_present "scripts/checks/remote-access-setup-smoke.sh" "Remote access setup wizard (Tailscale Path A): smoke check" +run_if_present "scripts/checks/tailscale-self-test-smoke.sh" "Tailscale self-test (Phase 6 — Wave 9 #122.d.iii) — round-trip HA → tunnel → phone → tunnel → HA: smoke check (10 bash assertions + pytest contract)" # Connection manifest smokes live under connections//tests/. We probe # for the well-known names so the chain picks them up automatically once diff --git a/scripts/checks/tailscale-self-test-smoke.sh b/scripts/checks/tailscale-self-test-smoke.sh new file mode 100755 index 00000000..62ef06d0 --- /dev/null +++ b/scripts/checks/tailscale-self-test-smoke.sh @@ -0,0 +1,516 @@ +#!/usr/bin/env bash +# scripts/checks/tailscale-self-test-smoke.sh +# +# Wave 9 #122.d.iii — Phase 6 Tailscale wizard (sub-slice D.iii: +# connectivity self-test HA → tunnel → phone → tunnel → HA +# round-trip). Repo-local verification of the self-test package + +# pytest rig + IKEA doc. Mirrors the convention in +# scripts/checks/.sh: +# - bash strict mode (set -euo pipefail) +# - repo-local only (no live HA / Proxmox / OpenWrt calls) +# - wrapper around the pytest rig that guards the data layer +# - 10 standalone bash assertions that don't need pytest +# - plain-English summary at exit 0 / non-zero exit +# +# This is a static check on the repo: nothing touches HA at runtime. +# Idempotent — safe to run repeatedly. +# +# Usage: +# bash scripts/checks/tailscale-self-test-smoke.sh +# +# Exit codes: +# 0 all package YAML helpers + automations are present + named right + +# pytest rig is green + no secrets leaked + idempotent (PASS) +# 1 one or more checks failed (FAIL — see summary above) +# +# Wired into scripts/check.sh as a `run_if_present` step in the +# core-only chain (next to the other package-layer smokes like +# remote-access-setup-smoke.sh + local-mdns-fallback-smoke.sh). + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +PACKAGE="$ROOT_DIR/homeassistant/packages/roamcore_tailscale_self_test.yaml" +PYTEST="$ROOT_DIR/homeassistant/packages/tests/test_tailscale_self_test.py" +DOC="$ROOT_DIR/docs/setup/tailscale-self-test.md" + +fail=0 +pass=0 + +note_pass() { printf ' \033[1;32m✓\033[0m %s\n' "$1"; pass=$((pass+1)); } +note_fail() { printf ' \033[1;31m✗\033[0m %s\n' "$1"; fail=$((fail+1)); } + +echo +echo "▶ Tailscale self-test: file presence" + +if [ -f "$PACKAGE" ]; then + note_pass "package exists: ${PACKAGE#$ROOT_DIR/}" +else + note_fail "missing package: ${PACKAGE#$ROOT_DIR/}" + echo + echo "Summary" + echo "=======" + printf ' PASS: %d\n' "$pass" + printf ' FAIL: %d\n' "$fail" + printf '\n\033[1;31m✗ tailscale-self-test smoke FAILED (missing package)\033[0m\n' + exit 1 +fi + +if [ -f "$PYTEST" ]; then + note_pass "pytest rig exists: ${PYTEST#$ROOT_DIR/}" +else + note_fail "missing pytest rig: ${PYTEST#$ROOT_DIR/}" +fi + +if [ -f "$DOC" ]; then + note_pass "IKEA doc exists: ${DOC#$ROOT_DIR/}" +else + note_fail "missing IKEA doc: ${DOC#$ROOT_DIR/}" +fi + +echo +echo "▶ Tailscale self-test: YAML pre-check (PyYAML parse)" + +if python3 -c "import yaml,sys; yaml.safe_load(open('$PACKAGE'))" 2>/dev/null; then + note_pass "YAML parses" +else + note_fail "YAML parse error — see python3 output above" +fi + +echo +echo "▶ Tailscale self-test: rc-entity-naming pre-check" + +# Every helper entity_id MUST start with one of the allowed prefixes. +naming_violations=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +allowed = ("rc_tailscale_self_test_", "rc_tailscale_run_self_test") +violations = [] +for kind in ("input_select", "input_text", "input_boolean", "input_number", "input_datetime", "input_button", "shell_command"): + for eid in (data.get(kind) or {}).keys(): + if not any(eid.startswith(p) for p in allowed): + violations.append((kind, eid)) +# command_line + template sensors + automation ids +for entry in (data.get("command_line") or []): + for s in (entry.get("sensor") or []): + uid = s.get("unique_id") or "" + if not any(uid.startswith(p) for p in allowed): + violations.append(("command_line_sensor", uid)) +for entry in (data.get("template") or []): + for bs in (entry.get("binary_sensor") or []): + uid = bs.get("unique_id") or "" + if not any(uid.startswith(p) for p in allowed): + violations.append(("template_binary_sensor", uid)) + for s in (entry.get("sensor") or []): + uid = s.get("unique_id") or "" + if not any(uid.startswith(p) for p in allowed): + violations.append(("template_sensor", uid)) +for a in (data.get("automation") or []): + aid = a.get("id") or "" + if not any(aid.startswith(p) for p in allowed): + violations.append(("automation", aid)) +if violations: + for k, eid in violations: + print(f" VIOLATION: {k}.{eid}") + sys.exit(1) +PYEOF +) || true +if [ -z "$naming_violations" ]; then + note_pass "all entity_ids + automation ids comply with docs/reference/rc-entity-naming.md" +else + note_fail "rc-naming violations found" + echo "$naming_violations" +fi + +echo +echo "▶ Tailscale self-test: helpers + automations + templates pre-check" + +# All required helpers, automations, sensors, and binary_sensors +# MUST be present. +helpers_present=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +missing = [] +for kind, ids in ( + ("input_text", ["rc_tailscale_self_test_tunnel_url"]), + ("input_boolean", ["rc_tailscale_self_test_running"]), + ("input_button", ["rc_tailscale_run_self_test"]), + ("input_datetime", ["rc_tailscale_self_test_last_run"]), + ("shell_command", [ + "rc_tailscale_self_test_outbound_probe", + "rc_tailscale_self_test_inbound_probe", + ]), +): + present = set((data.get(kind) or {}).keys()) + for eid in ids: + if eid not in present: + missing.append((kind, eid)) +# Automations +auto_ids = {a.get("id") for a in (data.get("automation") or [])} +for aid in ( + "rc_tailscale_self_test_run", + "rc_tailscale_self_test_recovery", + "rc_tailscale_self_test_wizard_advance", +): + if aid not in auto_ids: + missing.append(("automation", aid)) +# command_line sensors + template sensors + binary_sensors (unique_id) +seen_cl_uids = set() +seen_sensor_uids = set() +seen_bs_uids = set() +for entry in (data.get("command_line") or []): + for s in (entry.get("sensor") or []): + seen_cl_uids.add(s.get("unique_id")) +for entry in (data.get("template") or []): + for s in (entry.get("sensor") or []): + seen_sensor_uids.add(s.get("unique_id")) + for bs in (entry.get("binary_sensor") or []): + seen_bs_uids.add(bs.get("unique_id")) +for uid in ( + "rc_tailscale_self_test_outbound_code", + "rc_tailscale_self_test_expected_nonce_sensor", + "rc_tailscale_self_test_received_nonce_sensor", +): + if uid not in seen_cl_uids: + missing.append(("command_line_sensor", uid)) +for uid in ("rc_tailscale_self_test_status",): + if uid not in seen_sensor_uids: + missing.append(("template_sensor", uid)) +for uid in ( + "rc_tailscale_self_test_outbound_ok", + "rc_tailscale_self_test_inbound_ok", + "rc_tailscale_self_test_ok", + "rc_tailscale_self_test_recovery", +): + if uid not in seen_bs_uids: + missing.append(("template_binary_sensor", uid)) +if missing: + for k, ident in missing: + print(f" MISSING: {k}.{ident}") + sys.exit(1) +PYEOF +) || true +if [ -z "$helpers_present" ]; then + note_pass "all required helpers + automations + template entities present" +else + note_fail "missing required entries:" + echo "$helpers_present" +fi + +echo +echo "▶ Tailscale self-test: status template covers all branches" + +# The status template MUST cover all 6 state combinations with +# the canonical plain-English phrases. +coverage=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +status_sensor = None +for entry in (data.get("template") or []): + for s in (entry.get("sensor") or []): + if s.get("unique_id") == "rc_tailscale_self_test_status": + status_sensor = s + break +state_template = (status_sensor.get("state") or "") if status_sensor else "" +state_lower = state_template.lower() +required_markers = { + "not configured": ["type your tunnel address"], + "checking": ["checking your tunnel"], + "round-trip succeeded": ["round-trip succeeded"], + "outbound failed": ["hub can't reach itself"], + "inbound failed": ["phone-side callback didn't arrive"], + "ready": ["ready to check your tunnel"], +} +uncovered = [] +for label, markers in required_markers.items(): + if not any(m in state_lower for m in markers): + uncovered.append((label, markers)) +if uncovered: + for label, markers in uncovered: + print(f" UNCOVERED: {label!r} missing markers={markers}") + sys.exit(1) +PYEOF +) || true +if [ -z "$coverage" ]; then + note_pass "every (url, running, outbound, inbound, recovery, never_run) combo has a status branch" +else + note_fail "status coverage gap found:" + echo "$coverage" +fi + +echo +echo "▶ Tailscale self-test: status copy has no operator jargon" + +# The rendered status strings MUST NOT contain operator jargon +# (no entity IDs, no bash terms, no upstream-integration names). +# We exercise the 6 canonical combos via a pure-function +# reimplementation pinned by the pytest rig, and assert each +# rendered output is jargon-free. The template SOURCE naturally +# references entity_ids to compute its output — that's normal +# Home Assistant template syntax and is not jargon. +jargon=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml, re + +data = yaml.safe_load(open(sys.argv[1])) +status_sensor = None +for entry in (data.get("template") or []): + for s in (entry.get("sensor") or []): + if s.get("unique_id") == "rc_tailscale_self_test_status": + status_sensor = s + break +state_template = (status_sensor.get("state") or "") if status_sensor else "" +# Find each literal string in the if/elif chain. The branches +# use single-quoted strings ending with a period. +literals = re.findall(r"'([^']{20,250}\.)'", state_template) +literals = [s for s in literals if any( + kw in s.lower() for kw in ( + "type your tunnel", "checking your tunnel", "round-trip", + "hub can't reach", "phone-side callback", "ready to check", + ) +)] +forbidden = ( + "binary_sensor.", "input_boolean.", "input_text.", "input_datetime.", + "input_button.", "shell_command.", "command_line.", + "avahi-daemon", "zeroconf", "tskey-", "magicdns", ".ts.net", "curl ", "bash ", +) +hits = [] +for lit in literals: + for term in forbidden: + if term in lit.lower(): + hits.append((lit, term)) +if hits: + for lit, term in hits: + print(f" JARGON {term!r} in literal: {lit!r}") + sys.exit(1) +PYEOF +) || true +if [ -z "$jargon" ]; then + note_pass "status copy has no operator jargon (entity IDs / bash / integration names)" +else + note_fail "operator jargon in status copy:" + echo "$jargon" +fi + +echo +echo "▶ Tailscale self-test: secrets-leak check" + +# grep for tskey- / ts-auth- / hard-coded IPs — fail if found. +SECRETS=$(grep -E '(tskey-[A-Za-z0-9_-]{10,}|ts-auth-[A-Za-z0-9_-]{10,}|\b192\.168\.\d{1,3}\.\d{1,3}\b|\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|\b172\.(1[6-9]|2[0-9]|3[01])\.\d{1,3}\.\d{1,3}\b)' "$PACKAGE" || true) +if [ -z "$SECRETS" ]; then + note_pass "no secrets (tskey- / ts-auth-) or hard-coded IPs leaked in YAML" +else + note_fail "SECRET / IP PATTERN FOUND in YAML — operator credentials MUST NOT be committed" + echo "$SECRETS" +fi + +echo +echo "▶ Tailscale self-test: idempotency probe (PyYAML twice → same dict + exactly 2 probes)" + +if python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +text = open(sys.argv[1]).read() +d1 = yaml.safe_load(text); d2 = yaml.safe_load(text) +if yaml.safe_dump(d1, sort_keys=True) != yaml.safe_dump(d2, sort_keys=True): + print("NOT IDEMPOTENT — re-parsing diverges") + sys.exit(1) +shell = (d1.get("shell_command") or {}) +if len(shell) != 2: + print(f"WRONG SHELL_COMMAND COUNT — expected 2, got {len(shell)}: {list(shell.keys())}") + sys.exit(1) +PYEOF +then + note_pass "YAML is idempotent + exactly 2 shell_command probes defined (no duplicate probes)" +else + note_fail "YAML is NOT idempotent — random IDs / timestamps / non-deterministic ordering" +fi + +echo +echo "▶ Tailscale self-test: IKEA doc 5-step shape + translation table" + +if python3 - "$DOC" <<'PYEOF' +import sys, re +text = open(sys.argv[1]).read() +# 5 numbered sections +sections = re.findall(r"^##\s+(\d+)\.\s+", text, flags=re.MULTILINE) +if sections != ["1", "2", "3", "4", "5"]: + print(f"NOT 5 SECTIONS — got {sections}") + sys.exit(1) +# Translation table present +lower = text.lower() +if "operator" not in lower: + print("MISSING: 'operator' translation table") + sys.exit(1) +if not any(m in lower for m in ("you might call it", "you'd call it", "what you see", "what this means")): + print("MISSING: plain-English translation explanation") + sys.exit(1) +if "SUPERSEDED" in text or "CRON-HANDOFF" in text.upper(): + print("FOUND: SUPERSEDED / CRON-HANDOFF banner") + sys.exit(1) +# First paragraph must be plain English (no tier / wave / YAML jargon) +lines = text.splitlines() +first_content = next( + (line.strip() for line in lines[1:] if line.strip() and not line.strip().startswith("#")), + "", +) +forbidden_openers = ( + "this slice", "wave 9", "wave", "tier-a", "tier-b", "tier-c", + "yaml", "input_boolean", "binary_sensor", + "homeassistant/packages/", "scripts/check.sh", + "pr #", "commit ", "branch ", "lint-pass", "apple-grade", +) +lower_first = first_content.lower() +for term in forbidden_openers: + if term in lower_first: + print(f"OPENING JARGON {term!r}: {first_content!r}") + sys.exit(1) +PYEOF +then + note_pass "IKEA doc has 5 numbered sections + operator→vanlifer translation table + no SUPERSEDED + plain-English opener" +else + note_fail "IKEA doc 5-step shape check FAILED — see output above" +fi + +echo +echo "▶ Tailscale self-test: §8.T.1 run automation wired correctly (button + event + stage triggers)" + +# §8.T.1 must trigger on the operator button, the parent wizard event, +# AND the wizard stage transition into tailscale_verify. It must NOT +# call input_text.set_value on the tunnel URL helper. +T1=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +auto = next((a for a in (data.get("automation") or []) if a.get("id") == "rc_tailscale_self_test_run"), None) +if not auto: + print("MISSING AUTOMATION") + sys.exit(1) +triggers = auto.get("trigger") or [] +has_button = any( + t.get("platform") == "state" + and t.get("entity_id") == "input_button.rc_tailscale_run_self_test" + for t in triggers +) +has_event = any( + t.get("platform") == "event" + and t.get("event_type") == "rc_run_tailscale_self_test" + for t in triggers +) +has_stage = any( + t.get("platform") == "state" + and t.get("entity_id") == "input_select.rc_remote_access_setup_stage" + and t.get("to") == "tailscale_verify" + for t in triggers +) +if not (has_button and has_event and has_stage): + print(f"MISSING TRIGGER (button={has_button}, event={has_event}, stage={has_stage})") + sys.exit(1) +actions_dumped = yaml.safe_dump(auto.get("action") or [], default_flow_style=False) +if "shell_command.rc_tailscale_self_test_outbound_probe" not in actions_dumped: + print("MISSING outbound probe") + sys.exit(1) +if "shell_command.rc_tailscale_self_test_inbound_probe" not in actions_dumped: + print("MISSING inbound probe") + sys.exit(1) +if "input_boolean.turn_on" not in actions_dumped: + print("MISSING turn_on running flag") + sys.exit(1) +if "input_boolean.turn_off" not in actions_dumped: + print("MISSING turn_off running flag") + sys.exit(1) +if "input_datetime.set_datetime" not in actions_dumped: + print("MISSING set_datetime last_run") + sys.exit(1) +if "00:00:30" not in actions_dumped: + print("MISSING 30s wait") + sys.exit(1) +# Idempotency: MUST NOT call input_text.set_value on tunnel URL +if "input_text.set_value" in actions_dumped and "rc_tailscale_self_test_tunnel_url" in actions_dumped: + print("CLEARS TUNNEL URL — idempotent retry broken") + sys.exit(1) +PYEOF +) || true +if [ -z "$T1" ]; then + note_pass "§8.T.1 wired (button + event + stage triggers + both probes + 30s wait + idempotent)" +else + note_fail "§8.T.1 wiring check FAILED:" + echo "$T1" +fi + +echo +echo "▶ Tailscale self-test: §8.T.2 recovery automation wired correctly (60s timeout + plain-English)" + +# §8.T.2 must trigger on the running flag being on for 60s, fire a +# persistent_notification with plain-English copy, and NOT clear the +# tunnel URL helper (idempotent retry). +T2=$(python3 - "$PACKAGE" <<'PYEOF' +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +auto = next((a for a in (data.get("automation") or []) if a.get("id") == "rc_tailscale_self_test_recovery"), None) +if not auto: + print("MISSING AUTOMATION") + sys.exit(1) +triggers = auto.get("trigger") or [] +if not any( + t.get("platform") == "state" + and t.get("entity_id") == "input_boolean.rc_tailscale_self_test_running" + and t.get("to") == "on" + and t.get("for") == "00:01:00" + for t in triggers +): + print("MISSING 60s timeout trigger on running flag") + sys.exit(1) +actions_dumped = yaml.safe_dump(auto.get("action") or [], default_flow_style=False) +if "persistent_notification.create" not in actions_dumped: + print("MISSING persistent_notification.create") + sys.exit(1) +lower = actions_dumped.lower() +if "tunnel" not in lower: + print("MISSING 'tunnel' in plain-English copy") + sys.exit(1) +if "wizard" not in lower and "van" not in lower: + print("MISSING 'wizard' or 'van' in plain-English copy") + sys.exit(1) +# Idempotency: MUST NOT clear the tunnel URL +if "input_text.set_value" in actions_dumped and "rc_tailscale_self_test_tunnel_url" in actions_dumped: + print("CLEARS TUNNEL URL — idempotent retry broken") + sys.exit(1) +PYEOF +) || true +if [ -z "$T2" ]; then + note_pass "§8.T.2 wired (60s timeout + plain-English persistent_notification + idempotent)" +else + note_fail "§8.T.2 wiring check FAILED:" + echo "$T2" +fi + +echo +echo "▶ Tailscale self-test: pytest rig (test_tailscale_self_test.py)" + +# Temporarily disable `set -e` so we can capture pytest's exit code +# instead of the script aborting on the first failure. The pytest +# output is still streamed to the operator's terminal. +set +e +python3 -m pytest "$PYTEST" --tb=short -q 2>&1 | tail -20 +PYTEST_EXIT=${PIPESTATUS[0]} +set -e + +if [ "$PYTEST_EXIT" -eq 0 ]; then + note_pass "pytest rig green (test_tailscale_self_test.py)" +else + note_fail "pytest rig FAILED (exit=$PYTEST_EXIT) — see output above" +fi + +echo +echo "Summary" +echo "=======" +printf ' PASS: %d\n' "$pass" +printf ' FAIL: %d\n' "$fail" + +if [ "$fail" -gt 0 ]; then + printf '\n\033[1;31m✗ tailscale-self-test smoke FAILED\033[0m\n' + exit 1 +fi + +printf '\n\033[1;32m✓ tailscale-self-test smoke PASSED\033[0m\n' +exit 0