-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1011 from dhis2/feat-cSC-cherry-pick
feat(offline): add clear sensitive caches function
- Loading branch information
Showing
6 changed files
with
309 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
162 changes: 162 additions & 0 deletions
162
services/offline/src/lib/__tests__/clear-sensitive-caches.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import FDBFactory from 'fake-indexeddb/lib/FDBFactory' | ||
import { openDB } from 'idb' | ||
import 'fake-indexeddb/auto' | ||
import { | ||
clearSensitiveCaches, | ||
SECTIONS_DB, | ||
SECTIONS_STORE, | ||
} from '../clear-sensitive-caches' | ||
|
||
// Mocks for CacheStorage API | ||
|
||
// Returns true if an existing cache is deleted | ||
const makeCachesDeleteMock = (keys: string[]) => { | ||
return jest | ||
.fn() | ||
.mockImplementation(key => Promise.resolve(keys.includes(key))) | ||
} | ||
|
||
const keysMockDefault = jest.fn().mockImplementation(async () => []) | ||
const deleteMockDefault = makeCachesDeleteMock([]) | ||
const cachesDefault = { | ||
keys: keysMockDefault, | ||
delete: deleteMockDefault, | ||
} | ||
window.caches = cachesDefault | ||
|
||
afterEach(() => { | ||
window.caches = cachesDefault | ||
jest.clearAllMocks() | ||
}) | ||
|
||
// silence debug logs for these tests | ||
const originalDebug = console.debug | ||
beforeAll(() => { | ||
jest.spyOn(console, 'debug').mockImplementation((...args) => { | ||
const pattern = /Clearing sensitive caches/ | ||
if (typeof args[0] === 'string' && pattern.test(args[0])) { | ||
return | ||
} | ||
return originalDebug.call(console, ...args) | ||
}) | ||
}) | ||
afterAll(() => { | ||
;(console.debug as jest.Mock).mockRestore() | ||
}) | ||
|
||
it('does not fail if there are no caches or no sections-db', () => { | ||
return expect(clearSensitiveCaches()).resolves.toBe(false) | ||
}) | ||
|
||
it('clears potentially sensitive caches', async () => { | ||
const testKeys = ['cache1', 'cache2', 'app-shell'] | ||
const keysMock = jest | ||
.fn() | ||
.mockImplementation(() => Promise.resolve(testKeys)) | ||
const deleteMock = makeCachesDeleteMock(testKeys) | ||
window.caches = { keys: keysMock, delete: deleteMock } | ||
|
||
const cachesDeleted = await clearSensitiveCaches() | ||
expect(cachesDeleted).toBe(true) | ||
|
||
expect(deleteMock).toHaveBeenCalledTimes(3) | ||
expect(deleteMock.mock.calls[0][0]).toBe('cache1') | ||
expect(deleteMock.mock.calls[1][0]).toBe('cache2') | ||
expect(deleteMock.mock.calls[2][0]).toBe('app-shell') | ||
}) | ||
|
||
it('preserves keepable caches', async () => { | ||
const keysMock = jest | ||
.fn() | ||
.mockImplementation(async () => [ | ||
'cache1', | ||
'cache2', | ||
'app-shell', | ||
'other-assets', | ||
'workbox-precache-v2-https://hey.howareya.now/', | ||
]) | ||
window.caches = { ...cachesDefault, keys: keysMock } | ||
|
||
await clearSensitiveCaches() | ||
|
||
expect(deleteMockDefault).toHaveBeenCalledTimes(3) | ||
expect(deleteMockDefault.mock.calls[0][0]).toBe('cache1') | ||
expect(deleteMockDefault.mock.calls[1][0]).toBe('cache2') | ||
expect(deleteMockDefault.mock.calls[2][0]).toBe('app-shell') | ||
expect(deleteMockDefault).not.toHaveBeenCalledWith('other-assets') | ||
expect(deleteMockDefault).not.toHaveBeenCalledWith( | ||
'workbox-precache-v2-https://hey.howareya.now/' | ||
) | ||
}) | ||
|
||
describe('clears sections-db', () => { | ||
// Test DB | ||
function openTestDB(dbName: string) { | ||
// simplified version of app platform openDB logic | ||
return openDB(dbName, 1, { | ||
upgrade(db) { | ||
db.createObjectStore(SECTIONS_STORE, { keyPath: 'sectionId' }) | ||
}, | ||
}) | ||
} | ||
|
||
afterEach(() => { | ||
// reset indexedDB state | ||
window.indexedDB = new FDBFactory() | ||
}) | ||
|
||
it('clears sections-db if it exists', async () => { | ||
// Open and populate test DB | ||
const db = await openTestDB(SECTIONS_DB) | ||
await db.put(SECTIONS_STORE, { | ||
sectionId: 'id-1', | ||
lastUpdated: new Date(), | ||
requests: 3, | ||
}) | ||
await db.put(SECTIONS_STORE, { | ||
sectionId: 'id-2', | ||
lastUpdated: new Date(), | ||
requests: 3, | ||
}) | ||
|
||
await clearSensitiveCaches() | ||
|
||
// Sections-db should be cleared | ||
const allSections = await db.getAll(SECTIONS_STORE) | ||
expect(allSections).toHaveLength(0) | ||
}) | ||
|
||
it("doesn't clear sections-db if it doesn't exist and doesn't open a new one", async () => { | ||
const openMock = jest.fn() | ||
window.indexedDB.open = openMock | ||
|
||
expect(await indexedDB.databases()).not.toContain(SECTIONS_DB) | ||
|
||
await clearSensitiveCaches() | ||
|
||
expect(openMock).not.toHaveBeenCalled() | ||
return expect(await indexedDB.databases()).not.toContain(SECTIONS_DB) | ||
}) | ||
|
||
it("doesn't handle IDB if 'databases' property is not on window.indexedDB", async () => { | ||
// Open DB -- 'indexedDB.open' _would_ get called in this test | ||
// if 'databases' property exists | ||
await openTestDB(SECTIONS_DB) | ||
const openMock = jest.fn() | ||
window.indexedDB.open = openMock | ||
|
||
// Remove 'databases' from indexedDB prototype for this test | ||
// (simulates Firefox environment) | ||
const idbProto = Object.getPrototypeOf(window.indexedDB) | ||
const databases = idbProto.databases | ||
delete idbProto.databases | ||
|
||
expect('databases' in window.indexedDB).toBe(false) | ||
await expect(clearSensitiveCaches()).resolves.toBeDefined() | ||
expect(openMock).not.toHaveBeenCalled() | ||
|
||
// Restore indexedDB prototype for later tests | ||
idbProto.databases = databases | ||
expect('databases' in window.indexedDB).toBe(true) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
// IndexedDB names; should be the same as in @dhis2/pwa | ||
export const SECTIONS_DB = 'sections-db' | ||
export const SECTIONS_STORE = 'sections-store' | ||
|
||
// Non-sensitive caches that can be kept: | ||
const KEEPABLE_CACHES = [ | ||
/^workbox-precache/, // precached static assets | ||
/^other-assets/, // static assets cached at runtime - shouldn't be sensitive | ||
] | ||
|
||
declare global { | ||
interface IDBFactory { | ||
databases(): Promise<[{ name: string; version: number }]> | ||
} | ||
} | ||
|
||
/* | ||
* Clears the 'sections-db' IndexedDB if it exists. Designed to avoid opening | ||
* a new DB if it doesn't exist yet. Firefox can't check if 'sections-db' | ||
* exists, in which circumstance the IndexedDB is unaffected. It's inelegant | ||
* but acceptable because the IndexedDB has no sensitive data (only metadata | ||
* of recorded sections), and the OfflineInterface handles discrepancies | ||
* between CacheStorage and IndexedDB. | ||
*/ | ||
const clearDB = async (dbName: string): Promise<void> => { | ||
if (!('databases' in indexedDB)) { | ||
// FF does not have indexedDB.databases. For that, just clear caches, | ||
// and offline interface will handle discrepancies in PWA apps. | ||
return | ||
} | ||
|
||
const dbs = await window.indexedDB.databases() | ||
if (!dbs.some(({ name }) => name === dbName)) { | ||
// Sections-db is not created; nothing to do here | ||
return | ||
} | ||
|
||
return new Promise((resolve, reject) => { | ||
// IndexedDB fun: | ||
const openDBRequest = indexedDB.open(dbName) | ||
openDBRequest.onsuccess = e => { | ||
const db = (e.target as IDBOpenDBRequest).result | ||
const tx = db.transaction(SECTIONS_STORE, 'readwrite') | ||
// When the transaction completes is when the operation is done: | ||
tx.oncomplete = () => resolve() | ||
tx.onerror = e => reject((e.target as IDBRequest).error) | ||
const os = tx.objectStore(SECTIONS_STORE) | ||
const clearReq = os.clear() | ||
clearReq.onerror = e => reject((e.target as IDBRequest).error) | ||
} | ||
openDBRequest.onerror = e => { | ||
reject((e.target as IDBOpenDBRequest).error) | ||
} | ||
}) | ||
} | ||
|
||
/** | ||
* Used to clear caches and 'sections-db' IndexedDB when a user logs out or a | ||
* different user logs in to prevent someone from accessing a different user's | ||
* caches. Should be able to be used in a non-PWA app. | ||
*/ | ||
export async function clearSensitiveCaches( | ||
dbName: string = SECTIONS_DB | ||
): Promise<any> { | ||
console.debug('Clearing sensitive caches') | ||
|
||
const cacheKeys = await caches.keys() | ||
return Promise.all([ | ||
// (Resolves to 'false' because this can't detect if anything was deleted): | ||
clearDB(dbName).then(() => false), | ||
// Remove caches if not in keepable list | ||
...cacheKeys.map(key => { | ||
if (!KEEPABLE_CACHES.some(pattern => pattern.test(key))) { | ||
return caches.delete(key) | ||
} | ||
return false | ||
}), | ||
]).then(responses => { | ||
// Return true if any caches have been cleared | ||
// (caches.delete() returns true if a cache is deleted successfully) | ||
// PWA apps can reload to restore their app shell cache | ||
return responses.some(response => response) | ||
}) | ||
} |
Oops, something went wrong.