mirror of
https://github.com/tompro/sattle.git
synced 2026-08-26 23:05:58 +00:00
test: e2e coverage for mint management and inter-mint transfer
This commit is contained in:
+92
-11
@@ -5,13 +5,18 @@ import type { Page, Route } from '@playwright/test';
|
||||
// never touch the network. Protocol shapes mirror lnurlcash-kit's client:
|
||||
// the informational GET on the note URL (fetchNoteInfo) expects an LUD-03
|
||||
// withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the
|
||||
// rotation GET on the callback (rotateNote) expects {status: "OK"}.
|
||||
// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same
|
||||
// callback shape also answers a melt (meltNote sends k1 + pr to the same
|
||||
// callback and only requires status "OK").
|
||||
//
|
||||
// Every method takes an optional origin, so a spec can stand up a SECOND
|
||||
// mint (e.g. MINT2_ORIGIN) for inter-mint transfers.
|
||||
export const MINT_ORIGIN = 'https://mint.test';
|
||||
export const MINT2_ORIGIN = 'https://mint2.test';
|
||||
export const NOTE_PATH = '/note';
|
||||
export const CALLBACK_PATH = '/callback';
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const fulfillJson = async (route: Route, body: unknown): Promise<void> => {
|
||||
await route.fulfill({
|
||||
@@ -30,6 +35,23 @@ interface MockNoteInfoOptions {
|
||||
// when set, the mint answers the ERROR envelope with this reason instead
|
||||
// (a reason matching /spent/i surfaces the "already been spent" UI)
|
||||
spentReason?: string;
|
||||
// the mint's signing pubkey, advertised as mintPubkey in responses
|
||||
mintPubkey?: string;
|
||||
}
|
||||
|
||||
interface MockTargetMintOptions {
|
||||
// the mint's signing pubkey (66 hex chars), advertised in the payRequest
|
||||
// and note responses
|
||||
mintPubkey: string;
|
||||
// the invoice this mint hands out (and reports back as settled) - keep it
|
||||
// amount-less (decodeBolt11AmountMsat returns null) so the kit skips the
|
||||
// amount cross-check
|
||||
invoice: string;
|
||||
// the payment preimage a settled verify reveals - 64 hex chars (it IS the
|
||||
// claimed note's secret)
|
||||
preimage: string;
|
||||
// value of the note a claim mints, in msat
|
||||
noteAmountMsat: number;
|
||||
}
|
||||
|
||||
export class MintMocker {
|
||||
@@ -38,9 +60,9 @@ export class MintMocker {
|
||||
// The note's informational GET: a spec-shaped withdrawRequest, or the
|
||||
// ERROR envelope for a note the mint considers spent. The kit validates
|
||||
// that the service echoes back the queried k1, so read it off the request.
|
||||
async mockNoteInfo(options: MockNoteInfoOptions): Promise<void> {
|
||||
async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise<void> {
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(MINT_ORIGIN + NOTE_PATH)}\\?`),
|
||||
new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`),
|
||||
async (route: Route) => {
|
||||
if (options.spentReason !== undefined) {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: options.spentReason });
|
||||
@@ -49,25 +71,84 @@ export class MintMocker {
|
||||
const k1 = new URL(route.request().url()).searchParams.get('k1') ?? '';
|
||||
await fulfillJson(route, {
|
||||
tag: 'withdrawRequest',
|
||||
callback: `${MINT_ORIGIN}${CALLBACK_PATH}`,
|
||||
callback: `${origin}${CALLBACK_PATH}`,
|
||||
k1,
|
||||
minWithdrawable: options.amountMsat ?? 0,
|
||||
maxWithdrawable: options.amountMsat ?? 0,
|
||||
defaultDescription: 'mock mint note',
|
||||
...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The rotation callback GET (k1 + h params): confirm with the OK envelope.
|
||||
// No sig is returned - a plain LUD-03-style service that speaks rotate but
|
||||
// does not sign notes.
|
||||
async mockRotateOk(): Promise<void> {
|
||||
// The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr
|
||||
// params) both expect a plain {status: "OK"} confirmation. No sig is
|
||||
// returned - a plain LUD-03-style service that does not sign notes.
|
||||
async mockRotateOk(origin = MINT_ORIGIN): Promise<void> {
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`),
|
||||
new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`),
|
||||
async (route: Route) => {
|
||||
await fulfillJson(route, { status: 'OK' });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A mint that never answers its mint-address discovery endpoint with
|
||||
// anything usable - prepareMint treats that as "no mint-address support"
|
||||
// and falls back to the plain LNURL-pay guess.
|
||||
private async mockNoMintAddress(origin: string): Promise<void> {
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`),
|
||||
async (route: Route) => {
|
||||
await fulfillJson(route, { status: 'ERROR', reason: 'not supported' });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Everything the target side of a transfer (or a Lightning receive)
|
||||
// needs: the payRequest at the standard mint@ address, the invoice
|
||||
// callback, an immediately-settled verify endpoint revealing the
|
||||
// preimage, and the note info + rotate the claim then performs.
|
||||
async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise<void> {
|
||||
await this.mockNoMintAddress(origin);
|
||||
await this.mockNoteInfo(
|
||||
{ amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey },
|
||||
origin,
|
||||
);
|
||||
await this.mockRotateOk(origin);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`),
|
||||
async (route: Route) => {
|
||||
await fulfillJson(route, {
|
||||
tag: 'payRequest',
|
||||
callback: `${origin}/pay`,
|
||||
minSendable: 1000,
|
||||
maxSendable: 100_000_000_000,
|
||||
withdrawLink: `${origin}${NOTE_PATH}`,
|
||||
mintPubkey: options.mintPubkey,
|
||||
metadata: '[]',
|
||||
});
|
||||
},
|
||||
);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`),
|
||||
async (route: Route) => {
|
||||
await fulfillJson(route, {
|
||||
pr: options.invoice,
|
||||
verify: `${origin}/verify`,
|
||||
});
|
||||
},
|
||||
);
|
||||
await this.page.route(
|
||||
new RegExp(`^${escapeRegExp(`${origin}/verify`)}`),
|
||||
async (route: Route) => {
|
||||
await fulfillJson(route, {
|
||||
settled: true,
|
||||
preimage: options.preimage,
|
||||
pr: options.invoice,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
|
||||
import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
|
||||
import { createFreshWallet } from '../helpers/wallet';
|
||||
|
||||
// a 33-byte compressed secp256k1 pubkey, hex - what the trusted-mint
|
||||
// registry accepts
|
||||
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
|
||||
|
||||
// the payRequest discovery behind a one-tap suggestion: the app resolves
|
||||
// "@mint.600.wtf" to https://mint.600.wtf/.well-known/lnurlp/mint and reads
|
||||
// the mint's signing key out of the response
|
||||
const mockSuggestionMint = async (page: Page): Promise<void> => {
|
||||
const fulfill = async (route: import('@playwright/test').Route, body: unknown) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
};
|
||||
// no mint-address discovery support - the app falls back to the LNURL-pay
|
||||
await page.route(/^https:\/\/mint\.600\.wtf\/\.well-known\/lnurlw\//, (route) =>
|
||||
fulfill(route, { status: 'ERROR', reason: 'not supported' }),
|
||||
);
|
||||
await page.route(/^https:\/\/mint\.600\.wtf\/\.well-known\/lnurlp\/mint/, (route) =>
|
||||
fulfill(route, {
|
||||
tag: 'payRequest',
|
||||
callback: 'https://mint.600.wtf/pay',
|
||||
minSendable: 1000,
|
||||
maxSendable: 100_000_000_000,
|
||||
withdrawLink: 'https://mint.600.wtf/note',
|
||||
mintPubkey: MINT_PUBKEY,
|
||||
metadata: '[]',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('Manage mints', () => {
|
||||
test('trust a suggested mint, set it default, remove it', async ({ page }) => {
|
||||
await mockSuggestionMint(page);
|
||||
await createFreshWallet(page);
|
||||
|
||||
// Settings -> Mints group -> Manage mints
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('button', { name: 'Manage mints' }).click();
|
||||
await expect(page).toHaveURL(/#\/settings\/mints$/);
|
||||
|
||||
// empty state, and the public-mint suggestion is offered
|
||||
await expect(page.getByText('No mints yet', { exact: false })).toBeVisible();
|
||||
const suggestion = page.getByRole('button', { name: '@mint.600.wtf' });
|
||||
await expect(suggestion).toBeVisible();
|
||||
await suggestion.click();
|
||||
|
||||
// the mint lands in the trusted list with its key fingerprint
|
||||
const mintsList = page.locator('.q-list', { hasText: 'Your mints' });
|
||||
await expect(mintsList.getByText('mint.600.wtf')).toBeVisible();
|
||||
await expect(mintsList.getByText(`Key ${MINT_PUBKEY.slice(0, 10)}`)).toBeVisible();
|
||||
await expect(mintsList.getByText('0 sats held here')).toBeVisible();
|
||||
// the trusted suggestion disappears from the suggestion row
|
||||
await expect(page.getByRole('button', { name: '@mint.600.wtf' })).toHaveCount(0);
|
||||
|
||||
// set as default, then clear
|
||||
await mintsList.getByRole('button', { name: 'Set default' }).click();
|
||||
await expect(mintsList.locator('.q-badge', { hasText: 'Default' })).toBeVisible();
|
||||
await mintsList.getByRole('button', { name: 'Clear default' }).click();
|
||||
await expect(mintsList.locator('.q-badge')).toHaveCount(0);
|
||||
await mintsList.getByRole('button', { name: 'Set default' }).click();
|
||||
await expect(mintsList.locator('.q-badge', { hasText: 'Default' })).toBeVisible();
|
||||
|
||||
// remove: confirm dialog, then back to the empty state
|
||||
await mintsList.getByRole('button', { name: 'Remove' }).click();
|
||||
const confirm = page.locator('.q-dialog', { hasText: 'Remove mint' });
|
||||
await confirm.getByRole('button', { name: 'Remove' }).click();
|
||||
await expect(mintsList.getByText('mint.600.wtf')).toHaveCount(0);
|
||||
await expect(page.getByText('No mints yet', { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a mint with held notes cannot be removed', async ({ page, mint }) => {
|
||||
// hold a 21-sat note from the mock mint - holding funds locks the mint
|
||||
// against removal
|
||||
await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY });
|
||||
await mint.mockRotateOk();
|
||||
await createFreshWallet(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Receive' }).click();
|
||||
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
|
||||
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
||||
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||
await dialog
|
||||
.locator('textarea')
|
||||
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), 21_000));
|
||||
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
|
||||
await expect(dialog.getByText('Received 21 sats')).toBeVisible();
|
||||
// the note advertises a mint key, so the first-contact trust prompt
|
||||
// opens as its own dialog - trust it (the mint stays locked either way,
|
||||
// since we hold its note)
|
||||
await page
|
||||
.locator('.q-dialog', { hasText: 'New mint' })
|
||||
.getByRole('button', { name: 'Trust this mint' })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
await page.goto('/#/settings/mints');
|
||||
const mintsList = page.locator('.q-list', { hasText: 'Your mints' });
|
||||
await expect(mintsList.getByText('mint.test')).toBeVisible();
|
||||
await expect(mintsList.getByText('21 sats held here')).toBeVisible();
|
||||
|
||||
await mintsList.getByRole('button', { name: 'Remove' }).click();
|
||||
const confirm = page.locator('.q-dialog', { hasText: 'Remove mint' });
|
||||
await confirm.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
// the locked-mint error surfaces as a friendly banner, entry stays
|
||||
await expect(
|
||||
page.locator('.q-banner', { hasText: "can't be removed while you hold notes" }),
|
||||
).toBeVisible();
|
||||
await expect(mintsList.getByText('mint.test')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
|
||||
import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
|
||||
import { createFreshWallet } from '../helpers/wallet';
|
||||
|
||||
const AMOUNT_MSAT = 50_000; // 50 sats
|
||||
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
|
||||
const TARGET_PUBKEY = `03${'cd'.repeat(32)}`;
|
||||
// 64 hex chars - a valid preimage (it becomes the claimed note's secret)
|
||||
const PREIMAGE = 'ef'.repeat(32);
|
||||
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
|
||||
// amount cross-check against the requested value
|
||||
const INVOICE = 'lnmock1transfer';
|
||||
|
||||
// give the wallet a verified, spendable 50-sat note at the source mint
|
||||
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
|
||||
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
|
||||
await mint.mockRotateOk();
|
||||
await createFreshWallet(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Receive' }).click();
|
||||
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
|
||||
await chooser.getByRole('button', { name: 'Bearer note' }).click();
|
||||
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
|
||||
await dialog
|
||||
.locator('textarea')
|
||||
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT));
|
||||
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
|
||||
await expect(dialog.getByText('Received 50 sats')).toBeVisible();
|
||||
// the note advertises a mint key, so the first-contact trust prompt opens
|
||||
// as its own dialog - trust it (that also locks the mint)
|
||||
await page
|
||||
.locator('.q-dialog', { hasText: 'New mint' })
|
||||
.getByRole('button', { name: 'Trust this mint' })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Done' }).click();
|
||||
};
|
||||
|
||||
// pick an option from a Quasar select identified by its label
|
||||
const pickOption = async (page: Page, label: string, option: string) => {
|
||||
await page.locator('.q-field', { hasText: label }).click();
|
||||
await page.getByRole('option', { name: option }).click();
|
||||
};
|
||||
|
||||
test.describe('Move funds', () => {
|
||||
test('form renders and blocks bad amounts', async ({ page, mint }) => {
|
||||
await fundSourceMint(page, mint);
|
||||
|
||||
await page.goto('/#/settings/move');
|
||||
|
||||
// the source select lists only mints with spendable balance
|
||||
await pickOption(page, 'From mint', 'mint.test - 50 sats available');
|
||||
await pickOption(page, 'To mint', 'Another mint…');
|
||||
await page.getByLabel('Target mint address').fill('@mint2.test');
|
||||
|
||||
// no amount: Continue stays disabled
|
||||
const continueBtn = page.getByRole('button', { name: 'Continue' });
|
||||
await expect(continueBtn).toBeDisabled();
|
||||
|
||||
// over the source balance: blocked with an inline error
|
||||
await page.getByLabel('Amount').fill('51');
|
||||
await continueBtn.click();
|
||||
await expect(
|
||||
page.locator('.q-banner', { hasText: 'more than the 50 sats spendable at mint.test' }),
|
||||
).toBeVisible();
|
||||
|
||||
// the Max helper fills the source balance and passes validation
|
||||
await page.getByRole('button', { name: 'Max' }).click();
|
||||
await expect(page.getByLabel('Amount')).toHaveValue('50');
|
||||
await continueBtn.click();
|
||||
await expect(page.getByText('mint.test')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Move now' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a two-mint transfer moves the balance', async ({ page, mint }) => {
|
||||
await fundSourceMint(page, mint);
|
||||
// the target mint: hands out the invoice, reports it settled with the
|
||||
// preimage, and answers the claim's note info + rotation
|
||||
await mint.mockTargetMint(
|
||||
{
|
||||
mintPubkey: TARGET_PUBKEY,
|
||||
invoice: INVOICE,
|
||||
preimage: PREIMAGE,
|
||||
noteAmountMsat: AMOUNT_MSAT,
|
||||
},
|
||||
MINT2_ORIGIN,
|
||||
);
|
||||
|
||||
await page.goto('/#/settings/move');
|
||||
await pickOption(page, 'From mint', 'mint.test - 50 sats available');
|
||||
await pickOption(page, 'To mint', 'Another mint…');
|
||||
await page.getByLabel('Target mint address').fill('@mint2.test');
|
||||
await page.getByLabel('Amount').fill('50');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.getByRole('button', { name: 'Move now' }).click();
|
||||
|
||||
// settled: success screen with the fee summary (scoped to the page -
|
||||
// a toast repeats the "Moved" message)
|
||||
await expect(page.locator('.q-page').getByText('Moved 50 sats')).toBeVisible();
|
||||
await expect(page.getByText('No fees were charged.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
// the balance survived the move, now held at the target mint
|
||||
await page.goto('/#/');
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
|
||||
await page.goto('/#/settings/mints');
|
||||
const mintsList = page.locator('.q-list', { hasText: 'Your mints' });
|
||||
await expect(mintsList.getByText('mint2.test')).toBeVisible();
|
||||
await expect(mintsList.getByText('50 sats held here')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a locked wallet is sent back to the main page', async ({ page }) => {
|
||||
// no wallet on this device at all: the guard redirects to /
|
||||
await page.goto('/#/settings/move');
|
||||
await expect(page).toHaveURL(/#\/$/);
|
||||
await expect(page.getByText('Welcome to sattle')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user