Skip to content

Commit 2dcb9b3

Browse files
committed
Guard local data provider mutations against missing ids
1 parent 5a98a66 commit 2dcb9b3

4 files changed

Lines changed: 369 additions & 45 deletions

File tree

packages/ra-data-local-forage/src/index.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ jest.mock('localforage', () => ({
1313
}));
1414

1515
describe('ra-data-local-forage', () => {
16+
const posts = [
17+
{ id: 1, title: 'Hello world' },
18+
{ id: 2, title: 'Second post' },
19+
];
20+
1621
beforeEach(() => {
1722
jest.resetAllMocks();
1823
(localforage.keys as jest.Mock).mockResolvedValue([]);
@@ -45,4 +50,72 @@ describe('ra-data-local-forage', () => {
4550
} as any)
4651
).rejects.toThrow('Invalid resource key: __proto__');
4752
});
53+
54+
it('does not corrupt local data when update targets an unknown id', async () => {
55+
(localforage.keys as jest.Mock).mockResolvedValue([
56+
'ra-data-local-forage-posts',
57+
]);
58+
(localforage.getItem as jest.Mock).mockResolvedValue([...posts]);
59+
const dataProvider = localForageDataProvider();
60+
61+
await expect(
62+
dataProvider.update('posts', {
63+
id: 3,
64+
data: { title: 'Updated' },
65+
previousData: { id: 3 },
66+
} as any)
67+
).rejects.toThrow('No item with identifier 3');
68+
69+
expect(localforage.setItem).not.toHaveBeenCalled();
70+
});
71+
72+
it('does not partially update local data when updateMany includes an unknown id', async () => {
73+
(localforage.keys as jest.Mock).mockResolvedValue([
74+
'ra-data-local-forage-posts',
75+
]);
76+
(localforage.getItem as jest.Mock).mockResolvedValue([...posts]);
77+
const dataProvider = localForageDataProvider();
78+
79+
await expect(
80+
dataProvider.updateMany('posts', {
81+
ids: [1, 3],
82+
data: { title: 'Updated' },
83+
} as any)
84+
).rejects.toThrow('No item with identifier 3');
85+
86+
expect(localforage.setItem).not.toHaveBeenCalled();
87+
});
88+
89+
it('does not corrupt local data when delete targets an unknown id', async () => {
90+
(localforage.keys as jest.Mock).mockResolvedValue([
91+
'ra-data-local-forage-posts',
92+
]);
93+
(localforage.getItem as jest.Mock).mockResolvedValue([...posts]);
94+
const dataProvider = localForageDataProvider();
95+
96+
await expect(
97+
dataProvider.delete('posts', {
98+
id: 3,
99+
previousData: { id: 3 },
100+
} as any)
101+
).rejects.toThrow('No item with identifier 3');
102+
103+
expect(localforage.setItem).not.toHaveBeenCalled();
104+
});
105+
106+
it('does not partially delete local data when deleteMany includes an unknown id', async () => {
107+
(localforage.keys as jest.Mock).mockResolvedValue([
108+
'ra-data-local-forage-posts',
109+
]);
110+
(localforage.getItem as jest.Mock).mockResolvedValue([...posts]);
111+
const dataProvider = localForageDataProvider();
112+
113+
await expect(
114+
dataProvider.deleteMany('posts', {
115+
ids: [1, 3],
116+
} as any)
117+
).rejects.toThrow('No item with identifier 3');
118+
119+
expect(localforage.setItem).not.toHaveBeenCalled();
120+
});
48121
});

packages/ra-data-local-forage/src/index.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,16 +175,28 @@ export default (params?: LocalForageDataProviderParams): DataProvider => {
175175
throw new Error('The dataProvider is not initialized.');
176176
}
177177

178+
assertRecordsExist(getResourceCollection(data, resource), [
179+
params.id,
180+
]);
181+
const response = await baseDataProvider.update<RecordType>(
182+
resource,
183+
params
184+
);
178185
const resourceData = getResourceCollection(data, resource);
179186
const index = resourceData.findIndex(
180187
(record: { id: any }) => record.id === params.id
181188
);
189+
190+
if (index === -1) {
191+
return response;
192+
}
193+
182194
resourceData.splice(index, 1, {
183195
...resourceData[index],
184196
...params.data,
185197
});
186198
updateLocalForage(resource);
187-
return baseDataProvider.update<RecordType>(resource, params);
199+
return response;
188200
},
189201
updateMany: async (resource: string, params: UpdateManyParams<any>) => {
190202
checkResource(resource);
@@ -197,18 +209,28 @@ export default (params?: LocalForageDataProviderParams): DataProvider => {
197209
}
198210

199211
const resourceData = getResourceCollection(data, resource);
212+
assertRecordsExist(resourceData, params.ids);
213+
const response = await baseDataProvider.updateMany(
214+
resource,
215+
params
216+
);
200217

201218
params.ids.forEach((id: Identifier) => {
202219
const index = resourceData.findIndex(
203220
(record: { id: Identifier }) => record.id === id
204221
);
222+
223+
if (index === -1) {
224+
return;
225+
}
226+
205227
resourceData.splice(index, 1, {
206228
...resourceData[index],
207229
...params.data,
208230
});
209231
});
210232
updateLocalForage(resource);
211-
return baseDataProvider.updateMany(resource, params);
233+
return response;
212234
},
213235
create: async <RecordType extends Omit<RaRecord, 'id'> = any>(
214236
resource: string,
@@ -247,13 +269,25 @@ export default (params?: LocalForageDataProviderParams): DataProvider => {
247269
if (!data) {
248270
throw new Error('The dataProvider is not initialized.');
249271
}
272+
assertRecordsExist(getResourceCollection(data, resource), [
273+
params.id,
274+
]);
275+
const response = await baseDataProvider.delete<RecordType>(
276+
resource,
277+
params
278+
);
250279
const resourceData = getResourceCollection(data, resource);
251280
const index = resourceData.findIndex(
252281
(record: { id: any }) => record.id === params.id
253282
);
283+
284+
if (index === -1) {
285+
return response;
286+
}
287+
254288
pullAt(resourceData, [index]);
255289
updateLocalForage(resource);
256-
return baseDataProvider.delete<RecordType>(resource, params);
290+
return response;
257291
},
258292
deleteMany: async (resource: string, params: DeleteManyParams<any>) => {
259293
checkResource(resource);
@@ -265,14 +299,22 @@ export default (params?: LocalForageDataProviderParams): DataProvider => {
265299
throw new Error('The dataProvider is not initialized.');
266300
}
267301
const resourceData = getResourceCollection(data, resource);
268-
const indexes = params.ids.map((id: any) => {
269-
return resourceData.findIndex(
270-
(record: any) => record.id === id
271-
);
272-
});
302+
assertRecordsExist(resourceData, params.ids);
303+
const response = await baseDataProvider.deleteMany(
304+
resource,
305+
params
306+
);
307+
const indexes = params.ids
308+
.map((id: any) => {
309+
return resourceData.findIndex(
310+
(record: any) => record.id === id
311+
);
312+
})
313+
.filter(index => index !== -1);
314+
273315
pullAt(resourceData, indexes);
274316
updateLocalForage(resource);
275-
return baseDataProvider.deleteMany(resource, params);
317+
return response;
276318
},
277319
};
278320
};
@@ -313,6 +355,18 @@ const checkResource = resource => {
313355
}
314356
};
315357

358+
const assertRecordsExist = (resourceData, ids) => {
359+
ids.forEach(id => {
360+
if (
361+
resourceData.findIndex(
362+
(record: { id: Identifier }) => record.id === id
363+
) === -1
364+
) {
365+
throw new Error(`No item with identifier ${id}`);
366+
}
367+
});
368+
};
369+
316370
export interface LocalForageDataProviderParams {
317371
defaultData?: any;
318372
prefixLocalForageKey?: string;

packages/ra-data-local-storage/src/index.spec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import expect from 'expect';
33
import localStorageDataProvider from './index';
44

55
describe('ra-data-local-storage', () => {
6+
const posts = [
7+
{ id: 1, title: 'Hello world' },
8+
{ id: 2, title: 'Second post' },
9+
];
10+
611
beforeEach(() => {
712
localStorage.clear();
813
});
@@ -40,4 +45,104 @@ describe('ra-data-local-storage', () => {
4045
} as any)
4146
).toThrow('Invalid resource key: __proto__');
4247
});
48+
49+
it('does not corrupt local data when update targets an unknown id', async () => {
50+
localStorage.setItem(
51+
'ra-data-local-storage-test',
52+
JSON.stringify({ posts })
53+
);
54+
const dataProvider = localStorageDataProvider({
55+
localStorageKey: 'ra-data-local-storage-test',
56+
localStorageUpdateDelay: 0,
57+
});
58+
59+
await expect(
60+
dataProvider.update('posts', {
61+
id: 3,
62+
data: { title: 'Updated' },
63+
previousData: { id: 3 },
64+
} as any)
65+
).rejects.toThrow('No item with identifier 3');
66+
await new Promise(resolve => setTimeout(resolve, 0));
67+
68+
expect(
69+
JSON.parse(
70+
localStorage.getItem('ra-data-local-storage-test') || '{}'
71+
)
72+
).toEqual({ posts });
73+
});
74+
75+
it('does not partially update local data when updateMany includes an unknown id', async () => {
76+
localStorage.setItem(
77+
'ra-data-local-storage-test',
78+
JSON.stringify({ posts })
79+
);
80+
const dataProvider = localStorageDataProvider({
81+
localStorageKey: 'ra-data-local-storage-test',
82+
localStorageUpdateDelay: 0,
83+
});
84+
85+
await expect(
86+
dataProvider.updateMany('posts', {
87+
ids: [1, 3],
88+
data: { title: 'Updated' },
89+
} as any)
90+
).rejects.toThrow('No item with identifier 3');
91+
await new Promise(resolve => setTimeout(resolve, 0));
92+
93+
expect(
94+
JSON.parse(
95+
localStorage.getItem('ra-data-local-storage-test') || '{}'
96+
)
97+
).toEqual({ posts });
98+
});
99+
100+
it('does not corrupt local data when delete targets an unknown id', async () => {
101+
localStorage.setItem(
102+
'ra-data-local-storage-test',
103+
JSON.stringify({ posts })
104+
);
105+
const dataProvider = localStorageDataProvider({
106+
localStorageKey: 'ra-data-local-storage-test',
107+
localStorageUpdateDelay: 0,
108+
});
109+
110+
await expect(
111+
dataProvider.delete('posts', {
112+
id: 3,
113+
previousData: { id: 3 },
114+
} as any)
115+
).rejects.toThrow('No item with identifier 3');
116+
await new Promise(resolve => setTimeout(resolve, 0));
117+
118+
expect(
119+
JSON.parse(
120+
localStorage.getItem('ra-data-local-storage-test') || '{}'
121+
)
122+
).toEqual({ posts });
123+
});
124+
125+
it('does not partially delete local data when deleteMany includes an unknown id', async () => {
126+
localStorage.setItem(
127+
'ra-data-local-storage-test',
128+
JSON.stringify({ posts })
129+
);
130+
const dataProvider = localStorageDataProvider({
131+
localStorageKey: 'ra-data-local-storage-test',
132+
localStorageUpdateDelay: 0,
133+
});
134+
135+
await expect(
136+
dataProvider.deleteMany('posts', {
137+
ids: [1, 3],
138+
} as any)
139+
).rejects.toThrow('No item with identifier 3');
140+
await new Promise(resolve => setTimeout(resolve, 0));
141+
142+
expect(
143+
JSON.parse(
144+
localStorage.getItem('ra-data-local-storage-test') || '{}'
145+
)
146+
).toEqual({ posts });
147+
});
43148
});

0 commit comments

Comments
 (0)