wrap localstorage for LTI tool use

refs INTEROP-7087
flag=lti_platform_storage

why
* part of IMS LTI Platform Storage proposal
* will be used by LTI postMessages

* put limits on storage key count and size, partitioned by tool, as
defined by the Platform Storage spec

test plan
* specs

Change-Id: I3af9ad8487cccaf510826c50a2b6c478fda75110
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/274217
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Evan Battaglia <ebattaglia@instructure.com>
QA-Review: Evan Battaglia <ebattaglia@instructure.com>
Product-Review: Xander Moffatt <xmoffatt@instructure.com>
This commit is contained in:
Xander Moffatt 2021-09-22 11:56:04 -06:00
parent 117602ab88
commit a0ee9c29d6
2 changed files with 270 additions and 0 deletions

View File

@ -0,0 +1,182 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
addToLimit,
clearData,
clearLimit,
getData,
getLimit,
putData,
removeFromLimit
} from '../platform_storage'
describe('platform_storage', () => {
const tool_id = 'tool_id'
const key = 'key'
const value = 'value'
beforeEach(() => {
clearLimit(tool_id)
})
describe('getLimit', () => {
it('defaults counts to 0', () => {
const limit = getLimit('tool that does not have limit already')
expect(limit.keyCount).toBe(0)
expect(limit.charCount).toBe(0)
})
})
describe('addToLimit', () => {
it('increments key count for tool id', () => {
const before = {...getLimit(tool_id)}
addToLimit(tool_id, key, value)
const after = getLimit(tool_id)
expect(after.keyCount).toEqual(before.keyCount + 1)
})
it('adds key and value length to char count for tool', () => {
const before = {...getLimit(tool_id)}
addToLimit(tool_id, key, value)
const after = getLimit(tool_id)
expect(after.charCount).toEqual(before.charCount + key.length + value.length)
})
describe('when tool has reached key count limit', () => {
beforeEach(() => {
getLimit(tool_id).keyCount = 500
})
it('throws a storage_exhaustion error', () => {
expect(() => addToLimit(tool_id, key, value)).toThrow('Reached key limit')
})
})
describe('when tool has reached char count limit', () => {
beforeEach(() => {
getLimit(tool_id).charCount = 4096
})
it('throws a storage_exhaustion error', () => {
expect(() => addToLimit(tool_id, key, value)).toThrow('Reached byte limit')
})
})
})
describe('removeFromLimit', () => {
beforeEach(() => {
addToLimit(tool_id, 'hello', 'world')
addToLimit(tool_id, key, value)
})
it('decrements key count for tool id', () => {
const before = {...getLimit(tool_id)}
removeFromLimit(tool_id, key, value)
const after = getLimit(tool_id)
expect(after.keyCount).toEqual(before.keyCount - 1)
})
it('removes key and value length from char count for tool', () => {
const before = {...getLimit(tool_id)}
removeFromLimit(tool_id, key, value)
const after = getLimit(tool_id)
expect(after.charCount).toEqual(before.charCount - key.length - value.length)
})
describe('when key count goes below 0', () => {
beforeEach(() => {
removeFromLimit(tool_id, key, value)
removeFromLimit(tool_id, key, value)
removeFromLimit(tool_id, key, value)
})
it('resets key count to 0', () => {
const {keyCount} = getLimit(tool_id)
expect(keyCount).toBe(0)
})
})
describe('when char count goes below 0', () => {
beforeEach(() => {
removeFromLimit(tool_id, key, value)
removeFromLimit(tool_id, key, value)
removeFromLimit(tool_id, key, value)
})
it('resets char count to 0', () => {
const {charCount} = getLimit(tool_id)
expect(charCount).toBe(0)
})
})
})
describe('putData', () => {
beforeEach(() => {
jest.spyOn(window.localStorage, 'setItem')
})
it('namespaces key with tool id', () => {
putData(tool_id, key, value)
expect(window.localStorage.setItem).toHaveBeenCalledWith(
`lti|platform_storage|${tool_id}|${key}`,
value
)
})
})
describe('getData', () => {
beforeEach(() => {
jest.spyOn(window.localStorage, 'getItem')
putData(tool_id, key, value)
})
it('namespaces key with tool id', () => {
getData(tool_id, key)
expect(window.localStorage.getItem).toHaveBeenCalledWith(
`lti|platform_storage|${tool_id}|${key}`
)
})
})
describe('clearData', () => {
beforeEach(() => {
jest.spyOn(window.localStorage, 'removeItem')
})
describe('when key does not exist', () => {
it('does nothing', () => {
expect(window.localStorage.removeItem).not.toHaveBeenCalled()
})
})
describe('when key is already stored', () => {
beforeEach(() => {
jest.spyOn(window.localStorage, 'getItem').mockImplementation(() => value)
putData(tool_id, key, value)
})
it('namespaces key with tool id', () => {
clearData(tool_id, key)
expect(window.localStorage.removeItem).toHaveBeenCalledWith(
`lti|platform_storage|${tool_id}|${key}`
)
})
})
})
})

View File

@ -0,0 +1,88 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const STORAGE_CHAR_LIMIT = 4096 // IMS minimum storage limit is 4096 bytes so 4096 chars is more than enough
const STORAGE_KEY_LIMIT = 500
const limits = {}
const createLimit = tool_id => {
if (!limits[tool_id]) {
limits[tool_id] = {keyCount: 0, charCount: 0}
}
}
const getLimit = tool_id => {
createLimit(tool_id)
return limits[tool_id]
}
const clearLimit = tool_id => {
delete limits[tool_id]
}
const addToLimit = (tool_id, key, value) => {
createLimit(tool_id)
const length = key.length + value.length
if (limits[tool_id].keyCount >= STORAGE_KEY_LIMIT) {
const e = new Error('Reached key limit for tool')
e.code = 'storage_exhaustion'
throw e
}
if (limits[tool_id].charCount + length > STORAGE_CHAR_LIMIT) {
const e = new Error('Reached byte limit for tool')
e.code = 'storage_exhaustion'
throw e
}
limits[tool_id].keyCount++
limits[tool_id].charCount += length
}
const removeFromLimit = (tool_id, key, value) => {
limits[tool_id].keyCount--
limits[tool_id].charCount -= key.length + value.length
if (limits[tool_id].keyCount < 0) {
limits[tool_id].keyCount = 0
}
if (limits[tool_id].charCount < 0) {
limits[tool_id].charCount = 0
}
}
const getKey = (tool_id, key) => `lti|platform_storage|${tool_id}|${key}`
const putData = (tool_id, key, value) => {
addToLimit(tool_id, key, value)
window.localStorage.setItem(getKey(tool_id, key), value)
}
const getData = (tool_id, key) => window.localStorage.getItem(getKey(tool_id, key))
const clearData = (tool_id, key) => {
const value = getData(tool_id, key)
if (value) {
removeFromLimit(tool_id, key, value)
window.localStorage.removeItem(getKey(tool_id, key))
}
}
export {getLimit, clearLimit, addToLimit, removeFromLimit, clearData, getData, putData}