mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat: add note management and activity history
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
||||
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
|
||||
import type { ActivityKind } from '../../src/lnurlcash/storage/activityLog';
|
||||
|
||||
// Settings chrome (version footer, no About entry) and the activity
|
||||
// history: the home-page preview link and the full log's pagination.
|
||||
|
||||
type SeededEvent = { kind: ActivityKind; message: string; createdAt: number };
|
||||
|
||||
// Write encrypted activity records straight into localStorage, built with
|
||||
// the app's own wrap (keys.ts: sha256(linking key + context) -> AES-GCM).
|
||||
// The activity store only reads the log on unlock, so callers reload after
|
||||
// seeding.
|
||||
const seedActivityEvents = async (page: Page, events: SeededEvent[]): Promise<void> => {
|
||||
const linkingKeyHex = await page.evaluate(() => {
|
||||
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
|
||||
if (
|
||||
typeof saved !== 'object' ||
|
||||
saved === null ||
|
||||
!('enc' in saved) ||
|
||||
saved.enc !== false ||
|
||||
!('value' in saved) ||
|
||||
typeof saved.value !== 'string'
|
||||
) {
|
||||
throw new Error('expected a plaintext saved wallet material record');
|
||||
}
|
||||
const material: unknown = JSON.parse(saved.value);
|
||||
if (
|
||||
typeof material !== 'object' ||
|
||||
material === null ||
|
||||
!('linkingKeyHex' in material) ||
|
||||
typeof material.linkingKeyHex !== 'string'
|
||||
) {
|
||||
throw new Error('expected the saved material to carry its linking key');
|
||||
}
|
||||
return material.linkingKeyHex;
|
||||
});
|
||||
const material = sha256(
|
||||
new Uint8Array([
|
||||
...hexToBytes(linkingKeyHex),
|
||||
...utf8ToBytes('lnurlcash-bearer-encryption-v1'),
|
||||
]),
|
||||
);
|
||||
const aesKey = await crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt']);
|
||||
const records = [];
|
||||
for (const event of events) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
aesKey,
|
||||
utf8ToBytes(JSON.stringify(event)),
|
||||
),
|
||||
);
|
||||
records.push({
|
||||
id: bytesToHex(crypto.getRandomValues(new Uint8Array(8))),
|
||||
iv: bytesToHex(iv),
|
||||
ciphertext: bytesToHex(ciphertext),
|
||||
});
|
||||
}
|
||||
await page.evaluate(
|
||||
(json) => localStorage.setItem('sattle_activity', json),
|
||||
JSON.stringify(records),
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('Settings', () => {
|
||||
test('shows the app version and no About entry', async ({ page }) => {
|
||||
await createFreshWallet(page);
|
||||
|
||||
await page.goto('/#/settings');
|
||||
const settings = page.locator('.q-page');
|
||||
await expect(settings.getByText(/^sattle v\d+\.\d+\.\d+/)).toBeVisible();
|
||||
await expect(settings.locator('.q-item', { hasText: 'About' })).toHaveCount(0);
|
||||
// the advanced entries are live routes now
|
||||
await expect(settings.locator('.q-item', { hasText: 'Notes' })).toBeVisible();
|
||||
await expect(settings.locator('.q-item', { hasText: 'Activity log' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Activity history', () => {
|
||||
test('the home history preview links to the full activity log', async ({ page, mint }) => {
|
||||
await fundMockMintWallet(page, mint, 21_000);
|
||||
|
||||
// the preview sits inside the History expansion on the main page
|
||||
const history = page.locator('.q-expansion-item', { hasText: 'History' });
|
||||
await history.getByText('History', { exact: true }).click();
|
||||
await expect(
|
||||
history.locator('.history-list').getByText('Received 21 sats from mint.test.'),
|
||||
).toBeVisible();
|
||||
|
||||
await history.getByRole('button', { name: 'View all activity' }).click();
|
||||
await expect(page).toHaveURL(/#\/settings\/activity$/);
|
||||
await expect(
|
||||
page.locator('.q-page').getByText('Received 21 sats from mint.test.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('paginates the activity log', async ({ page }) => {
|
||||
await createFreshWallet(page);
|
||||
const base = Date.now();
|
||||
const kinds: ActivityKind[] = ['mint', 'receive', 'refresh'];
|
||||
await seedActivityEvents(
|
||||
page,
|
||||
Array.from({ length: 25 }, (_, index) => ({
|
||||
kind: kinds[index % kinds.length] ?? 'mint',
|
||||
message: `Seeded activity ${String(index).padStart(2, '0')}`,
|
||||
createdAt: base - index * 60_000,
|
||||
})),
|
||||
);
|
||||
|
||||
// the log is decrypted on unlock - reload into the auto-unlock
|
||||
await page.reload();
|
||||
await page.goto('/#/settings/activity');
|
||||
await expect(page.locator('.q-page').getByText('Activity log')).toBeVisible();
|
||||
|
||||
// page 1 shows the 20 newest events
|
||||
const rows = page.locator('.q-page .history-list .q-item');
|
||||
await expect(rows).toHaveCount(20);
|
||||
await expect(rows.first()).toContainText('Seeded activity 00');
|
||||
await expect(rows.last()).toContainText('Seeded activity 19');
|
||||
|
||||
const pagination = page.locator('.q-pagination');
|
||||
await expect(pagination).toBeVisible();
|
||||
await pagination.getByRole('button', { name: '2', exact: true }).click();
|
||||
|
||||
// page 2 shows the remaining five, still newest-first
|
||||
await expect(rows).toHaveCount(5);
|
||||
await expect(rows.first()).toContainText('Seeded activity 20');
|
||||
await expect(rows.last()).toContainText('Seeded activity 24');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test, expect } from '../fixtures';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { fundMockMintWallet } from '../helpers/wallet';
|
||||
|
||||
// The Notes page (Settings -> Advanced -> Notes): per-note detail dialog,
|
||||
// split/combine mutations against the mock mint, check & refresh rotation,
|
||||
// and the hand-off into a selected-note move.
|
||||
|
||||
const AMOUNT_MSAT = 21_000; // 21 sats
|
||||
|
||||
// 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();
|
||||
};
|
||||
|
||||
// split the wallet's 21-sat note into 8 + 13 sats through the notes UI
|
||||
const splitInto8And13 = async (page: Page) => {
|
||||
await page.goto('/#/settings/notes');
|
||||
await page.locator('.q-page .q-item', { hasText: '21 sats' }).click();
|
||||
const dialog = page.locator('.q-dialog .note-detail');
|
||||
await dialog.getByRole('button', { name: 'Split' }).click();
|
||||
const splitDialog = page.locator('.q-dialog .split-dialog');
|
||||
await splitDialog.getByLabel('New note amount').fill('8');
|
||||
await splitDialog.getByRole('button', { name: 'Split' }).click();
|
||||
await expect(page.locator('.q-notification').getByText('Note split.')).toBeVisible();
|
||||
};
|
||||
|
||||
test.describe('Notes page', () => {
|
||||
test('shows note details and check & refresh rotates to a fresh secret', async ({
|
||||
page,
|
||||
mint,
|
||||
}) => {
|
||||
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
|
||||
|
||||
await page.goto('/#/settings/notes');
|
||||
const list = page.locator('.q-page .q-list');
|
||||
await expect(list.getByText('21 sats')).toBeVisible();
|
||||
|
||||
// the detail dialog shows the note's identity, status and actions
|
||||
await list.getByText('21 sats').click();
|
||||
const dialog = page.locator('.q-dialog .note-detail');
|
||||
await expect(dialog.getByText('mint.test')).toBeVisible();
|
||||
await expect(dialog.getByText('Note ID')).toBeVisible();
|
||||
await expect(dialog.getByText('Mint signing key')).toBeVisible();
|
||||
await expect(dialog.locator('.q-badge', { hasText: 'Valid locally' })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Split' })).toBeEnabled();
|
||||
await expect(dialog.getByRole('button', { name: 'Move' })).toBeEnabled();
|
||||
|
||||
// A refresh is a secret rotation of the same logical note, so private
|
||||
// local metadata must follow the active replacement.
|
||||
await dialog.getByLabel('Private label').fill('Emergency cash');
|
||||
await dialog.getByRole('button', { name: 'Save label' }).click();
|
||||
await expect(page.locator('.q-notification').getByText('Label saved.')).toBeVisible();
|
||||
|
||||
// the check verifies the note mint-side and rotates it to a fresh
|
||||
// secret; completion is signalled by the toast
|
||||
await dialog.getByRole('button', { name: 'Check & refresh' }).click();
|
||||
await expect(
|
||||
page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'),
|
||||
).toBeVisible();
|
||||
|
||||
// the checked copy is retired and the rotated note stands valid - the
|
||||
// wallet still holds exactly 21 spendable sats
|
||||
await page.goto('/#/settings/notes');
|
||||
await expect(page.locator('.q-page .q-item', { hasText: '21 sats' })).toHaveCount(2);
|
||||
await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1);
|
||||
await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('.q-page .q-item', { hasText: 'Emergency cash' }).getByText('Valid locally'),
|
||||
).toBeVisible();
|
||||
|
||||
// and the refresh is on the activity log
|
||||
await page.goto('/#/settings/activity');
|
||||
await expect(
|
||||
page.locator('.q-page').getByText('Checked and refreshed a 21 sat note from mint.test.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('splits a note and combines the pieces back once the change is refreshed', async ({
|
||||
page,
|
||||
mint,
|
||||
}) => {
|
||||
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
|
||||
await splitInto8And13(page);
|
||||
|
||||
// the split retires the original and mints the 8-sat target verified;
|
||||
// the 13-sat change stays unverified (an upper bound until refreshed),
|
||||
// so it can neither be selected nor combined yet
|
||||
const list = page.locator('.q-page .q-list');
|
||||
await expect(list.locator('.q-item', { hasText: '8 sats' })).toHaveCount(1);
|
||||
await expect(list.locator('.q-item', { hasText: '13 sats' })).toHaveCount(1);
|
||||
await expect(list.locator('.q-item', { hasText: '13 sats' })).toContainText('Unverified');
|
||||
await expect(page.locator('.q-checkbox[aria-label="Select 13 sat note"]')).toBeDisabled();
|
||||
|
||||
// refreshing the change note verifies and rotates it
|
||||
await list.locator('.q-item', { hasText: '13 sats' }).click();
|
||||
const dialog = page.locator('.q-dialog .note-detail');
|
||||
await dialog.getByRole('button', { name: 'Check & refresh' }).click();
|
||||
await expect(
|
||||
page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'),
|
||||
).toBeVisible();
|
||||
|
||||
// now both pieces can be selected and combined back (the detail dialog
|
||||
// stays open on the retired copy - dismiss it to reach the list)
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.note-detail')).toHaveCount(0);
|
||||
await page.locator('.q-checkbox[aria-label="Select 8 sat note"]').click();
|
||||
await page.locator('.q-checkbox[aria-label="Select 13 sat note"]:not(.disabled)').click();
|
||||
const mergeBar = page.locator('.merge-bar');
|
||||
await expect(mergeBar.getByText('2 selected')).toBeVisible();
|
||||
await mergeBar.getByRole('button', { name: 'Combine' }).click();
|
||||
|
||||
// one valid 21-sat note again; every intermediate copy shows as spent
|
||||
await expect(page.locator('.q-notification').getByText('Notes combined.')).toBeVisible();
|
||||
await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1);
|
||||
await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(4);
|
||||
|
||||
// no sats were created or destroyed along the way
|
||||
await page.goto('/#/');
|
||||
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
|
||||
});
|
||||
|
||||
test('moving from a note prefills its amount and locks the source to that note', async ({
|
||||
page,
|
||||
mint,
|
||||
}) => {
|
||||
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
|
||||
await splitInto8And13(page);
|
||||
|
||||
// move the 8-sat piece: the move form opens with the note's mint and
|
||||
// amount prefilled, and the source select is locked
|
||||
await page.locator('.q-page .q-item', { hasText: '8 sats' }).click();
|
||||
const dialog = page.locator('.q-dialog .note-detail');
|
||||
await dialog.getByRole('button', { name: 'Move' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/#\/settings\/move\?noteId=/);
|
||||
const sourceField = page.locator('.q-field', { hasText: 'From mint' });
|
||||
await expect(sourceField).toHaveClass(/q-field--disabled/);
|
||||
await expect(sourceField).toContainText('mint.test - 8 sats available');
|
||||
await expect(page.getByLabel('Amount')).toHaveValue('8');
|
||||
|
||||
// Max tops up to the selected note alone, not the wallet's 21 sats
|
||||
await page.getByRole('button', { name: 'Max' }).click();
|
||||
await expect(page.getByLabel('Amount')).toHaveValue('8');
|
||||
|
||||
// and asking for more than the locked note is refused inline
|
||||
await pickOption(page, 'To mint', 'Another mint…');
|
||||
await page.getByLabel('Target mint address').fill('@mint2.test');
|
||||
await page.getByLabel('Amount').fill('9');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(
|
||||
page.locator('.q-banner', { hasText: 'more than the 8 sats spendable at mint.test' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user