Guides
Testing without a wallet
Unit-test wallet-authenticated routes with throwaway keys. No wallet app, no browser, no network, just node:test.
You can't click "Approve" in a wallet from CI. You don't have to. A wallet's job in a proof is to sign, and ProtoWallet from @bsv/sdk signs with a key held in memory. Give each test its own random key and you have as many users as you like.
A complete test file#
Five tests covering the guarantees you actually rely on. Copy it into server/test/auth.test.ts. It uses only what a scaffold already installs.
import { function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test } from 'node:test'
import function assert(value: unknown, message?: string | Error): asserts valueassert from 'node:assert/strict'
import { class PrivateKeyPrivateKey, class ProtoWalletProtoWallet } from '@bsv/sdk'
import { function createAuthProof(wallet: ProofSignerWallet, opts: {
counterparty: string;
action: string;
body?: RequestBody;
}): Promise<AuthProof>
createAuthProof, type type RequestBody = string | ArrayBuffer | ArrayBufferView<ArrayBufferLike> | unknown[] | Record<string, unknown>RequestBody } from '../src/bsv/auth.js'
import { function verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifySignedRequest } from '../src/bsv/verifySignedRequest.js'
// Two throwaway identities: no wallet app, no network, no browser.
const const server: ProtoWalletserver = new new ProtoWallet(rootKeyOrKeyDeriver?: PrivateKey | "anyone" | KeyDeriverApi): ProtoWalletProtoWallet(class PrivateKeyPrivateKey.PrivateKey.fromRandom(): PrivateKeyfromRandom())
const const alice: ProtoWalletalice = new new ProtoWallet(rootKeyOrKeyDeriver?: PrivateKey | "anyone" | KeyDeriverApi): ProtoWalletProtoWallet(class PrivateKeyPrivateKey.PrivateKey.fromRandom(): PrivateKeyfromRandom())
const { publicKey: stringpublicKey: const serverKey: stringserverKey } = await const server: ProtoWalletserver.ProtoWallet.getPublicKey(args: GetPublicKeyArgs): Promise<{
publicKey: PubKeyHex;
}>
getPublicKey({ GetPublicKeyArgs.identityKey?: true | undefinedidentityKey: true })
const { publicKey: stringpublicKey: const aliceKey: stringaliceKey } = await const alice: ProtoWalletalice.ProtoWallet.getPublicKey(args: GetPublicKeyArgs): Promise<{
publicKey: PubKeyHex;
}>
getPublicKey({ GetPublicKeyArgs.identityKey?: true | undefinedidentityKey: true })
// A fresh in-memory nonce store per test keeps tests independent.
const const nonceStore: () => (nonce: string) => booleannonceStore = () => {
const const seen: Set<string>seen = new var Set: SetConstructor
new <string>(iterable?: Iterable<string> | null | undefined) => Set<string> (+1 overload)
Set<string>()
return (nonce: stringnonce: string) => (const seen: Set<string>seen.Set<string>.has(value: string): booleanhas(nonce: stringnonce) ? false : (const seen: Set<string>seen.Set<string>.add(value: string): Set<string>add(nonce: stringnonce), true))
}
const const sign: (body: RequestBody) => Promise<AuthProof>sign = (body: RequestBodybody: type RequestBody = string | ArrayBuffer | ArrayBufferView<ArrayBufferLike> | unknown[] | Record<string, unknown>RequestBody) =>
function createAuthProof(wallet: ProofSignerWallet, opts: {
counterparty: string;
action: string;
body?: RequestBody;
}): Promise<AuthProof>
createAuthProof(const alice: ProtoWalletalice, { counterparty: stringcounterparty: const serverKey: stringserverKey, action: stringaction: 'create-note', body?: RequestBody | undefinedbody })
function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test('a signed request proves who sent it', async () => {
const const body: {
text: string;
}
body = { text: stringtext: 'gm' }
const const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r = await function verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifySignedRequest(const server: ProtoWalletserver, await const sign: (body: RequestBody) => Promise<AuthProof>sign(const body: {
text: string;
}
body), { action: stringaction: 'create-note', body?: RequestBody | undefinedbody }, const nonceStore: () => (nonce: string) => booleannonceStore())
function assert(value: unknown, message?: string | Error): asserts valueassert.strict.equal<true>(actual: unknown, expected: true, message?: string | Error): asserts actual is true
export strict.equal
equal(const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r.valid: booleanvalid, true)
function assert(value: unknown, message?: string | Error): asserts valueassert.strict.equal<string>(actual: unknown, expected: string, message?: string | Error): asserts actual is string
export strict.equal
equal(const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r.identityKey?: string | undefinedidentityKey, const aliceKey: stringaliceKey)
})
function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test('a changed body is rejected', async () => {
const const proof: AuthProofproof = await const sign: (body: RequestBody) => Promise<AuthProof>sign({ text: stringtext: 'gm' })
const const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r = await function verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifySignedRequest(const server: ProtoWalletserver, const proof: AuthProofproof, { action: stringaction: 'create-note', body?: RequestBody | undefinedbody: { text: stringtext: 'gn' } }, const nonceStore: () => (nonce: string) => booleannonceStore())
function assert(value: unknown, message?: string | Error): asserts valueassert.strict.equal<false>(actual: unknown, expected: false, message?: string | Error): asserts actual is false
export strict.equal
equal(const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r.valid: booleanvalid, false)
})
function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test('a different action is rejected', async () => {
const const body: {
text: string;
}
body = { text: stringtext: 'gm' }
const const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r = await function verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifySignedRequest(const server: ProtoWalletserver, await const sign: (body: RequestBody) => Promise<AuthProof>sign(const body: {
text: string;
}
body), { action: stringaction: 'delete-note', body?: RequestBody | undefinedbody }, const nonceStore: () => (nonce: string) => booleannonceStore())
function assert(value: unknown, message?: string | Error): asserts valueassert.strict.equal<false>(actual: unknown, expected: false, message?: string | Error): asserts actual is false
export strict.equal
equal(const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r.valid: booleanvalid, false)
})
function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test('a replayed proof is rejected', async () => {
const const body: {
text: string;
}
body = { text: stringtext: 'gm' }
const const proof: AuthProofproof = await const sign: (body: RequestBody) => Promise<AuthProof>sign(const body: {
text: string;
}
body)
const const consume: (nonce: string) => booleanconsume = const nonceStore: () => (nonce: string) => booleannonceStore()
function assert(value: unknown, message?: string | Error): asserts valueassert.strict.equal<true>(actual: unknown, expected: true, message?: string | Error): asserts actual is true
export strict.equal
equal((await function verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifySignedRequest(const server: ProtoWalletserver, const proof: AuthProofproof, { action: stringaction: 'create-note', body?: RequestBody | undefinedbody }, const consume: (nonce: string) => booleanconsume)).valid: booleanvalid, true)
function assert(value: unknown, message?: string | Error): asserts valueassert.strict.equal<false>(actual: unknown, expected: false, message?: string | Error): asserts actual is false
export strict.equal
equal((await function verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifySignedRequest(const server: ProtoWalletserver, const proof: AuthProofproof, { action: stringaction: 'create-note', body?: RequestBody | undefinedbody }, const consume: (nonce: string) => booleanconsume)).valid: booleanvalid, false)
})
function test(name?: string, fn?: test.TestFn): Promise<void> (+3 overloads)test('a proof made for another server is rejected', async () => {
const const other: ProtoWalletother = new new ProtoWallet(rootKeyOrKeyDeriver?: PrivateKey | "anyone" | KeyDeriverApi): ProtoWalletProtoWallet(class PrivateKeyPrivateKey.PrivateKey.fromRandom(): PrivateKeyfromRandom())
const const body: {
text: string;
}
body = { text: stringtext: 'gm' }
const const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r = await function verifySignedRequest(serverWallet: {
verifySignature: (args: any) => Promise<{
valid: boolean;
}>;
}, proof: AuthProof, opts: {
action: string;
body?: RequestBody;
}, consumeNonce: (nonce: string, expiresAt: Date) => boolean | Promise<boolean>): Promise<{
valid: boolean;
identityKey?: string;
error?: string;
}>
verifySignedRequest(const other: ProtoWalletother, await const sign: (body: RequestBody) => Promise<AuthProof>sign(const body: {
text: string;
}
body), { action: stringaction: 'create-note', body?: RequestBody | undefinedbody }, const nonceStore: () => (nonce: string) => booleannonceStore())
function assert(value: unknown, message?: string | Error): asserts valueassert.strict.equal<false>(actual: unknown, expected: false, message?: string | Error): asserts actual is false
export strict.equal
equal(const r: {
valid: boolean;
identityKey?: string;
error?: string;
}
r.valid: booleanvalid, false)
})Run it from server/:
npx tsx --test test/auth.test.ts
✔ a signed request proves who sent it
✔ a changed body is rejected
✔ a different action is rejected
✔ a replayed proof is rejected
✔ a proof made for another server is rejected
ℹ tests 5
ℹ pass 5
ℹ fail 0Add "test": "tsx --test test/*.test.ts" to server/package.json and it's npm test from then on.
Testing over HTTP#
To exercise your real routes, middleware included, start the server and post proofs at it. The tutorial's attack script does exactly that. The only extra step is fetching the server's identity first, because it's the proof's counterparty:
const { identityKey: server } = await (await fetch('http://localhost:3000/api/identity')).json()
const proof = await createAuthProof(wallet, { counterparty: server, action: 'login' })
await fetch('http://localhost:3000/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(proof),
})Testing the React side#
The client hooks call a real wallet through WalletClient('auto'). For component tests, mock useWallet() rather than the wallet. With Vitest (opens in a new tab) (not installed by the scaffold):
vi.mock('./bsv/WalletContext', () => ({
useWallet: () => ({ connected: true, identityKey: '02ab…', status: 'connected', wallet: null }),
}))For a true end-to-end run (Playwright clicking Connect wallet), you need a wallet that approves automatically. That's out of scope for the scaffold. Most teams cover the crypto in server tests like the ones above, and the UI with mocks.