From a0ee9c29d6d87b302ca037e4ca24906ddb058d07 Mon Sep 17 00:00:00 2001 From: Xander Moffatt Date: Wed, 22 Sep 2021 11:56:04 -0600 Subject: [PATCH] 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 Reviewed-by: Evan Battaglia QA-Review: Evan Battaglia Product-Review: Xander Moffatt --- .../jquery/__tests__/platform_storage.test.js | 182 ++++++++++++++++++ ui/shared/lti/jquery/platform_storage.js | 88 +++++++++ 2 files changed, 270 insertions(+) create mode 100644 ui/shared/lti/jquery/__tests__/platform_storage.test.js create mode 100644 ui/shared/lti/jquery/platform_storage.js diff --git a/ui/shared/lti/jquery/__tests__/platform_storage.test.js b/ui/shared/lti/jquery/__tests__/platform_storage.test.js new file mode 100644 index 00000000000..0e2d0496ed6 --- /dev/null +++ b/ui/shared/lti/jquery/__tests__/platform_storage.test.js @@ -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 . + */ + +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}` + ) + }) + }) + }) +}) diff --git a/ui/shared/lti/jquery/platform_storage.js b/ui/shared/lti/jquery/platform_storage.js new file mode 100644 index 00000000000..cb067aa1f80 --- /dev/null +++ b/ui/shared/lti/jquery/platform_storage.js @@ -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 . + */ + +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}