diff --git a/common/changes/@snowplow/browser-tracker-core/copilot-fix-localstorage-access-issue_2025-10-07-13-27.json b/common/changes/@snowplow/browser-tracker-core/copilot-fix-localstorage-access-issue_2025-10-07-13-27.json new file mode 100644 index 000000000..c77bff400 --- /dev/null +++ b/common/changes/@snowplow/browser-tracker-core/copilot-fix-localstorage-access-issue_2025-10-07-13-27.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-tracker-core", + "comment": "Fix SecurityError when accessing localStorage in restricted browser environments", + "type": "none" + } + ], + "packageName": "@snowplow/browser-tracker-core" +} \ No newline at end of file diff --git a/common/changes/@snowplow/react-native-tracker/fix-react_native_tracker_types_2025-10-07-13-25.json b/common/changes/@snowplow/react-native-tracker/fix-react_native_tracker_types_2025-10-07-13-25.json new file mode 100644 index 000000000..05a6935a9 --- /dev/null +++ b/common/changes/@snowplow/react-native-tracker/fix-react_native_tracker_types_2025-10-07-13-25.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/react-native-tracker", + "comment": "Add TypeScript definitions to \"package.json\" for \"react-native-tracker\"", + "type": "none" + } + ], + "packageName": "@snowplow/react-native-tracker" +} \ No newline at end of file diff --git a/libraries/browser-tracker-core/src/tracker/local_storage_event_store.ts b/libraries/browser-tracker-core/src/tracker/local_storage_event_store.ts index 52f7db9aa..b6e554aa4 100644 --- a/libraries/browser-tracker-core/src/tracker/local_storage_event_store.ts +++ b/libraries/browser-tracker-core/src/tracker/local_storage_event_store.ts @@ -21,9 +21,14 @@ export function newLocalStorageEventStore({ function newInMemoryEventStoreFromLocalStorage() { if (useLocalStorage) { - const localStorageQueue = window.localStorage.getItem(queueName); - const events: EventStorePayload[] = localStorageQueue ? JSON.parse(localStorageQueue) : []; - return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize, events }); + try { + const localStorageQueue = window.localStorage.getItem(queueName); + const events: EventStorePayload[] = localStorageQueue ? JSON.parse(localStorageQueue) : []; + return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize, events }); + } catch (e) { + console.error('Failed to access localStorage when initializing event store:', e); + return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize }); + } } else { return newInMemoryEventStore({ maxSize: maxLocalStorageQueueSize }); } @@ -34,7 +39,11 @@ export function newLocalStorageEventStore({ function sync(): Promise { if (useLocalStorage) { return getAll().then((events) => { - window.localStorage.setItem(queueName, JSON.stringify(events)); + try { + window.localStorage.setItem(queueName, JSON.stringify(events)); + } catch (e) { + console.error('Failed to persist events to localStorage:', e); + } }); } else { return Promise.resolve(); diff --git a/libraries/browser-tracker-core/test/tracker/local_storage_event_store.test.ts b/libraries/browser-tracker-core/test/tracker/local_storage_event_store.test.ts new file mode 100644 index 000000000..2c1ceebcf --- /dev/null +++ b/libraries/browser-tracker-core/test/tracker/local_storage_event_store.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { newLocalStorageEventStore } from '../../src/tracker/local_storage_event_store'; + +describe('LocalStorageEventStore', () => { + const trackerId = 'test-tracker'; + + beforeEach(() => { + localStorage.clear(); + }); + + it('should create an event store with useLocalStorage enabled', async () => { + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: true, + }); + + expect(await eventStore.count()).toBe(0); + }); + + it('should create an event store with useLocalStorage disabled', async () => { + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: false, + }); + + expect(await eventStore.count()).toBe(0); + }); + + it('should add and retrieve events when localStorage is accessible', async () => { + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: true, + }); + + const event = { payload: { e: 'pv', eid: 'test-event-id' } }; + await eventStore.add(event); + + expect(await eventStore.count()).toBe(1); + const events = await eventStore.getAllPayloads(); + expect(events[0]).toMatchObject(event.payload); + }); + + it('should handle SecurityError when accessing localStorage.getItem', () => { + const originalGetItem = Storage.prototype.getItem; + Storage.prototype.getItem = jest.fn(() => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }); + + // Should not throw an error, but should create an empty in-memory store + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: true, + }); + + expect(eventStore).toBeDefined(); + expect(eventStore.count).toBeDefined(); + + Storage.prototype.getItem = originalGetItem; + }); + + it('should handle SecurityError when accessing localStorage.setItem', async () => { + const originalSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = jest.fn(() => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }); + + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: true, + }); + + const event = { payload: { e: 'pv', eid: 'test-event-id' } }; + + // Should not throw an error, even though setItem fails + await expect(eventStore.add(event)).resolves.toBeDefined(); + + // Event should still be in the in-memory store + expect(await eventStore.count()).toBe(1); + + Storage.prototype.setItem = originalSetItem; + }); + + it('should gracefully handle errors when both getItem and setItem throw SecurityError', async () => { + const originalGetItem = Storage.prototype.getItem; + const originalSetItem = Storage.prototype.setItem; + + Storage.prototype.getItem = jest.fn(() => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }); + Storage.prototype.setItem = jest.fn(() => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + }); + + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: true, + }); + + const event = { payload: { e: 'pv', eid: 'test-event-id' } }; + await eventStore.add(event); + + // Event should be in the in-memory store + expect(await eventStore.count()).toBe(1); + const events = await eventStore.getAllPayloads(); + expect(events[0]).toMatchObject(event.payload); + + Storage.prototype.getItem = originalGetItem; + Storage.prototype.setItem = originalSetItem; + }); + + it('should persist events to localStorage when accessible', async () => { + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: true, + }); + + const event = { payload: { e: 'pv', eid: 'test-event-id' } }; + await eventStore.add(event); + + // Check that the event was persisted to localStorage + const queueName = `snowplowOutQueue_${trackerId}`; + const stored = localStorage.getItem(queueName); + expect(stored).toBeDefined(); + expect(JSON.parse(stored!)).toHaveLength(1); + }); + + it('should load events from localStorage on initialization', () => { + const queueName = `snowplowOutQueue_${trackerId}`; + const events = [{ payload: { e: 'pv', eid: 'event-1' } }, { payload: { e: 'pv', eid: 'event-2' } }]; + localStorage.setItem(queueName, JSON.stringify(events)); + + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: true, + }); + + expect(eventStore.count()).resolves.toBe(2); + }); + + it('should not load from localStorage when useLocalStorage is false', () => { + const queueName = `snowplowOutQueue_${trackerId}`; + const events = [{ payload: { e: 'pv', eid: 'event-1' } }, { payload: { e: 'pv', eid: 'event-2' } }]; + localStorage.setItem(queueName, JSON.stringify(events)); + + const eventStore = newLocalStorageEventStore({ + trackerId, + useLocalStorage: false, + }); + + expect(eventStore.count()).resolves.toBe(0); + }); +}); diff --git a/trackers/react-native-tracker/package.json b/trackers/react-native-tracker/package.json index 60b58ae66..e568d845b 100644 --- a/trackers/react-native-tracker/package.json +++ b/trackers/react-native-tracker/package.json @@ -23,6 +23,7 @@ "source": "./src/index.tsx", "main": "./dist/commonjs/index.js", "module": "./dist/module/index.js", + "types": "./dist/typescript/module/src/index.d.ts", "exports": { ".": { "import": {