diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index a8ff6d8..97d4d23 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -3778,8 +3778,15 @@ async function main(): Promise { console.error(`Invalid amount "${parsed.amount}" — must be a positive integer in smallest units (no decimals, no leading zeros)`); process.exit(1); } - const { coinId: resolvedCoinId } = resolveCoin(parsed.coin); - assets.push({ coin: [resolvedCoinId, parsed.amount] }); + // AccountingModule.createInvoice validates coinId as + // /^[A-Za-z0-9]+$/ with length ≤20 — i.e. it expects the + // human-readable symbol (UCT, USDU, ...), NOT the 64-char + // hex token-type id that `payments.send` uses. resolveCoin + // is still useful to fail fast on unknown symbols (it + // exits non-zero before invoice mint), but we hand the + // SYMBOL through to the SDK. + const { symbol: resolvedSymbol } = resolveCoin(parsed.coin); + assets.push({ coin: [resolvedSymbol, parsed.amount] }); } else if (nftId) { assets.push({ nft: { tokenId: nftId } }); } @@ -4103,7 +4110,11 @@ async function main(): Promise { if (assetIdx3 !== -1 && args[assetIdx3 + 1]) { const parsed = parseAssetArg(args[assetIdx3 + 1]); returnAmount = parsed.amount; - returnCoinId = resolveCoin(parsed.coin).coinId; + // Same SDK convention as invoice-create — the AccountingModule's + // ReturnPaymentParams.coinId is the symbol (UCT, USDU, ...), not + // the 64-char hex. resolveCoin still validates that the symbol + // is known to the TokenRegistry. + returnCoinId = resolveCoin(parsed.coin).symbol; } else { console.error('--asset " " is required for invoice-return'); process.exit(1); diff --git a/test/integration/cli-assets.integration.test.ts b/test/integration/cli-assets.integration.test.ts new file mode 100644 index 0000000..cf50ccc --- /dev/null +++ b/test/integration/cli-assets.integration.test.ts @@ -0,0 +1,181 @@ +/** + * Integration test: `sphere payments {assets,asset-info}` — token + * registry inspection surface. + * + * These two commands surface the global TokenRegistry, which is + * fetched from a remote URL and cached locally (see + * `NETWORKS[network].tokenRegistryUrl` in sphere-sdk constants.ts + + * `TokenRegistry.configure()` flow). They are NOT per-address — the + * registry is a network-level catalogue of all known coins / NFTs. + * + * SDK-layer coverage for the TokenRegistry itself + * (caching, auto-refresh, race-safe load, waitForReady, etc.) lives + * in sphere-sdk `tests/unit/registry/TokenRegistry.test.ts`. What + * this file pins is the CLI layer: dispatch + output shape + + * arg validation. + * + * Three layers of pins: + * + * 1. **Help-shape pins (offline, 2 tests)** — `payments help ` + * for `assets` and `asset-info`. HELP_TEXT keys ~lines 561-589. + * + * 2. **Arg-validation pin (offline, 1 test)** — `payments asset-info` + * with no identifier exits 1 with "Usage: ..." BEFORE getSphere() + * (~line 2122). `assets` accepts only optional `--type` so has + * no offline arg-validation pin. + * + * 3. **Network registry queries (4 tests)** — fresh testnet wallet + * drives: + * a. `payments assets` lists at least the canonical testnet + * coins (UCT) — proves remote fetch + format. + * b. `payments assets --type fungible` filters out NFTs. + * c. `payments asset-info UCT` returns the UCT entry with + * Symbol/Kind/Coin ID/Network fields populated. + * d. `payments asset-info ` exits 1 with "Asset not + * found" — pins the negative path of the multi-strategy + * lookup (symbol → name → coinId fallthrough). + * + * Note on namespace dispatch: `assets` and `asset-info` are NOT + * top-level commands (not in LEGACY_NAMESPACES). They are only + * reachable via `payments assets` / `payments asset-info` — same + * asymmetric registration as `topup` / `verify-balance`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +describe('sphere-cli — assets / asset-info command shape (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('assets-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + it('`sphere payments help assets` lists --type filter + documented behaviour', () => { + const r = runSphere(env, ['payments', 'help', 'assets'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Usage:.*assets/); + expect(r.stdout).toMatch(/--type/); + // The filter takes "fungible" or "nft" — documented in the flag + // description. Pin both keywords so a refactor that drops one + // option silently flips this red. + expect(r.stdout).toMatch(/fungible/i); + expect(r.stdout).toMatch(/nft/i); + }); + + it('`sphere payments help asset-info` lists the multi-strategy lookup positional', () => { + const r = runSphere(env, ['payments', 'help', 'asset-info'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Usage:.*asset-info/); + // The positional shape `` documents the + // three lookup paths in legacy-cli.ts:2136-2138. If a refactor + // drops one of the three (e.g. removes coinId lookup), the help + // line is the first place users discover that — pin it here. + expect(r.stdout).toMatch(//); + }); +}); + +describe('sphere-cli — asset-info arg validation (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('assets-args'); }); + afterAll(() => { destroySphereEnv(env); }); + + it('`sphere payments asset-info` with no identifier prints usage and exits non-zero', () => { + // Arg check at legacy-cli.ts ~line 2122 fires BEFORE getSphere(), + // so no wallet load. This is the cheapest pin — keeps the + // "did I type the right command" probe offline-fast for users. + const r = runSphere(env, ['payments', 'asset-info'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Usage:\s*asset-info\s*/i); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — assets / asset-info registry queries (real testnet)', + () => { + // One wallet shared — neither command mutates state; both just + // read from the TokenRegistry singleton (populated during the + // first getSphere() call from the remote registry URL). + let env: SphereEnv; + + beforeAll(() => { + env = createSphereEnv('assets-live'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with assets tests'); + } + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere payments assets` lists at least the canonical testnet coins (UCT)', () => { + const r = runSphere(env, ['payments', 'assets'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('assets failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + + // Header row from legacy-cli.ts ~line 2092 — load-bearing for + // column-aligned scrapers. + expect(r.stdout).toMatch(/SYMBOL\s+NAME\s+KIND\s+DECIMALS\s+COIN ID/); + // UCT is the canonical native testnet coin. Its presence proves + // the remote registry fetched successfully — if a network + // outage / cache miss returns an empty registry, this flips red. + expect(r.stdout).toMatch(/^UCT\b/m); + }, 180_000); + + it('`sphere payments assets --type fungible` filters out non-fungible entries', () => { + // The filter at legacy-cli.ts ~lines 2080-2084 maps user input + // (fungible/coin/coins) to `registry.getFungibleTokens()`. The + // result MUST contain UCT (fungible) but MUST NOT contain any + // entry whose KIND column reads "non-fungible". + const r = runSphere(env, ['payments', 'assets', '--type', 'fungible'], { timeoutMs: 120_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^UCT\b/m); + // No row should have KIND = "non-fungible" after the filter. + // The KIND column is the third whitespace-separated field + // (see legacy-cli.ts ~line 2106 padEnd(14)). Match it + // anywhere in the output to detect leaks. + expect(r.stdout).not.toMatch(/non-fungible/); + }, 180_000); + + it('`sphere payments asset-info UCT` returns the canonical fungible asset record', () => { + const r = runSphere(env, ['payments', 'asset-info', 'UCT'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('asset-info UCT failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Four load-bearing fields from the asset-info output block + // (~lines 2149-2155). Each anchors a different invariant: + // - Symbol: UCT — the lookup hit the symbol-strategy branch + // - Kind: fungible — coin classification (not NFT) + // - Coin ID — non-empty hex (registry contract is "ids are + // non-empty hex strings", so pin "hex chars + // present" without binding to the exact value) + // - Network — confirms the testnet registry was loaded + expect(r.stdout).toMatch(/Symbol:\s+UCT/); + expect(r.stdout).toMatch(/Kind:\s+fungible/); + expect(r.stdout).toMatch(/Coin ID:\s+[0-9a-f]{8,}/i); + expect(r.stdout).toMatch(/Network:\s+\S+/); + }, 180_000); + + it('`sphere payments asset-info ` reports "Asset not found" and exits non-zero', () => { + // The negative-lookup path (~line 2140). Multi-strategy lookup + // first tries symbol → name → coinId; failing all three falls + // through to the error block. A regression that returns a + // stale-cached entry or a partial match would flip this red. + const r = runSphere(env, ['payments', 'asset-info', 'NOT_A_REAL_TOKEN_ZZZ'], { timeoutMs: 120_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Asset not found/); + }, 180_000); + }, +); diff --git a/test/integration/cli-faucet.integration.test.ts b/test/integration/cli-faucet.integration.test.ts new file mode 100644 index 0000000..b0f6517 --- /dev/null +++ b/test/integration/cli-faucet.integration.test.ts @@ -0,0 +1,223 @@ +/** + * Integration test: `sphere {topup,top-up,faucet}` — testnet faucet surface. + * + * Backstop for the CLI extraction: the topup / top-up / faucet aliases + * post test tokens against the Unicity faucet HTTP endpoint + * (`https://faucet.unicity.network/api/v1/faucet/request`). All three + * names land in the same fall-through case in `src/legacy/legacy-cli.ts` + * (~line 2942), and `sphere faucet` is namespace-bridged to `topup` in + * `src/index.ts`. SDK-layer coverage doesn't exist for this — the faucet + * client is implemented entirely inside the CLI handler (it doesn't go + * through Sphere/SDK), so this file is the ONLY layer that pins it. + * + * Three layers of pins: + * + * 1. **Help-shape pins (offline, 3 tests)** — `payments help ` + * for each of `topup`, `top-up`, `faucet`. All three help blocks + * live in HELP_TEXT (~lines 597-636). Pinning all three catches a + * refactor that removes one alias's doc without updating the + * dispatch case below. + * + * 2. **No-nametag dispatch pins (wallet init, no HTTP)** — All three + * aliases require a registered nametag before they'll hit the + * faucet API. A fresh wallet has no nametag → command exits 1 with + * "No nametag registered" stderr message BEFORE any fetch is made. + * Running each alias and asserting the same error proves: + * a. the namespace bridge (`sphere faucet` → `topup`) is wired, + * b. all three legacy-CLI fall-through cases land on the same + * handler (the case label union — if a refactor splits them, + * only one alias would still error this way), + * c. the nametag precondition fires before the faucet round-trip + * so a user with a broken-or-rate-limited faucet endpoint + * still gets a clean "wallet needs a nametag" message. + * No faucet HTTP call is made — wallet init talks to Nostr + + * aggregator only. + * + * 3. **Live faucet request (opt-in, E2E_RUN_FAUCET=1)** — When the + * env var is set, register a fresh `it_` nametag and request + * a small amount of unicity (UCT) from the faucet. Asserts a + * "✓ Received" success line. Gated because (a) the faucet has rate + * limits and drain protection, (b) external service flakiness + * shouldn't break the default test suite, (c) it consumes real + * testnet tokens. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +/** Opt-in gate for the live faucet round-trip — disabled by default. */ +const RUN_FAUCET_E2E = process.env['E2E_RUN_FAUCET'] === '1'; + +/** + * The three CLI verbs that all dispatch to the same `legacy-cli.ts` + * case block (~line 2942: `case 'topup': case 'top-up': case 'faucet':`). + * + * Registration is asymmetric — `faucet` is the only one in + * `LEGACY_NAMESPACES` (src/index.ts:32), so it's the only bare top-level + * verb; `topup` and `top-up` are reachable only as `payments topup` / + * `payments top-up` (commander strips the `payments` namespace and + * forwards the rest to the legacy dispatcher). For each alias we record + * both the alias name (for help-text lookup, which goes through a + * different unified path) and the runnable argv (for dispatch). + */ +const FAUCET_ALIASES: ReadonlyArray<{ + /** The legacy command name, also the HELP_TEXT key. */ + readonly alias: 'topup' | 'top-up' | 'faucet'; + /** Human-readable form of `invoke` for test names — `sphere `. */ + readonly cmd: string; + /** Argv to invoke `sphere ...` so dispatch reaches the topup handler. */ + readonly invoke: readonly string[]; +}> = [ + // `sphere faucet` — registered top-level (bridge maps to `topup` in + // legacy argv, but the case label is reachable from any of the three + // names via fall-through). + { alias: 'faucet', cmd: 'faucet', invoke: ['faucet'] }, + // `sphere payments topup` — `payments` namespace strips its name and + // forwards `topup` to legacy. The bare `sphere topup` is NOT + // registered as a top-level command and will fail with "unknown + // command", so we explicitly route through `payments`. + { alias: 'topup', cmd: 'payments topup', invoke: ['payments', 'topup'] }, + // `sphere payments top-up` — same reasoning as `topup`. The + // `top-up` HELP_TEXT entry tells users this is an alias, but the + // dispatch path is the same fall-through case. + { alias: 'top-up', cmd: 'payments top-up', invoke: ['payments', 'top-up'] }, +]; + +describe('sphere-cli — faucet/topup command shape (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('faucet-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + it.each(FAUCET_ALIASES)('`sphere payments help $alias` lists documented usage', ({ alias }) => { + const r = runSphere(env, ['payments', 'help', alias], { timeoutMs: 15_000 }); + // Help dispatch is offline — pure HELP_TEXT lookup. Non-zero exit + // means an alias's help entry was dropped (~lines 597-636 of + // legacy-cli.ts). + expect(r.status).toBe(0); + // Pin the usage line and the `[ ]` positional shape + // — load-bearing for users scripting `topup 100 UCT` etc. + expect(r.stdout).toMatch(new RegExp(`Usage:.*${alias}`)); + expect(r.stdout).toMatch(/\[\s+\]/); + // The shared description (in `topup`) or alias note (in `top-up` + // and `faucet`) MUST reference the faucet — that's the verb's + // entire purpose, and the doc-string is what users grep when they + // forget which command requests tokens. + expect(r.stdout).toMatch(/faucet/i); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — faucet without nametag (real wallet, no HTTP)', + () => { + // One wallet shared across all three alias tests. The fresh wallet + // has no nametag → every faucet alias should bail BEFORE making + // an HTTP request. We don't tear down + re-init between aliases + // because none of them mutate wallet state on the error path. + let env: SphereEnv; + + beforeAll(() => { + env = createSphereEnv('faucet-no-nametag'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with faucet error-path tests'); + } + // Sanity: the wallet has NO nametag — confirms we're testing the + // pre-faucet error path, not a stale-state regression. + const myNt = runSphere(env, ['nametag', 'my'], { timeoutMs: 60_000 }); + expect(myNt.stdout).toMatch(/No nametag registered/i); + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it.each(FAUCET_ALIASES)( + '`sphere $cmd` on a wallet without nametag prints error and exits non-zero', + ({ alias, invoke }) => { + const r = runSphere(env, [...invoke], { timeoutMs: 60_000 }); + + // Exit code is the load-bearing signal for scripts wrapping + // `sphere faucet` to detect "must register nametag first" vs. + // a transient faucet outage. If the precondition check is + // moved AFTER the HTTP call, this exit-code shape changes + // (faucet failures exit 0 in the current handler). + expect(r.status).not.toBe(0); + + const out = `${r.stdout}\n${r.stderr}`; + // Exact wording from legacy-cli.ts ~2950: + // "Error: No nametag registered. Use \"nametag \" first." + // Match on the load-bearing prefix without binding to the exact + // "Use ..." hint — the hint may legitimately evolve to suggest + // `sphere nametag register ` (new namespace) instead of + // the legacy `nametag ` form. + expect(out, `${alias} should error on missing nametag`).toMatch( + /No nametag registered/i, + ); + }, + ); + }, +); + +describe.skipIf(integrationSkip || !RUN_FAUCET_E2E)( + 'sphere-cli integration — live faucet request (E2E_RUN_FAUCET=1)', + () => { + // Gated separately from the default suite because: + // - external faucet may be rate-limited / down + // - request consumes real testnet tokens + // - the on-chain nametag mint adds ~20s to the test even when + // the faucet itself succeeds. + let env: SphereEnv; + const randomName = `it_${randomBytes(4).toString('hex')}`; + + beforeAll(() => { + env = createSphereEnv('faucet-live'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + throw new Error(`wallet init failed:\n${init.stderr}`); + } + // Register a nametag — required precondition for the faucet API. + // Reuses the same on-chain registration path pinned in + // cli-nametag.integration.test.ts (which is the SDK-layer pin + // for this dependency; we don't re-assert it here). + const reg = runSphere(env, ['nametag', 'register', randomName], { timeoutMs: 180_000 }); + if (reg.status !== 0) { + throw new Error(`nametag register failed:\n${reg.stderr}`); + } + }, 240_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere faucet 1 UCT` returns a success line for the requested coin', () => { + // Request a small amount of UCT — the faucet's native testnet + // token. `UCT` is mapped via `FAUCET_COIN_MAP` to the `unicity` + // faucet name (see legacy-cli.ts ~2996), so this also pins the + // symbol→faucet-name resolution path. + const r = runSphere(env, ['faucet', '1', 'UCT'], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('faucet request failed', { stdout: r.stdout, stderr: r.stderr }); + } + // Note: the handler does NOT exit non-zero on faucet API failure + // (see ~3005-3007: it logs "✗ Failed" but doesn't process.exit). + // We assert on stdout content, not just status, so a silent + // failure flips this red. + expect(r.status).toBe(0); + + // Two load-bearing log lines from the specific-coin branch + // (~lines 3000-3007): + // "Requesting from faucet for @..." (always) + // "✓ Received " (success) + // Match the success suffix with the unicity faucet name — the + // ✓ glyph is non-ASCII; pin the "Received" word instead so a + // --no-emoji refactor doesn't flip red over cosmetics. + expect(r.stdout).toMatch(/Requesting 1 unicity from faucet/i); + expect(r.stdout).toMatch(/Received 1 unicity/i); + }, 120_000); + }, +); diff --git a/test/integration/cli-invoice.integration.test.ts b/test/integration/cli-invoice.integration.test.ts new file mode 100644 index 0000000..a91a328 --- /dev/null +++ b/test/integration/cli-invoice.integration.test.ts @@ -0,0 +1,258 @@ +/** + * Integration test: `sphere invoice ...` — AccountingModule CLI surface. + * + * Backstop for the CLI extraction: when the in-tree sphere-sdk CLI was + * deleted, the invoice/accounting surface lost binary-level coverage even + * though `AccountingModule` itself is well-tested at the SDK layer (see + * sphere-sdk `tests/unit/modules/AccountingModule.*.test.ts`). This file + * pins the CLI plumbing — namespace bridge, arg parsing, exit codes, help + * text — that sits between the user and the SDK module. + * + * Three layers of pins: + * + * 1. **Help-shape pins (offline)** — `sphere payments help ` + * returns the legacy help block. We assert the documented flags + + * positionals so a refactor that renames a flag (e.g. `--target` → + * `--to`) flips this red before silently breaking caller scripts. + * Cheap (<1s each, no wallet, no network). + * + * 2. **Arg-validation pins (offline-ish)** — Several invoice subcommands + * validate their first positional BEFORE calling `getSphere()` (see + * `src/legacy/legacy-cli.ts` invoice-status / invoice-close / + * invoice-cancel / invoice-pay cases). Running them with no id from a + * fresh tmp profile exits with "Usage: ..." before any wallet load. + * Pinning these guards prevents a refactor from reordering the wallet + * load above the arg check (which would force every "did I type the + * right command" probe to go through Sphere.init). + * + * 3. **End-to-end lifecycle pin (network)** — One real wallet, real + * aggregator, real Nostr publish. Drives create → list → status → + * close on a self-targeted invoice. Pins the full path: + * - namespace bridge (`invoice create` → `invoice-create`) + * - `getSphere()` Sphere.init with `accounting: true` + * - `sphere.accounting.createInvoice()` mints an on-chain token + * - prefix-based id resolution for status/close + * - state machine: OPEN → CLOSED transition + * Self-targeted because invoice creation does not require a recipient + * balance; we just need the address to be valid. + * + * Funded payment cycle (create → pay → COVERED) is deliberately NOT pinned + * here — that requires a funded sender wallet + nametag + multi-process + * orchestration, which is the SDK module's domain. See sphere-sdk + * `tests/unit/modules/AccountingModule.lifecycle.test.ts` for that pin. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +/** + * Subcommands of `sphere invoice ` and the legacy command name they + * bridge to. Keep in sync with `src/index.ts` namespace bridge — when a + * subcommand gets renamed or removed, this map is the single point of + * update for the help-shape sweep below. + */ +const INVOICE_SUBCOMMANDS: ReadonlyArray<{ + /** Legacy command name (what `payments help ` accepts). */ + readonly legacy: string; + /** Regex(es) that MUST appear in help output — flags, positionals, etc. */ + readonly mustMatch: RegExp[]; +}> = [ + { + legacy: 'invoice-create', + mustMatch: [/--target/, /--asset/, /--memo/, /--due/, /--terms/, /--nft/, /--delivery/], + }, + { legacy: 'invoice-import', mustMatch: [//] }, + { legacy: 'invoice-list', mustMatch: [/--state/, /--role/, /--limit/, /OPEN/, /CLOSED/] }, + { legacy: 'invoice-status', mustMatch: [//] }, + { legacy: 'invoice-close', mustMatch: [//, /--auto-return/] }, + { legacy: 'invoice-cancel', mustMatch: [//] }, + { legacy: 'invoice-pay', mustMatch: [//, /--amount/, /--target-index/] }, + { legacy: 'invoice-return', mustMatch: [//, /--recipient/, /--asset/] }, + { legacy: 'invoice-receipts', mustMatch: [//] }, + { legacy: 'invoice-notices', mustMatch: [//] }, + { legacy: 'invoice-auto-return', mustMatch: [/--enable/, /--disable/, /--invoice/] }, + { legacy: 'invoice-transfers', mustMatch: [//] }, + { legacy: 'invoice-export', mustMatch: [//] }, + { legacy: 'invoice-parse-memo', mustMatch: [//, /INV:/] }, +]; + +describe('sphere-cli — invoice command shape (offline)', () => { + // One env reused across the offline block — these don't write to disk, + // and `payments help` doesn't even read the wallet, so a single throwaway + // home is sufficient and keeps the suite under 5s total offline. + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('invoice-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + for (const { legacy, mustMatch } of INVOICE_SUBCOMMANDS) { + it(`\`sphere payments help ${legacy}\` lists documented flags + positionals`, () => { + const r = runSphere(env, ['payments', 'help', legacy], { timeoutMs: 15_000 }); + // Help dispatch is offline. If this exits non-zero, the help block + // for this subcommand was removed from `src/legacy/legacy-cli.ts`'s + // HELP_TEXT map — which usually means the command was renamed or + // deleted without updating the docs. + expect(r.status).toBe(0); + // Documented usage line — load-bearing for users scripting against + // the CLI. Per-flag pins below catch refactors that change one flag + // name without touching the usage line. + expect(r.stdout).toMatch(new RegExp(`Usage:.*${legacy}`)); + for (const re of mustMatch) { + expect(r.stdout, `${legacy} help missing ${re}`).toMatch(re); + } + }); + } +}); + +describe('sphere-cli — invoice arg validation (offline)', () => { + // These cases check args BEFORE `getSphere()` in src/legacy/legacy-cli.ts: + // invoice-status (line ~3907), invoice-close (line ~3941), + // invoice-cancel, invoice-pay. So missing positional → usage exit + // without any wallet load or network call. + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('invoice-args'); }); + afterAll(() => { destroySphereEnv(env); }); + + it.each([ + ['status', 'invoice-status'], + ['close', 'invoice-close'], + ['cancel', 'invoice-cancel'], + ['pay', 'invoice-pay'], + ['return', 'invoice-return'], + ['receipts', 'invoice-receipts'], + ['notices', 'invoice-notices'], + ['transfers','invoice-transfers'], + ['export', 'invoice-export'], + ])('`sphere invoice %s` with no id prints usage and exits non-zero', (sub, legacyName) => { + const r = runSphere(env, ['invoice', sub], { timeoutMs: 15_000 }); + + // Exit code is the load-bearing assertion — scripts wrapping + // `sphere invoice $id` rely on it for failure detection when + // $id is empty. + expect(r.status).not.toBe(0); + + const out = `${r.stdout}\n${r.stderr}`; + // The legacy CLI prints "Usage: ..." to stderr. If a + // refactor moves the arg check below the wallet load, this regex + // flips red (the user would instead see "No wallet exists ..."). + expect(out, `${sub} should show usage hint`).toMatch( + new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i'), + ); + }); + + it('`sphere invoice parse-memo` with no memo prints usage and exits non-zero', () => { + // parse-memo's case also validates `args[1]` before wallet load. + const r = runSphere(env, ['invoice', 'parse-memo'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Usage:\s*invoice-parse-memo|usage:\s*invoice-parse-memo/i); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — invoice lifecycle (real testnet)', + () => { + let env: SphereEnv; + let directAddress: string | null = null; + let invoiceId: string | null = null; + + beforeAll(() => { + env = createSphereEnv('invoice-lifecycle'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with invoice lifecycle tests'); + } + const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`); + directAddress = match[1]!; + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere invoice list` on a fresh wallet returns "No invoices found"', () => { + const r = runSphere(env, ['invoice', 'list'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('empty invoice list failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Exact wording from legacy-cli.ts invoice-list case. If the empty + // message changes, this pin needs to extend, not delete. + expect(r.stdout).toMatch(/No invoices found/i); + }, 180_000); + + it('`sphere invoice create --target --asset "1000000 UCT"` mints an invoice', () => { + expect(directAddress).toBeTruthy(); + + const r = runSphere( + env, + ['invoice', 'create', '--target', directAddress!, '--asset', '1000000 UCT', '--memo', 'integration-test'], + { timeoutMs: 180_000 }, + ); + + if (r.status !== 0) { + console.error('invoice create failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Legacy CLI prints "Invoice created:" then the JSON.stringify of + // the result, which includes an `invoiceId` field. Extract it for + // the downstream status / close pins. + expect(r.stdout).toMatch(/Invoice created:/); + const idMatch = r.stdout.match(/"invoiceId":\s*"([0-9a-fA-F]+)"/); + expect(idMatch, `invoiceId not found in output:\n${r.stdout}`).toBeTruthy(); + invoiceId = idMatch![1]!; + // Invoice token id is hex, ≥ 64 chars (state-transition-sdk token + // ids are prefixed by a fixed-length type tag in front of the + // 32-byte content hash, so the on-the-wire form is longer than the + // SHA-256 used for memo refs). Pin "hex-only, sane length" — a + // regression that returns a truncated/empty id or a non-hex token + // id flips red without overfitting to the exact prefix scheme. + expect(invoiceId).toMatch(/^[0-9a-f]{64,80}$/); + }, 240_000); + + it('`sphere invoice list` shows the freshly created invoice', () => { + expect(invoiceId).toBeTruthy(); + const r = runSphere(env, ['invoice', 'list'], { timeoutMs: 120_000 }); + expect(r.status).toBe(0); + // Output lists `ID: ` for each invoice (see legacy-cli.ts + // invoice-list output block). Match on the prefix we captured. + expect(r.stdout).toContain(invoiceId!); + expect(r.stdout).toMatch(/Invoices \(1\)/); + }, 180_000); + + it('`sphere invoice status ` reports state OPEN with no payments', () => { + expect(invoiceId).toBeTruthy(); + // Use the documented 8-char prefix shape from the help examples + // (`invoice-status a1b2c3d4`). Pins the prefix-resolution path + // through `getInvoices().filter(startsWith)` in invoice-status. + const prefix = invoiceId!.slice(0, 12); + const r = runSphere(env, ['invoice', 'status', prefix], { timeoutMs: 120_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Invoice Status:/); + // OPEN is the entry state — invoice was just minted, no payments yet. + expect(r.stdout).toMatch(/"state":\s*"OPEN"/); + }, 180_000); + + it('`sphere invoice close ` moves the invoice to CLOSED', () => { + expect(invoiceId).toBeTruthy(); + const prefix = invoiceId!.slice(0, 12); + const r = runSphere(env, ['invoice', 'close', prefix], { timeoutMs: 180_000 }); + if (r.status !== 0) { + console.error('invoice close failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + + // Verify the state transition stuck — `invoice status` now reports CLOSED. + const status = runSphere(env, ['invoice', 'status', prefix], { timeoutMs: 120_000 }); + expect(status.status).toBe(0); + expect(status.stdout).toMatch(/"state":\s*"CLOSED"/); + }, 360_000); + }, +); diff --git a/test/integration/cli-l1.integration.test.ts b/test/integration/cli-l1.integration.test.ts new file mode 100644 index 0000000..000c490 --- /dev/null +++ b/test/integration/cli-l1.integration.test.ts @@ -0,0 +1,116 @@ +/** + * Integration test: `sphere payments l1-balance` — L1 (ALPHA blockchain) surface. + * + * Scope note: the L1 surface exposed by this CLI is intentionally narrow. + * Only `l1-balance` is wired through `legacy-cli.ts` (~line 2168). There + * is NO `l1-send`, `l1-history`, or `l1-receive` command at the binary + * layer — those operations are still available via the SDK (see + * `L1PaymentsModule` in sphere-sdk) but are not exposed as CLI verbs. + * This file pins the one CLI surface that exists. + * + * SDK-layer coverage for L1 balance retrieval, Fulcrum WebSocket + * connection, vesting classification, etc., lives in sphere-sdk + * `tests/unit/l1/*.test.ts`. What this file pins is the CLI plumbing: + * the legacy-CLI dispatch, the L1-module presence check, and the + * human-readable output format that wallet scripts grep. + * + * Two layers of pins: + * + * 1. **Help-shape pin (offline)** — `sphere payments help l1-balance` + * returns the legacy help block with the usage line and the + * "Fulcrum" connection hint that signals to users this command + * will reach out to the electrum server on first call. + * + * 2. **End-to-end pin (network)** — Fresh testnet wallet → run + * `payments l1-balance` → assert the formatted output block: + * - "L1 (ALPHA) Balance:" header + * - "Confirmed: ALPHA" + * - "Unconfirmed: ALPHA" + * A fresh wallet has zero L1 balance, so we don't need any funding + * precondition — the assertion is purely on the output shape, not + * a non-zero value. + * + * Note on `payments.l1`: the L1 module is created automatically by the + * default Sphere.init() flow (see CLAUDE.md "What's Included by Default" + * → "L1 (ALPHA blockchain): Enabled, lazy Fulcrum connect"). So the + * "L1 module not available" error path in legacy-cli.ts l1-balance case + * (line ~2171) is unreachable through this CLI's normal init path. We + * deliberately do NOT pin it; pinning unreachable error paths leads to + * brittle tests that flip red on refactors of code nobody runs. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +describe('sphere-cli — l1-balance command shape (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('l1-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + it('`sphere payments help l1-balance` lists documented usage', () => { + const r = runSphere(env, ['payments', 'help', 'l1-balance'], { timeoutMs: 15_000 }); + // Help dispatch is fully offline — wallet not loaded, no network. + // Non-zero exit means the HELP_TEXT entry was deleted (almost always + // a rename or accidental drop). + expect(r.status).toBe(0); + // Pin the usage line — load-bearing for `--help` parsers. + expect(r.stdout).toMatch(/Usage:.*l1-balance/); + // Pin two pieces of documented behaviour: + // - "ALPHA" — names the L1 token symbol, distinguishes L1 from L3. + // - "Fulcrum" — signals to users the command opens a WebSocket + // to an electrum server on first call (load-bearing for ops / + // network-policy decisions). + expect(r.stdout).toMatch(/ALPHA/); + expect(r.stdout).toMatch(/Fulcrum/i); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — l1-balance (real testnet)', + () => { + let env: SphereEnv; + + beforeAll(() => { + env = createSphereEnv('l1-balance-live'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with l1-balance test'); + } + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere payments l1-balance` returns formatted balance block on fresh wallet', () => { + // Generous timeout — first L1 op opens a Fulcrum WebSocket and + // performs handshake + UTXO query. Subsequent calls reuse the + // connection, but this is the very first call in a fresh process. + const r = runSphere(env, ['payments', 'l1-balance'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('l1-balance failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + + // Three load-bearing output pins from legacy-cli.ts l1-balance + // case (~line 2178-2182): + // "L1 (ALPHA) Balance:" + // "Confirmed: ALPHA" + // "Unconfirmed: ALPHA" + // A fresh wallet's balance is zero, so we don't assert any + // numeric value — just the line structure. The numeric format + // goes through `toHumanReadable()`, which emits e.g. "0" or + // "0.00000000" depending on coin scale; pin the " + // ALPHA" shape without overfitting to a specific decimal count. + expect(r.stdout).toMatch(/L1 \(ALPHA\) Balance:/); + expect(r.stdout).toMatch(/Confirmed:\s+[\d.]+\s+ALPHA/); + expect(r.stdout).toMatch(/Unconfirmed:\s+[\d.]+\s+ALPHA/); + }, 180_000); + }, +); diff --git a/test/integration/cli-multiaddress.integration.test.ts b/test/integration/cli-multiaddress.integration.test.ts new file mode 100644 index 0000000..430d911 --- /dev/null +++ b/test/integration/cli-multiaddress.integration.test.ts @@ -0,0 +1,377 @@ +/** + * Integration test: `sphere payments {addresses,switch,hide,unhide}` — + * multi-address surface, with explicit proof of cross-address ISOLATION. + * + * This test pins two distinct concerns: + * + * A) **CLI plumbing** — namespace bridge, arg validation, help text + * for the four multi-address commands. + * + * B) **Token / asset isolation invariant** — tokens belonging to + * address #N must NEVER be visible from address #M (N ≠ M) after + * a `switch`. This is a security-critical guarantee: a leak would + * mean a user who switched to a fresh address could accidentally + * spend tokens that belong to a different HD branch (or vice-versa, + * receive tokens into the wrong branch and lose track of them). + * + * The architectural mechanism for this is per-address token + * storage: in Node.js the FileTokenStorageProvider keeps a + * separate `tokens//` subdirectory per tracked + * address (in the browser, `sphere-token-storage-{addressId}`). + * `sphere.payments.getTokens()` always reads from the storage + * bound to the currently-active address — so as long as the + * directory split is honoured, isolation holds. + * + * We pin this two ways: + * 1. **Filesystem inspection (no funding required)** — after + * switching from #0 to #1, the on-disk `tokens/` directory + * must contain TWO distinct subdirectories. If the SDK ever + * regresses to a single shared store, this flips red without + * needing real tokens. + * 2. **End-to-end token visibility (gated by E2E_RUN_FAUCET=1)** — + * faucet 1 UCT at #0, switch to #1, confirm `payments tokens` + * shows "No tokens found", switch back to #0, confirm the UCT + * is still there. This is the gold-standard proof — it + * catches any regression that breaks the per-address read + * binding, not just the directory split. + * + * Four layers of pins: + * + * 1. **Help-shape pins (offline, 4 tests)** — one per command. + * legacy-cli.ts HELP_TEXT keys: addresses / switch / hide / unhide + * (~lines 707-735). + * + * 2. **Arg-validation pins (offline, 4 tests)** — switch/hide/unhide + * validate `args[1]` BEFORE getSphere() (~lines 2538, 2565, 2579). + * switch additionally checks `isNaN(index) || index < 0` after + * parsing (~line 2545). Both guards run before any wallet load. + * + * 3. **Stateful local lifecycle (network-light, ~6 tests)** — fresh + * wallet → addresses shows #0 → switch 1 creates + activates #1 + * → addresses lists both → on-disk `tokens/` has two subdirs → + * hide/unhide round-trip → switch back to #0. + * + * 4. **Token isolation invariant (opt-in, E2E_RUN_FAUCET=1, ~4 tests)** — + * see (B) above. Requires registering a nametag (~20s on-chain + * mint) plus a faucet call (~5s), so gated. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +/** Opt-in gate for the funded isolation proof. */ +const RUN_FAUCET_E2E = process.env['E2E_RUN_FAUCET'] === '1'; + +/** + * Help-shape sweep — legacy-cli.ts HELP_TEXT keys for the four + * multi-address commands and one regex apiece that pins documented + * behaviour. Keep in sync with HELP_TEXT (~lines 707-735). + */ +const MULTIADDR_HELP_PINS: ReadonlyArray<{ + readonly legacy: string; + readonly mustMatch: RegExp[]; +}> = [ + { legacy: 'addresses', mustMatch: [/tracked/i, /HD/] }, + { legacy: 'switch', mustMatch: [//, /HD/i] }, + { legacy: 'hide', mustMatch: [//, /hidden/i] }, + { legacy: 'unhide', mustMatch: [//, /[Uu]nhide/] }, +]; + +describe('sphere-cli — multiaddress command shape (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('multiaddr-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + for (const { legacy, mustMatch } of MULTIADDR_HELP_PINS) { + it(`\`sphere payments help ${legacy}\` lists documented usage`, () => { + const r = runSphere(env, ['payments', 'help', legacy], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(new RegExp(`Usage:.*${legacy}`)); + for (const re of mustMatch) { + expect(r.stdout, `${legacy} help missing ${re}`).toMatch(re); + } + }); + } +}); + +describe('sphere-cli — multiaddress arg validation (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('multiaddr-args'); }); + afterAll(() => { destroySphereEnv(env); }); + + it.each([ + // No-index cases (~lines 2538, 2565, 2579): missing positional → + // "Usage: " exit 1 BEFORE getSphere(). + ['payments switch (no index)', ['payments', 'switch'], 'switch'], + ['payments hide (no index)', ['payments', 'hide'], 'hide'], + ['payments unhide (no index)', ['payments', 'unhide'], 'unhide'], + ])('`sphere %s` prints usage and exits non-zero', (_label, argv, legacyName) => { + const r = runSphere(env, argv, { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out, `${legacyName} should show usage hint`).toMatch( + new RegExp(`Usage:\\s*${legacyName}\\s*`, 'i'), + ); + }); + + it('`sphere payments switch abc` rejects non-numeric index with "Invalid index"', () => { + // The second arg-validation guard in the switch case (~line 2545): + // if (isNaN(index) || index < 0) { console.error('Invalid index...'); exit(1); } + // Runs AFTER `parseInt(indexStr)` but still BEFORE getSphere(), so + // no wallet load. Pin this to catch refactors that demote it to + // a "let the SDK reject it" path (which would produce a different + // error message and a different exit code). + const r = runSphere(env, ['payments', 'switch', 'abc'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Invalid index/i); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — address lifecycle and on-disk isolation (real wallet)', + () => { + // One wallet shared across all stateful tests — state evolves: #0 + // (fresh) → switch 1 → #1 active → hide #1 → unhide #1 → switch 0. + // Tests must run in order. Vitest serializes `it` blocks within a + // describe by default, so this is safe. + let env: SphereEnv; + /** directAddress at index #0, captured during wallet init. */ + let directAddr0: string | null = null; + /** directAddress at index #1, captured after first switch. */ + let directAddr1: string | null = null; + + beforeAll(() => { + env = createSphereEnv('multiaddr-lifecycle'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with multiaddress lifecycle'); + } + const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`); + directAddr0 = match[1]!; + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere payments addresses` on fresh wallet shows only #0 (active)', () => { + const r = runSphere(env, ['payments', 'addresses'], { timeoutMs: 60_000 }); + expect(r.status).toBe(0); + // Header + footer pin the output frame shape (separator widths + // are load-bearing for column-aligned scrapers). + expect(r.stdout).toMatch(/Tracked Addresses:/); + // The active marker `→ ` precedes the active address line. On + // a fresh wallet, only #0 exists and is active. + expect(r.stdout).toMatch(/→\s*#0:/); + // No #1 line should exist yet — proves we're not seeing stale + // state from a prior run leaking into this test. + expect(r.stdout).not.toMatch(/#1:/); + }, 120_000); + + it('`sphere payments switch 1` activates a new address with a DIFFERENT directAddress', () => { + const r = runSphere(env, ['payments', 'switch', '1'], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('switch 1 failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Confirmation line from the switch case (~line 2554). + expect(r.stdout).toMatch(/Switched to address #1/); + const match = r.stdout.match(/DIRECT:\s+(DIRECT:\/\/[0-9a-fA-F]+)/); + expect(match, `directAddress not in switch output:\n${r.stdout}`).toBeTruthy(); + directAddr1 = match![1]!; + // ISOLATION INVARIANT — pin 1: HD derivation MUST produce a + // different directAddress for index #1 than #0. If two HD + // indices ever derive to the same address, address-level + // separation is broken at the cryptographic layer. + expect(directAddr1).not.toBe(directAddr0); + }, 120_000); + + it('`sphere payments addresses` after switch lists BOTH #0 and #1 with #1 active', () => { + const r = runSphere(env, ['payments', 'addresses'], { timeoutMs: 60_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/#0:/); + // Active marker now precedes #1, not #0. + expect(r.stdout).toMatch(/→\s*#1:/); + // And the inverse — #0 line should be present but NOT marked + // active (the marker is `→ ` followed by `#N:`; a non-active + // line has two spaces or whitespace). + expect(r.stdout).not.toMatch(/→\s*#0:/); + }, 120_000); + + it('on-disk per-address token storage: switch creates a SEPARATE tokens/ subdirectory', () => { + // ISOLATION INVARIANT — pin 2: Node.js FileTokenStorageProvider + // keeps a separate `tokens//` per tracked address. + // After init at #0 + switch to #1, the tokens dir MUST contain + // exactly two subdirectories. A regression that shares one + // store across HD branches would shrink this to one entry, and + // the funded leak test (gated below) would also catch it — but + // this no-network filesystem pin is the cheapest and earliest + // signal. + const tokensDir = join(env.home, '.sphere-cli', 'tokens'); + const subdirs = readdirSync(tokensDir); + // Each entry should be a DIRECT_<6hex>_<6hex> directory keyed + // by addressId (see e.g. `DIRECT_000044_9ec9d7`). The exact + // format isn't load-bearing; what's load-bearing is the count + // and the fact that they're distinct. + expect(subdirs.length, `tokens dir should have 2 per-address subdirs after switch, got ${subdirs.length}: ${subdirs.join(', ')}`).toBe(2); + expect(new Set(subdirs).size, 'address subdirs must be distinct').toBe(2); + // Belt-and-braces: every subdir name should be DIRECT-shaped. + // If a non-DIRECT entry sneaks in (e.g. a tempfile dropped at + // the wrong level), this catches it before it confuses sync. + for (const dir of subdirs) { + expect(dir, `unexpected non-address entry in tokens/: ${dir}`).toMatch(/^DIRECT_/); + } + }); + + it('`sphere payments hide 1` marks #1 [hidden] in the addresses listing', () => { + const hide = runSphere(env, ['payments', 'hide', '1'], { timeoutMs: 60_000 }); + expect(hide.status).toBe(0); + expect(hide.stdout).toMatch(/Address #1 hidden/); + + const list = runSphere(env, ['payments', 'addresses'], { timeoutMs: 60_000 }); + expect(list.status).toBe(0); + // The `[hidden]` marker is appended on the address line + // (legacy-cli.ts ~line 2524). #1 should still be the active + // address (hide doesn't change active selection) but now + // tagged as hidden. + expect(list.stdout).toMatch(/#1:.*\[hidden\]/); + }, 120_000); + + it('`sphere payments unhide 1` removes the [hidden] marker', () => { + const unhide = runSphere(env, ['payments', 'unhide', '1'], { timeoutMs: 60_000 }); + expect(unhide.status).toBe(0); + expect(unhide.stdout).toMatch(/Address #1 unhidden/); + + const list = runSphere(env, ['payments', 'addresses'], { timeoutMs: 60_000 }); + expect(list.status).toBe(0); + // #1 line should be present without the [hidden] suffix. + // Match #1's full line and assert "hidden" is absent from it. + const line1 = list.stdout.split('\n').find((l) => /^\s*[→\s]?\s*#1:/.test(l)); + expect(line1, `no #1 line in addresses output:\n${list.stdout}`).toBeTruthy(); + expect(line1!).not.toMatch(/\[hidden\]/); + }, 120_000); + + it('`sphere payments switch 0` returns to the original directAddress (no leak)', () => { + const r = runSphere(env, ['payments', 'switch', '0'], { timeoutMs: 60_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Switched to address #0/); + // ISOLATION INVARIANT — pin 3: HD derivation is deterministic. + // Switching back to #0 must reproduce the exact original + // directAddress — proves wallet state for #0 was preserved + // intact while we were operating on #1, and confirms no + // cross-pollination of identity material. + const match = r.stdout.match(/DIRECT:\s+(DIRECT:\/\/[0-9a-fA-F]+)/); + expect(match, `directAddress not in switch output:\n${r.stdout}`).toBeTruthy(); + expect(match![1]).toBe(directAddr0); + }, 120_000); + }, +); + +describe.skipIf(integrationSkip || !RUN_FAUCET_E2E)( + 'sphere-cli integration — token isolation across addresses (E2E_RUN_FAUCET=1)', + () => { + // Funded proof of the isolation invariant. Requires: + // - wallet init (~5s) + // - on-chain nametag mint (~20s — required by faucet) + // - faucet request (~5s) + // - 3 token-list calls (~1s each) + // Total: ~35s for the full leak-proof loop. + let env: SphereEnv; + const randomName = `it_${randomBytes(4).toString('hex')}`; + + beforeAll(async () => { + env = createSphereEnv('multiaddr-isolation-live'); + + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) throw new Error(`wallet init failed:\n${init.stderr}`); + + const reg = runSphere(env, ['nametag', 'register', randomName], { timeoutMs: 180_000 }); + if (reg.status !== 0) throw new Error(`nametag register failed:\n${reg.stderr}`); + + const faucet = runSphere(env, ['faucet', '1', 'UCT'], { timeoutMs: 60_000 }); + if (faucet.status !== 0 || !/Received/i.test(faucet.stdout)) { + throw new Error(`faucet failed:\n${faucet.stdout}\n${faucet.stderr}`); + } + + // The faucet API returns "Received" as soon as the gift-wrap is + // queued on the relay — NOT when the wallet has finalized the + // token into local storage. Poll `payments tokens` (with sync) + // until the UCT lands at #0; otherwise the first test reads an + // empty token list. Each poll is one wallet-load + receive + // round-trip (~10-30s), so we cap retries at 3 (max ~90s). + // The subsequent isolation tests use `--no-sync` for fast reads + // once we've confirmed the token is locally present. + for (let attempt = 1; attempt <= 3; attempt++) { + const probe = runSphere(env, ['payments', 'tokens'], { timeoutMs: 60_000 }); + if (probe.status === 0 && /Coin:\s*UCT/.test(probe.stdout)) { + return; + } + if (attempt === 3) { + throw new Error( + `UCT token never landed at #0 after faucet (3 attempts):\n` + + `stdout: ${probe.stdout}\nstderr: ${probe.stderr}`, + ); + } + // Brief gap before retrying — gives the relay a chance to + // deliver the gift-wrap if it was just queued. + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + }, 360_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`payments tokens --no-sync` at #0 lists the faucet-received UCT', () => { + // beforeAll's poll loop already confirmed UCT is in local + // storage. Use --no-sync here to skip the receive() round-trip + // and assert purely on the persisted per-address state. + const r = runSphere(env, ['payments', 'tokens', '--no-sync'], { timeoutMs: 60_000 }); + expect(r.status).toBe(0); + // The faucet sends UCT; the tokens dump prints "Coin: UCT (...)". + // Match the UCT mention without binding to the truncated coinId + // format ("455ad872..." prefix). + expect(r.stdout).toMatch(/Coin:\s*UCT/); + }, 120_000); + + it('switch to #1 → `payments tokens` shows NO tokens (isolation enforced)', () => { + // THE LEAK TEST. If `sphere.payments.getTokens()` ever returns + // tokens from a different address's storage, this flips red. + const sw = runSphere(env, ['payments', 'switch', '1'], { timeoutMs: 60_000 }); + expect(sw.status).toBe(0); + expect(sw.stdout).toMatch(/Switched to address #1/); + + const r = runSphere(env, ['payments', 'tokens', '--no-sync'], { timeoutMs: 60_000 }); + expect(r.status).toBe(0); + // Exact wording from legacy-cli.ts ~line 2050. If a regression + // produces a token list here, the negative assertion below + // catches it; the positive "No tokens found" pin documents the + // expected user-facing message. + expect(r.stdout).toMatch(/No tokens found/); + // Belt-and-braces — there must be NO "Coin:" line, which would + // signal a token leaked through from #0's storage. + expect(r.stdout).not.toMatch(/Coin:/); + }, 120_000); + + it('switch back to #0 → UCT token is STILL there (state preserved across switches)', () => { + const sw = runSphere(env, ['payments', 'switch', '0'], { timeoutMs: 60_000 }); + expect(sw.status).toBe(0); + + const r = runSphere(env, ['payments', 'tokens', '--no-sync'], { timeoutMs: 60_000 }); + expect(r.status).toBe(0); + // The token must still be visible at #0 — proves the round-trip + // through #1 didn't drop, mutate, or migrate it. + expect(r.stdout).toMatch(/Coin:\s*UCT/); + }, 120_000); + }, +); diff --git a/test/integration/cli-nametag.integration.test.ts b/test/integration/cli-nametag.integration.test.ts new file mode 100644 index 0000000..a8418f4 --- /dev/null +++ b/test/integration/cli-nametag.integration.test.ts @@ -0,0 +1,243 @@ +/** + * Integration test: `sphere nametag ...` — nametag command surface. + * + * Backstop for the CLI extraction: when the in-tree sphere-sdk CLI was + * deleted, the four nametag-related commands (register / info / my / sync) + * lost binary-level coverage. SDK-layer coverage exists for the underlying + * `registerNametag()` / transport binding plumbing (see sphere-sdk + * `tests/unit/modules/NametagMinter.test.ts` and the nametag-sync test), + * but the CLI plumbing — namespace bridge, arg parsing, help text — sat + * uncovered post-extraction. + * + * Three layers of pins, same shape as `cli-invoice.integration.test.ts`: + * + * 1. **Help-shape pins (offline)** — `sphere payments help ` + * returns the legacy help block. We assert the documented usage line + * so a refactor that renames or removes a help entry flips this red + * before silently breaking caller-facing docs / discoverability. + * + * 2. **Arg-validation pins (offline)** — `nametag` and `nametag-info` + * validate their `` positional BEFORE `getSphere()` (see + * `src/legacy/legacy-cli.ts` cases at ~2592 and ~2619). Running them + * from a fresh tmp profile with no name argument exits non-zero with + * a "Usage: ..." hint and no wallet load. Pinning these guards stops + * a refactor from reordering the wallet load above the arg check, + * which would force every "did I type the right command" probe into + * a full Sphere.init. + * + * `my-nametag` and `nametag-sync` take no args, so they call + * `getSphere()` immediately — no offline arg-validation pin is + * possible for those. Their behaviour is covered by the e2e block. + * + * 3. **End-to-end lifecycle pin (network)** — One real testnet wallet, + * real Nostr relay, real aggregator. Drives: + * a. `my-nametag` on fresh wallet → "No nametag registered" + * b. `nametag info ` → "not found" + * c. `nametag register ` → on-chain mint + Nostr publish + * d. `my-nametag` → returns the freshly-registered name + * e. `nametag info ` → returns binding info + * f. `nametag sync` → re-publishes the binding + * Each registration mints a new on-chain token, so the name is + * randomized to avoid collisions across test runs. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +/** + * Help-shape sweep table. Maps `sphere nametag ` (new namespace) to + * `legacy-cli` command name (what `payments help ` accepts) and + * regexes that MUST appear in the help output. Keep in sync with the + * `case 'nametag':` block in `src/index.ts` and the HELP_TEXT entries + * in `src/legacy/legacy-cli.ts` (~lines 738-767). + */ +const NAMETAG_HELP_PINS: ReadonlyArray<{ + /** Legacy command name passed to `payments help `. */ + readonly legacy: string; + /** Regexes that MUST appear in help output. */ + readonly mustMatch: RegExp[]; +}> = [ + { legacy: 'nametag', mustMatch: [//, /Register/i] }, + { legacy: 'nametag-info', mustMatch: [//, /Look up/i] }, + { legacy: 'my-nametag', mustMatch: [/Show the nametag/i] }, + { legacy: 'nametag-sync', mustMatch: [/Re-publish/i, /chainPubkey/] }, +]; + +describe('sphere-cli — nametag command shape (offline)', () => { + // One env reused across the offline block — `payments help` doesn't + // read the wallet, so a single throwaway home is sufficient. + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('nametag-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + for (const { legacy, mustMatch } of NAMETAG_HELP_PINS) { + it(`\`sphere payments help ${legacy}\` lists documented usage`, () => { + const r = runSphere(env, ['payments', 'help', legacy], { timeoutMs: 15_000 }); + // Help dispatch is offline. Non-zero exit means the help block + // for this subcommand was removed from `legacy-cli.ts`'s HELP_TEXT + // map — almost always a rename or accidental deletion. + expect(r.status).toBe(0); + // Pin the usage line — load-bearing for users who script against + // the CLI and rely on `--help` parsing. + expect(r.stdout).toMatch(new RegExp(`Usage:.*${legacy}`)); + for (const re of mustMatch) { + expect(r.stdout, `${legacy} help missing ${re}`).toMatch(re); + } + }); + } +}); + +describe('sphere-cli — nametag arg validation (offline)', () => { + // These cases check `` BEFORE `getSphere()` in legacy-cli.ts: + // nametag (~2592), nametag-info (~2619). + // Missing positional → "Usage: ..." exit 1 with no wallet load. + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('nametag-args'); }); + afterAll(() => { destroySphereEnv(env); }); + + it.each([ + // `sphere nametag` (no sub, no name) → bridge keeps argv as + // ['nametag'] → legacy case detects missing name. + ['nametag (no args)', ['nametag'], 'nametag'], + // `sphere nametag register` (no name) → bridge maps to ['nametag'] + // (rest is empty) → same usage path. + ['nametag register (no name)', ['nametag', 'register'], 'nametag'], + // `sphere nametag info` (no name) → bridge maps to ['nametag-info'] + // → legacy case detects missing name. + ['nametag info (no name)', ['nametag', 'info'], 'nametag-info'], + ])('`sphere %s` prints usage and exits non-zero', (_label, argv, legacyName) => { + const r = runSphere(env, argv, { timeoutMs: 15_000 }); + + // Exit code is load-bearing — scripts wrapping `sphere nametag + // register $name` rely on it for failure detection when $name is + // empty. + expect(r.status).not.toBe(0); + + const out = `${r.stdout}\n${r.stderr}`; + // The legacy CLI prints "Usage: " to stderr. + // If a refactor moves the arg check below getSphere(), this regex + // flips red (the user would instead see "No wallet exists ..." or + // similar wallet-load output). + expect(out, `${legacyName} should show usage hint`).toMatch( + new RegExp(`Usage:\\s*${legacyName}\\s*`, 'i'), + ); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — nametag lifecycle (real testnet)', + () => { + let env: SphereEnv; + /** + * Random name with `it_` prefix (collision-free across runs) and a + * short hex tail (4 bytes → 8 hex chars). Stays well under any + * sensible length limit while remaining identifiable in relay logs + * as a test-suite artifact. + */ + const randomName = `it_${randomBytes(4).toString('hex')}`; + + beforeAll(() => { + env = createSphereEnv('nametag-lifecycle'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with nametag lifecycle tests'); + } + // Sanity-check the wallet has a directAddress — confirms init + // completed and we have an identity to bind the nametag to. + expect(init.stdout).toMatch(/"directAddress":\s*"DIRECT:\/\/[0-9a-fA-F]+"/); + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere nametag my` on a fresh wallet reports no nametag', () => { + const r = runSphere(env, ['nametag', 'my'], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('nametag my (fresh) failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Exact wording from legacy-cli.ts my-nametag case. The "Register + // one with: ..." hint changing is fine, but the "No nametag + // registered" line is the load-bearing scriptable signal. + expect(r.stdout).toMatch(/No nametag registered/i); + }, 120_000); + + it('`sphere nametag info ` for an unregistered name reports not found', () => { + // Generate a fresh random name for the lookup so we never hit a + // cached relay record from a prior test run. + const ghost = `nope_${randomBytes(4).toString('hex')}`; + const r = runSphere(env, ['nametag', 'info', ghost], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('nametag info (ghost) failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Match the "not found" path in legacy-cli.ts nametag-info case. + expect(r.stdout).toMatch(new RegExp(`Nametag @${ghost} not found`, 'i')); + }, 120_000); + + it(`\`sphere nametag register ${''}\` mints + publishes the binding`, () => { + const r = runSphere(env, ['nametag', 'register', randomName], { timeoutMs: 180_000 }); + if (r.status !== 0) { + console.error('nametag register failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Two load-bearing log lines from legacy-cli.ts nametag case: + // "Registering nametag @..." (start) + // "✓ Nametag @ registered successfully!" (success) + // The ✓ glyph is non-ASCII; match the unique suffix text instead + // so a `--no-emoji` refactor or terminal-strip pipeline doesn't + // flip this red over cosmetics. + expect(r.stdout).toMatch(new RegExp(`Registering nametag @${randomName}`)); + expect(r.stdout).toMatch(new RegExp(`Nametag @${randomName} registered successfully`)); + }, 240_000); + + it('`sphere nametag my` after register returns the freshly-registered name', () => { + const r = runSphere(env, ['nametag', 'my'], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('nametag my (after register) failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Wallet on disk should now carry the nametag in identity. New + // process load → Sphere.init reads it from local state (no relay + // dependency for this assertion). + expect(r.stdout).toMatch(new RegExp(`Your nametag: @${randomName}`)); + }, 120_000); + + it('`sphere nametag info ` resolves to a binding record', () => { + const r = runSphere(env, ['nametag', 'info', randomName], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('nametag info (registered) failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // legacy-cli prints "Nametag Info: @" followed by the JSON + // binding record. The record always carries the chainPubkey of + // the registering identity (see CommunicationsModule / transport + // binding format). Pin "header present + record carries pubkey + // field" without overfitting to the exact JSON shape. + expect(r.stdout).toMatch(new RegExp(`Nametag Info: @${randomName}`)); + expect(r.stdout).toMatch(/chainPubkey|publicKey|pubkey/i); + }, 120_000); + + it('`sphere nametag sync` re-publishes the binding successfully', () => { + const r = runSphere(env, ['nametag', 'sync'], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('nametag sync failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // legacy-cli prints "Re-publishing nametag @ ..." then on + // success "✓ Nametag @ synced successfully!". Match the + // unique suffix to dodge emoji-strip false negatives. + expect(r.stdout).toMatch(new RegExp(`Re-publishing nametag @${randomName}`)); + expect(r.stdout).toMatch(new RegExp(`Nametag @${randomName} synced successfully`)); + }, 120_000); + }, +); diff --git a/test/integration/cli-send.integration.test.ts b/test/integration/cli-send.integration.test.ts new file mode 100644 index 0000000..6d613ef --- /dev/null +++ b/test/integration/cli-send.integration.test.ts @@ -0,0 +1,287 @@ +/** + * Integration test: `sphere payments send` — UXF transfer command surface. + * + * Replaces the coverage previously held by sphere-sdk's + * `tests/integration/cli/uxf-transfer.test.ts`, which asserted against the + * in-tree `cli/index.ts` source that no longer exists. The replacement + * pins the CLI surface end-to-end: + * + * 1. **Help-shape pin** — `sphere payments help send` lists `--instant`, + * `--conservative`, ``, ``, ``. A future + * refactor that drops the mode flags or rewires the positional + * arguments flips this red. Replaces the `--help` grep half of the + * old uxf-transfer.test.ts. + * + * 2. **Arg-validation pin** — `sphere payments send` with missing + * positionals exits non-zero with the documented usage line. The + * legacy CLI's send case rejects empty argv before Sphere.init + * runs, so this is cheap and offline-safe. + * + * 3. **End-to-end wiring pin** — `sphere payments send 0.001 + * UCT --instant` from an empty fresh wallet reaches the SDK's + * `payments.send()`, fails with an "insufficient funds" / + * "no tokens" diagnostic, and exits non-zero. This exercises the + * full path: + * - arg parsing (`--instant` / `--conservative` → `transferMode`) + * - `Sphere.init()` (aggregator trustbase, IPFS publish, Nostr connect) + * - `sphere.payments.send({...})` call with the right `transferMode` + * - error surface back to the CLI exit code + stderr message + * + * 4. **Funded transfer (opt-in)** — When `E2E_FUNDED_MNEMONIC` is set + * to a 24-word testnet mnemonic with a UCT balance, the test + * imports that wallet, sends 0.001 UCT to a fresh recipient + * wallet, and polls the recipient's balance to confirm receipt. + * Gated to avoid forcing every test runner to maintain a funded + * fixture (faucet rate limits + drain protection). + * + * The send command shape is `sphere payments send + * [--instant|--conservative] [--direct|--proxy] [--no-sync]`. + * Defaults: `--instant`, address mode auto-detected. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +/** Set to a 24-word testnet mnemonic with UCT balance to enable the funded test. */ +const FUNDED_MNEMONIC = process.env['E2E_FUNDED_MNEMONIC']; + +describe('sphere-cli — send command shape (offline)', () => { + it('`sphere payments help send` lists --instant, --conservative, positionals', () => { + // No env / network — pure help-text inspection. Use a throwaway env + // anyway so `sphere config` lookups don't hit a real profile dir. + const env = createSphereEnv('send-help'); + try { + const r = runSphere(env, ['payments', 'help', 'send'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + + // Positionals — recipient, amount, coin. + expect(r.stdout).toMatch(//); + expect(r.stdout).toMatch(//); + expect(r.stdout).toMatch(//); + + // Mode flags — the load-bearing UXF wiring that the deleted + // uxf-transfer.test.ts used to pin via source-text grep. If a + // refactor drops either flag from the help, this test flips red. + expect(r.stdout).toMatch(/--instant/); + expect(r.stdout).toMatch(/--conservative/); + + // Mutual-exclusion note — the legacy CLI's "cannot use both" + // check is what guarantees `transferMode` ends up as exactly + // one of `'instant'` / `'conservative'` when handed to + // `payments.send()`. Pin its documentation so a refactor that + // drops the runtime check doesn't silently drop the doc too. + expect(r.stdout).toMatch(/[Cc]annot use both .*--instant.*--conservative|[Cc]annot use both .*--conservative.*--instant/); + } finally { + destroySphereEnv(env); + } + }); + + it('`sphere payments send` with no args prints usage and exits non-zero', () => { + const env = createSphereEnv('send-noargs'); + try { + const r = runSphere(env, ['payments', 'send'], { timeoutMs: 15_000 }); + + // Legacy CLI's send case prints "Usage: send ..." to + // stderr and exits non-zero when called with no positionals. The + // exit code is the load-bearing assertion — scripts wrapping + // `sphere payments send` rely on it for failure detection. + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Usage:.*send.*.*.*/); + } finally { + destroySphereEnv(env); + } + }); +}); + +describe.skipIf(integrationSkip)('sphere-cli integration — send end-to-end (real testnet)', () => { + let env: SphereEnv; + let directAddress: string | null = null; + + beforeAll(() => { + env = createSphereEnv('send-e2e'); + + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with send tests'); + } + + // Reuse the same extraction shape as cli-dm.integration.test.ts. + const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`); + directAddress = match[1]!; + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere payments send` from empty wallet fails cleanly with insufficient-funds error', () => { + expect(directAddress).toBeTruthy(); + + // Send to self for simplicity — the target is irrelevant when the + // sender has no tokens. What we're pinning is the path: + // arg-parse → Sphere.init → payments.send → empty-inventory error + // → non-zero CLI exit. A regression that swallows the error and + // exits 0 would flip this red. + const r = runSphere( + env, + ['payments', 'send', directAddress!, '0.001', 'UCT', '--instant'], + { timeoutMs: 120_000 }, + ); + + // Exit code is the load-bearing pin. Empty-wallet sends MUST fail + // (not silently succeed with a no-op bundle). + expect(r.status).not.toBe(0); + + // The diagnostic is best-effort — the SDK / CLI's exact wording + // has evolved (no tokens, insufficient balance, nothing to send). + // Accept any of the documented surfaces. If the wording diverges + // again, this regex is the place to extend, not to delete. + const combined = `${r.stdout}\n${r.stderr}`.toLowerCase(); + expect(combined).toMatch(/no tokens|no .* tokens|insufficient|not enough|balance|nothing to send|cannot send/); + }, 180_000); + + it('`sphere payments send --conservative` is accepted and reaches the empty-inventory path', () => { + expect(directAddress).toBeTruthy(); + + // Same shape as the --instant test, but with --conservative. Proves + // the flag is wired through to the SDK (not rejected by arg parsing). + // We're not asserting the bundle differs — that's the SDK's + // `tests/integration/accounting/uxf-transfer.test.ts` job. We are + // asserting the FLAG IS ACCEPTED by the CLI and reaches the call + // site without the "cannot use both" guard tripping. + const r = runSphere( + env, + ['payments', 'send', directAddress!, '0.001', 'UCT', '--conservative'], + { timeoutMs: 120_000 }, + ); + + expect(r.status).not.toBe(0); + + const combined = `${r.stdout}\n${r.stderr}`.toLowerCase(); + // Must NOT trip the mutual-exclusion guard — that would mean we + // accidentally regressed and the CLI thinks both flags are set. + expect(combined).not.toMatch(/cannot use both .*--instant.*--conservative|cannot use both .*--conservative.*--instant/); + // Must hit the same insufficient-funds surface as the --instant test. + expect(combined).toMatch(/no tokens|no .* tokens|insufficient|not enough|balance|nothing to send|cannot send/); + }, 180_000); + + it('`sphere payments send --instant --conservative` is rejected by mutual-exclusion guard', () => { + expect(directAddress).toBeTruthy(); + + // Defensive: even with empty inventory, the mutual-exclusion guard + // SHOULD trip before Sphere.init, so this test is fast and + // deterministic regardless of network state. + const r = runSphere( + env, + ['payments', 'send', directAddress!, '0.001', 'UCT', '--instant', '--conservative'], + { timeoutMs: 30_000 }, + ); + + expect(r.status).not.toBe(0); + const combined = `${r.stdout}\n${r.stderr}`.toLowerCase(); + expect(combined).toMatch(/cannot use both .*--instant.*--conservative|cannot use both .*--conservative.*--instant/); + }, 60_000); +}); + +describe.skipIf(integrationSkip || !FUNDED_MNEMONIC)( + 'sphere-cli integration — funded UXF transfer (E2E_FUNDED_MNEMONIC required)', + () => { + let senderEnv: SphereEnv; + let receiverEnv: SphereEnv; + let receiverDirectAddress: string | null = null; + + beforeAll(() => { + senderEnv = createSphereEnv('send-funded-tx'); + receiverEnv = createSphereEnv('send-funded-rx'); + + // Sender: import the funded mnemonic. The CLI's `init --mnemonic + // "..."` shape is documented in `help init`. + const importSender = runSphere( + senderEnv, + ['wallet', 'init', '--network', 'testnet', '--mnemonic', FUNDED_MNEMONIC!], + { timeoutMs: 180_000 }, + ); + if (importSender.status !== 0) { + console.error('sender import failed', { stdout: importSender.stdout, stderr: importSender.stderr }); + throw new Error('sender wallet import failed'); + } + + // Receiver: fresh wallet. + const initReceiver = runSphere( + receiverEnv, + ['wallet', 'init', '--network', 'testnet'], + { timeoutMs: 120_000 }, + ); + if (initReceiver.status !== 0) { + console.error('receiver init failed', { stdout: initReceiver.stdout, stderr: initReceiver.stderr }); + throw new Error('receiver wallet init failed'); + } + + const match = initReceiver.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + if (!match) throw new Error(`receiver directAddress not found in init output`); + receiverDirectAddress = match[1]!; + }, 360_000); + + afterAll(() => { + if (senderEnv) destroySphereEnv(senderEnv); + if (receiverEnv) destroySphereEnv(receiverEnv); + }); + + it('sender → receiver: 0.001 UCT --instant lands in receiver inventory', async () => { + expect(receiverDirectAddress).toBeTruthy(); + + // Drive an actual UXF send. + const send = runSphere( + senderEnv, + ['payments', 'send', receiverDirectAddress!, '0.001', 'UCT', '--instant'], + { timeoutMs: 180_000 }, + ); + + if (send.status !== 0) { + console.error('funded send failed', { stdout: send.stdout, stderr: send.stderr }); + } + expect(send.status).toBe(0); + expect(send.stdout).toMatch(/Transfer (successful|complete|submitted|sent)|Transfer ID:/i); + + // Poll receiver inventory for the inbound transfer. NIP-17 gift- + // wrap delivery + aggregator finalize is eventually consistent; + // budget 5 attempts × 5s = 25s comfortably under the 120s test + // budget. + const MAX_ATTEMPTS = 5; + const POLL_INTERVAL_MS = 5_000; + let received = false; + let lastBalance = ''; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + const balance = runSphere(receiverEnv, ['balance'], { timeoutMs: 60_000 }); + if (balance.status === 0) { + lastBalance = balance.stdout; + // Any non-zero UCT balance proves the transfer arrived. The + // exact rendering varies (table, JSON) — match the symbol + // adjacent to a non-zero digit. + if (/UCT[\s\S]{0,80}?[1-9]/.test(balance.stdout)) { + received = true; + break; + } + } + if (i < MAX_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + + if (!received) { + console.warn( + `receiver did not see inbound UCT within budget — likely relay/aggregator latency. ` + + `Last balance stdout:\n${lastBalance}`, + ); + } + expect(received).toBe(true); + }, 360_000); + }, +); diff --git a/test/integration/cli-wallet-lifecycle.integration.test.ts b/test/integration/cli-wallet-lifecycle.integration.test.ts new file mode 100644 index 0000000..662bc73 --- /dev/null +++ b/test/integration/cli-wallet-lifecycle.integration.test.ts @@ -0,0 +1,262 @@ +/** + * Integration test: `sphere {init, clear, config, status}` — wallet + * lifecycle commands, with explicit pins for the destructive `clear` + * confirmation guard and BIP-39 derivation determinism. + * + * This file fills the wallet-management gaps that + * `cli-wallet.integration.test.ts` doesn't reach (it only covers + * `wallet init` + `wallet status`): + * + * - `clear` — destructive wipe of ALL wallet data. Has a + * confirmation prompt that `--yes` bypasses. + * - `config` — show / mutate the CLI's persistent settings + * (network, dataDir, tokensDir). + * - `init --mnemonic`— explicit deterministic import path (not just + * the funded-gated test in cli-send). + * + * The most important pin is `clear`'s confirmation guard. Without it, + * a user could run `sphere clear` by accident (e.g. tab-completion + * mishap, scripted command misread) and lose their wallet keys + * permanently. The guard demands the user type "yes" literally; + * `--yes` / `-y` bypass it for scripted contexts. We pin both paths. + * + * Three layers of pins: + * + * 1. **Help-shape (offline, 4 tests)** — `payments help ` for + * `init`, `status`, `clear`, `config`. HELP_TEXT keys ~425-470. + * + * 2. **Config get/set (local, no network, 3 tests)** — `config` + * with no args shows JSON; `config set network ` + * mutates; `config set bogus value` exits 1 with "Unknown + * config key". No wallet load, no relay — pure file r/w. + * + * 3. **Init / clear / re-init round-trip (network, 3 tests)** — + * a. `wallet init` with SPHERE_ALLOW_MNEMONIC_NON_TTY=1 emits + * the 24-word mnemonic to stdout; capture it + the + * original directAddress. + * b. `clear` with stdin "no\n" prints "Aborted." and DOES NOT + * delete the wallet (re-check `status` still works). + * c. `clear --yes` removes the wallet (status now reports + * "No wallet found"), then `wallet init --mnemonic + * ` re-derives the SAME directAddress — proving + * BIP-39 + HD-derivation determinism end-to-end. + * + * The mnemonic-round-trip pin is the strongest integration-level + * proof of wallet-recovery correctness: any change to the derivation + * pipeline (BIP-39 seed → master key → HD-path → secp256k1 → + * directAddress) would surface here. SDK-level coverage of each step + * exists in sphere-sdk but this is the only end-to-end CLI pin. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +const LIFECYCLE_HELP_PINS: ReadonlyArray<{ + readonly legacy: string; + readonly mustMatch: RegExp[]; +}> = [ + // init: --network, --mnemonic, --nametag + { legacy: 'init', mustMatch: [/--network/, /--mnemonic/, /--nametag/] }, + // status: shows current wallet info + { legacy: 'status', mustMatch: [/[Ww]allet|status/i] }, + // clear: destructive, mentions irreversibility + { legacy: 'clear', mustMatch: [/[Dd]elete/, /irreversible|backed up|backup/i] }, + // config: shows or sets settings + { legacy: 'config', mustMatch: [/set/, /network/, /dataDir/] }, +]; + +describe('sphere-cli — wallet lifecycle command shape (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('lifecycle-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + for (const { legacy, mustMatch } of LIFECYCLE_HELP_PINS) { + it(`\`sphere payments help ${legacy}\` lists documented usage`, () => { + const r = runSphere(env, ['payments', 'help', legacy], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(new RegExp(`Usage:.*${legacy}`)); + for (const re of mustMatch) { + expect(r.stdout, `${legacy} help missing ${re}`).toMatch(re); + } + }); + } +}); + +describe('sphere-cli — config get/set (local, no network)', () => { + // config is purely file-system mutation on `.sphere-cli/config.json`. + // No wallet load, no relay. Tests can run unconditionally. + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('lifecycle-config'); }); + afterAll(() => { destroySphereEnv(env); }); + + it('`sphere config` (no args) prints the current configuration as JSON', () => { + const r = runSphere(env, ['config'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + // Output header + valid JSON body. The createSphereEnv helper + // seeded testnet into config.json, so the JSON should reflect + // that. + expect(r.stdout).toMatch(/Current Configuration:/); + expect(r.stdout).toMatch(/"network":\s*"testnet"/); + }); + + it('`sphere config set network dev` mutates the network setting on disk', () => { + const r = runSphere(env, ['config', 'set', 'network', 'dev'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + // Confirmation log line (~line 1747). + expect(r.stdout).toMatch(/Set network = dev/); + + // Verify the on-disk state changed (load-bearing — without this, + // a refactor that demotes the saveConfig() call would still pass + // the log-line assertion but silently no-op the persistence). + const cfg = JSON.parse(readFileSync(join(env.home, '.sphere-cli', 'config.json'), 'utf8')); + expect(cfg.network).toBe('dev'); + }); + + it('`sphere config set bogus-key value` rejects unknown keys with exit 1', () => { + // The key allowlist at ~lines 1735-1745 covers network / + // dataDir / tokensDir only. Anything else falls through to the + // error block. Pin both the rejection AND the helpful message + // listing valid keys — the latter is user-facing documentation. + const r = runSphere(env, ['config', 'set', 'bogus-key', 'value'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Unknown config key:\s*bogus-key/); + // The hint enumerates valid keys (~line 1743). If a future + // refactor adds a new valid key without updating the error + // hint, that's a doc bug — pin the three current keys. + expect(out).toMatch(/network/); + expect(out).toMatch(/dataDir/); + expect(out).toMatch(/tokensDir/); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — init / clear / re-init round-trip (real testnet)', + () => { + // The deterministic-derivation pin: same mnemonic must always + // produce the same directAddress. Plus the clear-confirmation + // guard pin. + let env: SphereEnv; + let capturedMnemonic: string | null = null; + let originalDirectAddress: string | null = null; + + beforeAll(() => { + env = createSphereEnv('lifecycle-roundtrip'); + // Emit the mnemonic to stdout via the test-harness opt-in + // (SPHERE_ALLOW_MNEMONIC_NON_TTY=1, documented at ~line 1675 + // of legacy-cli.ts). This is the SAFE way to capture a + // generated mnemonic in an e2e test — process.env is + // env-allowlisted in helpers.ts; the var is forwarded to the + // child only inside this test's env block, not globally. + env.env['SPHERE_ALLOW_MNEMONIC_NON_TTY'] = '1'; + + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('initial wallet init failed', { stdout: init.stdout, stderr: init.stderr }); + throw new Error('initial wallet init failed'); + } + + // Extract the BIP-39 mnemonic. The non-TTY branch emits it + // as a single line to stdout. BIP-39 valid lengths are 12, 15, + // 18, 21, or 24 words — Sphere currently generates 12, but + // older docs reference 24, so accept any valid length. + // Anchor to a line consisting ONLY of space-separated + // lowercase words to avoid matching multi-word phrases inside + // log output (e.g. "Wallet initialized successfully"). + const mnemonicMatch = init.stdout.match(/^([a-z]+(?:\s+[a-z]+){11,23})$/m); + expect(mnemonicMatch, `mnemonic not in stdout:\n${init.stdout}`).toBeTruthy(); + capturedMnemonic = mnemonicMatch![1]!; + + const addrMatch = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + expect(addrMatch, `directAddress not in stdout:\n${init.stdout}`).toBeTruthy(); + originalDirectAddress = addrMatch![1]!; + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere clear` with stdin "no" prints "Aborted." and KEEPS the wallet', () => { + // The confirmation guard (~lines 1755-1764) reads stdin via + // readline; "yes" proceeds, anything else aborts. Pipe "no\n" + // so the prompt resolves cleanly. + const r = runSphere(env, ['clear'], { + timeoutMs: 60_000, + input: 'no\n', + }); + // Even on abort, the command exits 0 — it successfully did + // what the user asked (cancel). Pin the "Aborted." log line + // and the exit code separately so a refactor that flips one + // without the other is visible. + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Aborted/); + + // BELT-AND-BRACES: the wallet must NOT have been wiped. + // Confirm by running `status` and asserting an identity is + // present. A regression that proceeds despite "no" would + // strip the identity here. + const status = runSphere(env, ['status'], { timeoutMs: 60_000 }); + expect(status.status).toBe(0); + expect(status.stdout).toMatch(new RegExp(`Direct Addr:\\s+${originalDirectAddress}`)); + }, 120_000); + + it('`sphere clear --yes` bypasses the guard and removes the wallet', () => { + // --yes (or -y, ~line 1756) bypasses the interactive prompt. + // Both flags reach the same fall-through; we pin --yes here + // because it's the more discoverable name. + const r = runSphere(env, ['clear', '--yes'], { timeoutMs: 60_000 }); + if (r.status !== 0) { + console.error('clear --yes failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Two log lines from the success path (~lines 1777, 1779): + // "Clearing all wallet data..." + // "All wallet data cleared." + expect(r.stdout).toMatch(/Clearing all wallet data/); + expect(r.stdout).toMatch(/All wallet data cleared/); + + // Verify the wallet really IS gone — `status` should now + // report "No wallet found" (~line 1707). + const status = runSphere(env, ['status'], { timeoutMs: 60_000 }); + expect(status.status).toBe(0); + expect(status.stdout).toMatch(/No wallet found/i); + }, 120_000); + + it('`wallet init --mnemonic ` re-derives the SAME directAddress (BIP-39 determinism)', () => { + expect(capturedMnemonic, 'mnemonic must be captured by beforeAll').toBeTruthy(); + expect(originalDirectAddress, 'directAddress must be captured').toBeTruthy(); + + // Re-init with the captured mnemonic. The fresh wallet should + // derive the EXACT same directAddress — this is the load- + // bearing determinism guarantee of the wallet-recovery flow. + const reinit = runSphere( + env, + ['wallet', 'init', '--network', 'testnet', '--mnemonic', capturedMnemonic!], + { timeoutMs: 120_000 }, + ); + if (reinit.status !== 0) { + console.error('reinit failed', { stdout: reinit.stdout, stderr: reinit.stderr }); + } + expect(reinit.status).toBe(0); + + const addrMatch = reinit.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + expect(addrMatch, `directAddress not in reinit:\n${reinit.stdout}`).toBeTruthy(); + // THE DETERMINISM PIN: same mnemonic → same directAddress. + // A regression in BIP-39 → seed → HD-path → secp256k1 → bech32 + // pipeline anywhere along that chain would flip this red. + expect(addrMatch![1]).toBe(originalDirectAddress); + + // Belt-and-braces: the on-disk wallet.json now exists again, + // populated from the imported mnemonic. + expect(existsSync(join(env.home, '.sphere-cli', 'wallet.json'))).toBe(true); + }, 180_000); + }, +); diff --git a/test/integration/cli-wallet-profile.integration.test.ts b/test/integration/cli-wallet-profile.integration.test.ts new file mode 100644 index 0000000..eb44975 --- /dev/null +++ b/test/integration/cli-wallet-profile.integration.test.ts @@ -0,0 +1,377 @@ +/** + * Integration test: `sphere wallet {list,use,create,current,delete}` — + * multi-profile management surface, with proof of CROSS-PROFILE isolation. + * + * This test pins two distinct concerns: + * + * A) **CLI plumbing** — namespace bridge, sub-command parsing, help + * text, and arg validation for the five wallet-profile commands. + * + * B) **Cross-profile isolation invariant** — each named profile must + * get its OWN data directory and mnemonic. A leak here is worse + * than the HD-address leak pinned in cli-multiaddress: profiles + * can hold completely different mnemonics (intended for + * separation between personas, organizations, or environments). + * If profile B's wallet init ever wrote into profile A's + * dataDir, the user could lose access to A entirely (or worse, + * sign transactions with the wrong key without realising). + * + * The architectural mechanism: `wallet create ` creates a + * profile in `profiles.json` with `dataDir = ./.sphere-cli-` + * and rewrites `config.json`'s active dataDir/tokensDir pointer. + * `getSphere()` reads from the current `config.dataDir`, so as + * long as the per-profile dir scheme is honoured and the config + * pointer is flipped atomically on `wallet use`, isolation holds. + * + * We pin this two ways: + * 1. Profile pointer in `wallet current` output reflects the + * per-profile dataDir after each create/use. + * 2. Two independent `wallet init` calls (one per profile) + * produce TWO DIFFERENT directAddresses, and switching back + * reproduces the original — proves mnemonics are separate. + * + * Four layers of pins: + * + * 1. **Help-shape pins (offline)** — `payments help ` for + * `wallet`, `wallet list`, `wallet use`, `wallet create`, + * `wallet current`, `wallet delete`. Multi-word HELP_TEXT keys + * are passed as a single argv element (commander preserves the + * space-containing arg). + * + * 2. **Arg-validation pins (offline)** — `wallet use`, `wallet + * create`, `wallet delete` without `` exit 1 with usage + * hint. `wallet create '!bogus'` rejects invalid name chars. + * `wallet ` exits 1 with the subcommand help block. + * + * 3. **CRUD lifecycle (offline)** — fresh wallet shows no profiles + * → create alice → list shows alice → create bob (auto-switches) + * → use alice → current shows alice → cannot delete current + * profile → delete bob (now non-current) → list shows only alice. + * + * 4. **Cross-profile isolation (network, gated by integrationSkip)** — + * create alice + wallet init → capture identity_A → create bob + * + wallet init → capture identity_B → assert A ≠ B → use alice + * → current shows alice's identity, not bob's → filesystem has + * both `.sphere-cli-alice/wallet.json` and `.sphere-cli-bob/wallet.json` + * as separate files. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +/** + * HELP_TEXT keys for the wallet umbrella + each subcommand. Multi-word + * keys (e.g. "wallet list") are looked up directly by passing the + * space-containing string as a SINGLE argv element to `payments help`. + * Keep in sync with HELP_TEXT entries ~lines 472-530 of legacy-cli.ts. + */ +const WALLET_HELP_KEYS: ReadonlyArray<{ key: string; mustMatch: RegExp[] }> = [ + { key: 'wallet', mustMatch: [/profile/i, //] }, + { key: 'wallet list', mustMatch: [/profiles/i] }, + { key: 'wallet create', mustMatch: [//, /--network/] }, + { key: 'wallet use', mustMatch: [//, /[Ss]witch/] }, + { key: 'wallet current', mustMatch: [/[Cc]urrent/] }, + { key: 'wallet delete', mustMatch: [//, /[Dd]elete/] }, +]; + +describe('sphere-cli — wallet profile help shape (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('wallet-profile-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + for (const { key, mustMatch } of WALLET_HELP_KEYS) { + it(`\`sphere payments help "${key}"\` lists documented usage`, () => { + const r = runSphere(env, ['payments', 'help', key], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + // Usage line — the key itself appears after "Usage:" in the + // HELP_TEXT body. Wallet subcommands include the full key. + expect(r.stdout).toMatch(new RegExp(`Usage:.*${key.replace(/\s+/g, '\\s+')}`)); + for (const re of mustMatch) { + expect(r.stdout, `${key} help missing ${re}`).toMatch(re); + } + }); + } +}); + +describe('sphere-cli — wallet profile arg validation (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('wallet-profile-args'); }); + afterAll(() => { destroySphereEnv(env); }); + + it.each([ + // No-name cases. legacy-cli.ts subCmd handlers (~lines 1814, 1844, + // 1934) check `profileName` and bail before any disk write. + // Bridge: `sphere wallet use` → legacy receives ['wallet', 'use'], + // dispatches into the wallet case, then into subCmd='use' with + // profileName=undefined. + ['wallet use (no name)', ['wallet', 'use'], 'wallet use'], + ['wallet create (no name)', ['wallet', 'create'], 'wallet create'], + ['wallet delete (no name)', ['wallet', 'delete'], 'wallet delete'], + ])('`sphere %s` prints usage and exits non-zero', (_label, argv, hint) => { + const r = runSphere(env, argv, { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + // Each handler prints "Usage: " to stderr (~lines + // 1815, 1845, 1935). Match the hint without binding to the + // example-suffix wording. + expect(out, `${hint} should show usage hint`).toMatch( + new RegExp(`Usage:\\s*${hint}\\s*`, 'i'), + ); + }); + + it('`sphere wallet create !invalid` rejects names with disallowed characters', () => { + // Name-charset guard at ~line 1849: + // if (!/^[a-zA-Z0-9_-]+$/.test(profileName)) { error... exit(1); } + // Runs BEFORE disk writes. A regression that demotes this to a + // post-write check would let through path-traversal-like names + // (`../foo`, `name with spaces`) and create weird subdirectories. + const r = runSphere(env, ['wallet', 'create', '!invalid'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/letters.*digits|alphanumeric/i); + }); + + it('`sphere wallet bogus-sub` reports unknown subcommand and exits non-zero', () => { + // The default-case `Unknown wallet subcommand` block (~line 1956) + // is the catch-all. A refactor that silently dispatches unknown + // subcommands to some other case would flip this red. + const r = runSphere(env, ['wallet', 'bogus-sub'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Unknown wallet subcommand:\s*bogus-sub/i); + }); +}); + +describe('sphere-cli — wallet profile CRUD lifecycle (offline)', () => { + // Lifecycle tests are ALL local file-system mutations (profiles.json + // + config.json). No network, no wallet load. Vitest serializes + // tests within a describe, so state evolves: empty → alice created + // → bob created (current) → use alice (current) → delete bob → + // attempt to delete alice (blocked). + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('wallet-profile-crud'); }); + afterAll(() => { destroySphereEnv(env); }); + + it('`wallet list` on a fresh profile store reports "No profiles found"', () => { + const r = runSphere(env, ['wallet', 'list'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/No profiles found/i); + }); + + it('`wallet create alice` adds the profile, auto-switches, and shows DataDir', () => { + const r = runSphere(env, ['wallet', 'create', 'alice'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + // Success line + DataDir pin — the dataDir scheme + // `./.sphere-cli-` is load-bearing for the isolation + // invariant proven in the next describe block. + expect(r.stdout).toMatch(/Created wallet profile:\s*alice/); + expect(r.stdout).toMatch(/DataDir:\s*\.\/\.sphere-cli-alice/); + // Sanity-check on the underlying file: profiles.json now exists + // and contains alice as an entry. If a refactor demotes the + // file-write to a no-op, this catches it before the next test + // (which depends on alice being persisted) gets confusing. + const profilesJson = join(env.home, '.sphere-cli', 'profiles.json'); + expect(existsSync(profilesJson), 'profiles.json should be created').toBe(true); + const parsed = JSON.parse(readFileSync(profilesJson, 'utf8')); + expect(parsed.profiles.find((p: { name: string }) => p.name === 'alice')).toBeTruthy(); + }); + + it('`wallet create alice` a second time reports "already exists" and exits non-zero', () => { + // Duplicate-name guard at ~line 1855. Without this, the second + // create would silently overwrite alice's dataDir pointer and + // potentially break a user who already initialized a wallet + // under that profile. + const r = runSphere(env, ['wallet', 'create', 'alice'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/already exists/i); + }); + + it('`wallet current` after create reports alice as the active profile', () => { + const r = runSphere(env, ['wallet', 'current'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Profile:\s*alice/); + expect(r.stdout).toMatch(/DataDir:\s*\.\/\.sphere-cli-alice/); + }); + + it('`wallet create bob` adds a second profile and auto-switches to it', () => { + const r = runSphere(env, ['wallet', 'create', 'bob'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Created wallet profile:\s*bob/); + + // `wallet current` should now report bob — proves create flipped + // the active-profile pointer. + const current = runSphere(env, ['wallet', 'current'], { timeoutMs: 15_000 }); + expect(current.stdout).toMatch(/Profile:\s*bob/); + }); + + it('`wallet list` shows BOTH profiles with the active one marked', () => { + const r = runSphere(env, ['wallet', 'list'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/alice/); + expect(r.stdout).toMatch(/bob/); + // Active marker `→ ` precedes bob (created last → currently active). + expect(r.stdout).toMatch(/→\s+bob/); + // alice is present but NOT preceded by →. + expect(r.stdout).not.toMatch(/→\s+alice/); + }); + + it('`wallet use alice` switches the active profile', () => { + const r = runSphere(env, ['wallet', 'use', 'alice'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Switched to wallet profile:\s*alice/); + + // Verify by re-reading current. + const current = runSphere(env, ['wallet', 'current'], { timeoutMs: 15_000 }); + expect(current.stdout).toMatch(/Profile:\s*alice/); + }); + + it('`wallet use nonexistent` reports "not found" and exits non-zero', () => { + const r = runSphere(env, ['wallet', 'use', 'nonexistent'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/not found/i); + }); + + it('`wallet delete alice` is REFUSED when alice is the current profile', () => { + // Safety guard at ~line 1940: cannot delete the active profile, + // because then `getSphere()` calls would point at a dataDir of + // a non-existent profile. Pin this so a refactor that drops the + // check doesn't silently let the user orphan their config. + const r = runSphere(env, ['wallet', 'delete', 'alice'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/Cannot delete.*current/i); + }); + + it('`wallet delete bob` removes the non-current profile', () => { + const r = runSphere(env, ['wallet', 'delete', 'bob'], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/Deleted profile:\s*bob/); + // Note: the dataDir on disk is intentionally NOT deleted (~line + // 1947 prints a hint about manual cleanup). We don't pin that — + // it's a UX choice that may legitimately change. + + // Verify by re-listing — bob should be gone, alice remains. + const list = runSphere(env, ['wallet', 'list'], { timeoutMs: 15_000 }); + expect(list.stdout).toMatch(/alice/); + expect(list.stdout).not.toMatch(/bob/); + }); + + it('`wallet delete nonexistent` reports "not found" and exits non-zero', () => { + const r = runSphere(env, ['wallet', 'delete', 'nonexistent'], { timeoutMs: 15_000 }); + expect(r.status).not.toBe(0); + const out = `${r.stdout}\n${r.stderr}`; + expect(out).toMatch(/not found/i); + }); +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — cross-profile wallet isolation (real testnet)', + () => { + // The strongest isolation proof: two profiles, each with an + // independent wallet init. The directAddresses (derived from the + // mnemonics) MUST differ. Switching profiles MUST flip the + // active dataDir + identity atomically. + // + // Cost: ~30-60s for two wallet inits + nostr identity binding. + // This is the e2e equivalent of the on-disk isolation pin in + // cli-multiaddress, but for profile-level (separate mnemonic) + // isolation rather than HD-derivation (shared mnemonic) isolation. + let env: SphereEnv; + let directAddrAlice: string | null = null; + let directAddrBob: string | null = null; + + beforeAll(() => { env = createSphereEnv('wallet-profile-isolation'); }, 30_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('init wallet in profile "alice" captures alice\'s directAddress', () => { + const create = runSphere(env, ['wallet', 'create', 'alice'], { timeoutMs: 15_000 }); + expect(create.status).toBe(0); + + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('alice init failed', { stdout: init.stdout, stderr: init.stderr }); + } + expect(init.status).toBe(0); + + const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + expect(match, `directAddress not in alice init:\n${init.stdout}`).toBeTruthy(); + directAddrAlice = match![1]!; + + // Wallet file landed in the per-profile dataDir, not the + // bare ./.sphere-cli — proves the active dataDir pointer is + // honoured by `wallet init`. + expect(existsSync(join(env.home, '.sphere-cli-alice', 'wallet.json'))).toBe(true); + }, 180_000); + + it('init wallet in profile "bob" captures a DIFFERENT directAddress', () => { + // ISOLATION INVARIANT — pin 1: `wallet create` auto-switches + // to the new profile. So the init below runs against bob's + // dataDir, with a freshly-generated mnemonic — not alice's. + const create = runSphere(env, ['wallet', 'create', 'bob'], { timeoutMs: 15_000 }); + expect(create.status).toBe(0); + + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('bob init failed', { stdout: init.stdout, stderr: init.stderr }); + } + expect(init.status).toBe(0); + + const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/); + expect(match, `directAddress not in bob init:\n${init.stdout}`).toBeTruthy(); + directAddrBob = match![1]!; + + // THE CORE ISOLATION PIN: bob's directAddress is derived from + // bob's mnemonic, which must be distinct from alice's. If a + // regression reuses alice's mnemonic (e.g. by reading the + // wrong wallet.json), this flips red. + expect(directAddrBob).not.toBe(directAddrAlice); + + // Filesystem belt-and-braces: BOTH per-profile wallet.json + // files exist, with different paths. A regression that wrote + // bob's wallet to alice's dataDir would either fail to create + // bob's file or overwrite alice's — both visible here. + expect(existsSync(join(env.home, '.sphere-cli-alice', 'wallet.json'))).toBe(true); + expect(existsSync(join(env.home, '.sphere-cli-bob', 'wallet.json'))).toBe(true); + }, 180_000); + + it('switching back to alice restores alice\'s identity (no cross-pollination)', () => { + const use = runSphere(env, ['wallet', 'use', 'alice'], { timeoutMs: 60_000 }); + expect(use.status).toBe(0); + + // Re-read the active identity via `sphere status` (legacy + // top-level alias, ~line 1700 in legacy-cli.ts). It prints + // human-readable output: + // Direct Addr: DIRECT://... + // L1 Address: alpha1... + // We match the Direct Addr line, which is the same identity + // material as the JSON `directAddress` field captured during + // wallet init. + const status = runSphere(env, ['status'], { timeoutMs: 120_000 }); + if (status.status !== 0) { + console.error('status failed', { stdout: status.stdout, stderr: status.stderr }); + } + expect(status.status).toBe(0); + expect(status.stdout).toMatch(/Profile:\s*alice/); + const match = status.stdout.match(/Direct Addr:\s+(DIRECT:\/\/[0-9a-fA-F]+)/); + expect(match, `Direct Addr not in status output:\n${status.stdout}`).toBeTruthy(); + // ISOLATION INVARIANT — pin 2: after switching back, the + // wallet's identity matches the captured pre-switch value + // EXACTLY. A leak would surface bob's directAddress here. + expect(match![1]).toBe(directAddrAlice); + }, 240_000); + }, +); diff --git a/test/integration/cli-wallet-state.integration.test.ts b/test/integration/cli-wallet-state.integration.test.ts new file mode 100644 index 0000000..0c80a25 --- /dev/null +++ b/test/integration/cli-wallet-state.integration.test.ts @@ -0,0 +1,178 @@ +/** + * Integration test: `sphere payments {history,sync,receive}` + + * `sphere verify-balance` — wallet state inspection / validation surface. + * + * These four commands all operate on per-address wallet state but + * along different axes: + * + * - `history` — reads local transaction history + * (per-address tx ledger) + * - `sync` — pulls remote storage (IPFS / token-store) + * into local state + * - `receive` — finalizes incoming gift-wrapped tokens + * from Nostr + * - `verify-balance` — validates ALL local tokens against the + * aggregator (detects double-spent tokens + * that escaped the normal sync path) + * + * SDK-layer coverage for each underlying operation exists in + * sphere-sdk's PaymentsModule + TokenValidator tests. What this file + * pins is the CLI plumbing — exit codes, output shape, no-network-flag + * behaviours — that wallet-management scripts rely on. + * + * Two layers of pins: + * + * 1. **Help-shape pins (offline, 4 tests)** — `payments help ` + * for each command. HELP_TEXT keys ~lines 637-700 of legacy-cli.ts. + * + * 2. **Fresh-wallet lifecycle (network, 4 tests)** — on a brand-new + * testnet wallet with no tokens, no history, no remote state: + * - `history` returns "No transactions found" + exit 0 + * - `sync` completes without error + exit 0 + * - `receive` completes without error + exit 0 + * - `verify-balance` reports zero valid AND zero spent tokens + * These pins catch refactors that break the "empty wallet" path + * (a common regression class — the code paths that handle 0 + * tokens / 0 entries are easy to inadvertently rely on a + * non-empty precondition). + * + * Note on isolation: per-address isolation for tokens (and by + * extension for history, which is keyed by per-address tx storage) + * is already pinned comprehensively by cli-multiaddress.integration.test.ts + * (HD-address scope) and cli-wallet-profile.integration.test.ts + * (profile scope). This file deliberately avoids re-running those + * proofs; it focuses on the command surfaces themselves. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + createSphereEnv, + destroySphereEnv, + runSphere, + integrationSkip, + type SphereEnv, +} from './helpers.js'; + +/** + * HELP_TEXT keys + the must-match regexes that pin documented + * flag/positional behaviour. Keep in sync with legacy-cli.ts + * HELP_TEXT entries (~lines 637-700). + */ +const STATE_HELP_PINS: ReadonlyArray<{ + readonly legacy: string; + readonly mustMatch: RegExp[]; +}> = [ + // history: [limit] [--no-sync] + { legacy: 'history', mustMatch: [/\[limit\]/, /--no-sync/] }, + // sync: no args, but documented as "pull from remote". + { legacy: 'sync', mustMatch: [/sync/i] }, + // receive: --finalize + { legacy: 'receive', mustMatch: [/--finalize|finalize/i] }, + // verify-balance: --remove, -v|--verbose + { legacy: 'verify-balance', mustMatch: [/--remove/, /verbose/i] }, +]; + +describe('sphere-cli — wallet state command shape (offline)', () => { + let env: SphereEnv; + + beforeAll(() => { env = createSphereEnv('wallet-state-help'); }); + afterAll(() => { destroySphereEnv(env); }); + + for (const { legacy, mustMatch } of STATE_HELP_PINS) { + it(`\`sphere payments help ${legacy}\` lists documented usage`, () => { + const r = runSphere(env, ['payments', 'help', legacy], { timeoutMs: 15_000 }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(new RegExp(`Usage:.*${legacy}`)); + for (const re of mustMatch) { + expect(r.stdout, `${legacy} help missing ${re}`).toMatch(re); + } + }); + } +}); + +describe.skipIf(integrationSkip)( + 'sphere-cli integration — wallet state on a fresh wallet (real testnet)', + () => { + // One wallet shared across all four state-inspection commands. + // None of these tests mutate token state, so the same wallet is + // safe to reuse — though `receive` and `sync` may finalize any + // gift-wraps that happen to arrive during the test window. We + // don't assert on the lack of tokens, only on the empty-history + // and zero-spent invariants, which are robust to stray faucet + // tokens (none of these tests trigger a faucet). + let env: SphereEnv; + + beforeAll(() => { + env = createSphereEnv('wallet-state-live'); + const init = runSphere(env, ['wallet', 'init', '--network', 'testnet'], { timeoutMs: 120_000 }); + if (init.status !== 0) { + console.error('wallet init failed', { status: init.status, stdout: init.stdout, stderr: init.stderr }); + throw new Error('wallet init failed; cannot proceed with wallet-state tests'); + } + }, 180_000); + + afterAll(() => { if (env) destroySphereEnv(env); }); + + it('`sphere payments history` on a fresh wallet reports no transactions', () => { + // history with default limit (10) and full sync. Fresh wallet + // has never sent or received, so getHistory() returns []. + const r = runSphere(env, ['payments', 'history'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('history failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Header pin — load-bearing for scrapers that parse the "(last N)" + // suffix to know how many entries to expect. + expect(r.stdout).toMatch(/Transaction History \(last 10\):/); + // Exact wording from legacy-cli.ts ~line 2488. + expect(r.stdout).toMatch(/No transactions found/); + }, 180_000); + + it('`sphere payments sync` completes successfully on a fresh wallet', () => { + // sync calls ensureSync(sphere, 'full') and exits. No specific + // output line — we pin exit code 0 (the load-bearing signal for + // scripts that chain `sync && send ...`). A regression that + // throws inside the sync path would flip the exit code red. + const r = runSphere(env, ['payments', 'sync'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('sync failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + }, 180_000); + + it('`sphere payments receive` on a fresh wallet completes (no errors)', () => { + // receive without --finalize: looks for incoming gift-wraps and + // adds them to local state (as pending if v5). A fresh wallet + // has nothing in-flight, so this should complete cleanly. + const r = runSphere(env, ['payments', 'receive'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('receive failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + }, 180_000); + + it('`sphere payments verify-balance` on a fresh wallet reports zero valid and zero spent tokens', () => { + // Asymmetric registration (same gotcha as topup / top-up): the + // bare `sphere verify-balance` is NOT a top-level command. It + // is reachable ONLY through `payments verify-balance` because + // the `payments` namespace strips its own name and forwards + // the rest to the legacy dispatcher. Pin the working form. + // + // verify-balance scans all local tokens against the aggregator + // for spent-detection. Fresh wallet has no tokens, so the + // summary block should report zero of each. Critical: a + // regression that mis-reads "no tokens" as "all spent" would + // surface here. + const r = runSphere(env, ['payments', 'verify-balance'], { timeoutMs: 120_000 }); + if (r.status !== 0) { + console.error('verify-balance failed', { stdout: r.stdout, stderr: r.stderr }); + } + expect(r.status).toBe(0); + // Two load-bearing pins from the summary block (~lines 2275-2277): + // "Valid tokens: 0" + // "Spent tokens: 0" + expect(r.stdout).toMatch(/Valid tokens:\s*0/); + expect(r.stdout).toMatch(/Spent tokens:\s*0/); + }, 180_000); + }, +);