mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
Formatting
This commit is contained in:
+135
-135
@@ -1,12 +1,12 @@
|
||||
// Storage round-trips and backup merge semantics. Runs in Node against an
|
||||
// in-memory localStorage stub; WebCrypto (crypto.subtle) is native.
|
||||
|
||||
import {beforeEach, describe, expect, it} from 'vitest'
|
||||
import {buildNoteUrl} from 'lnurlcash-kit'
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||
|
||||
import './storage.changeset.cases'
|
||||
import type {Bearer} from './types'
|
||||
import {deriveBearerAesKey} from './keys'
|
||||
import './storage.changeset.cases';
|
||||
import type { Bearer } from './types';
|
||||
import { deriveBearerAesKey } from './keys';
|
||||
import {
|
||||
applyBackup,
|
||||
buildBackup,
|
||||
@@ -20,16 +20,16 @@ import {
|
||||
persistBearer,
|
||||
readEncryptedBearers,
|
||||
MAX_ACTIVITY_ENTRIES,
|
||||
} from './storage'
|
||||
import {saveLinkingKey} from './keys'
|
||||
import {FUNDS_STORAGE_KEY} from './storage/bearers'
|
||||
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||
} from './storage';
|
||||
import { saveLinkingKey } from './keys';
|
||||
import { FUNDS_STORAGE_KEY } from './storage/bearers';
|
||||
import { requiredValue, stubLocalStorage } from './test-utils';
|
||||
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||
const OTHER_KEY = new Uint8Array(32).fill(9)
|
||||
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||
const OTHER_KEY = new Uint8Array(32).fill(9);
|
||||
|
||||
const K1_A = 'aa'.repeat(32)
|
||||
const K1_B = 'bb'.repeat(32)
|
||||
const K1_A = 'aa'.repeat(32);
|
||||
const K1_B = 'bb'.repeat(32);
|
||||
|
||||
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||
id: newBearerId(),
|
||||
@@ -40,204 +40,204 @@ const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
...overrides,
|
||||
})
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
stubLocalStorage()
|
||||
})
|
||||
stubLocalStorage();
|
||||
});
|
||||
|
||||
describe('encrypted bearer records', () => {
|
||||
it('round-trips a bearer through AES-GCM storage', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const bearer = bearerFixture()
|
||||
await persistBearer(key, bearer)
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
const bearer = bearerFixture();
|
||||
await persistBearer(key, bearer);
|
||||
|
||||
// at rest, nothing plaintext leaks: no k1, no amounts
|
||||
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY))
|
||||
expect(raw).not.toContain(K1_A)
|
||||
expect(raw).not.toContain('21000')
|
||||
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY));
|
||||
expect(raw).not.toContain(K1_A);
|
||||
expect(raw).not.toContain('21000');
|
||||
|
||||
const loaded = await loadBearers(key)
|
||||
expect(loaded).toEqual([bearer])
|
||||
})
|
||||
const loaded = await loadBearers(key);
|
||||
expect(loaded).toEqual([bearer]);
|
||||
});
|
||||
|
||||
it('skips records written under a different seed without destroying them', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const other = await deriveBearerAesKey(OTHER_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'mine'}))
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
const other = await deriveBearerAesKey(OTHER_KEY);
|
||||
await persistBearer(key, bearerFixture({ id: 'mine' }));
|
||||
await persistBearer(
|
||||
other,
|
||||
bearerFixture({id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)}),
|
||||
)
|
||||
bearerFixture({ id: 'foreign', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) }),
|
||||
);
|
||||
|
||||
const loaded = await loadBearers(key)
|
||||
expect(loaded.map((b) => b.id)).toEqual(['mine'])
|
||||
const loaded = await loadBearers(key);
|
||||
expect(loaded.map((b) => b.id)).toEqual(['mine']);
|
||||
// the foreign ciphertext is still there, untouched
|
||||
expect(
|
||||
readEncryptedBearers()
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
).toEqual(['foreign', 'mine'])
|
||||
})
|
||||
).toEqual(['foreign', 'mine']);
|
||||
});
|
||||
|
||||
it('overwrites a record when the same id is persisted again', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const bearer = bearerFixture()
|
||||
await persistBearer(key, bearer)
|
||||
await persistBearer(key, {...bearer, spent: true, updatedAt: 2000})
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
const bearer = bearerFixture();
|
||||
await persistBearer(key, bearer);
|
||||
await persistBearer(key, { ...bearer, spent: true, updatedAt: 2000 });
|
||||
|
||||
const loaded = await loadBearers(key)
|
||||
expect(loaded).toHaveLength(1)
|
||||
expect(requiredValue(loaded[0]).spent).toBe(true)
|
||||
})
|
||||
const loaded = await loadBearers(key);
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(requiredValue(loaded[0]).spent).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes a record by id and clears all', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const a = bearerFixture({id: 'a'})
|
||||
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
|
||||
await persistBearer(key, a)
|
||||
await persistBearer(key, b)
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
const a = bearerFixture({ id: 'a' });
|
||||
const b = bearerFixture({ id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) });
|
||||
await persistBearer(key, a);
|
||||
await persistBearer(key, b);
|
||||
|
||||
await deleteBearerRecord('a')
|
||||
expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b'])
|
||||
await deleteBearerRecord('a');
|
||||
expect((await loadBearers(key)).map((x) => x.id)).toEqual(['b']);
|
||||
|
||||
clearAllBearers()
|
||||
expect(readEncryptedBearers()).toEqual([])
|
||||
})
|
||||
})
|
||||
clearAllBearers();
|
||||
expect(readEncryptedBearers()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activity log', () => {
|
||||
it('round-trips events newest-first', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistActivityEvent(key, {id: '1', kind: 'mint', message: 'a', createdAt: 1000})
|
||||
await persistActivityEvent(key, {id: '2', kind: 'melt', message: 'b', createdAt: 2000})
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
await persistActivityEvent(key, { id: '1', kind: 'mint', message: 'a', createdAt: 1000 });
|
||||
await persistActivityEvent(key, { id: '2', kind: 'melt', message: 'b', createdAt: 2000 });
|
||||
|
||||
const loaded = await loadActivity(key)
|
||||
expect(loaded.map((e) => e.id)).toEqual(['2', '1'])
|
||||
})
|
||||
const loaded = await loadActivity(key);
|
||||
expect(loaded.map((e) => e.id)).toEqual(['2', '1']);
|
||||
});
|
||||
|
||||
it('caps the log, rolling the oldest entries off', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
for (let i = 0; i < MAX_ACTIVITY_ENTRIES + 5; i++) {
|
||||
await persistActivityEvent(key, {
|
||||
id: `ev-${i}`,
|
||||
kind: 'receive',
|
||||
message: `event ${i}`,
|
||||
createdAt: i,
|
||||
})
|
||||
});
|
||||
}
|
||||
const loaded = await loadActivity(key)
|
||||
expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES)
|
||||
const loaded = await loadActivity(key);
|
||||
expect(loaded).toHaveLength(MAX_ACTIVITY_ENTRIES);
|
||||
// the five oldest rolled off; the newest is first
|
||||
expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`)
|
||||
expect(requiredValue(loaded.at(-1)).id).toBe('ev-5')
|
||||
}, 30_000)
|
||||
})
|
||||
expect(requiredValue(loaded[0]).id).toBe(`ev-${MAX_ACTIVITY_ENTRIES + 4}`);
|
||||
expect(requiredValue(loaded.at(-1)).id).toBe('ev-5');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('mergeBearers (union by note id, spent-wins)', () => {
|
||||
it('unions notes with distinct secrets', () => {
|
||||
const a = bearerFixture({id: 'a'})
|
||||
const b = bearerFixture({id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000)})
|
||||
const merged = mergeBearers([a], [b])
|
||||
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
const a = bearerFixture({ id: 'a' });
|
||||
const b = bearerFixture({ id: 'b', url: buildNoteUrl('https://mint.example/w', K1_B, 5_000) });
|
||||
const merged = mergeBearers([a], [b]);
|
||||
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('lets the spent copy of a note win over a still-spendable one', () => {
|
||||
const spendable = bearerFixture({id: 'old-copy', updatedAt: 3000})
|
||||
const spent = bearerFixture({id: 'new-copy', spent: true, updatedAt: 1000})
|
||||
const spendable = bearerFixture({ id: 'old-copy', updatedAt: 3000 });
|
||||
const spent = bearerFixture({ id: 'new-copy', spent: true, updatedAt: 1000 });
|
||||
// same note (same server + k1), different record ids, and the spent
|
||||
// copy is even the STALER one - spent still wins, or a restored backup
|
||||
// would resurrect burned money
|
||||
const merged = mergeBearers([spendable], [spent])
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(requiredValue(merged[0]).id).toBe('new-copy')
|
||||
expect(requiredValue(merged[0]).spent).toBe(true)
|
||||
})
|
||||
const merged = mergeBearers([spendable], [spent]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(requiredValue(merged[0]).id).toBe('new-copy');
|
||||
expect(requiredValue(merged[0]).spent).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the newer copy when both agree on spent state', () => {
|
||||
const stale = bearerFixture({id: 'stale', updatedAt: 1000, amount: 1})
|
||||
const fresh = bearerFixture({id: 'fresh', updatedAt: 2000, amount: 2})
|
||||
const merged = mergeBearers([stale], [fresh])
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(requiredValue(merged[0]).id).toBe('fresh')
|
||||
})
|
||||
const stale = bearerFixture({ id: 'stale', updatedAt: 1000, amount: 1 });
|
||||
const fresh = bearerFixture({ id: 'fresh', updatedAt: 2000, amount: 2 });
|
||||
const merged = mergeBearers([stale], [fresh]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(requiredValue(merged[0]).id).toBe('fresh');
|
||||
});
|
||||
|
||||
it('treats the same secret on different servers as different notes', () => {
|
||||
const here = bearerFixture({id: 'here'})
|
||||
const here = bearerFixture({ id: 'here' });
|
||||
const there = bearerFixture({
|
||||
id: 'there',
|
||||
url: buildNoteUrl('https://other.example/w', K1_A, 21_000),
|
||||
})
|
||||
expect(mergeBearers([here], [there])).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
});
|
||||
expect(mergeBearers([here], [there])).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup', () => {
|
||||
it('never exports a plaintext linking key', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await saveLinkingKey(LINKING_KEY) // no password: stored plaintext
|
||||
await persistBearer(key, bearerFixture())
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
await saveLinkingKey(LINKING_KEY); // no password: stored plaintext
|
||||
await persistBearer(key, bearerFixture());
|
||||
|
||||
const backup = buildBackup()
|
||||
expect(backup.type).toBe('sattle-backup')
|
||||
expect(backup.linkingKey).toBeUndefined()
|
||||
expect(backup.bearers).toHaveLength(1)
|
||||
})
|
||||
const backup = buildBackup();
|
||||
expect(backup.type).toBe('sattle-backup');
|
||||
expect(backup.linkingKey).toBeUndefined();
|
||||
expect(backup.bearers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('exports the linking key when it is itself password-encrypted', async () => {
|
||||
await saveLinkingKey(LINKING_KEY, 'hunter2')
|
||||
const backup = buildBackup()
|
||||
expect(backup.linkingKey?.enc).toBe(true)
|
||||
})
|
||||
await saveLinkingKey(LINKING_KEY, 'hunter2');
|
||||
const backup = buildBackup();
|
||||
expect(backup.linkingKey?.enc).toBe(true);
|
||||
});
|
||||
|
||||
it('merges a backup by record id - union, never overwrite', async () => {
|
||||
const key = await deriveBearerAesKey(LINKING_KEY)
|
||||
await persistBearer(key, bearerFixture({id: 'existing'}))
|
||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||
await persistBearer(key, bearerFixture({ id: 'existing' }));
|
||||
|
||||
// a backup holding the same record id plus a new one
|
||||
const backup = buildBackup()
|
||||
const backup = buildBackup();
|
||||
const incoming = {
|
||||
...backup,
|
||||
bearers: [
|
||||
...backup.bearers,
|
||||
{id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
|
||||
{ id: 'from-backup', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
|
||||
],
|
||||
}
|
||||
const result = await applyBackup(incoming)
|
||||
expect(result.added).toBe(1)
|
||||
expect(result.skipped).toBe(1)
|
||||
};
|
||||
const result = await applyBackup(incoming);
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(
|
||||
readEncryptedBearers()
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
).toEqual(['existing', 'from-backup'])
|
||||
})
|
||||
).toEqual(['existing', 'from-backup']);
|
||||
});
|
||||
|
||||
it('restores the linking key only onto a device that has none', async () => {
|
||||
await saveLinkingKey(LINKING_KEY, 'hunter2')
|
||||
const backup = buildBackup()
|
||||
await saveLinkingKey(LINKING_KEY, 'hunter2');
|
||||
const backup = buildBackup();
|
||||
|
||||
// same device: a key already exists, so the backup's key is skipped
|
||||
const here = await applyBackup(backup)
|
||||
expect(here.linkingKeySkipped).toBe(true)
|
||||
expect(here.linkingKeyRestored).toBe(false)
|
||||
const here = await applyBackup(backup);
|
||||
expect(here.linkingKeySkipped).toBe(true);
|
||||
expect(here.linkingKeyRestored).toBe(false);
|
||||
|
||||
// fresh device: the key installs
|
||||
stubLocalStorage()
|
||||
const fresh = await applyBackup(backup)
|
||||
expect(fresh.linkingKeyRestored).toBe(true)
|
||||
expect(fresh.linkingKeySkipped).toBe(false)
|
||||
})
|
||||
stubLocalStorage();
|
||||
const fresh = await applyBackup(backup);
|
||||
expect(fresh.linkingKeyRestored).toBe(true);
|
||||
expect(fresh.linkingKeySkipped).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a file that is not a sattle backup', async () => {
|
||||
await expect(
|
||||
applyBackup({type: 'lnurlwallet-backup', version: 1, bearers: []}),
|
||||
).rejects.toThrow()
|
||||
await expect(applyBackup(null)).rejects.toThrow()
|
||||
await expect(applyBackup({type: 'sattle-backup', version: 1, bearers: []})).rejects.toThrow()
|
||||
applyBackup({ type: 'lnurlwallet-backup', version: 1, bearers: [] }),
|
||||
).rejects.toThrow();
|
||||
await expect(applyBackup(null)).rejects.toThrow();
|
||||
await expect(applyBackup({ type: 'sattle-backup', version: 1, bearers: [] })).rejects.toThrow();
|
||||
// a v2 file without the counter map is not a valid backup either
|
||||
await expect(applyBackup({type: 'sattle-backup', version: 2, bearers: []})).rejects.toThrow()
|
||||
})
|
||||
await expect(applyBackup({ type: 'sattle-backup', version: 2, bearers: [] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('skips malformed records instead of failing the whole restore', async () => {
|
||||
const result = await applyBackup({
|
||||
@@ -245,13 +245,13 @@ describe('backup', () => {
|
||||
version: 2,
|
||||
createdAt: 1,
|
||||
bearers: [
|
||||
{id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40)},
|
||||
{id: 42, iv: null, ciphertext: 'xx'},
|
||||
{ id: 'ok', iv: '00'.repeat(12), ciphertext: 'ab'.repeat(40) },
|
||||
{ id: 42, iv: null, ciphertext: 'xx' },
|
||||
'garbage',
|
||||
],
|
||||
nextByHost: {},
|
||||
})
|
||||
expect(result.added).toBe(1)
|
||||
expect(result.skipped).toBe(2)
|
||||
})
|
||||
})
|
||||
});
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.skipped).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user