diff --git a/src/lib/server-change.ts b/src/lib/server-change.ts index a554e87e..3744d661 100644 --- a/src/lib/server-change.ts +++ b/src/lib/server-change.ts @@ -151,8 +151,10 @@ async function forgetUnconfirmedServerState(accountId: string): Promise { * (username@host) and drop what was derived from the server when it changed. * On the first sight of an account only what can't be confirmed to belong * to its server goes (forgetUnconfirmedServerState). The fingerprint is - * written after that, so an interrupted drop is repeated next time. Resolves - * to true when caches were dropped. + * written after that, so an interrupted drop is repeated next time. A + * recorded fingerprint that cannot be read rejects before anything goes or + * is written, and the next session checks again. Resolves to true when + * caches were dropped. */ export async function reconcileServerFingerprint(accountId: string, session: JMAPSession): Promise { const next = sessionFingerprint(session); diff --git a/src/lib/server-fingerprint.ts b/src/lib/server-fingerprint.ts index c130f710..bb670fc1 100644 --- a/src/lib/server-fingerprint.ts +++ b/src/lib/server-fingerprint.ts @@ -48,13 +48,14 @@ export function sessionFingerprint(session: JMAPSession): string { return sha256Hex(new TextEncoder().encode(JSON.stringify(parts))).slice(0, 16); } -/** The fingerprint recorded for a local account (username@host), if any. */ +/** + * The fingerprint recorded for a local account (username@host), or null when + * none is. A read that fails throws rather than answer null: that would be + * the first sight of the account, which drops its queued changes (see + * reconcileServerFingerprint), on a server that may never have changed. + */ export async function readServerFingerprint(accountId: string): Promise { - try { - return await AsyncStorage.getItem(storageKey(accountId)); - } catch { - return null; - } + return AsyncStorage.getItem(storageKey(accountId)); } /** The primary JMAP account id recorded with the fingerprint, if any. */ diff --git a/src/stores/__tests__/server-change.test.ts b/src/stores/__tests__/server-change.test.ts index cbc85791..67cde321 100644 --- a/src/stores/__tests__/server-change.test.ts +++ b/src/stores/__tests__/server-change.test.ts @@ -390,4 +390,49 @@ describe('reconcileServerFingerprint', () => { expect(await AsyncStorage.getItem(`push:notifiedIds:v1:${ACCOUNT}`)).toBeNull(); expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('1.0'))); }); + + // A read that fails is no answer. Taken for no fingerprint, it made the + // session the first sight of the account: the queued changes went on a + // server that had not changed, and the fingerprint written then hid one + // that had. + describe('when the recorded fingerprint cannot be read', () => { + const checkWithFailingRead = async (current: JMAPSession) => { + const getItem = vi.mocked(AsyncStorage.getItem); + const read = getItem.getMockImplementation()!; + getItem.mockImplementation(async (key: string) => { + if (key === `server-fingerprint:v1:${ACCOUNT}`) throw new Error('SQLITE_BUSY'); + return read(key); + }); + try { + await expect(reconcileServerFingerprint(ACCOUNT, current)).rejects.toThrow('SQLITE_BUSY'); + } finally { + getItem.mockImplementation(read); + } + }; + + it('keeps the queued changes, and checks again with the next session (Stalwart 1.0)', async () => { + await seed(ACCOUNT); + await writeServerFingerprint(ACCOUNT, sessionFingerprint(session('1.0')), 'f'); + + await checkWithFailingRead(session('1.0')); + + expect(useOutboxStore.getState().entries).toHaveLength(2); + expect(useOutboxStore.getState().failed).toHaveLength(1); + expect(useToastStore.getState().toasts).toEqual([]); + expect(await reconcileServerFingerprint(ACCOUNT, session('1.0'))).toBe(false); + expect(useOutboxStore.getState().entries).toHaveLength(2); + }); + + it('records nothing, so the next session still drops what the old server left (0.16.23 to 1.0)', async () => { + await seed(ACCOUNT); + + await checkWithFailingRead(session('1.0')); + + expect(useEmailStore.getState().mailboxState).toBe('s1iaq'); + expect(await readServerFingerprint(ACCOUNT)).toBe(sessionFingerprint(session('0.16.23'))); + expect(await reconcileServerFingerprint(ACCOUNT, session('1.0'))).toBe(true); + expect(useEmailStore.getState().mailboxState).toBeUndefined(); + expect(useOutboxStore.getState().entries).toEqual([]); + }); + }); });