diff --git a/.changeset/tangy-walls-return.md b/.changeset/tangy-walls-return.md new file mode 100644 index 00000000..6d90424c --- /dev/null +++ b/.changeset/tangy-walls-return.md @@ -0,0 +1,5 @@ +--- +"@smartthings/core-sdk": patch +--- + +validate URLs in paged data diff --git a/.vscode/settings.json b/.vscode/settings.json index 9c847382..3cddd159 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,6 @@ "smartthingspi", "sshpk" ], - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "js/ts.tsdk.path": "node_modules/typescript/lib" } diff --git a/src/endpoint-client.ts b/src/endpoint-client.ts index f2750ec0..22a90bbe 100644 --- a/src/endpoint-client.ts +++ b/src/endpoint-client.ts @@ -36,7 +36,7 @@ export const chinaSmartThingsURLProvider: SmartThingsURLProvider = { export interface EndpointClientConfig { authenticator: Authenticator - urlProvider?: SmartThingsURLProvider + urlProvider: SmartThingsURLProvider logger?: Logger loggingId?: string version?: string @@ -129,6 +129,11 @@ export class EndpointClient { private logger: Logger constructor(public readonly basePath: string, public readonly config: EndpointClientConfig) { + try { + new URL(config.urlProvider.baseURL) // throws if invalid URL + } catch (error) { + throw new Error(`Invalid base URL ${config.urlProvider.baseURL}: ${error}`) + } this.logger = config.logger ? config.logger : noLogLogger } @@ -147,16 +152,35 @@ export class EndpointClient { return this } - private url(path?: string): string { - if (path) { - if (path.startsWith('/')) { - return `${this.config.urlProvider?.baseURL}${path}` - } else if (path.startsWith('https://')) { + private validateAndCalculateURL(path?: string): string { + const calculateURL = (): string => { + if (path && path.startsWith('http')) { return path } - return `${this.config.urlProvider?.baseURL}/${this.basePath}/${path}` + + const baseURL = this.config.urlProvider.baseURL + if (path) { + // A path starting with a slash "breaks out of" the base path but not the base URL. + // Put another way, it ignores the path specified in the constructor but still uses the base URL. + return path.startsWith('/') ? `${baseURL}${path}` : `${baseURL}/${this.basePath}/${path}` + } + return `${baseURL}/${this.basePath}` + } + + const calculatedURL = calculateURL() + + const isSameOrigin = (candidate: string, baseURLString: string): boolean => { + const candidateURL = new URL(candidate) + const baseURL = new URL(baseURLString) + + return candidateURL.origin === baseURL.origin } - return `${this.config.urlProvider?.baseURL}/${this.basePath}` + + if (!isSameOrigin(calculatedURL, this.config.urlProvider.baseURL)) { + throw Error(`illegal url ${calculatedURL} does not match base URL ${this.config.urlProvider.baseURL}`) + } + + return calculatedURL } public async request(method: HttpClientMethod, path?: string, @@ -179,7 +203,7 @@ export class EndpointClient { } const axiosConfig: AxiosRequestConfig = { - url: this.url(path), + url: this.validateAndCalculateURL(path), method, headers: options?.headerOverrides ? { ...headers, ...options.headerOverrides } : headers, params, diff --git a/test/unit/apps.test.ts b/test/unit/apps.test.ts index eff3f7a4..59ff5c99 100644 --- a/test/unit/apps.test.ts +++ b/test/unit/apps.test.ts @@ -11,7 +11,7 @@ const MOCK_APP_OAUTH_GENERATE = { oauthClientId: 'oauthClientId' } as GenerateAp describe('AppsEndpoint', () => { const authenticator = new NoOpAuthenticator() - const apps = new AppsEndpoint({ authenticator }) + const apps = new AppsEndpoint({ authenticator, urlProvider: { baseURL: 'https://example.com/baseURL' } }) const getSpy = jest.spyOn(EndpointClient.prototype, 'get') const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems') diff --git a/test/unit/channels.test.ts b/test/unit/channels.test.ts index f1dd9cbe..5c421fd6 100644 --- a/test/unit/channels.test.ts +++ b/test/unit/channels.test.ts @@ -16,7 +16,10 @@ describe('ChannelsEndpoint', () => { const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').mockImplementation() const authenticator = new NoOpAuthenticator() - const channelsEndpoint = new ChannelsEndpoint({ authenticator }) + const channelsEndpoint = new ChannelsEndpoint({ + authenticator, + urlProvider: { baseURL: 'https://example.com/baseURL' }, + }) test('create', async () => { const createRequest = { name: 'channel-to-create' } as ChannelCreate diff --git a/test/unit/devicepreferences.test.ts b/test/unit/devicepreferences.test.ts index d191317e..9e3e8495 100644 --- a/test/unit/devicepreferences.test.ts +++ b/test/unit/devicepreferences.test.ts @@ -12,7 +12,10 @@ const MOCK_LOCALE_LIST = [{ tag: 'tag' }] as LocaleReference[] describe('DevicePreferencesEndpoint', () => { const authenticator = new NoOpAuthenticator() - const devicepreferences = new DevicePreferencesEndpoint({ authenticator }) + const devicepreferences = new DevicePreferencesEndpoint({ + authenticator, + urlProvider: { baseURL: 'https://example.com/baseURL' }, + }) const getSpy = jest.spyOn(EndpointClient.prototype, 'get') const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems') diff --git a/test/unit/devices.test.ts b/test/unit/devices.test.ts index 96266a16..04ac1b27 100644 --- a/test/unit/devices.test.ts +++ b/test/unit/devices.test.ts @@ -26,7 +26,11 @@ describe('DevicesEndpoint', () => { const installedAppIdMock = jest.fn() .mockReturnValue('installed-app-id') - const devicesEndpoint = new DevicesEndpoint({ authenticator }) + const baseConfig = { + authenticator, + urlProvider: { baseURL: 'https://example.com/baseURL' }, + } + const devicesEndpoint = new DevicesEndpoint(baseConfig) devicesEndpoint.locationId = locationIdMock devicesEndpoint.installedAppId = installedAppIdMock @@ -44,7 +48,7 @@ describe('DevicesEndpoint', () => { }) it('includes configured locationId', async () => { - const devices = new DevicesEndpoint({ authenticator, locationId: 'configured-location-id' }) + const devices = new DevicesEndpoint({ ...baseConfig, locationId: 'configured-location-id' }) expect(await devices.list()).toBe(deviceList) expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) @@ -76,7 +80,7 @@ describe('DevicesEndpoint', () => { describe('listInLocation', () => { it('works on happy path', async () => { - const devices = new DevicesEndpoint({ authenticator, locationId: 'configured-location-id' }) + const devices = new DevicesEndpoint({ ...baseConfig, locationId: 'configured-location-id' }) const listSpy = jest.spyOn(devices, 'list').mockResolvedValue(deviceList) expect(await devices.listInLocation()).toBe(deviceList) @@ -101,7 +105,7 @@ describe('DevicesEndpoint', () => { describe('findByCapability', () => { it('works on happy path', async () => { - const devices = new DevicesEndpoint({ authenticator, locationId: 'unused-in-test' }) + const devices = new DevicesEndpoint({ ...baseConfig, locationId: 'unused-in-test' }) devices.locationId = locationIdMock const listSpy = jest.spyOn(devices, 'list').mockResolvedValue(deviceList) @@ -363,7 +367,7 @@ describe('DevicesEndpoint', () => { test('executeCommand', async () => { // create a new instance of devices so we can spy on it and not affect other tests - const devices = new DevicesEndpoint({ authenticator }) + const devices = new DevicesEndpoint(baseConfig) const executeCommandsSpy = jest.spyOn(devices, 'executeCommands') .mockResolvedValueOnce(commandResponse) const command = { command: 'command-1' } as Command @@ -495,7 +499,7 @@ describe('DevicesEndpoint', () => { // do nothing }) - const devices = new DevicesEndpoint({ authenticator }) + const devices = new DevicesEndpoint(baseConfig) expect(await devices.createEvents('device-id', events)).toBe(SuccessStatusValue) @@ -506,7 +510,7 @@ describe('DevicesEndpoint', () => { test('sendEvents', async () => { const events = { deviceEvents: [] } - const devices = new DevicesEndpoint({ authenticator }) + const devices = new DevicesEndpoint(baseConfig) await devices.sendEvents('device-id', events) @@ -518,7 +522,7 @@ describe('DevicesEndpoint', () => { const expected = {} as PresentationDevicePresentation getSpy.mockResolvedValueOnce(expected) - const devices = new DevicesEndpoint({ authenticator }) + const devices = new DevicesEndpoint(baseConfig) expect(await devices.getPresentation('device-id')).toBe(expected) @@ -530,7 +534,7 @@ describe('DevicesEndpoint', () => { const expected = {} as DevicePreferenceResponse getSpy.mockResolvedValueOnce(expected) - const devices = new DevicesEndpoint({ authenticator }) + const devices = new DevicesEndpoint(baseConfig) expect(await devices.getPreferences('device-id')).toBe(expected) diff --git a/test/unit/drivers.test.ts b/test/unit/drivers.test.ts index 6bb722c5..6dcb6d8c 100644 --- a/test/unit/drivers.test.ts +++ b/test/unit/drivers.test.ts @@ -14,7 +14,10 @@ describe('DriversEndpoint', () => { const requestSpy = jest.spyOn(EndpointClient.prototype, 'request').mockImplementation() const authenticator = new NoOpAuthenticator() - const driversEndpoint = new DriversEndpoint({ authenticator }) + const driversEndpoint = new DriversEndpoint({ + authenticator, + urlProvider: { baseURL: 'https://example.com/baseURL' }, + }) test('get', async () => { const driver = { driverId: 'driver-id' } diff --git a/test/unit/endpoint-client.test.ts b/test/unit/endpoint-client.test.ts index ea6cb28a..af1adc61 100644 --- a/test/unit/endpoint-client.test.ts +++ b/test/unit/endpoint-client.test.ts @@ -99,10 +99,8 @@ describe('EndpointClient', () => { let client: EndpointClient const configWithoutHeaders = { - urlProvider: globalSmartThingsURLProvider, + urlProvider: { baseURL: 'https://example.com' }, authenticator: new RefreshTokenAuthenticator(token, tokenStore), - baseURL: 'https://api.smartthings.com', - authURL: 'https://auth.smartthings.com', } const headers = { 'Content-Type': 'application/json;charset=utf-8', @@ -119,6 +117,13 @@ describe('EndpointClient', () => { jest.clearAllMocks() }) + test('constructor throws when invalid base URL', () => { + expect(() => new EndpointClient('base/path', { + urlProvider: { baseURL: 'invalid-url' }, + authenticator: new RefreshTokenAuthenticator(token, tokenStore), + })).toThrow('Invalid base URL') + }) + describe('setHeader', () => { it('adds header to config', () => { client.setHeader('NewHeader', 'header value') @@ -158,7 +163,7 @@ describe('EndpointClient', () => { expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/my/path', + url: 'https://example.com/base/path/my/path', method: 'GET', headers: { ...headers, @@ -191,7 +196,7 @@ describe('EndpointClient', () => { expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/my/path', + url: 'https://example.com/base/path/my/path', method: 'GET', headers: { Accept: 'application/vnd.smartthings+json;v=api-version, accept-header', @@ -210,7 +215,7 @@ describe('EndpointClient', () => { expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/my/path', + url: 'https://example.com/base/path/my/path', method: 'GET', headers: { Accept: 'application/vnd.smartthings+json;v=api-version', @@ -231,7 +236,7 @@ describe('EndpointClient', () => { const response = await client.request('POST', 'my/path', { name: 'Bob' }, undefined, { headerOverrides }) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/my/path', + url: 'https://example.com/base/path/my/path', method: 'POST', headers: { 'Content-Type': 'overridden content type', @@ -251,7 +256,7 @@ describe('EndpointClient', () => { const response = await client.request('GET', 'my/path') expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/my/path', + url: 'https://example.com/base/path/my/path', method: 'GET', headers: { Authorization: `Bearer ${token}`, @@ -275,7 +280,7 @@ describe('EndpointClient', () => { const response = await client.request('GET', 'my/path') expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/my/path', + url: 'https://example.com/base/path/my/path', method: 'GET', headers: { Authorization: `Bearer ${token}`, @@ -321,6 +326,24 @@ describe('EndpointClient', () => { expect(mockRequest).toHaveBeenCalledTimes(0) }) + it.each([ + 'https://example.com/paged-thing/next-url', + 'https://example.com/next-url', + ])('accepts URL %s with correct base path', async (goodURL) => { + await expect(client.request('GET', goodURL)).resolves.not.toThrow() + }) + + it.each([ + 'https://example.cоm/paged-thing/next-url', // the "o" in "com" is a Cyrillic character, not an ASCII "o" + 'https://example.com@evil.test/paged-thing', + 'https://example.com.evil.com/paged-thing', + ])('rejects URL %s not matching base', async (badURL) => { + const params = { paramName: 'param-value' } + const options = { dryRun: false } + + await expect(client.request('GET', badURL, undefined, params, options)).rejects.toThrow('illegal url') + }) + describe('logging', () => { const isDebugEnabledMock = jest.fn().mockReturnValue(true) const isTraceEnabledMock = jest.fn().mockReturnValue(true) @@ -343,7 +366,7 @@ describe('EndpointClient', () => { expect(isDebugEnabledMock).toHaveBeenCalledTimes(1) expect(debugMock).toHaveBeenCalledTimes(1) expect(debugMock).toHaveBeenCalledWith('making axios request: {' + - '"url":"https://api.smartthings.com/base/path/my/path",' + + '"url":"https://example.com/base/path/my/path",' + '"method":"GET",' + '"headers":{' + '"Content-Type":"application/json;charset=utf-8",' + @@ -431,7 +454,7 @@ describe('EndpointClient', () => { const response = await client.get('path2') expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/path2', + url: 'https://example.com/base/path/path2', method: 'get', headers: { ...headers, @@ -448,7 +471,7 @@ describe('EndpointClient', () => { const response = await client.get('my/path', { locationId: 'XXX' }) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/my/path', + url: 'https://example.com/base/path/my/path', method: 'get', headers: { ...headers, @@ -467,7 +490,7 @@ describe('EndpointClient', () => { const response = await client.get('/base2/this/path') expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base2/this/path', + url: 'https://example.com/base2/this/path', method: 'get', headers: { ...headers, @@ -481,10 +504,10 @@ describe('EndpointClient', () => { }) it('skips base URL and path with absolute URL', async () => { - const response = await client.get('https://api.smartthings.com/absolute/url') + const response = await client.get('https://example.com/absolute/url') expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/absolute/url', + url: 'https://example.com/absolute/url', method: 'get', headers: { ...headers, @@ -502,7 +525,7 @@ describe('EndpointClient', () => { const response = await client.post('myotherpath', { name: 'Bill' }) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/myotherpath', + url: 'https://example.com/base/path/myotherpath', method: 'post', headers: { ...headers, @@ -521,7 +544,7 @@ describe('EndpointClient', () => { const response = await client.put('myotherpath', { name: 'Bill' }) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/myotherpath', + url: 'https://example.com/base/path/myotherpath', method: 'put', headers: { ...headers, @@ -540,7 +563,7 @@ describe('EndpointClient', () => { const response = await client.patch('path3', { name: 'Joe' }) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/path3', + url: 'https://example.com/base/path/path3', method: 'patch', headers: { ...headers, @@ -559,7 +582,7 @@ describe('EndpointClient', () => { const response = await client.delete('path3') expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ - url: 'https://api.smartthings.com/base/path/path3', + url: 'https://example.com/base/path/path3', method: 'delete', headers: { ...headers, @@ -577,6 +600,7 @@ describe('EndpointClient', () => { const client = new EndpointClient('paged-thing', { authenticator: new NoOpAuthenticator(), logger: new NoLogLogger(), + urlProvider: { baseURL: 'https://example.com' }, }) client.get = getMock @@ -595,17 +619,18 @@ describe('EndpointClient', () => { }) it('combines multiple pages', async () => { + const nextURL = 'https://example.com/paged-thing/next-url' const params = { paramName: 'param-value' } const options = { dryRun: false } getMock - .mockResolvedValueOnce({ items: [item1], _links: { next: { href: 'next-url' } } }) + .mockResolvedValueOnce({ items: [item1], _links: { next: { href: nextURL } } }) .mockResolvedValueOnce({ items: [item2] }) expect(await client.getPagedItems('first-url', params, options)).toEqual([item1, item2]) expect(getMock).toHaveBeenCalledTimes(2) expect(getMock).toHaveBeenCalledWith('first-url', params, options) - expect(getMock).toHaveBeenCalledWith('next-url', undefined, options) + expect(getMock).toHaveBeenCalledWith(nextURL, undefined, options) }) }) @@ -626,10 +651,9 @@ describe('EndpointClient', () => { test('expired token request with mutex', async () => { // TODO -- actually test mutex?? const mutex = new Mutex() - const mutexConfig = { + const mutexConfig: EndpointClientConfig = { authenticator: new SequentialRefreshTokenAuthenticator(token, tokenStore, mutex), - baseURL: 'https://api.smartthings.com', - authURL: 'https://auth.smartthings.com', + urlProvider: globalSmartThingsURLProvider, headers: { ...headers }, } const mutexClient = buildClient(mutexConfig) @@ -687,6 +711,7 @@ describe('EndpointClient', () => { const bearerToken = '00000000-0000-0000-0000-000000000000' const config: EndpointClientConfig = { authenticator: new BearerTokenAuthenticator(bearerToken), + urlProvider: globalSmartThingsURLProvider, logger: new NoLogLogger, } const bearerClient = new EndpointClient('basePath', config) @@ -707,6 +732,7 @@ describe('EndpointClient', () => { } const config: EndpointClientConfig = { authenticator: new BasicAuthenticator, + urlProvider: globalSmartThingsURLProvider, logger: new NoLogLogger(), } const basicClient = new EndpointClient('basePath', config) diff --git a/test/unit/hubdevices.test.ts b/test/unit/hubdevices.test.ts index 7057b404..9d2eca09 100644 --- a/test/unit/hubdevices.test.ts +++ b/test/unit/hubdevices.test.ts @@ -15,7 +15,10 @@ describe('HubdevicesEndpoint', () => { const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') const authenticator = new NoOpAuthenticator() - const hubdevicesEndpoint = new HubdevicesEndpoint({ authenticator }) + const hubdevicesEndpoint = new HubdevicesEndpoint({ + authenticator, + urlProvider: { baseURL: 'https://example.com/baseURL' }, + }) test('get', async () => { putSpy.mockImplementationOnce(() => Promise.resolve()) diff --git a/test/unit/invites-schemaApp.test.ts b/test/unit/invites-schemaApp.test.ts index 21378303..3eea6bd2 100644 --- a/test/unit/invites-schemaApp.test.ts +++ b/test/unit/invites-schemaApp.test.ts @@ -12,7 +12,10 @@ const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').m const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') const authenticator = new NoOpAuthenticator() -const invitesEndpoint = new InvitesSchemaAppEndpoint( { authenticator }) +const invitesEndpoint = new InvitesSchemaAppEndpoint({ + authenticator, + urlProvider: { baseURL: 'https://example.com/baseURL' }, +}) test('create', async () => { const invitationId = { invitationId: 'my-invitation-id' } diff --git a/test/unit/locations.test.ts b/test/unit/locations.test.ts index fe3174c7..09308756 100644 --- a/test/unit/locations.test.ts +++ b/test/unit/locations.test.ts @@ -12,7 +12,11 @@ const MOCK_LOCATION_UPDATE = { name: 'locationUpdate' } as LocationUpdate describe('LocationsEndpoint', () => { const authenticator = new NoOpAuthenticator() const locationId = 'locationId' - const locations = new LocationsEndpoint({ authenticator, locationId }) + const locations = new LocationsEndpoint({ + authenticator, + locationId, + urlProvider: { baseURL: 'https://example.com/baseURL' }, + }) const getSpy = jest.spyOn(EndpointClient.prototype, 'get') const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems') diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index 02736d5d..4765fac7 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -18,7 +18,7 @@ describe('RulesEndpoint', () => { .mockReturnValue('final-location-id') const authenticator = new NoOpAuthenticator() - const rulesEndpoint = new RulesEndpoint({ authenticator }) + const rulesEndpoint = new RulesEndpoint({ authenticator, urlProvider: { baseURL: 'https://example.com/baseURL' } }) rulesEndpoint.locationId = locationIdMock const rulesList = [{ id: 'listed-rule' }] as Rule[] diff --git a/test/unit/virtualdevices.test.ts b/test/unit/virtualdevices.test.ts index 2f700363..3584b7f6 100644 --- a/test/unit/virtualdevices.test.ts +++ b/test/unit/virtualdevices.test.ts @@ -18,7 +18,11 @@ describe('VirtualDevicesEndpoint', () => { const postSpy = jest.spyOn(EndpointClient.prototype, 'post').mockImplementation() const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').mockImplementation() - const virtualDevicesEndpoint = new VirtualDevicesEndpoint({ authenticator }) + const baseConfig = { + authenticator, + urlProvider: { baseURL: 'https://example.com/baseURL' }, + } + const virtualDevicesEndpoint = new VirtualDevicesEndpoint(baseConfig) const deviceList = [{ listed: 'device' }] as unknown as Device[] @@ -33,7 +37,10 @@ describe('VirtualDevicesEndpoint', () => { }) it('includes configured locationId', async () => { - const devices = new VirtualDevicesEndpoint({ authenticator, locationId: 'configured-location-id' }) + const devices = new VirtualDevicesEndpoint({ + ...baseConfig, + locationId: 'configured-location-id', + }) expect(await devices.list()).toBe(deviceList) expect(getPagedItemsSpy).toHaveBeenCalledTimes(1)