From b3085f137ee88538c088747a12d43f268c8b5350 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 11 Jun 2026 20:35:17 -0700 Subject: [PATCH] fix(security): community-pulse + both dashboards never report fake zeros (#1947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security-signaling surface failed open at three layers — every failure mode read as a reassuring "0 attacks" / "0 installs": - community-pulse edge function: supabase-js returns {data,error} without throwing, and all five queries discarded `error` — a DB outage produced real-looking zeros via the SUCCESS path, and the catch (also returning zeros with HTTP 200) was unreachable for query failures. Every query now destructures and throws; the catch serves the stale cache (marked "stale": true) when one exists, else 503 {"error":"pulse_unavailable"}. Success responses carry "status":"ok" so clients can distinguish authoritative data from legacy backends. NOTE: the edge function deploys out-of-band (supabase functions deploy community-pulse). - gstack-security-dashboard: captures the HTTP status; non-200 / network failure / error body / missing section → "unknown — backend error"; jq missing → "unknown — install jq" (the lossy grep fallback broke on nested arrays and under-reported attacks as zero — removed); a 200 without the new marker shows figures with an "unverified (legacy backend)" note. Also fixes a latent display bug: the TOTAL grep matched the digit 7 inside "attacks_last_7_days" and misreported every count. - gstack-community-dashboard: same class — curl || echo "{}" plus grep || echo "0" printed "Weekly active installs: 0" on any failure. Now "unknown — backend error (HTTP N)". test/security-dashboard-fallback.test.ts pins the matrix (200+marker, 200-legacy, 503, network failure) x (jq present, jq absent) for both bins: "unknown" states never render as 0. Co-Authored-By: Claude Fable 5 --- bin/gstack-community-dashboard | 19 +- bin/gstack-security-dashboard | 73 +++++-- supabase/functions/community-pulse/index.ts | 101 +++++---- test/security-dashboard-fallback.test.ts | 215 ++++++++++++++++++++ 4 files changed, 346 insertions(+), 62 deletions(-) create mode 100644 test/security-dashboard-fallback.test.ts diff --git a/bin/gstack-community-dashboard b/bin/gstack-community-dashboard index 1f469283d..10a1ffa1f 100755 --- a/bin/gstack-community-dashboard +++ b/bin/gstack-community-dashboard @@ -31,20 +31,35 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then fi # ─── Fetch aggregated stats from edge function ──────────────── -DATA="$(curl -sf --max-time 15 \ +# HTTP status captured (#1947): a backend failure must read as "unknown", +# never as a healthy "Weekly active installs: 0". +TMPBODY="$(mktemp)" +trap 'rm -f "$TMPBODY"' EXIT +HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \ "${SUPABASE_URL}/functions/v1/community-pulse" \ -H "apikey: ${ANON_KEY}" \ - 2>/dev/null || echo "{}")" + 2>/dev/null || echo "000")" +DATA="$(cat "$TMPBODY" 2>/dev/null || echo "")" echo "gstack community dashboard" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" +if [ "$HTTP_CODE" != "200" ] || [ -z "$DATA" ] || ! printf '%s' "$DATA" | grep -q '"weekly_active"'; then + echo "Community stats: unknown — backend error (HTTP ${HTTP_CODE})" + echo "" + echo "For local analytics: gstack-analytics" + exit 0 +fi + # ─── Weekly active installs ────────────────────────────────── WEEKLY="$(echo "$DATA" | grep -o '"weekly_active":[0-9]*' | grep -o '[0-9]*' || echo "0")" CHANGE="$(echo "$DATA" | grep -o '"change_pct":[0-9-]*' | grep -o '[0-9-]*' || echo "0")" echo "Weekly active installs: ${WEEKLY}" +if ! printf '%s' "$DATA" | grep -q '"status":"ok"'; then + echo " (unverified — legacy backend response; deploy the latest community-pulse for verified figures)" +fi if [ "$CHANGE" -gt 0 ] 2>/dev/null; then echo " Change: +${CHANGE}%" elif [ "$CHANGE" -lt 0 ] 2>/dev/null; then diff --git a/bin/gstack-security-dashboard b/bin/gstack-security-dashboard index 3a509307b..01c803a36 100755 --- a/bin/gstack-security-dashboard +++ b/bin/gstack-security-dashboard @@ -41,28 +41,48 @@ if [ -z "$SUPABASE_URL" ] || [ -z "$ANON_KEY" ]; then exit 0 fi -DATA="$(curl -sf --max-time 15 \ +# Fetch with the HTTP status captured (#1947). A backend failure must read +# as "unknown", never as a healthy "0 attacks" — fake zeros on a security +# surface are indistinguishable from good news. +TMPBODY="$(mktemp)" +trap 'rm -f "$TMPBODY"' EXIT +HTTP_CODE="$(curl -s --max-time 15 -w '%{http_code}' -o "$TMPBODY" \ "${SUPABASE_URL}/functions/v1/community-pulse" \ -H "apikey: ${ANON_KEY}" \ - 2>/dev/null || echo "{}")" + 2>/dev/null || echo "000")" +DATA="$(cat "$TMPBODY" 2>/dev/null || echo "")" -# Extract the security section. Prefer jq for brace-balanced parsing of -# nested arrays/objects (top_attack_domains etc.). Fall back to regex if -# jq isn't installed — the regex is lossy but the dashboard degrades -# gracefully to "0 attacks" rather than misreporting numbers. -if command -v jq >/dev/null 2>&1; then - SEC_SECTION="$(echo "$DATA" | jq -rc '.security // empty | "\"security\":\(.)"' 2>/dev/null || echo "")" -else - SEC_SECTION="$(echo "$DATA" | grep -o '"security":{[^}]*}' 2>/dev/null || echo "")" +# Classify the response: +# ok — 200 from the new backend (carries "status":"ok"); figures authoritative +# legacy — 200 with a security section but no marker (pre-#1947 backend); +# figures shown but flagged unverified (old backend masked errors as zeros) +# unknown — non-200 / network failure / error body / missing section / no jq +STATE="ok" +REASON="" +if [ "$HTTP_CODE" != "200" ] || [ -z "$DATA" ]; then + STATE="unknown"; REASON="backend_error" +elif ! command -v jq >/dev/null 2>&1; then + # No lossy-grep fallback: the old regex broke on nested arrays and + # under-reported attacks as zero. Without jq the honest answer is unknown. + STATE="unknown"; REASON="jq_missing" +elif ! echo "$DATA" | jq -e '.security' >/dev/null 2>&1; then + STATE="unknown"; REASON="backend_error" +elif [ "$(echo "$DATA" | jq -r '.status // empty' 2>/dev/null)" != "ok" ]; then + STATE="legacy" fi if [ "$JSON_MODE" = "1" ]; then - # Machine-readable — echo the whole security section (or empty object) - if [ -n "$SEC_SECTION" ]; then - echo "{${SEC_SECTION}}" - else - echo '{"security":{"attacks_last_7_days":0,"top_attack_domains":[],"top_attack_layers":[],"verdict_distribution":[]}}' - fi + case "$STATE" in + unknown) + echo "{\"security\":null,\"status\":\"unknown\",\"reason\":\"${REASON}\"}" + ;; + legacy) + echo "$DATA" | jq -c '{security: .security, status: "legacy_unverified"}' + ;; + ok) + echo "$DATA" | jq -c '{security: .security, status: "ok", stale: (.stale // false)}' + ;; + esac exit 0 fi @@ -71,9 +91,26 @@ echo "gstack security dashboard" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -TOTAL="$(echo "$DATA" | grep -o '"attacks_last_7_days":[0-9]*' | grep -o '[0-9]*' | head -1 || echo "0")" +if [ "$STATE" = "unknown" ]; then + if [ "$REASON" = "jq_missing" ]; then + echo "Attacks detected last 7 days: unknown — install jq for exact figures" + else + echo "Attacks detected last 7 days: unknown — backend error (HTTP ${HTTP_CODE})" + fi + echo "" + echo "Your local log: ~/.gstack/security/attempts.jsonl" + echo "Your telemetry mode: $(${GSTACK_DIR}/bin/gstack-config get telemetry 2>/dev/null || echo unknown)" + exit 0 +fi + +# jq is guaranteed here (jq-missing classified as unknown above). The old +# grep chain matched the digit 7 inside "attacks_last_7_days" itself and +# misreported every count as 7. +TOTAL="$(echo "$DATA" | jq -r '.security.attacks_last_7_days // 0' 2>/dev/null || echo "0")" echo "Attacks detected last 7 days: ${TOTAL}" -if [ "$TOTAL" = "0" ]; then +if [ "$STATE" = "legacy" ]; then + echo " (unverified — legacy backend response; deploy the latest community-pulse for verified figures)" +elif [ "$TOTAL" = "0" ]; then echo " (No attack attempts reported by the community yet. Good news.)" fi echo "" diff --git a/supabase/functions/community-pulse/index.ts b/supabase/functions/community-pulse/index.ts index c4ecb3cb2..d2a16664b 100644 --- a/supabase/functions/community-pulse/index.ts +++ b/supabase/functions/community-pulse/index.ts @@ -2,54 +2,75 @@ // Returns aggregated community stats for the dashboard: // weekly active count, top skills, crash clusters, version distribution. // Uses server-side cache (community_pulse_cache table) to prevent DoS. +// +// Fail-closed contract (#1947): success responses carry `status: "ok"` so +// clients can distinguish authoritative data from legacy responses. Errors +// NEVER masquerade as healthy zeros — the catch serves a stale cache (marked +// `stale: true`) when one exists, else 503 {"error":"pulse_unavailable"}. +// supabase-js does not throw on query failure, so every query destructures +// `error` and throws explicitly; previously errors were discarded and `?? 0` +// turned outages into fake zeros via the success path. import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour +const JSON_HEADERS = { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", +}; + Deno.serve(async () => { const supabase = createClient( Deno.env.get("SUPABASE_URL") ?? "", Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "" ); + // Cache fetch is hoisted above the recompute try so the catch can serve a + // stale-but-real snapshot instead of an error when recompute fails. + let cached: { data: Record; refreshed_at: string } | null = null; try { - // Check cache first - const { data: cached } = await supabase + const { data } = await supabase .from("community_pulse_cache") .select("data, refreshed_at") .eq("id", 1) .single(); + cached = data ?? null; + } catch { + cached = null; // cache miss/failure is non-fatal — recompute decides + } - if (cached?.refreshed_at) { - const age = Date.now() - new Date(cached.refreshed_at).getTime(); - if (age < CACHE_MAX_AGE_MS) { - return new Response(JSON.stringify(cached.data), { - status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=3600", - }, - }); - } + if (cached?.refreshed_at) { + const age = Date.now() - new Date(cached.refreshed_at).getTime(); + if (age < CACHE_MAX_AGE_MS) { + // Serving the cache means this (new) backend is healthy; assert the + // marker even for blobs cached by older code. + return new Response(JSON.stringify({ ...cached.data, status: "ok" }), { + status: 200, + headers: JSON_HEADERS, + }); } + } + try { // Cache is stale or missing — recompute const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(); // Weekly active (update checks this week) - const { count: thisWeek } = await supabase + const { count: thisWeek, error: thisWeekErr } = await supabase .from("update_checks") .select("*", { count: "exact", head: true }) .gte("checked_at", weekAgo); + if (thisWeekErr) throw thisWeekErr; // Last week (for change %) - const { count: lastWeek } = await supabase + const { count: lastWeek, error: lastWeekErr } = await supabase .from("update_checks") .select("*", { count: "exact", head: true }) .gte("checked_at", twoWeeksAgo) .lt("checked_at", weekAgo); + if (lastWeekErr) throw lastWeekErr; const current = thisWeek ?? 0; const previous = lastWeek ?? 0; @@ -58,13 +79,14 @@ Deno.serve(async () => { : 0; // Top skills (last 7 days) - const { data: skillRows } = await supabase + const { data: skillRows, error: skillErr } = await supabase .from("telemetry_events") .select("skill") .eq("event_type", "skill_run") .gte("event_timestamp", weekAgo) .not("skill", "is", null) .limit(1000); + if (skillErr) throw skillErr; const skillCounts: Record = {}; for (const row of skillRows ?? []) { @@ -78,19 +100,21 @@ Deno.serve(async () => { .map(([skill, count]) => ({ skill, count })); // Crash clusters (top 5) - const { data: crashes } = await supabase + const { data: crashes, error: crashErr } = await supabase .from("crash_clusters") .select("error_class, gstack_version, total_occurrences, identified_users") .limit(5); + if (crashErr) throw crashErr; // Version distribution (last 7 days) const versionCounts: Record = {}; - const { data: versionRows } = await supabase + const { data: versionRows, error: versionErr } = await supabase .from("telemetry_events") .select("gstack_version") .eq("event_type", "skill_run") .gte("event_timestamp", weekAgo) .limit(1000); + if (versionErr) throw versionErr; for (const row of versionRows ?? []) { if (row.gstack_version) { @@ -106,12 +130,13 @@ Deno.serve(async () => { // Fields emitted by gstack-telemetry-log --event-type attack_attempt: // security_url_domain, security_payload_hash, security_confidence, // security_layer, security_verdict. - const { data: attackRows } = await supabase + const { data: attackRows, error: attackErr } = await supabase .from("telemetry_events") .select("security_url_domain, security_layer, security_verdict, installation_id") .eq("event_type", "attack_attempt") .gte("event_timestamp", weekAgo) .limit(5000); + if (attackErr) throw attackErr; // k-anonymity threshold. A domain (or layer) must be reported by at least // K_ANON distinct installations to appear in the aggregate. Without this, @@ -161,6 +186,7 @@ Deno.serve(async () => { .map(([verdict, count]) => ({ verdict, count })); const result = { + status: "ok", weekly_active: current, change_pct: changePct, top_skills: topSkills, @@ -186,30 +212,21 @@ Deno.serve(async () => { return new Response(JSON.stringify(result), { status: 200, - headers: { - "Content-Type": "application/json", - "Cache-Control": "public, max-age=3600", - }, + headers: JSON_HEADERS, }); } catch { - return new Response( - JSON.stringify({ - weekly_active: 0, - change_pct: 0, - top_skills: [], - crashes: [], - versions: [], - security: { - attacks_last_7_days: 0, - top_attack_domains: [], - top_attack_layers: [], - verdict_distribution: [], - }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); + // Recompute failed. A stale snapshot of real data beats an error — and + // both beat fake zeros, which are indistinguishable from a healthy + // "no attacks" reading on a security surface. + if (cached?.data) { + return new Response( + JSON.stringify({ ...cached.data, status: "ok", stale: true }), + { status: 200, headers: JSON_HEADERS }, + ); + } + return new Response(JSON.stringify({ error: "pulse_unavailable" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); } }); diff --git a/test/security-dashboard-fallback.test.ts b/test/security-dashboard-fallback.test.ts new file mode 100644 index 000000000..55cba86d9 --- /dev/null +++ b/test/security-dashboard-fallback.test.ts @@ -0,0 +1,215 @@ +/** + * #1947 — security/community dashboards must never report fake zeros. + * + * A backend failure, a network failure, or a missing jq used to degrade to + * "0 attacks" / "Weekly active installs: 0" — indistinguishable from a + * genuinely healthy reading on a security-signaling surface. The contract + * pinned here: + * + * - non-200 / network failure / error body → "unknown", never 0 + * - jq missing (security dashboard) → "unknown — install jq", never 0 + * - 200 with the new backend's status:"ok" → figures trusted + * - 200 without the marker (legacy backend) → figures shown + "unverified" note + * + * curl is stubbed via a prepended PATH; the jq-missing case runs with a + * PATH containing only the stub + whitelisted tools (no jq). + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { spawnSync } from "child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const ROOT = join(import.meta.dir, ".."); +const SEC_BIN = join(ROOT, "bin", "gstack-security-dashboard"); +const COMM_BIN = join(ROOT, "bin", "gstack-community-dashboard"); +// Absolute path: the jq-missing case runs with a whitelist-only PATH, so +// "bash" itself wouldn't resolve through the child env. +const BASH = Bun.which("bash") || "/bin/bash"; + +const GOOD_BODY_MARKER = JSON.stringify({ + status: "ok", + weekly_active: 42, + change_pct: 5, + top_skills: [{ skill: "ship", count: 9 }], + crashes: [], + versions: [], + security: { + attacks_last_7_days: 3, + top_attack_domains: [{ domain: "evil.example", count: 7 }], + top_attack_layers: [{ layer: "L4", count: 3 }], + verdict_distribution: [{ verdict: "block", count: 3 }], + }, +}); + +// Pre-#1947 backend shape: same data, no status marker. +const GOOD_BODY_LEGACY = JSON.stringify({ + ...JSON.parse(GOOD_BODY_MARKER), + status: undefined, +}); + +const CURL_STUB = `#!/bin/sh +# Test stub for curl: honors -o , prints the HTTP code (as -w would). +out="" +prev="" +for a in "$@"; do + if [ "$prev" = "-o" ]; then out="$a"; fi + prev="$a" +done +case "\${STUB_CURL_MODE:-ok}" in + ok) [ -n "$out" ] && printf '%s' "$STUB_CURL_BODY" > "$out"; printf '200' ;; + error503) [ -n "$out" ] && printf '%s' '{"error":"pulse_unavailable"}' > "$out"; printf '503' ;; + netfail) exit 7 ;; +esac +`; + +let tmp: string; +let stubBin: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "gstack-dash-test-")); + stubBin = join(tmp, "stub-bin"); + mkdirSync(stubBin, { recursive: true }); + writeFileSync(join(stubBin, "curl"), CURL_STUB); + chmodSync(join(stubBin, "curl"), 0o755); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +function run( + bin: string, + opts: { + mode: "ok" | "error503" | "netfail"; + body?: string; + json?: boolean; + noJq?: boolean; + }, +) { + let pathEnv = `${stubBin}:${process.env.PATH || ""}`; + if (opts.noJq) { + // Whitelist-only PATH: the curl stub plus the real tools the script + // needs — everything except jq. + const toolBin = join(tmp, "tool-bin"); + mkdirSync(toolBin, { recursive: true }); + for (const tool of ["mktemp", "cat", "grep", "head", "sed", "awk", "rm", "sh", "bash"]) { + const real = Bun.which(tool); + if (real) symlinkSync(real, join(toolBin, tool)); + } + pathEnv = `${stubBin}:${toolBin}`; + } + return spawnSync(BASH, opts.json ? [bin, "--json"] : [bin], { + encoding: "utf-8", + timeout: 20_000, + env: { + ...process.env, + PATH: pathEnv, + GSTACK_DIR: ROOT, + GSTACK_SUPABASE_URL: "https://stub.supabase.test", + GSTACK_SUPABASE_ANON_KEY: "stub-key", + STUB_CURL_MODE: opts.mode, + STUB_CURL_BODY: opts.body ?? "", + }, + }); +} + +describe("gstack-security-dashboard — never reports fake zeros (#1947)", () => { + it("backend 503 → unknown, not 0 (human mode)", () => { + const r = run(SEC_BIN, { mode: "error503" }); + expect(r.stdout).toContain("unknown — backend error (HTTP 503)"); + expect(r.stdout).not.toContain("Attacks detected last 7 days: 0"); + }); + + it("backend 503 → status unknown (json mode)", () => { + const r = run(SEC_BIN, { mode: "error503", json: true }); + const parsed = JSON.parse(r.stdout.trim()); + expect(parsed.status).toBe("unknown"); + expect(parsed.reason).toBe("backend_error"); + expect(parsed.security).toBeNull(); + }); + + it("network failure → unknown, not 0", () => { + const r = run(SEC_BIN, { mode: "netfail", json: true }); + const parsed = JSON.parse(r.stdout.trim()); + expect(parsed.status).toBe("unknown"); + expect(parsed.security).toBeNull(); + }); + + it("jq missing → unknown with install hint, never the lossy-grep zero", () => { + const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER, noJq: true }); + expect(r.stdout).toContain("unknown — install jq"); + expect(r.stdout).not.toContain("Attacks detected last 7 days: 0"); + expect(r.stdout).not.toContain("Attacks detected last 7 days: 3"); + }); + + it("jq missing → reason jq_missing (json mode)", () => { + const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER, noJq: true, json: true }); + const parsed = JSON.parse(r.stdout.trim()); + expect(parsed.status).toBe("unknown"); + expect(parsed.reason).toBe("jq_missing"); + }); + + it("200 + status:ok marker → figures trusted (human mode)", () => { + const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER }); + expect(r.stdout).toContain("Attacks detected last 7 days: 3"); + expect(r.stdout).toContain("evil.example"); + expect(r.stdout).not.toContain("unverified"); + }); + + it("200 + status:ok marker → status ok with full security section (json mode)", () => { + const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_MARKER, json: true }); + const parsed = JSON.parse(r.stdout.trim()); + expect(parsed.status).toBe("ok"); + expect(parsed.security.attacks_last_7_days).toBe(3); + // Nested arrays survive (the old lossy-grep fallback broke on these). + expect(parsed.security.top_attack_domains[0].domain).toBe("evil.example"); + }); + + it("200 without marker (legacy backend) → figures shown with unverified note", () => { + const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_LEGACY }); + expect(r.stdout).toContain("Attacks detected last 7 days: 3"); + expect(r.stdout).toContain("unverified"); + }); + + it("200 without marker → legacy_unverified (json mode)", () => { + const r = run(SEC_BIN, { mode: "ok", body: GOOD_BODY_LEGACY, json: true }); + const parsed = JSON.parse(r.stdout.trim()); + expect(parsed.status).toBe("legacy_unverified"); + expect(parsed.security.attacks_last_7_days).toBe(3); + }); +}); + +describe("gstack-community-dashboard — never reports fake zeros (#1947)", () => { + it("backend 503 → unknown, not 'Weekly active installs: 0'", () => { + const r = run(COMM_BIN, { mode: "error503" }); + expect(r.stdout).toContain("unknown — backend error (HTTP 503)"); + expect(r.stdout).not.toContain("Weekly active installs: 0"); + }); + + it("network failure → unknown, not 0", () => { + const r = run(COMM_BIN, { mode: "netfail" }); + expect(r.stdout).toContain("unknown — backend error (HTTP 000)"); + expect(r.stdout).not.toContain("Weekly active installs:"); + }); + + it("200 + status:ok marker → figures shown without unverified note", () => { + const r = run(COMM_BIN, { mode: "ok", body: GOOD_BODY_MARKER }); + expect(r.stdout).toContain("Weekly active installs: 42"); + expect(r.stdout).not.toContain("unverified"); + }); + + it("200 without marker (legacy backend) → figures shown with unverified note", () => { + const r = run(COMM_BIN, { mode: "ok", body: GOOD_BODY_LEGACY }); + expect(r.stdout).toContain("Weekly active installs: 42"); + expect(r.stdout).toContain("unverified"); + }); +});