feat: commit bearer changesets in one write

This commit is contained in:
2026-08-22 16:55:00 +02:00
parent 69fbdb68ee
commit 586d12b666
6 changed files with 773 additions and 61 deletions
+249
View File
@@ -0,0 +1,249 @@
// Imported by storage.test.ts so the focused storage command exercises the
// fund-critical changeset boundary without mixing it into unrelated storage
// round-trip and backup cases.
import {describe, expect, it, vi} from 'vitest'
import {buildNoteUrl} from 'lnurlcash-kit'
import {deriveBearerAesKey, encryptRecord} from './keys'
import {
applyBearerChangeset,
deleteBearerRecord,
loadBearers,
newBearerId,
persistBearer,
readEncryptedBearers,
} from './storage'
import type {BearerChangeset} from './storage'
import type {Bearer, NewBearer} from './types'
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_C = 'cc'.repeat(32)
const K1_D = 'dd'.repeat(32)
const bearerFixture = (overrides: Partial<Bearer> = {}): Bearer => ({
id: newBearerId(),
url: buildNoteUrl('https://mint.example/w', K1_A, 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
createdAt: 1000,
updatedAt: 1000,
...overrides,
})
const newBearerFixture = (overrides: Partial<NewBearer> = {}): NewBearer => ({
url: buildNoteUrl('https://mint.example/w', K1_C, 3_000),
callback: 'https://mint.example/w/cb',
amount: 3_000,
verified: true,
...overrides,
})
describe('baseline: per-record bearer persistence', () => {
it('writes once for every persist or delete call', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const writes = vi.spyOn(localStorage, 'setItem')
await persistBearer(key, bearerFixture({id: 'a'}))
await persistBearer(key, bearerFixture({id: 'b'}))
await deleteBearerRecord('a')
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(3)
})
})
describe('applyBearerChangeset', () => {
it('commits additions and spent replacements with exactly one write', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const oldA = bearerFixture({id: 'old-a'})
const oldB = bearerFixture({
id: 'old-b',
url: buildNoteUrl('https://mint.example/w', K1_B, 5_000),
amount: 5_000,
})
await persistBearer(key, oldA)
await persistBearer(key, oldB)
const snapshot = [oldA, oldB]
const changeset: BearerChangeset = {
add: [
newBearerFixture(),
newBearerFixture({
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
amount: 4_000,
}),
],
markSpent: ['old-a', 'old-b'],
}
const writes = vi.spyOn(localStorage, 'setItem')
const result = await applyBearerChangeset(key, snapshot, changeset)
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(1)
expect(result).toHaveLength(4)
expect(result.slice(2).map((bearer) => bearer.spent)).toEqual([true, true])
expect(result[0]?.id).not.toBe(result[1]?.id)
expect(snapshot.map((bearer) => bearer.spent)).toEqual([undefined, undefined])
expect(changeset.markSpent).toEqual(['old-a', 'old-b'])
expect(changeset.add.some((note) => 'id' in note)).toBe(false)
expect((await loadBearers(key)).map((bearer) => bearer.id).sort()).toEqual(
result.map((bearer) => bearer.id).sort(),
)
const raw = localStorage.getItem('sattle_bearers') ?? ''
expect(raw).not.toContain(K1_C)
expect(raw).not.toContain(K1_D)
})
it('writes nothing when the second record encryption fails', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
await persistBearer(key, bearerFixture({id: 'kept'}))
const writes = vi.spyOn(localStorage, 'setItem')
const encrypt = vi.spyOn(crypto.subtle, 'encrypt')
encrypt
.mockResolvedValueOnce(new ArrayBuffer(32))
.mockRejectedValueOnce(new Error('second encryption failed'))
try {
await expect(
applyBearerChangeset(key, [], {
add: [
newBearerFixture(),
newBearerFixture({
url: buildNoteUrl('https://mint.example/w', K1_D, 4_000),
}),
],
markSpent: [],
}),
).rejects.toThrow('second encryption failed')
expect(encrypt).toHaveBeenCalledTimes(2)
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(0)
expect(readEncryptedBearers().map((record) => record.id)).toEqual(['kept'])
} finally {
encrypt.mockRestore()
}
})
it('rejects a failed storage write without changing persisted state', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const kept = bearerFixture({id: 'kept'})
await persistBearer(key, kept)
const before = localStorage.getItem('sattle_bearers')
const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
vi.stubGlobal('navigator', {})
try {
await expect(
applyBearerChangeset(key, [kept], {
add: [newBearerFixture()],
markSpent: ['kept'],
}),
).rejects.toThrow('QuotaExceededError')
expect(localStorage.getItem('sattle_bearers')).toBe(before)
} finally {
write.mockRestore()
vi.unstubAllGlobals()
}
})
it('deduplicates repeated spent ids into one stored replacement', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const bearer = bearerFixture({id: 'duplicate'})
await persistBearer(key, bearer)
const result = await applyBearerChangeset(key, [bearer], {
add: [],
markSpent: ['duplicate', 'duplicate'],
})
expect(readEncryptedBearers().filter((record) => record.id === 'duplicate')).toHaveLength(1)
expect(result[0]?.spent).toBe(true)
expect((await loadBearers(key))[0]?.spent).toBe(true)
})
it('encrypts before locking and preserves a fresh unrelated record', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
const other = await deriveBearerAesKey(OTHER_KEY)
const mine = bearerFixture({id: 'mine'})
const foreign = bearerFixture({
id: 'fresh-tab',
url: buildNoteUrl('https://mint.example/w', K1_B, 9_000),
})
await persistBearer(key, mine)
const {id: foreignId, ...foreignPlain} = foreign
const foreignParts = await encryptRecord(other, foreignPlain)
const encrypted = vi.spyOn(crypto.subtle, 'encrypt')
const queue: {release: () => Promise<void>}[] = []
vi.stubGlobal('navigator', {
locks: {
request: (_name: string, fn: () => unknown): Promise<unknown> =>
new Promise((resolve, reject) => {
queue.push({
release: async () => {
await Promise.resolve(fn)
.then((callback) => callback())
.then(resolve, reject)
},
})
}),
},
})
try {
const commit = applyBearerChangeset(key, [mine], {
add: [newBearerFixture()],
markSpent: [],
})
await vi.waitFor(() => expect(queue).toHaveLength(1))
expect(encrypted).toHaveBeenCalledTimes(1)
localStorage.setItem(
'sattle_bearers',
JSON.stringify([...readEncryptedBearers(), {id: foreignId, ...foreignParts}]),
)
const pendingLock = queue.at(0)
if (pendingLock === undefined) throw new Error('Expected pending lock')
await pendingLock.release()
const result = await commit
expect(
readEncryptedBearers()
.map((record) => record.id)
.sort(),
).toEqual(['fresh-tab', 'mine', result[0]?.id].sort())
expect((await loadBearers(other)).map((bearer) => bearer.id)).toEqual(['fresh-tab'])
} finally {
encrypted.mockRestore()
vi.unstubAllGlobals()
}
})
it('replaces corrupt JSON with the single committed changeset write', async () => {
const key = await deriveBearerAesKey(LINKING_KEY)
localStorage.setItem('sattle_bearers', 'not json {{{')
const writes = vi.spyOn(localStorage, 'setItem')
const result = await applyBearerChangeset(key, [], {
add: [newBearerFixture()],
markSpent: ['missing'],
})
expect(result).toHaveLength(1)
expect(
writes.mock.calls.filter(([storageKey]) => storageKey === 'sattle_bearers'),
).toHaveLength(1)
expect((await loadBearers(key)).map((bearer) => bearer.id)).toEqual(
result.map((bearer) => bearer.id),
)
})
})