diff --git a/build/dependencies.lock b/build/dependencies.lock index a1e7632c5..6f8144ba9 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 331 file_count = 2130 -byte_count = 314646675 +byte_count = 314651736 source_build_components = 227 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 45b03a183..8b1e21a4d 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -995,8 +995,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi-profiles", "file_count": 1, - "byte_count": 9043, - "tree_sha256": "269e3600718c2d1c88c3083c9a751ad572d2de9e5e12d50aa1883a333be74c13", + "byte_count": 14104, + "tree_sha256": "07502370e9ac8bc9a6df02a732bdf60ecf362f620641b0f659c734a40ff6552a", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index a516f7175..60fed660f 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -47,7 +47,7 @@ shoulder button. | Settings | One sectioned surface; inline values; capability filtering; one-layer details; hidden Developer section; optional overlapping L1/R1 list paging | | Quick Settings | START from core surfaces; immediate brightness/volume/mute; capability-gated Wi-Fi; readable battery; B/START close | | Network services | Desired/runtime separation; carrier without IPv4 remains acquiring/not-ready; canonical DHCP restart and bounded post-address listener reconciliation; SSH shell and SFTP independent key-only states; canonical transfer roots and modes; Saves read-only; private roots denied; traversal/symlink/path bounds; same-filesystem atomic publication; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | -| Wi-Fi profile store | JFFS2 private root; stdin-only bounded replacement; schema/unknown-field/count/size enforcement; WPA2-PSK, explicit open, and manual hidden WPA2; WEP and hidden open denial; unique IDs/SSIDs; redacted status; mode-0600 atomic single-profile supplicant projection; symlink, occupied-slot, live-lock, invalid-profile, and prior-byte preservation | +| Wi-Fi profile store | JFFS2 private root; stdin-only bounded replacement and single-profile upsert; requested/document ID binding; inactive-ID deletion with active-profile refusal; schema/unknown-field/count/size enforcement; WPA2-PSK, explicit open, and manual hidden WPA2; WEP and hidden open denial; unique IDs/SSIDs; redacted status; mode-0600 atomic single-profile supplicant projection; symlink, occupied-slot, live-lock, invalid-profile, and prior-byte preservation | | GameSwitcher | MENU open/toggle; Left/Right select; L1/R1 page; A resume; X actions; START Quick Settings; B Home; no inherited brightness/view/delete behavior | | Apps/Tweaks | Every app disposition recorded; normal configuration requires no Tweaks; supervised launch remains bounded | | Persistent state | Versioned atomic publication; exact restoration across Details/game/GameSwitcher/Quick Settings; malformed/oversized/symlink input defaults without Safe Mode | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9abdcd88b..63f1fb24e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -288,6 +288,13 @@ profile ID, the service validates the private store again, writes one `update_config=0` supplicant network to a mode-0600 same-directory candidate, synchronizes it, and atomically replaces `/appconfigs/wpa_supplicant.conf`. The previous runtime configuration remains authoritative until that rename. +NET-04 extends the same locked authority with stdin-only `upsert ID` and +non-secret `delete ID` operations. Upsert binds the profile document's ID to +the requested handle and republishes the complete validated store without +returning profile data. Delete refuses the currently projected ID, identified +by a non-secret managed-config marker, so forgetting a profile cannot silently +strand or mutate the active station. Active-profile replacement and removal +remain the responsibility of the later rollback-bounded association slice. The service never scans, associates, changes the Wi-Fi preference, or restarts the radio; those behaviors remain with later NET-04 API work and the existing `bloom-network`/`bloom-wifi` owners. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2fbf1e8d2..f4a675f7d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -271,15 +271,19 @@ received on stdin; credentials are never command arguments: ```sh bloom-wifi-profiles replace < synthetic-profiles.json +bloom-wifi-profiles upsert fixture-profile-id < synthetic-profile.json +bloom-wifi-profiles delete inactive-fixture-profile-id bloom-wifi-profiles status --json bloom-wifi-profiles project fixture-profile-id ``` -`replace` is bounded to 16 profiles and 16 KiB. `project` accepts only the -non-secret stable ID, emits no SSID or credential, and writes a single managed -supplicant configuration through a mode-0600 same-directory candidate and -atomic rename. Neither operation starts/stops Wi-Fi or associates to a -network. Run the focused gate with the pinned shell-test image: +`replace` and `upsert` are bounded to 16 profiles and 16 KiB. `upsert` binds +the stdin document to the requested non-secret ID and republishes the complete +validated store. `delete` refuses the currently projected ID. `project` +accepts only the non-secret stable ID, emits no SSID or credential, and writes +a single managed supplicant configuration through a mode-0600 same-directory +candidate and atomic rename. None of these operations starts/stops Wi-Fi or +associates to a network. Run the focused gate with the pinned shell-test image: ```sh ./tools/run-shell-tests.sh "$PWD" "$PINNED_SHELL_IMAGE" 1 \ @@ -289,8 +293,9 @@ network. Run the focused gate with the pinned shell-test image: Do not copy a live `/appconfigs/wpa_supplicant.conf` into the repository, fixture output, shell history, logs, screenshots, or evidence. Device preflight may inspect only filesystem type, mode, byte count, structural counts, and -redacted health. A future NET-04 connect/forget surface must retain stdin or an -equivalent fixed private input boundary and the existing rollback rules. +redacted health. The remaining NET-04 scan/connect orchestration and active +profile removal must retain stdin or an equivalent fixed private input +boundary and the existing rollback rules. An activated update is not considered successful merely because the installer or MainUI started. The runtime records each unconfirmed boot in durable diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c7c88a648..7485c8c6f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -95,5 +95,11 @@ single-network mode-0600 configuration with `update_config=0`, preventing selection, hidden open profiles, unsafe links, duplicate identities, control characters, oversized stores, occupied atomic slots, and unknown fields are rejected without replacing the prior store or runtime configuration. +Incremental profile mutation preserves that boundary: `upsert ID` receives one +profile only on stdin, requires its document ID to match the non-secret command +handle, revalidates the resulting complete store, and publishes atomically. +`delete ID` returns only a redacted count and refuses the ID marked as active in +the managed supplicant projection. Neither operation scans, associates, +restarts Wi-Fi, or exposes an SSID or credential. Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation. diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 9e7e6aaec..d8fa40d83 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -198,7 +198,9 @@ "title": "Establish clean recovery workspace and current ledger", "status": "merged", "dependencies": [], - "github_prs": [], + "github_prs": [ + 487 + ], "risk_tier": 0, "deployment_tier": "host", "device_gate_required": false, @@ -827,7 +829,7 @@ "id": "NET-04", "track": "Network", "title": "Bounded scan/connect/forget API", - "status": "planned", + "status": "in_progress", "dependencies": [ "NET-03" ], @@ -835,8 +837,12 @@ "risk_tier": 2, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "3a7e78290285806e1ce3eb0c126887644ff665f2", - "evidence": [] + "last_audited_main": "fca10bb0168d8bc38737881745b68cd3bab9da59", + "evidence": [ + "issue #486 tracks NET-04 after NET-03 merged as fca10bb0168d8bc38737881745b68cd3bab9da59; no prior NET-04 issue or implementation PR exists and current main has no bounded scan/connect/forget orchestration", + "draft PR #487 is the first host-safe NET-04 slice and extends the private NET-03 authority with stdin-only atomic single-profile upsert, requested/document ID binding, inactive-profile deletion, a non-secret projected-profile marker, and fail-closed refusal to replace or forget the active profile; 16/16 focused tests passed in 4.8 seconds with exact 314651736-byte dependency preflight, and the slice does not scan, associate, restart Wi-Fi, change preferences, or complete NET-04", + "exact head b6e96eebd08df0eb8b75867b8e341f3e7a76a830 passed risk-scoped shell workflow 33171784731 with 1m29s test execution; its exact 14104-byte helper with SHA-256 5fd88c120a0a4a979a890865a5ac06e7dbeb7e50ffcd99ce08cfa389e637ecc7 was transferred through strict key-only SSH and exercised only under /tmp/bloom-net04a-fixture-b6e96eeb with synthetic profiles, where active deletion failed closed, inactive deletion succeeded, root/store/config modes were 0700/0600/0600, live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef remained unchanged, temporary state was removed, and aggregate device health remained green" + ] }, { "id": "NET-05", diff --git a/static/build/.tmp_update/bin/bloom-wifi-profiles b/static/build/.tmp_update/bin/bloom-wifi-profiles index b3a6bc618..7a34fdae5 100755 --- a/static/build/.tmp_update/bin/bloom-wifi-profiles +++ b/static/build/.tmp_update/bin/bloom-wifi-profiles @@ -13,12 +13,14 @@ MAX_PROFILES=16 LOCKED=0 TEMP_STORE= TEMP_CONFIG= +TEMP_INPUT= fail() { printf 'bloom-wifi-profiles: %s\n' "$*" >&2; exit 1; } cleanup() { [ -z "$TEMP_STORE" ] || rm -f "$TEMP_STORE" 2>/dev/null || true [ -z "$TEMP_CONFIG" ] || rm -f "$TEMP_CONFIG" 2>/dev/null || true + [ -z "$TEMP_INPUT" ] || rm -f "$TEMP_INPUT" 2>/dev/null || true if [ "$LOCKED" -eq 1 ]; then rm -f "$LOCK/owner" 2>/dev/null || true rmdir "$LOCK" 2>/dev/null || true @@ -35,6 +37,16 @@ safe_profile_id() { [ "${#1}" -le 32 ] } +config_has_profile() { + expected_id=$1 + if [ -e "$CONFIG" ] || [ -L "$CONFIG" ]; then + [ -f "$CONFIG" ] && [ ! -L "$CONFIG" ] || fail "supplicant configuration is unsafe" + grep -Fqx "# bloom-profile-id=$expected_id" "$CONFIG" + return + fi + return 1 +} + ensure_root() { parent=${ROOT%/*} [ -n "$parent" ] || parent=/ @@ -94,6 +106,48 @@ validate_store() { ' "$candidate" >/dev/null 2>&1 } +receive_bounded_input() { + destination=$1 + [ ! -e "$destination" ] && [ ! -L "$destination" ] || fail "profile input slot is occupied" + TEMP_INPUT="$destination" + (umask 077; "$DD" of="$TEMP_INPUT" bs=$((MAX_STORE_BYTES + 1)) count=1 2>/dev/null) || fail "cannot receive profile input" + bytes=$(wc -c <"$TEMP_INPUT" | tr -d ' ') + case "$bytes" in ''|*[!0-9]*) fail "profile input size is invalid" ;; esac + [ "$bytes" -gt 0 ] && [ "$bytes" -le "$MAX_STORE_BYTES" ] || fail "profile input exceeds the bounded size" +} + +validate_single_profile() { + candidate=$1 + expected_id=$2 + [ -f "$candidate" ] && [ ! -L "$candidate" ] || return 1 + "$JQ" -e --arg id "$expected_id" ' + type == "object" and + .id == $id and + (.id | type == "string" and test("^[a-z0-9][a-z0-9._-]{0,31}$")) and + (.ssid | type == "string" and test("^[ -~]{1,32}$")) and + (.security == "wpa2-psk" or .security == "open") and + (.hidden | type == "boolean") and + (if .security == "wpa2-psk" then + (keys | sort) == ["hidden", "id", "psk", "security", "ssid"] and + (.psk | type == "string" and + (test("^[ -~]{8,63}$") or test("^[0-9A-Fa-f]{64}$"))) + else + (keys | sort) == ["hidden", "id", "security", "ssid"] and + .hidden == false + end) + ' "$candidate" >/dev/null 2>&1 +} + +publish_store_candidate() { + candidate=$1 + validate_store "$candidate" || fail "profile store is invalid" + chmod 600 "$candidate" || fail "cannot protect profile store" + sync_filesystem + mv -f "$candidate" "$STORE" || fail "cannot atomically publish profile store" + TEMP_STORE= + sync_filesystem +} + sync_filesystem() { [ -x "$SYNC" ] || fail "durability helper is unavailable" "$SYNC" || fail "cannot synchronize profile state" @@ -113,15 +167,72 @@ replace_store() { case "$bytes" in ''|*[!0-9]*) fail "profile-store size is invalid" ;; esac [ "$bytes" -gt 0 ] && [ "$bytes" -le "$MAX_STORE_BYTES" ] || fail "profile store exceeds the bounded size" validate_store "$TEMP_STORE" || fail "profile store is invalid" - chmod 600 "$TEMP_STORE" || fail "cannot protect profile store" - sync_filesystem - mv -f "$TEMP_STORE" "$STORE" || fail "cannot atomically publish profile store" - TEMP_STORE= - sync_filesystem + publish_store_candidate "$TEMP_STORE" count=$("$JQ" -r '.profiles | length' "$STORE") || fail "cannot read published profile store" printf '{"schema":1,"service":"bloom-wifi-profiles","operation":"replace","published":true,"profile_count":%s}\n' "$count" } +upsert_profile() { + profile_id=$1 + safe_profile_id "$profile_id" || fail "profile id is invalid" + ensure_root + acquire_lock + if [ -e "$STORE" ] || [ -L "$STORE" ]; then + [ -f "$STORE" ] && [ ! -L "$STORE" ] || fail "profile store is unsafe" + validate_store "$STORE" || fail "profile store is invalid" + fi + + profile_input="$STORE.profile-incoming" + receive_bounded_input "$profile_input" + validate_single_profile "$TEMP_INPUT" "$profile_id" || fail "profile input is invalid" + + candidate_store="$STORE.incoming" + [ ! -e "$candidate_store" ] && [ ! -L "$candidate_store" ] || fail "profile-store replacement slot is occupied" + TEMP_STORE="$candidate_store" + replaced=false + if [ -f "$STORE" ]; then + matches=$("$JQ" -r --arg id "$profile_id" '[.profiles[] | select(.id == $id)] | length' "$STORE") || fail "cannot read profile store" + [ "$matches" -le 1 ] || fail "profile store is invalid" + [ "$matches" -eq 0 ] || replaced=true + if [ "$replaced" = true ] && config_has_profile "$profile_id"; then + fail "active profile cannot be replaced" + fi + (umask 077; "$JQ" --slurpfile profile "$TEMP_INPUT" --arg id "$profile_id" \ + '.profiles = ((.profiles | map(select(.id != $id))) + [$profile[0]])' \ + "$STORE" >"$TEMP_STORE") || fail "cannot build profile store" + else + (umask 077; "$JQ" -n --slurpfile profile "$TEMP_INPUT" \ + '{schema:1,profiles:[$profile[0]]}' >"$TEMP_STORE") || fail "cannot build profile store" + fi + publish_store_candidate "$TEMP_STORE" + rm -f "$TEMP_INPUT" || fail "cannot remove profile input" + TEMP_INPUT= + count=$("$JQ" -r '.profiles | length' "$STORE") || fail "cannot read published profile store" + printf '{"schema":1,"service":"bloom-wifi-profiles","operation":"upsert","published":true,"replaced":%s,"profile_count":%s}\n' "$replaced" "$count" +} + +delete_profile() { + profile_id=$1 + safe_profile_id "$profile_id" || fail "profile id is invalid" + ensure_root + acquire_lock + validate_store "$STORE" || fail "profile store is invalid" + matches=$("$JQ" -r --arg id "$profile_id" '[.profiles[] | select(.id == $id)] | length' "$STORE") || fail "cannot read profile store" + [ "$matches" -eq 1 ] || fail "profile is unavailable" + if config_has_profile "$profile_id"; then + fail "active profile cannot be forgotten" + fi + + candidate_store="$STORE.incoming" + [ ! -e "$candidate_store" ] && [ ! -L "$candidate_store" ] || fail "profile-store replacement slot is occupied" + TEMP_STORE="$candidate_store" + (umask 077; "$JQ" --arg id "$profile_id" \ + '.profiles = [.profiles[] | select(.id != $id)]' "$STORE" >"$TEMP_STORE") || fail "cannot build profile store" + publish_store_candidate "$TEMP_STORE" + count=$("$JQ" -r '.profiles | length' "$STORE") || fail "cannot read published profile store" + printf '{"schema":1,"service":"bloom-wifi-profiles","operation":"delete","deleted":true,"profile_count":%s}\n' "$count" +} + store_status() { if [ ! -e "$ROOT" ] && [ ! -L "$ROOT" ]; then printf '{"schema":1,"service":"bloom-wifi-profiles","initialized":false,"profile_count":0,"projection_managed":false}\n' @@ -169,6 +280,7 @@ project_profile() { printf '%s\n' 'ctrl_interface=/var/run/wpa_supplicant' printf '%s\n' 'update_config=0' printf '%s\n' '# managed-by=BloomOS wifi-profile-store-v1' + printf '# bloom-profile-id=%s\n' "$profile_id" printf '%s\n' 'network={' printf '\tssid=%s\n' "$ssid_quoted" [ "$hidden" = true ] && printf '\tscan_ssid=1\n' @@ -205,6 +317,8 @@ project_profile() { case "$#:${1:-}" in 1:replace) replace_store ;; 2:status) [ "$2" = --json ] || fail "invalid arguments"; store_status ;; + 2:upsert) upsert_profile "$2" ;; + 2:delete) delete_profile "$2" ;; 2:project) project_profile "$2" ;; *) fail "invalid arguments" ;; esac diff --git a/test/shell/wifi_profiles.bats b/test/shell/wifi_profiles.bats index fe917fbe2..733ce39d6 100644 --- a/test/shell/wifi_profiles.bats +++ b/test/shell/wifi_profiles.bats @@ -76,6 +76,7 @@ replace_from_input() { grep -Fqx 'ctrl_interface=/var/run/wpa_supplicant' "$BLOOM_WIFI_PROFILE_CONFIG" grep -Fqx 'update_config=0' "$BLOOM_WIFI_PROFILE_CONFIG" grep -Fqx '# managed-by=BloomOS wifi-profile-store-v1' "$BLOOM_WIFI_PROFILE_CONFIG" + grep -Fqx '# bloom-profile-id=fixture-wpa2' "$BLOOM_WIFI_PROFILE_CONFIG" grep -Fqx $'\tssid="Fixture WPA2"' "$BLOOM_WIFI_PROFILE_CONFIG" grep -Fqx $'\tkey_mgmt=WPA-PSK' "$BLOOM_WIFI_PROFILE_CONFIG" grep -Fqx $'\tpsk="test-only-passphrase"' "$BLOOM_WIFI_PROFILE_CONFIG" @@ -202,6 +203,11 @@ JSON [ "$status" -ne 0 ] [[ "$output" == *'supplicant configuration is unsafe'* ]] [ -L "$BLOOM_WIFI_PROFILE_CONFIG" ] + + run "$SERVICE" delete fixture-open + [ "$status" -ne 0 ] + [[ "$output" == *'supplicant configuration is unsafe'* ]] + [ "$(jq -r '.profiles | length' "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" -eq 2 ] } @test "occupied atomic slots preserve the previous store and supplicant bytes" { @@ -283,3 +289,66 @@ JSON [ "$(grep -Fc 'network={' "$BLOOM_WIFI_PROFILE_CONFIG")" -eq 1 ] [ "$(grep -Fc 'key_mgmt=' "$BLOOM_WIFI_PROFILE_CONFIG")" -eq 1 ] } + +@test "stdin upsert creates and replaces one exact non-secret profile id" { + cat >"$INPUT" <<'JSON' +{"id":"fixture-upsert","ssid":"Fixture Initial","security":"wpa2-psk","hidden":false,"psk":"initial-fixture-passphrase"} +JSON + run bash -c '"$1" upsert fixture-upsert <"$2"' _ "$SERVICE" "$INPUT" + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-wifi-profiles","operation":"upsert","published":true,"replaced":false,"profile_count":1}' ] + [ "$(stat -c %a "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" = 600 ] + + cat >"$INPUT" <<'JSON' +{"id":"fixture-upsert","ssid":"Fixture Replacement","security":"open","hidden":false} +JSON + run bash -c '"$1" upsert fixture-upsert <"$2"' _ "$SERVICE" "$INPUT" + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-wifi-profiles","operation":"upsert","published":true,"replaced":true,"profile_count":1}' ] + [ "$(jq -r '.profiles[0].security' "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" = open ] + [ "$(jq -r '.profiles[0].ssid' "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" = 'Fixture Replacement' ] +} + +@test "upsert binds stdin data to the requested id and preserves known good state" { + write_valid_store + replace_from_input + before=$(sha256sum "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" | awk '{print $1}') + + cat >"$INPUT" <<'JSON' +{"id":"different-id","ssid":"Fixture Replacement","security":"open","hidden":false} +JSON + run bash -c '"$1" upsert expected-id <"$2"' _ "$SERVICE" "$INPUT" + [ "$status" -ne 0 ] + [[ "$output" == *'profile input is invalid'* ]] + [ "$(sha256sum "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json" | awk '{print $1}')" = "$before" ] + [ ! -e "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json.profile-incoming" ] +} + +@test "delete removes only an inactive id and refuses the projected profile" { + write_valid_store + replace_from_input + run "$SERVICE" project fixture-wpa2 + [ "$status" -eq 0 ] + + run "$SERVICE" delete fixture-wpa2 + [ "$status" -ne 0 ] + [[ "$output" == *'active profile cannot be forgotten'* ]] + [ "$(jq -r '.profiles | length' "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" -eq 2 ] + + cat >"$INPUT" <<'JSON' +{"id":"fixture-wpa2","ssid":"Fixture Replacement","security":"wpa2-psk","hidden":false,"psk":"replacement-fixture-passphrase"} +JSON + run bash -c '"$1" upsert fixture-wpa2 <"$2"' _ "$SERVICE" "$INPUT" + [ "$status" -ne 0 ] + [[ "$output" == *'active profile cannot be replaced'* ]] + [ "$(jq -r '.profiles[] | select(.id == "fixture-wpa2") | .ssid' "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" = 'Fixture WPA2' ] + + run "$SERVICE" delete fixture-open + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-wifi-profiles","operation":"delete","deleted":true,"profile_count":1}' ] + [ "$(jq -r '.profiles[0].id' "$BLOOM_WIFI_PROFILE_STORE_ROOT/profiles.json")" = fixture-wpa2 ] + + run "$SERVICE" delete fixture-open + [ "$status" -ne 0 ] + [[ "$output" == *'profile is unavailable'* ]] +}