Skip to content

Commit

Permalink
Merge pull request #1011 from dhis2/feat-cSC-cherry-pick
Browse files Browse the repository at this point in the history
feat(offline): add clear sensitive caches function
  • Loading branch information
varl committed Sep 17, 2021
2 parents 3d5ea08 + 4b68b22 commit dfea957
Show file tree
Hide file tree
Showing 6 changed files with 309 additions and 17 deletions.
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -27,6 +27,8 @@
"concurrently": "^5.0.2",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-react-hooks": "^4.2.0",
"fake-indexeddb": "^3.1.3",
"idb": "^6.1.3",
"jest": "^24.9.0",
"loop": "^3.3.4",
"prop-types": "^15.7.2",
Expand Down
1 change: 1 addition & 0 deletions runtime/src/index.ts
Expand Up @@ -17,6 +17,7 @@ export {
useCacheableSection,
CacheableSection,
useCachedSections,
clearSensitiveCaches,
} from '@dhis2/app-service-offline'

export { Provider } from './Provider'
1 change: 1 addition & 0 deletions services/offline/src/index.ts
Expand Up @@ -2,3 +2,4 @@ export { OfflineProvider } from './lib/offline-provider'
export { CacheableSection, useCacheableSection } from './lib/cacheable-section'
export { useCachedSections } from './lib/cacheable-section-state'
export { useOnlineStatus } from './lib/online-status'
export { clearSensitiveCaches } from './lib/clear-sensitive-caches'
162 changes: 162 additions & 0 deletions services/offline/src/lib/__tests__/clear-sensitive-caches.test.ts
@@ -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)
})
})
84 changes: 84 additions & 0 deletions services/offline/src/lib/clear-sensitive-caches.ts
@@ -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)
})
}

0 comments on commit dfea957

Please sign in to comment.