diff --git a/src/SIL.XForge.Scripture/ClientApp/src/main.ts b/src/SIL.XForge.Scripture/ClientApp/src/main.ts index 099b5868e4c..0602f1a7090 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/main.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/main.ts @@ -49,6 +49,17 @@ if (environment.production || environment.pwaTest) { ExceptionHandlingService.initBugsnag(); +// The browser may snapshot a page into the back/forward cache when navigating away (Chrome 149+ +// does this even while the realtime WebSocket is open), and pressing Back restores the snapshot — +// including auth state and user data that logging out was meant to destroy (SF-3855). No browser +// API can prevent or destroy the snapshot (the browser evicts it after a bounded time), so make +// restoring it a dead end: reload, so authentication is re-evaluated from scratch. +window.addEventListener('pageshow', (event: PageTransitionEvent) => { + if (event.persisted) { + window.location.reload(); + } +}); + bootstrapApplication(AppComponent, { providers: [ { provide: 'BASE_URL', useFactory: getBaseUrl, deps: [] as any[] }, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts index efca2dd9a6d..06dbbb2f39c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.spec.ts @@ -632,6 +632,18 @@ describe('AuthService', () => { env.discardTokenExpiryTimer(); })); + it('should disable the offline store on log out so in-flight writes cannot re-create it', fakeAsync(() => { + const env = new TestEnvironment({ isOnline: true, isLoggedIn: true }); + expect(env.isAuthenticated).toBe(true); + const offlineStore = TestBed.inject(OfflineStore); + expect(offlineStore.disabled).toBe(false); + + env.logOut(); + tick(); + expect(offlineStore.disabled).toBe(true); + env.discardTokenExpiryTimer(); + })); + it('prompt on log out if transparent authentication cookie is set', fakeAsync(() => { const env = new TestEnvironment({ isOnline: true, isLoggedIn: true, setTransparentAuthenticationCookie: true }); expect(env.isAuthenticated).toBe(true); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.ts index 245024140d7..adf58754957 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/auth.service.ts @@ -276,6 +276,9 @@ export class AuthService { } if (proceedWithLogout) { this.cookieService.deleteAll('/'); + // Disable before deleting so realtime persistence still in flight cannot re-create the + // database with the logged-out user's data (SF-3855) + this.offlineStore.disable(); await this.offlineStore.deleteDB(); this.localSettings.clear(); this.unscheduleRenewal(); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/indexeddb-offline-store.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/indexeddb-offline-store.spec.ts new file mode 100644 index 00000000000..d73e124fd19 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/indexeddb-offline-store.spec.ts @@ -0,0 +1,87 @@ +import { TestBed } from '@angular/core/testing'; +import { IndexeddbOfflineStore } from './indexeddb-offline-store'; +import { RealtimeDocConstructor } from './models/realtime-doc'; +import { TypeRegistry } from './type-registry'; + +const TEST_COLLECTION = 'users'; + +describe('IndexeddbOfflineStore', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + IndexeddbOfflineStore, + { + provide: TypeRegistry, + useValue: new TypeRegistry( + [{ COLLECTION: TEST_COLLECTION, INDEX_PATHS: [] } as unknown as RealtimeDocConstructor], + [], + [] + ) + } + ] + }); + }); + + afterEach(async () => { + await deleteDatabase(); + }); + + it('should store and retrieve data', async () => { + const store = TestBed.inject(IndexeddbOfflineStore); + await store.put(TEST_COLLECTION, { id: 'user01' }); + expect(await store.getAllIds(TEST_COLLECTION)).toEqual(['user01']); + }); + + it('should not re-create the database when written to after deleteDB and disable', async () => { + const store = TestBed.inject(IndexeddbOfflineStore); + await store.put(TEST_COLLECTION, { id: 'user01' }); + + store.disable(); + await store.deleteDB(); + + // Simulates realtime doc persistence that is still in flight during logout (SF-3855) + await expectNeverSettles(store.put(TEST_COLLECTION, { id: 'user01' })); + expect(await databaseExists()).toBe(false); + }); + + it('should not settle reads or writes once disabled', async () => { + const store = TestBed.inject(IndexeddbOfflineStore); + await store.put(TEST_COLLECTION, { id: 'user01' }); + + store.disable(); + expect(store.disabled).toBe(true); + await expectNeverSettles(store.put(TEST_COLLECTION, { id: 'user02' })); + await expectNeverSettles(store.getAllIds(TEST_COLLECTION)); + await expectNeverSettles(store.getAll(TEST_COLLECTION)); + await expectNeverSettles(store.get(TEST_COLLECTION, 'user01')); + await expectNeverSettles(store.query(TEST_COLLECTION, {})); + await expectNeverSettles(store.delete(TEST_COLLECTION, 'user01')); + }); +}); + +const PENDING = 'pending'; + +/** Expects the promise to be still pending (i.e. to lose a race against a short timer). */ +async function expectNeverSettles(promise: Promise): Promise { + const result = await Promise.race([ + promise.then( + () => 'resolved', + () => 'rejected' + ), + new Promise(resolve => setTimeout(() => resolve(PENDING), 25)) + ]); + expect(result).toBe(PENDING); +} + +function databaseExists(): Promise { + return indexedDB.databases().then(dbs => dbs.some(db => db.name === 'xforge')); +} + +function deleteDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase('xforge'); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => resolve(); + }); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/indexeddb-offline-store.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/indexeddb-offline-store.ts index 1150c4b059c..f6188e742b5 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/indexeddb-offline-store.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/indexeddb-offline-store.ts @@ -178,6 +178,13 @@ export class IndexeddbOfflineStore extends OfflineStore { } private openDB(): Promise { + if (this.disabled) { + // Never settles, so that reads and writes still in flight at logout halt rather than + // re-create the deleted database or act on fabricated empty results (SF-3855). The page is + // about to unload; this is the same graceful waiting used in AuthHttpInterceptor while a + // redirect is pending. Do not store this promise in openDBPromise, or closeDB would hang. + return new Promise(() => {}); + } if (this.openDBPromise != null) { return this.openDBPromise; } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/offline-store.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/offline-store.ts index c21a7f3673e..d1aeb0e41f3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/offline-store.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/offline-store.ts @@ -9,6 +9,23 @@ export interface OfflineData { * retrieving offline data in the browser. */ export abstract class OfflineStore { + private _disabled = false; + + /** Whether the store has been permanently disabled by {@link disable}. */ + get disabled(): boolean { + return this._disabled; + } + + /** + * Permanently prevents this store from reading or writing data; calls made after this may never + * settle. Called on logout (which is followed by a redirect away from the app) before the data is + * deleted, because reads and writes that are still in flight would otherwise re-create the + * deleted database with the logged-out user's data (SF-3855). + */ + disable(): void { + this._disabled = true; + } + abstract getAllIds(collection: string): Promise; abstract getAll(collection: string): Promise; abstract query(collection: string, parameters: QueryParameters): Promise>;