diff --git a/.eslintrc.js b/.eslintrc.js index 98fed75..bd06f0f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -173,7 +173,8 @@ module.exports = { 'camelcase': 'off', 'dot-notation': 'off', 'new-cap': 'off', - 'no-shadow': 'off' + 'no-shadow': 'off', + 'prefer-promise-reject-errors': 'off' } } ], diff --git a/package-lock.json b/package-lock.json index 51d1a7c..9db35b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1435,9 +1435,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true, "funding": [ { @@ -6277,9 +6277,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", "dev": true }, "caseless": { diff --git a/src/Errors.ts b/src/Errors.ts index 4218e5d..2d2d835 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -15,6 +15,13 @@ export class UnauthorizedDeviceResponseError extends Error { } } +export class EcpNetworkAccessModeDisabledError extends Error { + constructor(message: string, public results?: any) { + super(message); + Object.setPrototypeOf(this, EcpNetworkAccessModeDisabledError.prototype); + } +} + export class UnparsableDeviceResponseError extends Error { constructor(message: string, public results?: any) { super(message); diff --git a/src/RokuDeploy.spec.ts b/src/RokuDeploy.spec.ts index e7931e7..6a755fd 100644 --- a/src/RokuDeploy.spec.ts +++ b/src/RokuDeploy.spec.ts @@ -563,6 +563,130 @@ describe('RokuDeploy', () => { } assert.fail('Exception should have been thrown'); }); + + it('handles all error scenarios in catch block', async () => { + const doGetRequestStub = sinon.stub(rokuDeploy as any, 'doGetRequest'); + + doGetRequestStub.rejects({ results: { response: { headers: { server: 'Roku' } } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.instanceof(errors.EcpNetworkAccessModeDisabledError); + } + + doGetRequestStub.rejects({ results: { response: { headers: { server: 'Apache' } } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers.server).to.equal('Apache'); + } + + doGetRequestStub.rejects({ results: { response: { headers: {} } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers.server).to.be.undefined; + } + + doGetRequestStub.rejects({ results: { response: { headers: { server: null } } } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers.server).to.be.null; + } + + doGetRequestStub.rejects({ results: { response: {} } }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response.headers).to.be.undefined; + } + + doGetRequestStub.rejects({ results: {} }); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results.response).to.be.undefined; + } + + doGetRequestStub.rejects({}); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect((e as any).results).to.be.undefined; + } + + const err = new Error('Network error'); + doGetRequestStub.rejects(err); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.equal(err); + } + + // eslint-disable-next-line prefer-promise-reject-errors + doGetRequestStub.callsFake(() => Promise.reject(null)); + try { + await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.null; + } + }); + }); + + describe('getEcpNetworkAccessMode', () => { + it('returns ecpSettingMode from device info', async () => { + sinon.stub(rokuDeploy, 'getDeviceInfo').resolves({ ecpSettingMode: 'enabled' }); + const result = await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' }); + expect(result).to.equal('enabled'); + }); + + it(`returns 'disabled' when response header had Roku in it`, async () => { + const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo'); + getDeviceInfoStub.rejects({ results: { response: { headers: { server: 'Roku' } } } }); + expect(await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' })).to.equal('disabled'); + }); + + it('handles all error scenarios in catch block', async () => { + const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo'); + async function doTest(rejectionValue: any) { + getDeviceInfoStub.rejects(rejectionValue); + try { + await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.instanceof(errors.UnknownDeviceResponseError); + } + } + + await doTest({ results: { response: { headers: { server: 'Apache' } } } }); + await doTest({ results: { response: { headers: {} } } }); + await doTest({ results: { response: { headers: { server: null } } } }); + await doTest({ results: { response: {} } }); + await doTest({ results: {} }); + await doTest({}); + await doTest(new Error('Network error')); + }); + + it('handles null error from rejected promise', async () => { + const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo'); + getDeviceInfoStub.callsFake(() => Promise.reject(null)); + try { + await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' }); + assert.fail('Exception should have been thrown'); + } catch (e) { + expect(e).to.be.instanceof(errors.UnknownDeviceResponseError); + } + }); }); describe('normalizeDeviceInfoFieldValue', () => { diff --git a/src/RokuDeploy.ts b/src/RokuDeploy.ts index f6033ef..cdc7106 100644 --- a/src/RokuDeploy.ts +++ b/src/RokuDeploy.ts @@ -1196,10 +1196,18 @@ export class RokuDeploy { const url = `http://${options.host}:${options.remotePort}/query/device-info`; - let response = await this.doGetRequest({ - url: url, - timeout: options.timeout - }); + let response; + try { + response = await this.doGetRequest({ + url: url, + timeout: options.timeout + }); + } catch (e) { + if ((e as any)?.results?.response?.headers?.server?.includes('Roku')) { + throw new errors.EcpNetworkAccessModeDisabledError(`Unable to access device-info because ecp-setting-mode is 'disabled'`, response); + } + throw e; + } try { const parsedContent = await xml2js.parseStringPromise(response.body, { explicitArray: false @@ -1223,6 +1231,30 @@ export class RokuDeploy { } } + /** + * Get the External Control Protocol (ECP) setting mode of the device. This determines whether + * the device accepts remote control commands via the ECP API. + * + * @param options - Configuration options including host, remotePort, timeout, etc. + * @returns The ECP setting mode: + * - 'enabled': fully enabled and accepting commands + * - 'disabled': ECP is disabled (device may still be reachable but ECP commands won't work) + * - 'limited': Restricted functionality, text and movement commands only + * - 'permissive': Full access for internal networks + */ + public async getEcpNetworkAccessMode(options: GetDeviceInfoOptions): Promise { + try { + const deviceInfo = await this.getDeviceInfo(options); + return deviceInfo.ecpSettingMode; + } catch (e) { + if ((e as any)?.results?.response?.headers?.server?.includes('Roku')) { + return 'disabled'; + } + throw new errors.UnknownDeviceResponseError('Could not retrieve device ECP setting'); + } + + } + /** * Normalize a deviceInfo field value. This includes things like converting boolean strings to booleans, number strings to numbers, * decoding HtmlEntities, etc. @@ -1488,3 +1520,5 @@ export interface GetDeviceInfoOptions { */ enhance?: boolean; } + +export type EcpNetworkAccessMode = 'enabled' | 'disabled' | 'limited' | 'permissive';