From d6d739652cd1c0b27c443d6852f10ef9db4fbdbf Mon Sep 17 00:00:00 2001 From: Krist Wongsuphasawat Date: Fri, 7 Dec 2018 16:56:57 -0800 Subject: [PATCH] Separate SupersetClient and SupersetClientClass (#53) feat: Export `SupersetClientClass` internal: Increase test coverage. --- package.json | 1 + .../src/SupersetClient.ts | 207 +------ .../src/SupersetClientClass.ts | 189 ++++++ .../src/{index.js => index.ts} | 1 + .../test/SupersetClient.test.ts | 575 ++---------------- .../test/SupersetClientClass.test.ts | 474 +++++++++++++++ .../test/callApi/parseResponse.test.js | 37 ++ 7 files changed, 769 insertions(+), 715 deletions(-) create mode 100644 packages/superset-ui-connection/src/SupersetClientClass.ts rename packages/superset-ui-connection/src/{index.js => index.ts} (68%) create mode 100644 packages/superset-ui-connection/test/SupersetClientClass.test.ts create mode 100644 packages/superset-ui-connection/test/callApi/parseResponse.test.js diff --git a/package.json b/package.json index 40a6db268..59efae32d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:esm": "NODE_ENV=production beemo babel ./src --out-dir esm/ --esm --minify --workspaces=\"@superset-ui/!(demo|generator-superset)\"", "build:ts": "NODE_ENV=production beemo typescript --workspaces=\"@superset-ui/(connection|chart)\"", "lint": "beemo create-config prettier && beemo eslint \"./packages/*/{src,test,storybook}/**/*.{js,jsx,ts,tsx}\"", + "lint:fix": "beemo create-config prettier && beemo eslint --fix \"./packages/*/{src,test,storybook}/**/*.{js,jsx,ts,tsx}\"", "jest": "beemo jest --color --coverage --react", "postrelease": "lerna run gh-pages", "prepare-release": "git checkout master && git pull --rebase origin master && lerna bootstrap && yarn run test", diff --git a/packages/superset-ui-connection/src/SupersetClient.ts b/packages/superset-ui-connection/src/SupersetClient.ts index 1158e52f1..6c048f743 100644 --- a/packages/superset-ui-connection/src/SupersetClient.ts +++ b/packages/superset-ui-connection/src/SupersetClient.ts @@ -1,196 +1,11 @@ -import callApi from './callApi'; -import { - ClientTimeout, - Credentials, - Headers, - Host, - Mode, - SupersetClientResponse, - RequestConfig, -} from './types'; +import { ClientConfig, SupersetClientClass } from './SupersetClientClass'; +import { RequestConfig } from './types'; -type CsrfToken = string; -type CsrfPromise = Promise; -type Protocol = 'http:' | 'https:'; +let singletonClient: SupersetClientClass | undefined; -export interface ClientConfig { - credentials?: Credentials; - csrfToken?: CsrfToken; - headers?: Headers; - host?: Host; - protocol?: Protocol; - mode?: Mode; - timeout?: ClientTimeout; -} - -class SupersetClient { - credentials: Credentials; - csrfToken?: CsrfToken; - csrfPromise?: CsrfPromise; - protocol: Protocol; - host: Host; - headers: Headers; - mode: Mode; - timeout: ClientTimeout; - - constructor({ - protocol = 'http:', - host = 'localhost', - headers = {}, - mode = 'same-origin', - timeout, - credentials = undefined, - csrfToken = undefined, - }: ClientConfig = {}) { - this.headers = { ...headers }; - this.host = host; - this.mode = mode; - this.timeout = timeout; - this.protocol = protocol; - this.credentials = credentials; - this.csrfToken = csrfToken; - this.csrfPromise = undefined; - - if (typeof this.csrfToken === 'string') { - this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken }; - this.csrfPromise = Promise.resolve(this.csrfToken); - } - } - - init(force: boolean = false) { - if (this.isAuthenticated() && !force) { - return this.csrfPromise; - } - - return this.getCSRFToken(); - } - - isAuthenticated() { - // if CSRF protection is disabled in the Superset app, the token may be an empty string - return this.csrfToken !== null && this.csrfToken !== undefined; - } - - async get({ - body, - credentials, - headers, - host, - endpoint, - mode, - parseMethod, - signal, - timeout, - url, - }: RequestConfig): Promise { - return this.ensureAuth().then(() => - callApi({ - body, - credentials: credentials || this.credentials, - headers: { ...this.headers, ...headers }, - method: 'GET', - mode: mode || this.mode, - parseMethod, - signal, - timeout: timeout || this.timeout, - url: this.getUrl({ endpoint, host, url }), - }), - ); - } - - async post({ - credentials, - endpoint, - headers, - host, - mode, - parseMethod, - postPayload, - signal, - stringify, - timeout, - url, - }: RequestConfig): Promise { - return this.ensureAuth().then(() => - callApi({ - credentials: credentials || this.credentials, - headers: { ...this.headers, ...headers }, - method: 'POST', - mode: mode || this.mode, - parseMethod, - postPayload, - signal, - stringify, - timeout: timeout || this.timeout, - url: this.getUrl({ endpoint, host, url }), - }), - ); - } - - ensureAuth() { - return ( - this.csrfPromise || - Promise.reject({ - error: `SupersetClient has no CSRF token, ensure it is initialized or - try logging into the Superset instance at ${this.getUrl({ - endpoint: '/login', - })}`, - }) - ); - } - - async getCSRFToken() { - this.csrfToken = undefined; - - // If we can request this resource successfully, it means that the user has - // authenticated. If not we throw an error prompting to authenticate. - this.csrfPromise = callApi({ - credentials: this.credentials, - headers: { - ...this.headers, - }, - method: 'GET', - mode: this.mode, - timeout: this.timeout, - url: this.getUrl({ endpoint: 'superset/csrf_token/' }), - }).then(response => { - if (typeof response.json === 'object') { - this.csrfToken = response.json.csrf_token; - if (typeof this.csrfToken === 'string') { - this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken }; - } - } - - if (!this.isAuthenticated()) { - return Promise.reject({ error: 'Failed to fetch CSRF token' }); - } - - return Promise.resolve(this.csrfToken); - }); - - return this.csrfPromise; - } - - getUrl({ - host: inputHost, - endpoint = '', - url, - }: { - endpoint?: string; - host?: Host; - url?: string; - } = {}) { - if (typeof url === 'string') return url; - - const host = inputHost || this.host; - const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash - - return `${this.protocol}//${cleanHost}/${endpoint[0] === '/' ? endpoint.slice(1) : endpoint}`; - } -} - -let singletonClient: SupersetClient | undefined; - -function hasInstance(maybeClient: SupersetClient | undefined): maybeClient is SupersetClient { +function hasInstance( + maybeClient: SupersetClientClass | undefined, +): maybeClient is SupersetClientClass { if (!maybeClient) { throw new Error('You must call SupersetClient.configure(...) before calling other methods'); } @@ -198,9 +13,9 @@ function hasInstance(maybeClient: SupersetClient | undefined): maybeClient is Su return true; } -const PublicAPI = { - configure: (config: ClientConfig = {}): SupersetClient => { - singletonClient = new SupersetClient(config); +const SupersetClient = { + configure: (config: ClientConfig = {}): SupersetClientClass => { + singletonClient = new SupersetClientClass(config); return singletonClient; }, @@ -214,6 +29,4 @@ const PublicAPI = { }, }; -export { SupersetClient }; - -export default PublicAPI; +export default SupersetClient; diff --git a/packages/superset-ui-connection/src/SupersetClientClass.ts b/packages/superset-ui-connection/src/SupersetClientClass.ts new file mode 100644 index 000000000..38147c36c --- /dev/null +++ b/packages/superset-ui-connection/src/SupersetClientClass.ts @@ -0,0 +1,189 @@ +import callApi from './callApi'; +import { + ClientTimeout, + Credentials, + Headers, + Host, + Mode, + SupersetClientResponse, + RequestConfig, +} from './types'; + +type CsrfToken = string; +type CsrfPromise = Promise; +type Protocol = 'http:' | 'https:'; + +export interface ClientConfig { + credentials?: Credentials; + csrfToken?: CsrfToken; + headers?: Headers; + host?: Host; + protocol?: Protocol; + mode?: Mode; + timeout?: ClientTimeout; +} + +export class SupersetClientClass { + credentials: Credentials; + csrfToken?: CsrfToken; + csrfPromise?: CsrfPromise; + protocol: Protocol; + host: Host; + headers: Headers; + mode: Mode; + timeout: ClientTimeout; + + constructor({ + protocol = 'http:', + host = 'localhost', + headers = {}, + mode = 'same-origin', + timeout, + credentials = undefined, + csrfToken = undefined, + }: ClientConfig = {}) { + this.headers = { ...headers }; + this.host = host; + this.mode = mode; + this.timeout = timeout; + this.protocol = protocol; + this.credentials = credentials; + this.csrfToken = csrfToken; + this.csrfPromise = undefined; + + if (typeof this.csrfToken === 'string') { + this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken }; + this.csrfPromise = Promise.resolve(this.csrfToken); + } + } + + init(force: boolean = false): CsrfPromise { + if (this.isAuthenticated() && !force) { + return this.csrfPromise as CsrfPromise; + } + + return this.getCSRFToken(); + } + + isAuthenticated(): boolean { + // if CSRF protection is disabled in the Superset app, the token may be an empty string + return this.csrfToken !== null && this.csrfToken !== undefined; + } + + async get({ + body, + credentials, + headers, + host, + endpoint, + mode, + parseMethod, + signal, + timeout, + url, + }: RequestConfig): Promise { + return this.ensureAuth().then(() => + callApi({ + body, + credentials: credentials || this.credentials, + headers: { ...this.headers, ...headers }, + method: 'GET', + mode: mode || this.mode, + parseMethod, + signal, + timeout: timeout || this.timeout, + url: this.getUrl({ endpoint, host, url }), + }), + ); + } + + async post({ + credentials, + endpoint, + headers, + host, + mode, + parseMethod, + postPayload, + signal, + stringify, + timeout, + url, + }: RequestConfig): Promise { + return this.ensureAuth().then(() => + callApi({ + credentials: credentials || this.credentials, + headers: { ...this.headers, ...headers }, + method: 'POST', + mode: mode || this.mode, + parseMethod, + postPayload, + signal, + stringify, + timeout: timeout || this.timeout, + url: this.getUrl({ endpoint, host, url }), + }), + ); + } + + ensureAuth(): CsrfPromise { + return ( + this.csrfPromise || + Promise.reject({ + error: `SupersetClient has no CSRF token, ensure it is initialized or + try logging into the Superset instance at ${this.getUrl({ + endpoint: '/login', + })}`, + }) + ); + } + + async getCSRFToken(): CsrfPromise { + this.csrfToken = undefined; + + // If we can request this resource successfully, it means that the user has + // authenticated. If not we throw an error prompting to authenticate. + this.csrfPromise = callApi({ + credentials: this.credentials, + headers: { + ...this.headers, + }, + method: 'GET', + mode: this.mode, + timeout: this.timeout, + url: this.getUrl({ endpoint: 'superset/csrf_token/' }), + }).then(response => { + if (typeof response.json === 'object') { + this.csrfToken = response.json.csrf_token; + if (typeof this.csrfToken === 'string') { + this.headers = { ...this.headers, 'X-CSRFToken': this.csrfToken }; + } + } + + if (!this.isAuthenticated()) { + return Promise.reject({ error: 'Failed to fetch CSRF token' }); + } + + return Promise.resolve(this.csrfToken); + }); + + return this.csrfPromise; + } + + getUrl({ + host: inputHost, + endpoint = '', + url, + }: { + endpoint?: string; + host?: Host; + url?: string; + } = {}) { + if (typeof url === 'string') return url; + + const host = inputHost || this.host; + const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash + + return `${this.protocol}//${cleanHost}/${endpoint[0] === '/' ? endpoint.slice(1) : endpoint}`; + } +} diff --git a/packages/superset-ui-connection/src/index.js b/packages/superset-ui-connection/src/index.ts similarity index 68% rename from packages/superset-ui-connection/src/index.js rename to packages/superset-ui-connection/src/index.ts index 3ab9bf6b6..04f003d37 100644 --- a/packages/superset-ui-connection/src/index.js +++ b/packages/superset-ui-connection/src/index.ts @@ -1,3 +1,4 @@ export { default as callApi } from './callApi'; export { default as SupersetClient } from './SupersetClient'; +export { SupersetClientClass } from './SupersetClientClass'; export * from './types'; diff --git a/packages/superset-ui-connection/test/SupersetClient.test.ts b/packages/superset-ui-connection/test/SupersetClient.test.ts index ac80e62a4..34e40bb8d 100644 --- a/packages/superset-ui-connection/test/SupersetClient.test.ts +++ b/packages/superset-ui-connection/test/SupersetClient.test.ts @@ -1,7 +1,6 @@ import fetchMock from 'fetch-mock'; -import PublicAPI, { SupersetClient, ClientConfig } from '../src/SupersetClient'; -import throwIfCalled from './utils/throwIfCalled'; +import { SupersetClient, SupersetClientClass } from '../src'; import { LOGIN_GLOB } from './fixtures/constants'; describe('SupersetClient', () => { @@ -11,526 +10,66 @@ describe('SupersetClient', () => { afterAll(fetchMock.restore); - afterEach(PublicAPI.reset); + afterEach(SupersetClient.reset); - describe('Public API', () => { - it('exposes reset, configure, init, get, post, isAuthenticated, and reAuthenticate methods', () => { - expect(PublicAPI.configure).toEqual(expect.any(Function)); - expect(PublicAPI.init).toEqual(expect.any(Function)); - expect(PublicAPI.get).toEqual(expect.any(Function)); - expect(PublicAPI.post).toEqual(expect.any(Function)); - expect(PublicAPI.isAuthenticated).toEqual(expect.any(Function)); - expect(PublicAPI.reAuthenticate).toEqual(expect.any(Function)); - expect(PublicAPI.reset).toEqual(expect.any(Function)); - }); - - it('throws if you call init, get, post, isAuthenticated, or reAuthenticate before configure', () => { - expect(PublicAPI.init).toThrow(); - expect(PublicAPI.get).toThrow(); - expect(PublicAPI.post).toThrow(); - expect(PublicAPI.isAuthenticated).toThrow(); - expect(PublicAPI.reAuthenticate).toThrow(); - - expect(PublicAPI.configure).not.toThrow(); - }); - - // this also tests that the ^above doesn't throw if configure is called appropriately - it('calls appropriate SupersetClient methods when configured', () => { - const mockGetUrl = '/mock/get/url'; - const mockPostUrl = '/mock/post/url'; - const mockGetPayload = { get: 'payload' }; - const mockPostPayload = { post: 'payload' }; - fetchMock.get(mockGetUrl, mockGetPayload); - fetchMock.post(mockPostUrl, mockPostPayload); - - const initSpy = jest.spyOn(SupersetClient.prototype, 'init'); - const getSpy = jest.spyOn(SupersetClient.prototype, 'get'); - const postSpy = jest.spyOn(SupersetClient.prototype, 'post'); - const authenticatedSpy = jest.spyOn(SupersetClient.prototype, 'isAuthenticated'); - const csrfSpy = jest.spyOn(SupersetClient.prototype, 'getCSRFToken'); - - PublicAPI.configure({}); - PublicAPI.init(); - - expect(initSpy).toHaveBeenCalledTimes(1); - expect(authenticatedSpy).toHaveBeenCalledTimes(1); - expect(csrfSpy).toHaveBeenCalledTimes(1); - - PublicAPI.get({ url: mockGetUrl }); - PublicAPI.post({ url: mockPostUrl }); - PublicAPI.isAuthenticated(); - PublicAPI.reAuthenticate(); - - expect(initSpy).toHaveBeenCalledTimes(2); - expect(getSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledTimes(1); - expect(csrfSpy).toHaveBeenCalledTimes(2); // from init() + reAuthenticate() - - initSpy.mockRestore(); - getSpy.mockRestore(); - postSpy.mockRestore(); - authenticatedSpy.mockRestore(); - csrfSpy.mockRestore(); - - fetchMock.reset(); - }); + it('exposes reset, configure, init, get, post, isAuthenticated, and reAuthenticate methods', () => { + expect(SupersetClient.configure).toEqual(expect.any(Function)); + expect(SupersetClient.init).toEqual(expect.any(Function)); + expect(SupersetClient.get).toEqual(expect.any(Function)); + expect(SupersetClient.post).toEqual(expect.any(Function)); + expect(SupersetClient.isAuthenticated).toEqual(expect.any(Function)); + expect(SupersetClient.reAuthenticate).toEqual(expect.any(Function)); + expect(SupersetClient.reset).toEqual(expect.any(Function)); }); - describe('SupersetClient', () => { - describe('getUrl', () => { - let client; - beforeEach(() => { - client = new SupersetClient({ protocol: 'https:', host: 'CONFIG_HOST' }); - }); - - it('uses url if passed', () => { - expect(client.getUrl({ url: 'myUrl', endpoint: 'blah', host: 'blah' })).toBe('myUrl'); - }); - - it('constructs a valid url from config.protocol + host + endpoint if passed', () => { - expect(client.getUrl({ endpoint: '/test', host: 'myhost' })).toBe('https://myhost/test'); - expect(client.getUrl({ endpoint: '/test', host: 'myhost/' })).toBe('https://myhost/test'); - expect(client.getUrl({ endpoint: 'test', host: 'myhost' })).toBe('https://myhost/test'); - expect(client.getUrl({ endpoint: '/test/test//', host: 'myhost/' })).toBe( - 'https://myhost/test/test//', - ); - }); - - it('constructs a valid url from config.host + endpoint if host is omitted', () => { - expect(client.getUrl({ endpoint: '/test' })).toBe('https://CONFIG_HOST/test'); - }); - - it('does not throw if url, endpoint, and host are', () => { - client = new SupersetClient({ protocol: 'https:', host: '' }); - expect(client.getUrl()).toBe('https:///'); - }); - }); - - describe('CSRF', () => { - afterEach(fetchMock.reset); - - it('calls superset/csrf_token/ when init() is called if no CSRF token is passed', () => { - expect.assertions(1); - const client = new SupersetClient({}); - - return client.init().then(() => { - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1); - - return Promise.resolve(); - }); - }); - - it('does NOT call superset/csrf_token/ when init() is called if a CSRF token is passed', () => { - expect.assertions(1); - const client = new SupersetClient({ csrfToken: 'abc' }); - - return client.init().then(() => { - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0); - - return Promise.resolve(); - }); - }); - - it('calls superset/csrf_token/ when init(force=true) is called even if a CSRF token is passed', () => { - expect.assertions(4); - const initialToken = 'inital_token'; - const client = new SupersetClient({ csrfToken: initialToken }); - - return client.init().then(() => { - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0); - expect(client.csrfToken).toBe(initialToken); - - return client.init(true).then(() => { - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1); - expect(client.csrfToken).not.toBe(initialToken); - - return Promise.resolve(); - }); - }); - }); - - it('isAuthenticated() returns true if there is a token and false if not', () => { - expect.assertions(2); - const client = new SupersetClient({}); - expect(client.isAuthenticated()).toBe(false); - - return client.init().then(() => { - expect(client.isAuthenticated()).toBe(true); - - return Promise.resolve(); - }); - }); - - it('isAuthenticated() returns true if a token is passed at configuration', () => { - expect.assertions(2); - const clientWithoutToken = new SupersetClient({ csrfToken: null }); - const clientWithToken = new SupersetClient({ csrfToken: 'token' }); - - expect(clientWithoutToken.isAuthenticated()).toBe(false); - expect(clientWithToken.isAuthenticated()).toBe(true); - }); - - it('init() throws if superset/csrf_token/ returns an error', () => { - expect.assertions(1); - - fetchMock.get(LOGIN_GLOB, () => Promise.reject({ status: 403 }), { - overwriteRoutes: true, - }); - - const client = new SupersetClient({}); - - return client - .init() - .then(throwIfCalled) - .catch(error => { - expect(error.status).toBe(403); - - // reset - fetchMock.get( - LOGIN_GLOB, - { csrf_token: '1234' }, - { - overwriteRoutes: true, - }, - ); - - return Promise.resolve(); - }); - }); - - it('init() throws if superset/csrf_token/ does not return a token', () => { - expect.assertions(1); - fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true }); - - const client = new SupersetClient({}); - - return client - .init() - .then(throwIfCalled) - .catch(error => { - expect(error).toBeDefined(); - - // reset - fetchMock.get( - LOGIN_GLOB, - { csrf_token: 1234 }, - { - overwriteRoutes: true, - }, - ); - - return Promise.resolve(); - }); - }); - }); - - describe('CSRF queuing', () => { - it(`client.ensureAuth() returns a promise that rejects init() has not been called`, () => { - expect.assertions(2); - - const client = new SupersetClient({}); - - return client - .ensureAuth() - .then(throwIfCalled) - .catch(error => { - expect(error).toEqual(expect.objectContaining({ error: expect.any(String) })); - expect(client.isAuthenticated()).toBe(false); - - return Promise.resolve(); - }); - }); - - it('client.ensureAuth() returns a promise that resolves if client.init() resolves successfully', () => { - expect.assertions(1); - - const client = new SupersetClient({}); + it('throws if you call init, get, post, isAuthenticated, or reAuthenticate before configure', () => { + expect(SupersetClient.init).toThrow(); + expect(SupersetClient.get).toThrow(); + expect(SupersetClient.post).toThrow(); + expect(SupersetClient.isAuthenticated).toThrow(); + expect(SupersetClient.reAuthenticate).toThrow(); - return client.init().then(() => - client - .ensureAuth() - .then(throwIfCalled) - .catch(() => { - expect(client.isAuthenticated()).toBe(true); - - return Promise.resolve(); - }), - ); - }); - - it(`client.ensureAuth() returns a promise that rejects if init() is unsuccessful`, () => { - const rejectValue = { status: 403 }; - fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), { - overwriteRoutes: true, - }); - - expect.assertions(3); - - const client = new SupersetClient({}); - - return client - .init() - .then(throwIfCalled) - .catch(error => { - expect(error).toEqual(expect.objectContaining(rejectValue)); - - return client - .ensureAuth() - .then(throwIfCalled) - .catch(error2 => { - expect(error2).toEqual(expect.objectContaining(rejectValue)); - expect(client.isAuthenticated()).toBe(false); - - // reset - fetchMock.get( - LOGIN_GLOB, - { csrf_token: 1234 }, - { - overwriteRoutes: true, - }, - ); - - return Promise.resolve(); - }); - }); - }); - }); - - describe('requests', () => { - afterEach(fetchMock.reset); - const protocol = 'https:'; - const host = 'HOST'; - const mockGetEndpoint = '/get/url'; - const mockPostEndpoint = '/post/url'; - const mockTextEndpoint = '/text/endpoint'; - const mockGetUrl = `${protocol}//${host}${mockGetEndpoint}`; - const mockPostUrl = `${protocol}//${host}${mockPostEndpoint}`; - const mockTextUrl = `${protocol}//${host}${mockTextEndpoint}`; - const mockTextJsonResponse = '{ "value": 9223372036854775807 }'; - - fetchMock.get(mockGetUrl, { json: 'payload' }); - fetchMock.post(mockPostUrl, { json: 'payload' }); - fetchMock.get(mockTextUrl, mockTextJsonResponse); - fetchMock.post(mockTextUrl, mockTextJsonResponse); - - it('checks for authentication before every get and post request', () => { - expect.assertions(3); - const authSpy = jest.spyOn(SupersetClient.prototype, 'ensureAuth'); - const client = new SupersetClient({ protocol, host }); - - return client.init().then(() => - Promise.all([client.get({ url: mockGetUrl }), client.post({ url: mockPostUrl })]).then( - () => { - expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); - expect(authSpy).toHaveBeenCalledTimes(2); - authSpy.mockRestore(); - - return Promise.resolve(); - }, - ), - ); - }); - - it('sets protocol, host, headers, mode, and credentials from config', () => { - expect.assertions(3); - const clientConfig: ClientConfig = { - host, - protocol, - mode: 'cors', - credentials: 'include', - headers: { my: 'header' }, - }; - - const client = new SupersetClient(clientConfig); - - return client.init().then(() => - client.get({ url: mockGetUrl }).then(() => { - const fetchRequest = fetchMock.calls(mockGetUrl)[0][1]; - expect(fetchRequest.mode).toBe(clientConfig.mode); - expect(fetchRequest.credentials).toBe(clientConfig.credentials); - expect(fetchRequest.headers).toEqual(expect.objectContaining(clientConfig.headers)); - - return Promise.resolve(); - }), - ); - }); - - describe('GET', () => { - it('makes a request using url or endpoint', () => { - expect.assertions(1); - const client = new SupersetClient({ protocol, host }); - - return client.init().then(() => - Promise.all([ - client.get({ url: mockGetUrl }), - client.get({ endpoint: mockGetEndpoint }), - ]).then(() => { - expect(fetchMock.calls(mockGetUrl)).toHaveLength(2); - - return Promise.resolve(); - }), - ); - }); - - it('supports parsing a response as text', () => { - expect.assertions(2); - const client = new SupersetClient({ protocol, host }); - - return client - .init() - .then(() => - client - .get({ url: mockTextUrl, parseMethod: 'text' }) - .then(({ text }) => { - expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); - expect(text).toBe(mockTextJsonResponse); - - return Promise.resolve(); - }) - .catch(throwIfCalled), - ) - .catch(throwIfCalled); - }); - - it('allows overriding host, headers, mode, and credentials per-request', () => { - expect.assertions(3); - const clientConfig: ClientConfig = { - host, - protocol, - mode: 'cors', - credentials: 'include', - headers: { my: 'header' }, - }; - - const overrideConfig: ClientConfig = { - host: 'override_host', - mode: 'no-cors', - credentials: 'omit', - headers: { my: 'override', another: 'header' }, - }; - - const client = new SupersetClient(clientConfig); - - return client - .init() - .then(() => - client - .get({ url: mockGetUrl, ...overrideConfig }) - .then(() => { - const fetchRequest = fetchMock.calls(mockGetUrl)[0][1]; - expect(fetchRequest.mode).toBe(overrideConfig.mode); - expect(fetchRequest.credentials).toBe(overrideConfig.credentials); - expect(fetchRequest.headers).toEqual( - expect.objectContaining(overrideConfig.headers), - ); - - return Promise.resolve(); - }) - .catch(throwIfCalled), - ) - .catch(throwIfCalled); - }); - }); - - describe('POST', () => { - it('makes a request using url or endpoint', () => { - expect.assertions(1); - const client = new SupersetClient({ protocol, host }); - - return client.init().then(() => - Promise.all([ - client.post({ url: mockPostUrl }), - client.post({ endpoint: mockPostEndpoint }), - ]).then(() => { - expect(fetchMock.calls(mockPostUrl)).toHaveLength(2); - - return Promise.resolve(); - }), - ); - }); - - it('allows overriding host, headers, mode, and credentials per-request', () => { - const clientConfig: ClientConfig = { - host, - protocol, - mode: 'cors', - credentials: 'include', - headers: { my: 'header' }, - }; - - const overrideConfig: ClientConfig = { - host: 'override_host', - mode: 'no-cors', - credentials: 'omit', - headers: { my: 'override', another: 'header' }, - }; - - const client = new SupersetClient(clientConfig); - - return client.init().then(() => - client.post({ url: mockPostUrl, ...overrideConfig }).then(() => { - const fetchRequest = fetchMock.calls(mockPostUrl)[0][1]; - expect(fetchRequest.mode).toBe(overrideConfig.mode); - expect(fetchRequest.credentials).toBe(overrideConfig.credentials); - expect(fetchRequest.headers).toEqual(expect.objectContaining(overrideConfig.headers)); - - return Promise.resolve(); - }), - ); - }); - - it('supports parsing a response as text', () => { - expect.assertions(2); - const client = new SupersetClient({ protocol, host }); - - return client.init().then(() => - client.post({ url: mockTextUrl, parseMethod: 'text' }).then(({ text }) => { - expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); - expect(text).toBe(mockTextJsonResponse); - - return Promise.resolve(); - }), - ); - }); - - it('passes postPayload key,values in the body', () => { - expect.assertions(3); - - const postPayload = { number: 123, array: [1, 2, 3] }; - const client = new SupersetClient({ protocol, host }); - - return client.init().then(() => - client.post({ url: mockPostUrl, postPayload }).then(() => { - const formData = fetchMock.calls(mockPostUrl)[0][1].body; - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); - Object.keys(postPayload).forEach(key => { - expect(formData.get(key)).toBe(JSON.stringify(postPayload[key])); - }); - - return Promise.resolve(); - }), - ); - }); - - it('respects the stringify parameter for postPayload key,values', () => { - expect.assertions(3); - const postPayload = { number: 123, array: [1, 2, 3] }; - const client = new SupersetClient({ protocol, host }); - - return client.init().then(() => - client.post({ url: mockPostUrl, postPayload, stringify: false }).then(() => { - const formData = fetchMock.calls(mockPostUrl)[0][1].body; - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); - Object.keys(postPayload).forEach(key => { - expect(formData.get(key)).toBe(String(postPayload[key])); - }); + expect(SupersetClient.configure).not.toThrow(); + }); - return Promise.resolve(); - }), - ); - }); - }); - }); + // this also tests that the ^above doesn't throw if configure is called appropriately + it('calls appropriate SupersetClient methods when configured', () => { + const mockGetUrl = '/mock/get/url'; + const mockPostUrl = '/mock/post/url'; + const mockGetPayload = { get: 'payload' }; + const mockPostPayload = { post: 'payload' }; + fetchMock.get(mockGetUrl, mockGetPayload); + fetchMock.post(mockPostUrl, mockPostPayload); + + const initSpy = jest.spyOn(SupersetClientClass.prototype, 'init'); + const getSpy = jest.spyOn(SupersetClientClass.prototype, 'get'); + const postSpy = jest.spyOn(SupersetClientClass.prototype, 'post'); + const authenticatedSpy = jest.spyOn(SupersetClientClass.prototype, 'isAuthenticated'); + const csrfSpy = jest.spyOn(SupersetClientClass.prototype, 'getCSRFToken'); + + SupersetClient.configure({}); + SupersetClient.init(); + + expect(initSpy).toHaveBeenCalledTimes(1); + expect(authenticatedSpy).toHaveBeenCalledTimes(1); + expect(csrfSpy).toHaveBeenCalledTimes(1); + + SupersetClient.get({ url: mockGetUrl }); + SupersetClient.post({ url: mockPostUrl }); + SupersetClient.isAuthenticated(); + SupersetClient.reAuthenticate(); + + expect(initSpy).toHaveBeenCalledTimes(2); + expect(getSpy).toHaveBeenCalledTimes(1); + expect(postSpy).toHaveBeenCalledTimes(1); + expect(csrfSpy).toHaveBeenCalledTimes(2); // from init() + reAuthenticate() + + initSpy.mockRestore(); + getSpy.mockRestore(); + postSpy.mockRestore(); + authenticatedSpy.mockRestore(); + csrfSpy.mockRestore(); + + fetchMock.reset(); }); }); diff --git a/packages/superset-ui-connection/test/SupersetClientClass.test.ts b/packages/superset-ui-connection/test/SupersetClientClass.test.ts new file mode 100644 index 000000000..67ae3b1b8 --- /dev/null +++ b/packages/superset-ui-connection/test/SupersetClientClass.test.ts @@ -0,0 +1,474 @@ +import fetchMock from 'fetch-mock'; + +import { SupersetClientClass, ClientConfig } from '../src/SupersetClientClass'; +import throwIfCalled from './utils/throwIfCalled'; +import { LOGIN_GLOB } from './fixtures/constants'; + +describe('SupersetClientClass', () => { + beforeAll(() => { + fetchMock.get(LOGIN_GLOB, { csrf_token: '' }); + }); + + afterAll(fetchMock.restore); + + describe('new SupersetClientClass()', () => { + const client = new SupersetClientClass(); + expect(client).toBeInstanceOf(SupersetClientClass); + }); + + describe('.getUrl()', () => { + let client; + beforeEach(() => { + client = new SupersetClientClass({ protocol: 'https:', host: 'CONFIG_HOST' }); + }); + + it('uses url if passed', () => { + expect(client.getUrl({ url: 'myUrl', endpoint: 'blah', host: 'blah' })).toBe('myUrl'); + }); + + it('constructs a valid url from config.protocol + host + endpoint if passed', () => { + expect(client.getUrl({ endpoint: '/test', host: 'myhost' })).toBe('https://myhost/test'); + expect(client.getUrl({ endpoint: '/test', host: 'myhost/' })).toBe('https://myhost/test'); + expect(client.getUrl({ endpoint: 'test', host: 'myhost' })).toBe('https://myhost/test'); + expect(client.getUrl({ endpoint: '/test/test//', host: 'myhost/' })).toBe( + 'https://myhost/test/test//', + ); + }); + + it('constructs a valid url from config.host + endpoint if host is omitted', () => { + expect(client.getUrl({ endpoint: '/test' })).toBe('https://CONFIG_HOST/test'); + }); + + it('does not throw if url, endpoint, and host are', () => { + client = new SupersetClientClass({ protocol: 'https:', host: '' }); + expect(client.getUrl()).toBe('https:///'); + }); + }); + + describe('.init()', () => { + afterEach(() => { + fetchMock.reset(); + // reset + fetchMock.get(LOGIN_GLOB, { csrf_token: 1234 }, { overwriteRoutes: true }); + }); + + it('calls superset/csrf_token/ when init() is called if no CSRF token is passed', () => { + expect.assertions(1); + + return new SupersetClientClass().init().then(() => { + expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1); + + return Promise.resolve(); + }); + }); + + it('does NOT call superset/csrf_token/ when init() is called if a CSRF token is passed', () => { + expect.assertions(1); + + return new SupersetClientClass({ csrfToken: 'abc' }).init().then(() => { + expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0); + + return Promise.resolve(); + }); + }); + + it('calls superset/csrf_token/ when init(force=true) is called even if a CSRF token is passed', () => { + expect.assertions(4); + const initialToken = 'initial_token'; + const client = new SupersetClientClass({ csrfToken: initialToken }); + + return client.init().then(() => { + expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0); + expect(client.csrfToken).toBe(initialToken); + + return client.init(true).then(() => { + expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1); + expect(client.csrfToken).not.toBe(initialToken); + + return Promise.resolve(); + }); + }); + }); + + it('throws if superset/csrf_token/ returns an error', () => { + expect.assertions(1); + + fetchMock.get(LOGIN_GLOB, () => Promise.reject({ status: 403 }), { + overwriteRoutes: true, + }); + + return new SupersetClientClass({}) + .init() + .then(throwIfCalled) + .catch(error => { + expect(error.status).toBe(403); + + return Promise.resolve(); + }); + }); + + it('throws if superset/csrf_token/ does not return a token', () => { + expect.assertions(1); + fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true }); + + return new SupersetClientClass({}) + .init() + .then(throwIfCalled) + .catch(error => { + expect(error).toBeDefined(); + + return Promise.resolve(); + }); + }); + + it('does not set csrfToken if response is not json', () => { + fetchMock.get(LOGIN_GLOB, '123', { + overwriteRoutes: true, + sendAsJson: false, + }); + + return new SupersetClientClass({}) + .init() + .then(throwIfCalled) + .catch(error => { + expect(error).toBeDefined(); + + return Promise.resolve(); + }); + }); + }); + + describe('.isAuthenticated()', () => { + afterEach(fetchMock.reset); + + it('returns true if there is a token and false if not', () => { + expect.assertions(2); + const client = new SupersetClientClass({}); + expect(client.isAuthenticated()).toBe(false); + + return client.init().then(() => { + expect(client.isAuthenticated()).toBe(true); + + return Promise.resolve(); + }); + }); + + it('returns true if a token is passed at configuration', () => { + expect.assertions(2); + const clientWithoutToken = new SupersetClientClass({ csrfToken: null }); + const clientWithToken = new SupersetClientClass({ csrfToken: 'token' }); + + expect(clientWithoutToken.isAuthenticated()).toBe(false); + expect(clientWithToken.isAuthenticated()).toBe(true); + }); + }); + + describe('.ensureAuth()', () => { + it(`returns a promise that rejects if .init() has not been called`, () => { + expect.assertions(2); + + const client = new SupersetClientClass({}); + + return client + .ensureAuth() + .then(throwIfCalled) + .catch(error => { + expect(error).toEqual(expect.objectContaining({ error: expect.any(String) })); + expect(client.isAuthenticated()).toBe(false); + + return Promise.resolve(); + }); + }); + + it('returns a promise that resolves if .init() resolves successfully', () => { + expect.assertions(1); + + const client = new SupersetClientClass({}); + + return client.init().then(() => + client + .ensureAuth() + .then(throwIfCalled) + .catch(() => { + expect(client.isAuthenticated()).toBe(true); + + return Promise.resolve(); + }), + ); + }); + + it(`returns a promise that rejects if .init() is unsuccessful`, () => { + const rejectValue = { status: 403 }; + fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), { + overwriteRoutes: true, + }); + + expect.assertions(3); + + const client = new SupersetClientClass({}); + + return client + .init() + .then(throwIfCalled) + .catch(error => { + expect(error).toEqual(expect.objectContaining(rejectValue)); + + return client + .ensureAuth() + .then(throwIfCalled) + .catch(error2 => { + expect(error2).toEqual(expect.objectContaining(rejectValue)); + expect(client.isAuthenticated()).toBe(false); + + // reset + fetchMock.get( + LOGIN_GLOB, + { csrf_token: 1234 }, + { + overwriteRoutes: true, + }, + ); + + return Promise.resolve(); + }); + }); + }); + }); + + describe('requests', () => { + afterEach(fetchMock.reset); + const protocol = 'https:'; + const host = 'HOST'; + const mockGetEndpoint = '/get/url'; + const mockPostEndpoint = '/post/url'; + const mockTextEndpoint = '/text/endpoint'; + const mockGetUrl = `${protocol}//${host}${mockGetEndpoint}`; + const mockPostUrl = `${protocol}//${host}${mockPostEndpoint}`; + const mockTextUrl = `${protocol}//${host}${mockTextEndpoint}`; + const mockTextJsonResponse = '{ "value": 9223372036854775807 }'; + + fetchMock.get(mockGetUrl, { json: 'payload' }); + fetchMock.post(mockPostUrl, { json: 'payload' }); + fetchMock.get(mockTextUrl, mockTextJsonResponse); + fetchMock.post(mockTextUrl, mockTextJsonResponse); + + it('checks for authentication before every get and post request', () => { + expect.assertions(3); + const authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth'); + const client = new SupersetClientClass({ protocol, host }); + + return client.init().then(() => + Promise.all([client.get({ url: mockGetUrl }), client.post({ url: mockPostUrl })]).then( + () => { + expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); + expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); + expect(authSpy).toHaveBeenCalledTimes(2); + authSpy.mockRestore(); + + return Promise.resolve(); + }, + ), + ); + }); + + it('sets protocol, host, headers, mode, and credentials from config', () => { + expect.assertions(3); + const clientConfig: ClientConfig = { + host, + protocol, + mode: 'cors', + credentials: 'include', + headers: { my: 'header' }, + }; + + const client = new SupersetClientClass(clientConfig); + + return client.init().then(() => + client.get({ url: mockGetUrl }).then(() => { + const fetchRequest = fetchMock.calls(mockGetUrl)[0][1]; + expect(fetchRequest.mode).toBe(clientConfig.mode); + expect(fetchRequest.credentials).toBe(clientConfig.credentials); + expect(fetchRequest.headers).toEqual(expect.objectContaining(clientConfig.headers)); + + return Promise.resolve(); + }), + ); + }); + + describe('.get()', () => { + it('makes a request using url or endpoint', () => { + expect.assertions(1); + const client = new SupersetClientClass({ protocol, host }); + + return client.init().then(() => + Promise.all([ + client.get({ url: mockGetUrl }), + client.get({ endpoint: mockGetEndpoint }), + ]).then(() => { + expect(fetchMock.calls(mockGetUrl)).toHaveLength(2); + + return Promise.resolve(); + }), + ); + }); + + it('supports parsing a response as text', () => { + expect.assertions(2); + const client = new SupersetClientClass({ protocol, host }); + + return client + .init() + .then(() => + client + .get({ url: mockTextUrl, parseMethod: 'text' }) + .then(({ text }) => { + expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); + expect(text).toBe(mockTextJsonResponse); + + return Promise.resolve(); + }) + .catch(throwIfCalled), + ) + .catch(throwIfCalled); + }); + + it('allows overriding host, headers, mode, and credentials per-request', () => { + expect.assertions(3); + const clientConfig: ClientConfig = { + host, + protocol, + mode: 'cors', + credentials: 'include', + headers: { my: 'header' }, + }; + + const overrideConfig: ClientConfig = { + host: 'override_host', + mode: 'no-cors', + credentials: 'omit', + headers: { my: 'override', another: 'header' }, + }; + + const client = new SupersetClientClass(clientConfig); + + return client + .init() + .then(() => + client + .get({ url: mockGetUrl, ...overrideConfig }) + .then(() => { + const fetchRequest = fetchMock.calls(mockGetUrl)[0][1]; + expect(fetchRequest.mode).toBe(overrideConfig.mode); + expect(fetchRequest.credentials).toBe(overrideConfig.credentials); + expect(fetchRequest.headers).toEqual( + expect.objectContaining(overrideConfig.headers), + ); + + return Promise.resolve(); + }) + .catch(throwIfCalled), + ) + .catch(throwIfCalled); + }); + }); + + describe('.post()', () => { + it('makes a request using url or endpoint', () => { + expect.assertions(1); + const client = new SupersetClientClass({ protocol, host }); + + return client.init().then(() => + Promise.all([ + client.post({ url: mockPostUrl }), + client.post({ endpoint: mockPostEndpoint }), + ]).then(() => { + expect(fetchMock.calls(mockPostUrl)).toHaveLength(2); + + return Promise.resolve(); + }), + ); + }); + + it('allows overriding host, headers, mode, and credentials per-request', () => { + const clientConfig: ClientConfig = { + host, + protocol, + mode: 'cors', + credentials: 'include', + headers: { my: 'header' }, + }; + + const overrideConfig: ClientConfig = { + host: 'override_host', + mode: 'no-cors', + credentials: 'omit', + headers: { my: 'override', another: 'header' }, + }; + + const client = new SupersetClientClass(clientConfig); + + return client.init().then(() => + client.post({ url: mockPostUrl, ...overrideConfig }).then(() => { + const fetchRequest = fetchMock.calls(mockPostUrl)[0][1]; + expect(fetchRequest.mode).toBe(overrideConfig.mode); + expect(fetchRequest.credentials).toBe(overrideConfig.credentials); + expect(fetchRequest.headers).toEqual(expect.objectContaining(overrideConfig.headers)); + + return Promise.resolve(); + }), + ); + }); + + it('supports parsing a response as text', () => { + expect.assertions(2); + const client = new SupersetClientClass({ protocol, host }); + + return client.init().then(() => + client.post({ url: mockTextUrl, parseMethod: 'text' }).then(({ text }) => { + expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); + expect(text).toBe(mockTextJsonResponse); + + return Promise.resolve(); + }), + ); + }); + + it('passes postPayload key,values in the body', () => { + expect.assertions(3); + + const postPayload = { number: 123, array: [1, 2, 3] }; + const client = new SupersetClientClass({ protocol, host }); + + return client.init().then(() => + client.post({ url: mockPostUrl, postPayload }).then(() => { + const formData = fetchMock.calls(mockPostUrl)[0][1].body; + expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); + Object.keys(postPayload).forEach(key => { + expect(formData.get(key)).toBe(JSON.stringify(postPayload[key])); + }); + + return Promise.resolve(); + }), + ); + }); + + it('respects the stringify parameter for postPayload key,values', () => { + expect.assertions(3); + const postPayload = { number: 123, array: [1, 2, 3] }; + const client = new SupersetClientClass({ protocol, host }); + + return client.init().then(() => + client.post({ url: mockPostUrl, postPayload, stringify: false }).then(() => { + const formData = fetchMock.calls(mockPostUrl)[0][1].body; + expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); + Object.keys(postPayload).forEach(key => { + expect(formData.get(key)).toBe(String(postPayload[key])); + }); + + return Promise.resolve(); + }), + ); + }); + }); + }); +}); diff --git a/packages/superset-ui-connection/test/callApi/parseResponse.test.js b/packages/superset-ui-connection/test/callApi/parseResponse.test.js new file mode 100644 index 000000000..054df1c68 --- /dev/null +++ b/packages/superset-ui-connection/test/callApi/parseResponse.test.js @@ -0,0 +1,37 @@ +import fetchMock from 'fetch-mock'; +import callApi from '../../src/callApi/callApi'; +import parseResponse from '../../src/callApi/parseResponse'; + +import { LOGIN_GLOB } from '../fixtures/constants'; + +describe('parseResponse()', () => { + beforeAll(() => { + fetchMock.get(LOGIN_GLOB, { csrf_token: '1234' }); + }); + + afterAll(fetchMock.restore); + + const mockGetUrl = '/mock/get/url'; + const mockPostUrl = '/mock/post/url'; + const mockErrorUrl = '/mock/error/url'; + + const mockGetPayload = { get: 'payload' }; + const mockPostPayload = { post: 'payload' }; + const mockErrorPayload = { status: 500, statusText: 'Internal error' }; + + fetchMock.get(mockGetUrl, mockGetPayload); + fetchMock.post(mockPostUrl, mockPostPayload); + fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload)); + + afterEach(fetchMock.reset); + + it('throw errors if parseMethod is not null|json|text', () => { + const mockNoParseUrl = '/mock/noparse/url'; + const mockResponse = new Response('test response'); + fetchMock.get(mockNoParseUrl, mockResponse); + + const apiPromise = callApi({ url: mockNoParseUrl, method: 'GET' }); + + expect(() => parseResponse(apiPromise, 'something-else')).toThrow(); + }); +});