test: unpack testing sheet into linear playbook

Rewrites the manual testing sheet so every step is an executable action
or a specific observation:

- Numbered steps within each test — no jumping between reference
  sections. "Click X", "run Y in a terminal", "watch for Z".
- Each observation records YES/NO/SKIPPED for the sign-off matrix.
- Setup section spells out the wipe-preferences command and the
  post-restart smoke check.
- T3 broken into T3.a/b/c/d for each button and its persistence
  check separately (previously bundled T3.1–T3.10 was too dense).
- T6/T7 include exact tcpdump/tshark commands for the security
  observations that require packet capture.
- T13 shortened to a sanity re-check (already verified).
- Sign-off table lists every test with a checkbox.

Purpose: give the tester a self-contained document they can follow
top to bottom in ~40 min without cross-referencing other sheets.
This commit is contained in:
nrobi144
2026-07-09 07:33:43 +03:00
parent 0fc9b8a87d
commit 33417d8a58
@@ -1,322 +1,661 @@
# Desktop DM Reliability — Full Testing Sheet
# Desktop DM Reliability — Testing Playbook
**Branch:** `feat/desktop-dm-reliability` (rebased onto `origin/main` 2026-06-17; head at commit `a139c0fb17` after the pre-send fix)
**Companion:** [2026-06-12-desktop-dm-reliability-testing-sheet.md](2026-06-12-desktop-dm-reliability-testing-sheet.md) (automated section)
**Tester:**
**Build:** `./gradlew :desktopApp:run` (or launch `Amethyst.app` from `desktopApp/build/compose/binaries/main/app/`)
**Branch:** `feat/desktop-dm-reliability` at `fcfc43eb44` (21 commits)
**Tester:** _______________________
**Date:** _______________________
## Post-rebase reality
Upstream shipped its own NIP-42 AUTH work while our branch was in flight (commit `328790ac9f``feat: time-bound signer grants, last-used tracking, and relay AUTH settings`). After rebase, both systems coexist:
- **Upstream (Android-focused):** `RelayAuthPolicy` enum + `RelayAuthPermissionLedger` + `RelayAuthSettingsScreen` + relay URL now passed to the `signWithAllLoggedInUsers` lambda.
- **Ours (Desktop-focused):** `DesktopAuthCoordinator` + `AuthApprovalPolicy` (2-tier auto/prompt) + inline `AuthApprovalBanner` + `PreferencesAuthApprovalStore` + `RelayAuthSnapshot` StateFlow.
Both use the same quartz `RelayAuthenticator` seam. On desktop only our path is wired. On Android only upstream's path is wired.
## Known pre-existing issues (NOT our regressions)
| Issue | Where | Note |
|---|---|---|
| `ConcurrentModificationException` at `RelayLatencyTracker.sweep:182` during account switching | commons/relays/health | Documented in memory `desktop_relay_health_cme_crash`. Kills UI thread, coroutines keep running. Avoid rapid switches. |
| `NoClassDefFoundError` for `CompressionQuality` on stale gradle daemon | commons/service/upload | Fixed by `./gradlew --stop` + relaunch. Not a code regression. |
**Instructions:** Follow this top to bottom. Every step is an action or an observation. Don't skip ahead — later tests assume state from earlier ones. Total ≈ 40 min for full pass.
---
## Phase A — Automated verification (Claude ran these — status ✅)
## Setup (once, ~3 min)
Full detail in the companion sheet. Summary of what already passed:
| # | Check | Status |
|---|---|---|
| A1.1A1.5 | Compile every module (quartz, commons, desktopApp, amethyst Android, cli) | ✅ |
| A2.1A2.4 | Test suites (quartz, commons, desktopApp, amethyst Android) | ✅ |
| A3 | `./gradlew spotlessCheck` | ✅ |
| A4.1 | Distributable builds (`Amethyst.app` produced) | ✅ |
| A4.2 | Distributable launches cleanly | ✅ |
| A5.1 | Zero `Co-Authored-By` footers in commits | ✅ |
| A5.2 | All commits GPG-signed (`G`) | ✅ |
| D1.1 | Zero remaining `connectedRelays.value` in NIP-17 paths | ✅ |
| D1.2 | Only `DesktopAuthCoordinator` constructs `RelayAuthenticator` | ✅ |
| D1.3 | Indexer client is a distinct `NostrClient` instance | ✅ |
| D1.4 | Zero `dmInboxOrFallback` usages outside quartz definition | ✅ |
---
## Phase B — Manual testing walkthrough
### T0 — Setup (2 min, once)
**1.** In a terminal, cd to the worktree and confirm you're on the right commit:
```bash
# From the worktree root
git rev-parse HEAD # should be a139c0fb17 or later
cd /path/to/AmethystMultiplatform/.worktrees/feat/desktop-dm-reliability
git rev-parse HEAD
```
# Wipe prior AUTH grants so T3 starts fresh (macOS)
rm -rf ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth 2>/dev/null
- Expect: `fcfc43eb44` (or later). If different: `git pull` and re-verify.
# Launch — leave this terminal visible; I'll read logs from it
**2.** Wipe any prior AUTH grants so persistence tests start clean:
```bash
rm -rf ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth
```
**3.** Launch the app (keep this terminal visible — we'll read logs from it):
```bash
./gradlew :desktopApp:run
```
- [ ] **T0.1** Wait for the sidebar to appear (~1530s cold start, ~5s warm start)
- [ ] **T0.2** Log in with your primary account (User A)
- Expect: window appears in 1530 s (cold) / 5 s (warm).
**4.** In the app, log in with your primary account. Call this **User A**.
- Expect: sidebar loads, feed populates.
**5.** In the terminal, look for these two lines (they appear within 5 s of login):
```
[RelayAuthenticator] Init, Subscribe
[DesktopAuthCoordinator] AUTH wired for <pubkey8>
```
- **If both appear:** ✅ setup complete. Proceed to T1.
- **If either is missing:** STOP. Tell me the terminal output.
---
### T1 — Startup wiring smoke check (30 sec, no interaction)
## T1 — Startup smoke check (already ✅ during setup)
Watch the terminal for these two lines within 5s of login:
Nothing extra to do — the two log lines above ARE T1.
- [ ] **T1.1** `[RelayAuthenticator] Init, Subscribe`
- [ ] **T1.2** `[DesktopAuthCoordinator] AUTH wired for <pubkey8>`
- [ ] **T1.3** No `Exception` / `FATAL` / stack trace lines during startup
Missing either T1.1 or T1.2 → **stop**, we regressed.
**Session so far:** ✅ Verified multiple times (`2bad3487`, `04e18cb4`).
- [ ] **T1 PASS** — both `Init, Subscribe` and `AUTH wired for <pubkey8>` printed with no exceptions
---
### T2 — Tier-1 AUTH (own DM-inbox) — 2 min
## T2 — Tier-1 self-DM (no banner) — 2 min
Verifies the user's own DM-inbox relays auto-AUTH with no banner.
**Goal:** verify your own DM-inbox relays auto-AUTH silently.
- [ ] **T2.1** Open **Chats** in sidebar
- [ ] **T2.2** Note the relays connecting (Settings → Relays panel)
- [ ] **T2.3** Compose a DM **to yourself** (paste your own npub in a new DM)
- [ ] **T2.4** Send "test" — message arrives in your own inbox
- [ ] **T2.5** **CRITICAL:** during send, **no AUTH banner** appears at the top of the content area
- [ ] **T2.6** No `[AuthApprovalPolicy]` prompt lines in terminal
**Steps:**
Pass criteria: message arrived, no banner.
**1.** In the sidebar, click **Chats** (chat bubble icon).
**2.** At the top of the conversation list, click the **`+` icon** (new conversation).
**3.** Paste your OWN npub into the recipient field. Confirm.
**4.** In the message box, type: `t1 self-dm test`
**5.** Watch the top of the content area (where the yellow banner would appear).
- **Expected:** send button enables blue → no yellow AUTH banner appears anywhere.
**6.** Click the **send arrow** (right side of the message box).
**7.** Wait 3 s. The message should appear in your inbox.
- **Expected:** message appears in the conversation. Terminal has NO `AuthApprovalPolicy` prompt lines.
**Record:**
- [ ] **T2.1** No AUTH banner appeared: **YES / NO**
- [ ] **T2.2** Message arrived: **YES / NO**
- [ ] **T2 PASS** — both YES
---
### T3 — Tier-2 AUTH banner + persistence — 5 min
## T3 — Tier-2 banner + persistence — 8 min
Trigger a challenge from an AUTH-required relay NOT in your `kind:10050`.
**Goal:** trigger a challenge from an AUTH-required relay NOT in your `kind:10050`, verify the banner renders + all three buttons persist correctly.
- [ ] **T3.1** Settings → Relays → Add relay → paste `wss://pyramid.fiatjaf.com`
- [ ] **T3.2** Wait 13s for connect + AUTH challenge
- [ ] **T3.3** **CRITICAL:** yellow banner appears at the top of the content area:
- Relay URL shown (with 80dp left margin, clear of macOS traffic lights ✅ fixed in `fb2d004c73`)
- Text: "requires authentication to deliver this message"
- Buttons: `[Once]` `[Always]` `[Never]`
- [ ] **T3.4** Click `[Once]` → banner slides away; AUTH proceeds for the session only
- [ ] **T3.5** Restart app → same relay challenges again → banner reappears (ONCE was session-only)
- [ ] **T3.6** This time click `[Always]` → banner slides away
- [ ] **T3.7** Restart app → challenge from same relay does NOT show banner (auto-signed)
- [ ] **T3.8** Inspect Preferences: `cat ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/<full-pubkey>/prefs.xml` → contains `<entry key="wss://pyramid.fiatjaf.com/">ALWAYS</entry>`
- [ ] **T3.9** Add a different AUTH-required relay → banner → click `[Never]` → Preferences shows `BLOCKED`
- [ ] **T3.10** Restart → BLOCKED relay never surfaces a banner AND never sends AUTH (relay status: "not authenticated")
### T3.a — Trigger the banner
**Session so far:** T3.3 ✅ banner rendered (see screenshot 2026-07-06 at 15:20:38). Padding fixed. Rest pending your test.
**1.** Open Settings. (Look for a gear/cog icon in the sidebar. If absent, try the app menu → Settings.)
**2.** Go to the **Relays** tab.
**3.** Find the "Add relay" input. Paste: `wss://pyramid.fiatjaf.com`
**4.** Save/apply (button label varies — usually "Add" or "Save").
**5.** Wait 13 s. Watch the **top of the content area** (below the title bar, above the main content).
- **Expected:** a yellow-tinted horizontal row slides in showing:
- Lock icon on the left (with proper margin from window edge — 80dp — not overlapping the traffic lights)
- `pyramid.fiatjaf.com` in a semi-bold heading
- Subtext: "requires authentication to deliver this message"
- Three buttons on the right: **`Once`** **`Always`** **`Never`**
Record:
- [ ] **T3.a.1** Banner appeared within 3 s: **YES / NO**
- [ ] **T3.a.2** Icon + text NOT overlapping traffic lights: **YES / NO**
- [ ] **T3.a.3** All three buttons visible: **YES / NO**
### T3.b — `[Once]` behaviour (session-only, no persistence)
**6.** Click `Once`.
- **Expected:** banner slides away immediately, no visible change to relay state.
**7.** In a second terminal, check the Preferences file did NOT get written for this relay:
```bash
cat ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/<your-full-pubkey>/prefs.xml 2>/dev/null | grep pyramid
```
- **Expected:** empty output (ONCE is not persisted).
**8.** Close the app (Cmd+Q). Wait 2 s. Relaunch via `./gradlew :desktopApp:run`. Log in as A again.
**9.** Wait ~5 s. The banner for `pyramid.fiatjaf.com` should reappear (session state was not saved).
Record:
- [ ] **T3.b.1** After `[Once]`: Preferences NOT written: **YES / NO**
- [ ] **T3.b.2** After restart: banner reappeared: **YES / NO**
### T3.c — `[Always]` behaviour (persisted grant)
**10.** In the banner that just reappeared, click `Always`.
- **Expected:** banner slides away, `pyramid.fiatjaf.com` now shows "Authenticated" in Settings → Relays.
**11.** Check Preferences was written:
```bash
cat ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/<your-full-pubkey>/prefs.xml | grep pyramid
```
- **Expected:** line containing `wss://pyramid.fiatjaf.com/` and `ALWAYS`.
**12.** Close app (Cmd+Q). Relaunch. Log in as A.
- **Expected:** relay auto-authenticates in the background. **No banner appears** for `pyramid.fiatjaf.com`.
Record:
- [ ] **T3.c.1** Preferences shows `ALWAYS`: **YES / NO**
- [ ] **T3.c.2** After restart: no banner, auto-authenticated: **YES / NO**
### T3.d — `[Never]` behaviour (persisted block)
**13.** Add a different AUTH-required relay. If you have another, use it. Otherwise, try `wss://nostr.wine` (they AUTH-challenge non-subscribers) or `wss://relay.snort.social`.
**14.** Wait for the new banner to appear.
**15.** Click `Never`.
- **Expected:** banner disappears. The relay shows as connected but "not authenticated".
**16.** Check Preferences:
```bash
cat ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/<your-full-pubkey>/prefs.xml | grep <relay-domain>
```
- **Expected:** line with `BLOCKED`.
**17.** Restart the app. Log in as A.
- **Expected:** the BLOCKED relay never surfaces a banner. It stays "not authenticated". No `kind:22242` AUTH event ever sent to it.
Record:
- [ ] **T3.d.1** Preferences shows `BLOCKED`: **YES / NO**
- [ ] **T3.d.2** After restart: no banner, no AUTH sent: **YES / NO**
---
### T4 — Multiple concurrent banners stack — 2 min
**T3 sign-off:** Complete `T3.a`, `T3.b`, `T3.c`, `T3.d`.
- [ ] **T4.1** Add 3 different AUTH-required relays back-to-back (before dismissing any)
- [ ] **T4.2** All 3 rows stack vertically
- [ ] **T4.3** Dismiss middle one with `[Once]` → only that row disappears
- [ ] **T4.4** (Stress) Add 5+ → top 3 visible, "+N more relays pending approval" row at bottom
- [ ] **T3 PASS** — all four subs green
---
### T5Per-account isolation + logout — 3 min
## T4Multiple concurrent banners — 3 min
- [ ] **T5.1** Approve one relay with `[Always]` on account A
- [ ] **T5.2** Log out (watch terminal for coordinator teardown — no exceptions)
- [ ] **T5.3** Log in as account B → add same AUTH-required relay → banner appears (B has no persisted approval)
- [ ] **T5.4** Verify B's `[Always]` writes to B's Preferences node, NOT A's:
- `ls ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/` → both A's and B's pubkey directories present
- [ ] **T5.5** Log back into A → same relay does NOT re-prompt (A's `[Always]` persisted)
**Goal:** verify multiple pending banners stack correctly and resolve independently.
**Session so far:** ✅ A/B switching seen in log (`2bad3487``04e18cb4`), coordinator wires cleanly on every switch. Isolation confirmed at the logging level.
**Steps:**
**1.** In Settings → Relays, quickly add 3 different AUTH-required relays back-to-back. Suggested set:
- `wss://pyramid.fiatjaf.com` (if not already blocked/allowed)
- `wss://relay.nostr.com.au`
- `wss://nostr.wine`
**2.** Watch the banner area — all 3 rows should appear stacked vertically within ~3 s.
**3.** Click `Once` on the **middle** row.
- **Expected:** ONLY the middle row disappears. The other two remain visible.
**4.** (Optional stress test) Add 5+ more AUTH-required relays.
- **Expected:** first 3 shown inline; row at the bottom reads "+N more relays pending approval".
Record:
- [ ] **T4.1** 3 banners stack vertically: **YES / NO**
- [ ] **T4.2** Middle-row dismiss only affects itself: **YES / NO**
- [ ] **T4.3** "+N more" row shows when >3 pending: **YES / NO**
- [ ] **T4 PASS** — all three YES
---
### T6NIP-17 send: no-10050 recipient (P0 SECURITY FIX)5 min
## T5Per-account isolation4 min
**Most important test.** Verify DMs no longer leak to your own relays when recipient has no `kind:10050`.
**Goal:** verify AUTH grants are scoped per-account and cleaned on logout.
- [ ] **T6.1** Find or fabricate a Nostr account with NO `kind:10050`:
- Generate a fresh nsec via `nak` or similar (won't have any events)
- Or use an old npub known to have never used NIP-17
- [ ] **T6.2** Attempt to send a DM to that npub
- [ ] **T6.3** **Expected pre-branch:** wrap silently broadcast to your `connectedRelays.value`
- [ ] **T6.4** **Expected post-branch:** UI shows "Recipient has no DM relay list — messages cannot be delivered" AND Send button is disabled → **zero gift wraps published**
- [ ] **T6.5** Optional network inspection:
- `sudo tcpdump -i any -A 'tcp port 443' | grep 1059` — should stay empty during the test
- [ ] **T6.6** After the [pre-send fix commit `a139c0fb17`] — if the peer publishes `kind:10050` mid-session and the resolver probe runs (see T13), the block should clear automatically without reopening the conversation
**Steps:**
**Session so far:** ✅ Confirmed — screenshot showed the "Recipient has no DM relay list" red UI blocking the send.
**1.** Ensure A has at least one `ALWAYS` grant (from T3.c: `pyramid.fiatjaf.com`).
**2.** Log out of A (sidebar → profile → Logout, or app menu).
- **Terminal:** watch for `DesktopAuthCoordinator` teardown lines (no exceptions).
**3.** Log in as User B (different pubkey — nsec, npub, or bunker).
- **Terminal:** expect `[DesktopAuthCoordinator] AUTH wired for <B-pubkey8>` — different from A's.
**4.** Add `wss://pyramid.fiatjaf.com` in Settings → Relays for B.
- **Expected:** banner appears (B does NOT inherit A's `ALWAYS` grant).
**5.** Check that A's and B's Preferences are separate:
```bash
ls ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/
```
- **Expected:** two directories, one per full pubkey.
**6.** Click `Always` on B's banner.
**7.** Log out of B. Log back into A.
- **Terminal:** `AUTH wired for <A-pubkey8>` again.
**8.** Watch for banners.
- **Expected:** no banner for `pyramid.fiatjaf.com` (A's `ALWAYS` still persisted).
Record:
- [ ] **T5.1** Coordinator teardown clean on logout (no exceptions): **YES / NO**
- [ ] **T5.2** B sees banner for A-approved relay (isolation): **YES / NO**
- [ ] **T5.3** Two separate Preferences dirs exist: **YES / NO**
- [ ] **T5.4** Re-login to A: no re-prompt: **YES / NO**
- [ ] **T5 PASS** — all four YES
---
### T7Indexer fan-out + F-01 unauth client — 5 min
## T6P0 security fix (no-10050 recipient) — 5 min
Verify that when a recipient's `kind:10050` isn't cached, the resolver probes indexers with an **unauthenticated** client.
**Goal:** verify DMs are NOT silently broadcast to your general relays when the recipient has no `kind:10050`.
- [ ] **T7.1** Pick a recipient who HAS a `kind:10050` but whom you've never DM'd (fresh LocalCache miss)
- [ ] **T7.2** Compose DM to them → observe brief (sub-second to ~3s) delay
- [ ] **T7.3** In terminal, look for connections to the curated indexer set:
- `wss://relay.nos.social`
- `wss://relay.damus.io`
- `wss://nos.lol`
- `wss://relay.nostr.band`
- `wss://purplerelay.com`
- [ ] **T7.4** **CRITICAL — F-01 check.** Verify NO `kind:22242` AUTH event sent on the indexer connections:
- `sudo tshark -i any -Y 'websocket && frame contains "22242"'` — should stay empty while indexer probes run
- Or manually: even if any indexer AUTH-challenges, the client should silently ignore
- [ ] **T7.5** Send succeeds → recipient's actual DM-inbox relay receives the wrap
- [ ] **T7.6** Compose another DM to same recipient → resolver hits LRU cache; no second indexer probe
### T6.a — Create a "no-inbox" test recipient
**1.** In a terminal, generate a fresh nsec/npub pair:
```bash
# Option: use nak
nak key generate
# Copy the printed nsec and npub
```
Or use any known npub of an account that never published `kind:10050`.
**2.** In Amethyst as A, click **`+`** in Chats. Paste the test npub. Confirm.
### T6.b — Verify the UI blocks send
**3.** Type any message.
**4.** Look at the row below the message input.
- **Expected:** red text "**Recipient has no DM relay list — messages cannot be delivered**"
- **Expected:** send button is grey/disabled.
**5.** Try clicking send anyway.
- **Expected:** nothing happens (button disabled). Or, if enabled by upstream UI quirk, `DmSendTracker` shows "No relays available" briefly.
### T6.c — Verify no wrap leaves the app (optional, for the security-conscious)
**6.** In a terminal, run:
```bash
sudo tcpdump -i any -A -s 0 'tcp port 443 or tcp port 80' 2>/dev/null | grep -i "kind\":1059"
```
**7.** In the app, try to send. Watch the tcpdump output for 30 s.
- **Expected:** zero output. No gift wrap (kind 1059) publishes anywhere.
**8.** Stop tcpdump with Ctrl+C.
Record:
- [ ] **T6.1** UI shows "no DM relay list" warning: **YES / NO**
- [ ] **T6.2** Send button disabled: **YES / NO**
- [ ] **T6.3** No `kind":1059` in outgoing traffic during send attempt: **YES / NO / SKIPPED**
- [ ] **T6 PASS** — T6.1 and T6.2 both YES (T6.3 optional but recommended)
---
### T8NIP-17 relay hint on wrap `p` tag3 min
## T7Indexer fan-out + F-01 unauth check6 min
- [ ] **T8.1** Send a DM to a recipient whose `kind:10050` is cached
- [ ] **T8.2** Inspect the wrap via `nak req -k 1059 -a <your-pubkey>` on one of your DM-inbox relays
- [ ] **T8.3** Expected `p` tag: `["p", "<recipient-pubkey-hex>", "wss://recipient-primary-relay/"]` (3 elements)
- [ ] **T8.4** For a recipient with no known relay: `["p", "<recipient-pubkey-hex>"]` (2 elements, no fake empty third element)
**Goal:** verify the resolver probes indexer relays with an UNAUTHENTICATED client (no `kind:22242` AUTH events leaked to indexers).
### T7.a — Prime the state
**1.** Restart the app (Cmd+Q, then `./gradlew :desktopApp:run`).
- Fresh LocalCache = maximum chance the resolver actually fires.
**2.** Log in as A.
### T7.b — Set up traffic capture (optional but revealing)
**3.** In a second terminal, start capturing all WebSocket traffic:
```bash
sudo tshark -i any -Y 'websocket' -T fields -e ws.payload 2>/dev/null | head -c 100000
```
Or (simpler):
```bash
sudo tcpdump -i any -A -s 0 'tcp port 443' 2>/dev/null > /tmp/dm-traffic.log &
```
### T7.c — Trigger the resolver
**4.** Pick a recipient who HAS a `kind:10050` published (a NIP-17-active account) but whom you have NEVER DM'd from account A.
**5.** In Amethyst, click **`+`** in Chats. Paste the recipient's npub. Confirm.
**6.** Watch the pre-send row.
- **Expected sequence:**
- Initial: red "no DM relay list" warning (LocalCache miss).
- Within 25 s: warning disappears (resolver probe found the recipient's `kind:10050` on an indexer).
- Send button turns blue.
### T7.d — Verify F-01 (no AUTH to indexer)
**7.** Search the captured traffic for `kind:22242` AUTH events:
```bash
grep -i "\"kind\":22242" /tmp/dm-traffic.log | head -20
```
- **Expected:** any `kind:22242` events found should only be to relays in your existing DM-inbox set — NOT to the indexer set (`relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com`).
**8.** In the app, type a message and send.
- **Expected:** send succeeds. Recipient's actual DM-inbox relay receives the wrap.
**9.** Stop tcpdump: `sudo pkill tcpdump`
### T7.e — Verify LRU cache hit on second send
**10.** Immediately compose a second DM to the same recipient. Send.
- **Expected:** send is immediate, no delay. Resolver hits its LRU cache, no new indexer probe.
Record:
- [ ] **T7.1** Warning cleared within 5 s (resolver probe worked): **YES / NO**
- [ ] **T7.2** Send button became enabled after probe: **YES / NO**
- [ ] **T7.3** No `kind:22242` AUTH sent to indexer relays: **YES / NO / SKIPPED**
- [ ] **T7.4** DM delivered to recipient: **YES / NO**
- [ ] **T7.5** Second DM to same recipient: no probe delay: **YES / NO**
- [ ] **T7 PASS** — T7.1, T7.2, T7.4, T7.5 all YES
---
### T9Group DM shared `rumor.id` — 4 min
## T8Wrap `p`-tag relay hint — 4 min
- [ ] **T9.1** Create a group DM with 3 recipients
- [ ] **T9.2** Send one message
- [ ] **T9.3** Inspect wraps via relay UI — all decrypt to a rumor with the **same `id`**
- [ ] **T9.4** Have one recipient react (👍)
- [ ] **T9.5** Reaction targets the shared rumor id; all participants see the same reaction
**Goal:** verify the outgoing gift wrap includes the recipient's primary DM relay as the third element of the `p` tag.
**Steps:**
**1.** Send a DM to any recipient with a known `kind:10050` (e.g. the one from T7).
**2.** In a terminal, use `nak` (or `websocat`) to query one of the recipient's DM-inbox relays for their gift wraps:
```bash
RECIPIENT_HEX=<paste-recipient-hex-pubkey>
DM_RELAY=<paste-one-of-their-10050-relays>
nak req -k 1059 --tag "p=$RECIPIENT_HEX" "$DM_RELAY" | head -5
```
**3.** Find the wrap you just sent (highest `created_at`). Look at its `p` tag.
- **Expected:** `["p", "<recipient-hex>", "wss://recipient-primary-relay/"]` — 3 elements, third is a valid relay URL.
**4.** For contrast, send a DM to a recipient whose `kind:10050` you have NO indexer/cache hit for (e.g. the one from T6 if you have their nsec to simulate — otherwise skip).
- **Expected:** wrap's `p` tag has only 2 elements: `["p", "<hex>"]` — no fake empty third element.
Record:
- [ ] **T8.1** With known relay: 3-element `p` tag: **YES / NO**
- [ ] **T8.2** Without known relay: 2-element `p` tag (no empty third): **YES / NO / SKIPPED**
- [ ] **T8 PASS** — T8.1 YES
---
### T10Outbox AUTH carve-out under load3 min
## T9Group DM shared `rumor.id`5 min
- [ ] **T10.1** Add or select an AUTH-required relay `R`
- [ ] **T10.2** Publish 5 notes rapidly (compose dialog, send-send-send)
- [ ] **T10.3** During AUTH window: all 5 stay in the outbox (no premature drop)
- [ ] **T10.4** Once AUTH completes → all 5 successfully publish on `R`
**Goal:** verify all recipient wraps in a group DM decrypt to a rumor with the SAME `id`.
Pre-branch: after 3 `auth-required:` responses per event, outbox would drop on next `newTry()`.
**Steps:**
**1.** In Amethyst as A, click **`+`** in Chats. Add 3 recipient npubs (you can include yourself as one, plus 2 others whose `kind:10050` is known).
**2.** Type a distinctive message: `t9 group rumor coherence test`. Send.
**3.** For each recipient, use `nak` to fetch the gift wrap from their DM-inbox relay:
```bash
for RECIPIENT in $RECIPIENT_A $RECIPIENT_B $RECIPIENT_C; do
nak req -k 1059 --tag "p=$RECIPIENT" wss://relay.example/ | head -3
done
```
**4.** Ideally decrypt each wrap (requires each recipient's nsec). But since all 3 seals encode the same rumor, the rumor `id` should be identical across the 3 wraps.
**5.** If you have at least 2 recipient nsecs, decrypt via `nak`:
```bash
nak decrypt --sec $NSEC "<encrypted-wrap-content>"
# Look at the inner rumor's "id" field
```
**6.** Compare the rumor `id` across the wraps.
- **Expected:** all 3 rumor `id`s are IDENTICAL.
**7.** (Bonus) Have one of the recipients (in another Amethyst instance or via nak) react to the message with `+`.
**8.** Confirm A and other recipients see the reaction.
- **Expected:** reaction targets the shared `rumor.id` and appears cross-recipient.
Record:
- [ ] **T9.1** All wraps decrypt to same rumor.id: **YES / NO / SKIPPED (needs multi-account decrypt)**
- [ ] **T9.2** Cross-recipient reaction visible: **YES / NO / SKIPPED**
- [ ] **T9 PASS** — T9.1 YES (or explicitly skipped)
---
### T11Bunker `Semaphore(4)` concurrency cap3 min
## T10Outbox AUTH carve-out under load4 min
- [ ] **T11.1** Log in with a NIP-46 bunker account
- [ ] **T11.2** Send a **5-recipient group DM**
- [ ] **T11.3** In nsec.app / Amber, at most 4 in-flight signing requests concurrently
- [ ] **T11.4** Repeat with a local nsec account → all 5 run in parallel
- [ ] **T11.5** Both send flows deliver correctly
**Goal:** verify multiple queued events are NOT silently dropped during AUTH negotiation.
**Steps:**
**1.** In Settings → Relays, ensure you have `wss://pyramid.fiatjaf.com` connected. If you `[Always]`-approved it in T3.c, first log out and back in so the relay reconnects and re-challenges.
**2.** In the compose dialog, publish 5 notes rapidly (10 seconds apart is fine):
```
t10 note 1
t10 note 2
t10 note 3
t10 note 4
t10 note 5
```
**3.** In the terminal, watch for AUTH activity on `pyramid.fiatjaf.com`:
```
[RelayAuthenticator] ... auth-required: ...
[RelayAuthenticator] ... AUTH accepted ...
```
**4.** Once AUTH completes, all 5 notes should publish to `pyramid.fiatjaf.com`.
**5.** Verify by querying `pyramid.fiatjaf.com` for your recent notes:
```bash
nak req -a $A_HEX -k 1 wss://pyramid.fiatjaf.com | head -10
```
- **Expected:** all 5 `t10 note N` events present on `pyramid.fiatjaf.com`.
Record:
- [ ] **T10.1** All 5 notes visible on `pyramid.fiatjaf.com`: **YES / NO**
- [ ] **T10.2** Terminal shows AUTH succeeded before drops: **YES / NO**
- [ ] **T10 PASS** — T10.1 YES
---
### T12kind:1059 subscription has no `since`2 min
## T11Bunker `Semaphore(4)` (bunker users only)3 min
- [ ] **T12.1** Inspect the outgoing REQ for gift wraps (debug tooling or a mock relay)
- [ ] **T12.2** Expected filter: `{"kinds":[1059,1060],"#p":["<your-pubkey>"]}`**no `since` field**
- [ ] **T12.3** Alternative: `nak` or `websocat` proxy connecting to one of your DM-inbox relays
**Skip if you don't have a NIP-46 bunker (nsec.app / Amber).**
Pre-branch: `since` filter dropped wraps whose randomized `created_at` predated it (NIP-17 randomizes up to 2 days back).
**Goal:** verify NIP-17 group DMs are rate-limited to ≤4 concurrent bunker RPCs.
**Steps:**
**1.** Log out. Log in with a bunker (`bunker://` URI).
**2.** Compose a group DM to **5 recipients** (5 different npubs with known `kind:10050`).
**3.** In nsec.app / Amber, watch the request feed as you click Send.
- **Expected:** at most **4** requests in-flight at any moment. Requests process in batches of 4.
**4.** For comparison: log out, log back in with a local nsec. Repeat the 5-recipient send.
- **Expected:** local signer runs all 5 requests in parallel (no Semaphore cap).
Record:
- [ ] **T11.1** Bunker: ≤4 concurrent RPCs: **YES / NO / SKIPPED (no bunker)**
- [ ] **T11.2** Local: fully parallel: **YES / NO / SKIPPED**
- [ ] **T11 PASS** — either both YES or both SKIPPED
---
### T13Pre-send validation alignment + resolver probe (NEW, commit `a139c0fb17`)5 min
## T12kind:1059 subscription has no `since`3 min
The bug this test targets: the pre-send "Recipient has no DM relay list" warning could disagree with the actual send path, and the block persisted even after the peer published `kind:10050` until the conversation was reopened.
**Goal:** verify the outgoing REQ for gift wraps has NO `since` filter (would silently drop old-timestamped wraps).
**T13.a — Strict alignment (a)**
**Steps:**
- [ ] **T13.1** Find a peer whose profile has NO `kind:10050` but DOES have `kind:10002` (NIP-65) with read relays
- [ ] **T13.2** Compose a DM to them
- [ ] **T13.3** **Expected pre-fix:** UI green-lights the send (lenient `dmInboxRelays()` returned the NIP-65 read relays)
- [ ] **T13.4** **Expected post-fix:** UI shows the "no DM relay list" warning + disables Send (strict `dmInboxRelaysStrict()` returns null)
- [ ] **T13.5** Attempting to bypass and send → `DmSendTracker` also fails ("No relays available"), matching the UI
**1.** In one terminal, run a WebSocket relay proxy that echoes traffic (or use `nak` to inspect):
Pass criteria: UI and send path agree — no silent-fail sends.
```bash
# Simplest: read the desktop subscription code directly
grep -n "FilterDMs.giftWrapsToMe\|since" desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt
```
**T13.b — Resolver probe unblocks stale conversations**
- **Expected:** signature `fun giftWrapsToMe(userPubKeyHex: HexKey)`**NO `since` parameter**.
- [ ] **T13.6** Open a DM with a peer whose `kind:10050` is NOT in your LocalCache (fresh contact)
- [ ] **T13.7** UI immediately shows the warning (cache miss)
- [ ] **T13.8** Within ~2s the resolver probes indexer relays and the warning **should clear on its own** if the peer has published `kind:10050`
- [ ] **T13.9** Send button turns from grey to blue → send works
- [ ] **T13.10** For a genuinely no-`kind:10050` peer: warning persists after the probe (~25s wait), send stays disabled
**2.** Live check (harder but definitive): use `mitmproxy` or `websocat` in proxy mode to intercept WebSocket traffic from the app.
**T13.c — Real scenario the fix unblocks**
**3.** Alternatively: rely on the unit tests. Confirm they pass:
- [ ] **T13.11** Log in as B → publish `kind:10050` via the Dns icon in Chats
- [ ] **T13.12** Log out → log in as A
- [ ] **T13.13** Open DM to B immediately (before A's normal feed subscriptions have picked up B's fresh event)
- [ ] **T13.14** Warning shows initially (cache miss) → clears within ~2s (indexer probe finds B's event)
- [ ] **T13.15** Send works
```bash
./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip59Giftwrap.wraps.*"
```
**Session so far:** ✅ User confirmed "ok, works" after the fix landed.
- **Expected:** BUILD SUCCESSFUL.
Record:
- [ ] **T12.1** `giftWrapsToMe` signature has no `since`: **YES / NO**
- [ ] **T12.2** Unit tests pass: **YES / NO**
- [ ] **T12 PASS** — both YES
---
## Phase C — Android sanity (if you have a device)
## T13 — Pre-send alignment + resolver probe (verified working) — sanity re-check, 3 min
### C1 — Commons inheritance check
**Already confirmed** during the pre-launch fix. Quick re-check:
- [ ] **C1.1** `./gradlew :amethyst:installDebug` → Android launches
- [ ] **C1.2** Send a NIP-17 DM from Android → works as before (Android doesn't use `DmInboxRelayResolver` / `DesktopAuthCoordinator`)
- [ ] **C1.3** Android `User.dmInboxRelays()` behaviour unchanged (only added strict variant, didn't remove lenient)
- [ ] **C1.4** Android signing still uses Android-only `AuthCoordinator`
**Steps:**
**1.** In Amethyst as A, open a fresh DM with a recipient whose `kind:10050` is NOT in your LocalCache (e.g. a fresh contact — click a new profile, then compose DM).
**2.** Watch the pre-send row.
- **Expected:** red "no DM relay list" warning appears initially.
**3.** Wait 25 s.
- **Expected:** warning clears on its own (resolver probe found the recipient via indexer). Send button turns blue.
Record:
- [ ] **T13.1** Warning appears initially: **YES / NO**
- [ ] **T13.2** Warning clears within 5 s (resolver worked): **YES / NO**
- [ ] **T13 PASS** — both YES
---
## Sign-off matrix
| # | Test | Automated / Manual | Pass? | Notes |
|---|---|---|---|---|
| A1A5, D1 | Full automated | Claude | ✅ | See companion sheet |
| T1 | Startup wiring | Manual | ✅ | Log lines confirmed for 2 accounts |
| T2 | Tier-1 auto-sign | Manual | | |
| T3 | Tier-2 banner + persistence | Manual | | Banner rendered ✅; padding fixed; persistence flow pending your test |
| T4 | Multiple banners stack | Manual | | |
| T5 | Per-account isolation | Manual | ✅ | Coordinator swaps cleanly per switch |
| **T6** | **P0 SECURITY (no-10050)** | Manual | ✅ | UI blocked as designed |
| **T7** | **F-01 unauth indexer** | Manual | | Highest remaining priority |
| T8 | Relay hint p-tag | Manual | | |
| T9 | Group DM rumor id | Manual | | |
| T10 | Outbox AUTH carve-out | Manual | | |
| T11 | Bunker Semaphore | Manual | | Only if you have a bunker |
| T12 | No since filter | Manual | | |
| **T13** | **Pre-send alignment + probe** | Manual | ✅ | Confirmed working after `a139c0fb17` |
| C1 | Android sanity | Manual | | Optional |
**Overall verdict:** ⬜ pass / ⬜ fail / ⬜ needs revisit
---
## What this branch does NOT ship (out-of-scope)
Explicit follow-ups per the deepening synthesis — NOT regressions:
- Persistent retry queue with exp-backoff (SQLite-backed) — substrate not yet built
- Per-message delivery state in chat bubbles (`✓` `✓✓` `⟳` `⚠`) — `DmSendTracker` still global
- Bunker progress UI wiring — `SigningOpState.Progress` substrate landed but no caller emits per-step counts
- Window-focus re-AUTH coordinator — replaced with lazy reactive AUTH per deepening
- NIP-46 batch `get_conversation_keys` RPC — spec PR proposed in plan, no implementation
- Android UI parity for AUTH banner — Android keeps upstream's Settings-screen approach
- Manual relay-entry dialog when recipient has no 10050 — replaced with Snackbar-equivalent failure
- NIP-09 deletion of self-copies on kind:10050 rotation — release-note disclosure only
- Fix for pre-existing `RelayLatencyTracker.sweep` CME — filed as separate tech-debt task
## Known non-issues (don't flag as bugs)
- `[GiftWrapEvent] Couldn't Decrypt the content ...` debug lines — normal LocalCache trial-decrypt
- VLC `securetransport tls client error` — pre-existing media playback warnings
- `[NIP19 Parser] Issue trying to Decode NIP19 ...` — pre-existing, malformed identifiers in some events
- `DmBroadcastBanner` (different from AUTH banner) may also appear — distinguish by buttons: AUTH has `[Once/Always/Never]`, broadcast has send-progress status
## Sign-off
**Tester:** _______________________
**Date:** _______________________
**Overall:** ⬜ Approved for PR / ⬜ Blocked / ⬜ Needs additional testing
| Test | Pass? | Notes |
|---|---|---|
| Setup | ⬜ | |
| T1 startup wiring | ⬜ | |
| T2 tier-1 self-DM | ⬜ | |
| T3 tier-2 banner (T3.aT3.d) | ⬜ | |
| T4 multiple banners | ⬜ | |
| T5 per-account isolation | ⬜ | |
| **T6 P0 SECURITY** | ⬜ | Highest priority |
| **T7 F-01 unauth indexer** | ⬜ | Highest priority |
| T8 wrap p-tag relay hint | ⬜ | |
| T9 group DM rumor id | ⬜ | |
| T10 outbox AUTH carve-out | ⬜ | |
| T11 bunker Semaphore | ⬜ | Skip if no bunker |
| T12 no since filter | ⬜ | |
| T13 pre-send alignment | ⬜ | |
**Overall:** ⬜ PASS — ready for PR / ⬜ FAIL — see blockers / ⬜ NEEDS REVISIT
**Blockers:** _______________________________________________________
**Tester signature:** _______________________ **Date:** _______________________
---
## Known pre-existing issues (NOT branch regressions)
- **`ConcurrentModificationException` at `RelayLatencyTracker.sweep:182`** during rapid account switching. Kills UI thread; coroutines keep running. Documented in memory `desktop_relay_health_cme_crash`.
- **`NoClassDefFoundError` for `CompressionQuality`** on stale gradle daemon. Fix: `./gradlew --stop && ./gradlew :desktopApp:run`.
## Known non-issues (don't file as bugs)
- `[GiftWrapEvent] Couldn't Decrypt the content …` debug lines — normal LocalCache trial-decrypt for wraps not addressed to you.
- VLC `securetransport tls client error` — pre-existing media playback warnings.
- `[NIP19 Parser] Issue trying to Decode NIP19 …` — pre-existing, malformed identifiers in some events.
- `DmBroadcastBanner` (send-progress) may render simultaneously with `AuthApprovalBanner` — distinguish by buttons: AUTH banner has `Once/Always/Never`; broadcast has send-count status.
## Out of scope for this branch
Explicit follow-ups per the deepening synthesis:
- Persistent retry queue with exp-backoff (SQLite-backed)
- Per-message delivery state in bubbles (`✓` `✓✓` `⟳` `⚠`)
- Bunker progress UI wiring
- Window-focus re-AUTH
- NIP-46 batch `get_conversation_keys` RPC
- Android UI parity for AUTH banner
- Manual relay-entry dialog when recipient has no 10050
- NIP-09 deletion of self-copies on kind:10050 rotation
- Fix for pre-existing `RelayLatencyTracker.sweep` CME