From e243e5282c3cacd2bc76b84894a020d887985fd0 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:54:01 +0900 Subject: [PATCH] fix(mobile-history): drop history cache writes that outlive the auth scope --- apps/mobile-rn/App.tsx | 29 ++- ...history-cache-scope-redteam-r1-26.test.tsx | 241 ++++++++++++++++++ .../features/history/history-cache-writer.ts | 71 ++++++ .../src/features/history/use-history-sync.ts | 69 +++-- .../notifications/missed-notification-sync.ts | 53 ++++ 5 files changed, 431 insertions(+), 32 deletions(-) create mode 100644 apps/mobile-rn/__tests__/history-cache-scope-redteam-r1-26.test.tsx create mode 100644 apps/mobile-rn/src/features/history/history-cache-writer.ts create mode 100644 apps/mobile-rn/src/features/notifications/missed-notification-sync.ts diff --git a/apps/mobile-rn/App.tsx b/apps/mobile-rn/App.tsx index 868a46a..dd52318 100644 --- a/apps/mobile-rn/App.tsx +++ b/apps/mobile-rn/App.tsx @@ -61,7 +61,8 @@ import type { NotificationNavigationTarget } from './src/features/notifications/ import { loadPendingInviteToken } from './src/features/teams/pending-invite' import { listTeams } from './src/features/teams/team-service' import { listHistoryPage } from './src/features/history/history-service' -import { saveHistoryCache } from './src/features/history/history-cache' +import { createHistoryCacheScope } from './src/features/history/history-cache-writer' +import { synchronizeAfterMissedNotifications } from './src/features/notifications/missed-notification-sync' import { useRecordingProcessingQueue } from './src/features/recording/use-recording-processing-queue' import { getPendingIncomingMediaId, @@ -246,22 +247,22 @@ function RootNavigator(): React.ReactElement { void routeIncomingMedia() }, [routeIncomingMedia]) + // Invalidated whenever the signed-in user changes (including the null that + // the logout privacy purge publishes) so an in-flight full sync cannot write + // the previous account's history cache back after the purge. + const [historyCacheScope] = useState(createHistoryCacheScope) + useEffect(() => () => historyCacheScope.invalidate(), [historyCacheScope, user?.id]) + const synchronizeMissedNotifications = useCallback(async (): Promise => { const userId = user?.id if (!userId) return false - try { - const [history] = await Promise.all([ - listHistoryPage({ userId, filter: 'all', pageSize: 50 }), - listTeams(userId), - fetchEntitlementSnapshot(userId), - ]) - await saveHistoryCache(userId, history.entries) - await entitlement.refresh() - return true - } catch { - return false - } - }, [entitlement, user?.id]) + return synchronizeAfterMissedNotifications(userId, historyCacheScope.capture(), { + listRecentHistory: (id) => listHistoryPage({ userId: id, filter: 'all', pageSize: 50 }), + listTeams, + fetchEntitlement: fetchEntitlementSnapshot, + refreshEntitlement: () => entitlement.refresh(), + }) + }, [entitlement, historyCacheScope, user?.id]) useNotificationRuntime({ navigate: navigateFromNotification, diff --git a/apps/mobile-rn/__tests__/history-cache-scope-redteam-r1-26.test.tsx b/apps/mobile-rn/__tests__/history-cache-scope-redteam-r1-26.test.tsx new file mode 100644 index 0000000..f1fbffb --- /dev/null +++ b/apps/mobile-rn/__tests__/history-cache-scope-redteam-r1-26.test.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import * as Keychain from 'react-native-keychain' +import type { HistoryListEntry, HistoryPageResult } from '../src/features/history/history-service' + +const USER_A = '11111111-1111-4111-8111-111111111111' +const HISTORY_CACHE_PREFIX = 'com.d3ro.voice.history-cache.v1.' + +let mockAuthUser: { id: string } | null = null +const mockListHistoryPage = jest.fn, [unknown]>() + +jest.mock('../src/lib/supabase', () => ({ + supabase: { + channel: jest.fn(), + removeChannel: jest.fn(async () => 'ok'), + }, +})) +jest.mock('../src/lib/auth-context', () => ({ + useAuth: () => ({ user: mockAuthUser }), +})) +jest.mock('../src/features/history/history-service', () => ({ + ...jest.requireActual('../src/features/history/history-service'), + listHistoryPage: (options: unknown) => mockListHistoryPage(options), + subscribeToHistory: () => ({ unsubscribe: async () => undefined }), +})) + +import { clearAllHistoryCaches } from '../src/features/history/history-cache' +import { + createHistoryCacheScope, + saveHistoryCacheInScope, + type HistoryCacheStore, +} from '../src/features/history/history-cache-writer' +import { useHistorySync } from '../src/features/history/use-history-sync' +import { synchronizeAfterMissedNotifications } from '../src/features/notifications/missed-notification-sync' + +const keychainMock = Keychain as unknown as typeof Keychain & { __reset: () => void } + +function entry(id: string): HistoryListEntry { + return { + id, + user_id: USER_A, + title: null, + original_text: `original ${id}`, + polished_text: null, + focused_app: null, + focused_app_name: null, + focused_app_window_title: null, + mode: 'dictation', + status: 'completed', + error_code: null, + audio_storage_key: null, + duration: 2, + detected_language: 'ko', + mic_device: null, + word_count: 2, + stt_model: 'whisper', + llm_model: null, + stt_latency_ms: null, + llm_latency_ms: null, + app_version: '1.0.0', + summary_text: null, + is_favorite: false, + revision: 1, + created_at: '2026-08-21T00:00:00.000Z', + updated_at: '2026-08-21T00:00:00.000Z', + activeJob: null, + } +} + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve: (value: T) => void = () => undefined + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +async function storedHistoryServices(): Promise { + const services = await Keychain.getAllGenericPasswordServices() + return services.filter((service) => service.startsWith(HISTORY_CACHE_PREFIX)) +} + +async function flush(): Promise { + for (let index = 0; index < 10; index += 1) await Promise.resolve() +} + +function Harness(): null { + useHistorySync() + return null +} + +function fakeStore(): HistoryCacheStore & { save: jest.Mock; clear: jest.Mock } { + return { + save: jest.fn(async () => undefined), + clear: jest.fn(async () => undefined), + } +} + +describe('history cache auth-scope guard', () => { + beforeEach(() => { + keychainMock.__reset() + mockAuthUser = null + mockListHistoryPage.mockReset() + }) + + test('drops a write whose scope went stale before it started', async () => { + const store = fakeStore() + const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => false, store) + expect(outcome).toBe('dropped') + expect(store.save).not.toHaveBeenCalled() + expect(store.clear).not.toHaveBeenCalled() + }) + + test('deletes the entry it just stored when the scope went stale during the write', async () => { + const store = fakeStore() + let current = true + store.save.mockImplementation(async () => { current = false }) + const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => current, store) + expect(outcome).toBe('dropped') + expect(store.clear).toHaveBeenCalledWith(USER_A) + }) + + test('writes normally while the scope stays current', async () => { + const store = fakeStore() + const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => true, store) + expect(outcome).toBe('written') + expect(store.save).toHaveBeenCalledWith(USER_A, [entry('a')]) + expect(store.clear).not.toHaveBeenCalled() + }) + + test('scope checks captured before invalidate report stale, later captures are current', () => { + const scope = createHistoryCacheScope() + const before = scope.capture() + expect(before()).toBe(true) + scope.invalidate() + expect(before()).toBe(false) + expect(scope.capture()()).toBe(true) + }) + + test('missed-notification sync does not persist history after the auth scope changed', async () => { + const store = fakeStore() + const scope = createHistoryCacheScope() + const history = deferred<{ entries: HistoryListEntry[] }>() + const refreshEntitlement = jest.fn(async () => null) + + const pending = synchronizeAfterMissedNotifications(USER_A, scope.capture(), { + listRecentHistory: () => history.promise, + listTeams: async () => [], + fetchEntitlement: async () => null, + refreshEntitlement, + historyCacheStore: store, + }) + scope.invalidate() + history.resolve({ entries: [entry('a')] }) + + await expect(pending).resolves.toBe(false) + expect(store.save).not.toHaveBeenCalled() + expect(refreshEntitlement).not.toHaveBeenCalled() + }) + + test('missed-notification sync still caches and refreshes for the current user', async () => { + const store = fakeStore() + const refreshEntitlement = jest.fn(async () => null) + const ok = await synchronizeAfterMissedNotifications(USER_A, () => true, { + listRecentHistory: async () => ({ entries: [entry('a')] }), + listTeams: async () => [], + fetchEntitlement: async () => null, + refreshEntitlement, + historyCacheStore: store, + }) + expect(ok).toBe(true) + expect(store.save).toHaveBeenCalledWith(USER_A, [entry('a')]) + expect(refreshEntitlement).toHaveBeenCalledTimes(1) + }) + + test('an in-flight refresh cannot write the history cache back after the logout purge', async () => { + const page = deferred() + mockListHistoryPage.mockReturnValueOnce(page.promise) + mockAuthUser = { id: USER_A } + + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create() + await flush() + }) + expect(mockListHistoryPage).toHaveBeenCalledTimes(1) + + // Logout: the purge clears every cache and the history screen unmounts + // while user A's request is still in flight with a valid JWT. + mockAuthUser = null + await clearAllHistoryCaches() + await act(async () => { + renderer?.unmount() + await flush() + }) + + await act(async () => { + page.resolve({ entries: [entry('a')], nextCursor: null }) + await flush() + }) + + expect(await storedHistoryServices()).toEqual([]) + }) + + test('a write that lands after the purge enumerated the caches is compensated', async () => { + mockListHistoryPage.mockResolvedValueOnce({ entries: [entry('a')], nextCursor: null }) + const write = deferred() + const setGenericPassword = Keychain.setGenericPassword as jest.Mock + const realSet = setGenericPassword.getMockImplementation() + setGenericPassword.mockImplementationOnce(async (...args: unknown[]) => { + await write.promise + return realSet?.(...args) + }) + mockAuthUser = { id: USER_A } + + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create() + await flush() + }) + // The cache write was issued while A was still current but has not landed + // yet. The purge enumerates now (nothing to clear), then the screen + // unmounts, and only afterwards does the native write complete. + mockAuthUser = null + await clearAllHistoryCaches() + await act(async () => { + renderer?.unmount() + await flush() + }) + + await act(async () => { + write.resolve(false) + await flush() + }) + + expect(await storedHistoryServices()).toEqual([]) + }) +}) diff --git a/apps/mobile-rn/src/features/history/history-cache-writer.ts b/apps/mobile-rn/src/features/history/history-cache-writer.ts new file mode 100644 index 0000000..139a81e --- /dev/null +++ b/apps/mobile-rn/src/features/history/history-cache-writer.ts @@ -0,0 +1,71 @@ +import type { HistoryListEntry } from './history-service' +import { clearHistoryCache, saveHistoryCache } from './history-cache' + +/** + * Storage port for the per-user history cache. The default adapter is the + * Keychain-backed cache; tests and alternative stores inject their own. + */ +export interface HistoryCacheStore { + save: (userId: string, entries: HistoryListEntry[]) => Promise + clear: (userId: string) => Promise +} + +export const keychainHistoryCacheStore: HistoryCacheStore = { + save: saveHistoryCache, + clear: clearHistoryCache, +} + +/** + * Reports whether the auth scope that started an asynchronous history + * operation is still the active one. It must turn false as soon as the target + * user signs out, is replaced, or its owning component unmounts. + */ +export type HistoryCacheScopeCheck = () => boolean + +export type HistoryCacheWriteOutcome = 'written' | 'dropped' + +/** + * Writes the history cache only while the originating auth scope is current. + * + * The logout privacy purge enumerates the cache services once and deletes + * them, so a write that was already in flight can land after the enumeration + * and silently survive the purge. The scope is therefore checked twice: before + * the write (skip it) and after the write (compensate by deleting the entry + * that was just stored). Either way nothing for a stale scope stays on disk. + */ +export async function saveHistoryCacheInScope( + userId: string, + entries: HistoryListEntry[], + isCurrent: HistoryCacheScopeCheck, + store: HistoryCacheStore = keychainHistoryCacheStore, +): Promise { + if (!isCurrent()) return 'dropped' + await store.save(userId, entries) + if (isCurrent()) return 'written' + await store.clear(userId) + return 'dropped' +} + +/** + * Monotonic generation counter for an auth scope. `capture` returns a check + * bound to the current generation; `invalidate` makes every earlier check + * report stale. React owners call `invalidate` from effect cleanup on user + * change and on unmount. + */ +export interface HistoryCacheScope { + capture: () => HistoryCacheScopeCheck + invalidate: () => void +} + +export function createHistoryCacheScope(): HistoryCacheScope { + let generation = 0 + return { + capture: () => { + const captured = generation + return () => captured === generation + }, + invalidate: () => { + generation += 1 + }, + } +} diff --git a/apps/mobile-rn/src/features/history/use-history-sync.ts b/apps/mobile-rn/src/features/history/use-history-sync.ts index 089a901..0e6baf5 100644 --- a/apps/mobile-rn/src/features/history/use-history-sync.ts +++ b/apps/mobile-rn/src/features/history/use-history-sync.ts @@ -16,8 +16,12 @@ import { import { filterCachedHistory, loadHistoryCache, - saveHistoryCache, } from './history-cache' +import { + createHistoryCacheScope, + saveHistoryCacheInScope, + type HistoryCacheScopeCheck, +} from './history-cache-writer' export interface HistorySyncError { code: HistoryServiceErrorCode @@ -76,6 +80,10 @@ export function useHistorySync(): HistorySyncModel { const mutatingIdsRef = useRef>(new Set()) const requestId = useRef(0) const entriesRef = useRef(entries) + // Auth scope for cache writes. Invalidated on every user change and on + // unmount so a request started for user A can never persist A's history + // after the logout privacy purge (see saveHistoryCacheInScope). + const [cacheScope] = useState(createHistoryCacheScope) useEffect(() => { entriesRef.current = entries @@ -86,21 +94,30 @@ export function useHistorySync(): HistorySyncModel { return () => clearTimeout(timeout) }, [search]) - const writeCache = useCallback(async (nextEntries: HistoryListEntry[]): Promise => { + useEffect(() => () => { + cacheScope.invalidate() + requestId.current += 1 + }, [cacheScope, userId]) + + const writeCache = useCallback(async ( + nextEntries: HistoryListEntry[], + isCurrent: HistoryCacheScopeCheck, + ): Promise => { if (userId === null) return try { - await saveHistoryCache(userId, nextEntries) - setCacheWarning(false) + const outcome = await saveHistoryCacheInScope(userId, nextEntries, isCurrent) + if (outcome === 'written') setCacheWarning(false) } catch { - setCacheWarning(true) + if (isCurrent()) setCacheWarning(true) } }, [userId]) const patchCachedEntry = useCallback(async ( entryId: string, replacement: HistoryListEntry | null, + isCurrent: HistoryCacheScopeCheck, ): Promise => { - if (userId === null) return + if (userId === null || !isCurrent()) return try { const snapshot = await loadHistoryCache(userId) if (snapshot === null) return @@ -110,12 +127,11 @@ export function useHistorySync(): HistorySyncModel { snapshot.entries.filter((entry) => entry.id !== entryId), [replacement], ) - await saveHistoryCache(userId, next) - setCacheWarning(false) + await writeCache(next, isCurrent) } catch { - setCacheWarning(true) + if (isCurrent()) setCacheWarning(true) } - }, [userId]) + }, [userId, writeCache]) const performRefresh = useCallback(async (showRefresh: boolean): Promise => { if (userId === null) { @@ -127,6 +143,7 @@ export function useHistorySync(): HistorySyncModel { } const currentRequest = ++requestId.current + const isCurrentScope = cacheScope.capture() if (showRefresh) setRefreshing(true) if (entriesRef.current.length === 0) setLoading(true) @@ -145,7 +162,7 @@ export function useHistorySync(): HistorySyncModel { setError(null) if (filter === 'all' && debouncedSearch.length === 0) { - await writeCache(page.entries) + await writeCache(page.entries, isCurrentScope) } } catch (requestError) { if (currentRequest !== requestId.current) return @@ -183,7 +200,7 @@ export function useHistorySync(): HistorySyncModel { setRefreshing(false) } } - }, [debouncedSearch, filter, userId, writeCache]) + }, [cacheScope, debouncedSearch, filter, userId, writeCache]) useEffect(() => { setEntries([]) @@ -255,6 +272,7 @@ export function useHistorySync(): HistorySyncModel { const loadMore = useCallback(async (): Promise => { if (userId === null || nextCursor === null || loadingMore || offline) return + const isCurrentScope = cacheScope.capture() setLoadingMore(true) try { @@ -264,21 +282,34 @@ export function useHistorySync(): HistorySyncModel { search: debouncedSearch, cursor: nextCursor, }) + if (!isCurrentScope()) return setEntries((current) => { const merged = mergeHistoryEntries(current, page.entries) - if (filter === 'all' && debouncedSearch.length === 0) void writeCache(merged) + if (filter === 'all' && debouncedSearch.length === 0) { + void writeCache(merged, isCurrentScope) + } return merged }) setNextCursor(page.nextCursor) setError(null) } catch (requestError) { + if (!isCurrentScope()) return const normalized = syncError(requestError) setError(normalized) if (normalized.code === 'network') setOffline(true) } finally { setLoadingMore(false) } - }, [debouncedSearch, filter, loadingMore, nextCursor, offline, userId, writeCache]) + }, [ + cacheScope, + debouncedSearch, + filter, + loadingMore, + nextCursor, + offline, + userId, + writeCache, + ]) const beginMutation = useCallback((entryId: string): boolean => { if (mutatingIdsRef.current.has(entryId)) return false @@ -298,6 +329,7 @@ export function useHistorySync(): HistorySyncModel { const toggleFavorite = useCallback(async (entry: HistoryListEntry): Promise => { if (userId === null) throw new HistoryServiceError('auth', 'Login is required') if (!beginMutation(entry.id)) return + const isCurrentScope = cacheScope.capture() const optimisticFavorite = !entry.is_favorite setEntries((current) => current.map((item) => ( @@ -318,7 +350,7 @@ export function useHistorySync(): HistorySyncModel { } return current.map((item) => item.id === entry.id ? replacement : item) }) - await patchCachedEntry(entry.id, replacement) + await patchCachedEntry(entry.id, replacement, isCurrentScope) } catch (mutationError) { setEntries((current) => current.map((item) => ( item.id === entry.id ? entry : item @@ -327,23 +359,24 @@ export function useHistorySync(): HistorySyncModel { } finally { endMutation(entry.id) } - }, [beginMutation, endMutation, filter, patchCachedEntry, userId]) + }, [beginMutation, cacheScope, endMutation, filter, patchCachedEntry, userId]) const deleteEntry = useCallback(async (entry: HistoryListEntry): Promise => { if (userId === null) throw new HistoryServiceError('auth', 'Login is required') if (!beginMutation(entry.id)) return + const isCurrentScope = cacheScope.capture() setEntries((current) => current.filter((item) => item.id !== entry.id)) try { await deleteHistoryRevisionSafe(userId, entry.id, entry.revision) - await patchCachedEntry(entry.id, null) + await patchCachedEntry(entry.id, null, isCurrentScope) } catch (mutationError) { setEntries((current) => mergeHistoryEntries(current, [entry])) throw mutationError } finally { endMutation(entry.id) } - }, [beginMutation, endMutation, patchCachedEntry, userId]) + }, [beginMutation, cacheScope, endMutation, patchCachedEntry, userId]) const isMutating = useCallback((entryId: string): boolean => { return mutatingIds.has(entryId) diff --git a/apps/mobile-rn/src/features/notifications/missed-notification-sync.ts b/apps/mobile-rn/src/features/notifications/missed-notification-sync.ts new file mode 100644 index 0000000..a925295 --- /dev/null +++ b/apps/mobile-rn/src/features/notifications/missed-notification-sync.ts @@ -0,0 +1,53 @@ +import type { HistoryListEntry } from '../history/history-service' +import { + saveHistoryCacheInScope, + type HistoryCacheScopeCheck, + type HistoryCacheStore, +} from '../history/history-cache-writer' + +/** + * Ports the missed-notification full sync depends on. App wires the real + * services; tests inject fakes. Keeping the orchestration free of React and + * Supabase lets the auth-scope rules be verified in isolation. + */ +export interface MissedNotificationSyncPorts { + listRecentHistory: (userId: string) => Promise<{ entries: HistoryListEntry[] }> + listTeams: (userId: string) => Promise + fetchEntitlement: (userId: string) => Promise + refreshEntitlement: () => Promise + historyCacheStore?: HistoryCacheStore +} + +/** + * Re-pulls the data a missed push could have announced and refreshes the + * offline history cache. The cache write and the entitlement refresh only run + * while the auth scope that started the sync is still current, so a logout + * purge that happens during the network round trip cannot be undone by this + * sync writing the signed-out account's history back to secure storage. + * + * Returns true only when the sync completed for the still-current user. + */ +export async function synchronizeAfterMissedNotifications( + userId: string, + isCurrent: HistoryCacheScopeCheck, + ports: MissedNotificationSyncPorts, +): Promise { + try { + const [history] = await Promise.all([ + ports.listRecentHistory(userId), + ports.listTeams(userId), + ports.fetchEntitlement(userId), + ]) + const outcome = await saveHistoryCacheInScope( + userId, + history.entries, + isCurrent, + ports.historyCacheStore, + ) + if (outcome === 'dropped') return false + await ports.refreshEntitlement() + return true + } catch { + return false + } +}