mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: publish activity only after persistence
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { deriveBearerAesKey } from '@/lnurlcash/keys';
|
||||||
|
import type { ActivityEvent } from '@/lnurlcash/storage';
|
||||||
|
import type * as StorageExports from '@/lnurlcash/storage';
|
||||||
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||||
|
|
||||||
|
const persistence = vi.hoisted(() => ({
|
||||||
|
persistActivityEvent: vi.fn<(key: CryptoKey, event: ActivityEvent) => Promise<void>>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lnurlcash/storage', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof StorageExports>()),
|
||||||
|
persistActivityEvent: persistence.persistActivityEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useActivityStore } from './activity';
|
||||||
|
|
||||||
|
const LINKING_KEY = new Uint8Array(32).fill(7);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
stubLocalStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('activity store durability', () => {
|
||||||
|
it('publishes an event only after its encrypted write completes', async () => {
|
||||||
|
let finishWrite: (() => void) | undefined;
|
||||||
|
persistence.persistActivityEvent.mockReturnValue(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
finishWrite = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const activity = useActivityStore();
|
||||||
|
await activity.loadFor(await deriveBearerAesKey(LINKING_KEY));
|
||||||
|
|
||||||
|
const logging = activity.log('receive', 'Received funds.', () => {
|
||||||
|
throw new Error('Unexpected persistence failure.');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activity.events).toEqual([]);
|
||||||
|
finishWrite?.();
|
||||||
|
await logging;
|
||||||
|
expect(activity.events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a failed write without leaving a false reactive event', async () => {
|
||||||
|
const writeError = new Error('activity storage unavailable');
|
||||||
|
persistence.persistActivityEvent.mockRejectedValue(writeError);
|
||||||
|
const activity = useActivityStore();
|
||||||
|
await activity.loadFor(await deriveBearerAesKey(LINKING_KEY));
|
||||||
|
|
||||||
|
let surfaced: Error | null = null;
|
||||||
|
await activity.log('receive', 'Received funds.', (error) => {
|
||||||
|
surfaced = error;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activity.events).toEqual([]);
|
||||||
|
expect(surfaced).toMatchObject({
|
||||||
|
name: 'ActivityPersistenceError',
|
||||||
|
actionCommitted: true,
|
||||||
|
cause: writeError,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+50
-32
@@ -1,60 +1,78 @@
|
|||||||
import {ref} from 'vue'
|
import { ref } from 'vue';
|
||||||
import {defineStore} from 'pinia'
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
import type {ActivityEvent, ActivityKind} from '@/lnurlcash/storage'
|
import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage';
|
||||||
import {
|
import {
|
||||||
loadActivity,
|
loadActivity,
|
||||||
persistActivityEvent,
|
persistActivityEvent,
|
||||||
clearAllActivity,
|
clearAllActivity,
|
||||||
newActivityId,
|
newActivityId,
|
||||||
MAX_ACTIVITY_ENTRIES
|
MAX_ACTIVITY_ENTRIES,
|
||||||
} from '@/lnurlcash/storage'
|
} from '@/lnurlcash/storage';
|
||||||
|
|
||||||
|
export class ActivityPersistenceError extends Error {
|
||||||
|
override readonly name = 'ActivityPersistenceError';
|
||||||
|
readonly actionCommitted = true;
|
||||||
|
|
||||||
|
constructor(options: { cause: unknown }) {
|
||||||
|
super(
|
||||||
|
'The wallet action completed, but activity history could not be saved. Do not retry the action.',
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The activity log: append-only, encrypted at rest with the same
|
// The activity log: append-only, encrypted at rest with the same
|
||||||
// bearer-AES key as the notes themselves. Loaded by the wallet store on
|
// bearer-AES key as the notes themselves. Loaded by the wallet store on
|
||||||
// unlock (loadFor) and dropped on lock (unload) - it never holds plaintext
|
// unlock (loadFor) and dropped on lock (unload) - it never holds plaintext
|
||||||
// while the wallet is locked.
|
// while the wallet is locked.
|
||||||
export const useActivityStore = defineStore('activity', () => {
|
export const useActivityStore = defineStore('activity', () => {
|
||||||
const events = ref<ActivityEvent[]>([])
|
const events = ref<ActivityEvent[]>([]);
|
||||||
let aesKey: CryptoKey | null = null
|
let aesKey: CryptoKey | null = null;
|
||||||
|
|
||||||
const loadFor = async (key: CryptoKey): Promise<void> => {
|
const loadFor = async (key: CryptoKey): Promise<void> => {
|
||||||
aesKey = key
|
aesKey = key;
|
||||||
events.value = await loadActivity(key)
|
events.value = await loadActivity(key);
|
||||||
}
|
};
|
||||||
|
|
||||||
const unload = (): void => {
|
const unload = (): void => {
|
||||||
aesKey = null
|
aesKey = null;
|
||||||
events.value = []
|
events.value = [];
|
||||||
}
|
};
|
||||||
|
|
||||||
// both unload and wipe the stored log - part of forgetting a wallet
|
// both unload and wipe the stored log - part of forgetting a wallet
|
||||||
const unloadAndClear = (): void => {
|
const unloadAndClear = (): void => {
|
||||||
clearAllActivity()
|
clearAllActivity();
|
||||||
unload()
|
unload();
|
||||||
}
|
};
|
||||||
|
|
||||||
// best-effort and silent on failure - a wallet action that already
|
const log = async (
|
||||||
// succeeded (the note was split/melted/whatever) must never surface an
|
kind: ActivityKind,
|
||||||
// error just because the log entry for it couldn't be written
|
message: string,
|
||||||
const log = (kind: ActivityKind, message: string): void => {
|
onPersistenceError: (error: ActivityPersistenceError) => void,
|
||||||
if (!aesKey) return
|
): Promise<void> => {
|
||||||
|
if (!aesKey) return;
|
||||||
const event: ActivityEvent = {
|
const event: ActivityEvent = {
|
||||||
id: newActivityId(),
|
id: newActivityId(),
|
||||||
kind,
|
kind,
|
||||||
message,
|
message,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now(),
|
||||||
}
|
};
|
||||||
events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES)
|
try {
|
||||||
persistActivityEvent(aesKey, event).catch(() => {
|
await persistActivityEvent(aesKey, event);
|
||||||
// deliberately swallowed - see the comment above
|
} catch (error) {
|
||||||
})
|
const cause =
|
||||||
|
error instanceof Error ? error : new Error('Activity storage failed.', { cause: error });
|
||||||
|
onPersistenceError(new ActivityPersistenceError({ cause }));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
events.value = [event, ...events.value].slice(0, MAX_ACTIVITY_ENTRIES);
|
||||||
|
};
|
||||||
|
|
||||||
const clear = (): void => {
|
const clear = (): void => {
|
||||||
clearAllActivity()
|
clearAllActivity();
|
||||||
events.value = []
|
events.value = [];
|
||||||
}
|
};
|
||||||
|
|
||||||
return {events, loadFor, unload, unloadAndClear, log, clear}
|
return { events, loadFor, unload, unloadAndClear, log, clear };
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user