diff --git a/README.md b/README.md index 77010da..9ed7407 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,31 @@ Node.js SDK for Permanent.org Built on [bitjson/typescript-starter](https://github.com/bitjson/typescript-starter) ![Unit tests](https://github.com/PermanentOrg/node-sdk/workflows/Unit%20tests/badge.svg?branch=main) + +## Usage + +The client needs to be configured with access keys, available on request from engineers@permanent.org. + +```js +// ES import +import { Permanent } from '@permanentorg/node-sdk'; + +// CommonJS +const Permanent = require('@permanentorg/node-sdk').Permanent; + +// Configure with credentials +const permanent = new Permanent({ + sessionToken, + mfaToken, + archiveId, +}); + +// Ready to use! +const isSessionValid = await permanent.auth.isSessionValid(); +``` + ## Developing + To start working, run the `watch:build` task using [`npm`](https://docs.npmjs.com/getting-started/what-is-npm) or [`yarn`](https://yarnpkg.com/). ```sh @@ -19,6 +43,7 @@ npm run watch:test ``` ## Testing + To run all tests (unit tests, formatting, linting), run the `test` task: ```sh diff --git a/package-lock.json b/package-lock.json index 356bc05..e81b783 100644 --- a/package-lock.json +++ b/package-lock.json @@ -322,11 +322,6 @@ "to-fast-properties": "^2.0.0" } }, - "@bitauth/libauth": { - "version": "1.17.2", - "resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-1.17.2.tgz", - "integrity": "sha512-Jo3wFdhtEfJCi9lOPrHJL5Yvk3z68aBWlrf1Y8l4vWFox9rKjWnOnQYOSwwtKvBcd5kg54JrUpolq/H8hSVYLQ==" - }, "@commitlint/execute-rule": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-11.0.0.tgz", @@ -479,6 +474,51 @@ "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", "dev": true }, + "@sinonjs/commons": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz", + "integrity": "sha512-892K+kWUUi3cl+LlqEWIDrhvLgdL79tECi8JZUyq6IviKy/DNhuzCRlbHUjxK89f4ypPMMaFnFuR9Ie6DoIMsw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/formatio": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-5.0.1.tgz", + "integrity": "sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^5.0.2" + } + }, + "@sinonjs/samsam": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.2.0.tgz", + "integrity": "sha512-CaIcyX5cDsjcW/ab7HposFWzV1kC++4HNsfnEdFJa7cP1QIuILAKV+BgfeqRXhcnSAc76r/Rh/O5C+300BwUIw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", @@ -500,6 +540,12 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true }, + "@types/chai": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.13.tgz", + "integrity": "sha512-o3SGYRlOpvLFpwJA6Sl1UPOwKFEvE4FxTEB/c9XHI2whdnd4kmPVkNLL8gY4vWGBxWWDumzLbKsAhEH5SKn37Q==", + "dev": true + }, "@types/debug": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", @@ -543,6 +589,31 @@ "dev": true, "optional": true }, + "@types/sinon": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-9.0.8.tgz", + "integrity": "sha512-IVnI820FZFMGI+u1R+2VdRaD/82YIQTdqLYC9DLPszZuynAJDtCvCtCs3bmyL66s7FqRM3+LPX7DhHnVTaagDw==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinon-chai": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.5.tgz", + "integrity": "sha512-bKQqIpew7mmIGNRlxW6Zli/QVyc3zikpGzCa797B/tRnD9OtHvZ/ts8sYXV+Ilj9u3QRaUEM8xrjgd1gwm1BpQ==", + "dev": true, + "requires": { + "@types/chai": "*", + "@types/sinon": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz", + "integrity": "sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.4.1.tgz", @@ -991,6 +1062,32 @@ "yargs": "^16.0.3" } }, + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, + "axios-mock-adapter": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.18.2.tgz", + "integrity": "sha512-e5aTsPy2Viov22zNpFTlid76W1Scz82pXeEwwCXdtO85LROhHAF8pHF2qDhiyMONLxKyY3lQ+S4UCsKgrlx8Hw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "is-buffer": "^2.0.3" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", + "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==", + "dev": true + } + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -3934,6 +4031,29 @@ "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", "dev": true }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "for-in": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", @@ -5797,6 +5917,12 @@ "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", "dev": true }, + "just-extend": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.1.1.tgz", + "integrity": "sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA==", + "dev": true + }, "keyv": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", @@ -5877,6 +6003,12 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", @@ -6278,6 +6410,19 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "nise": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-4.0.4.tgz", + "integrity": "sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, "node-fetch": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", @@ -6951,6 +7096,23 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7683,6 +7845,21 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "sinon": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.0.tgz", + "integrity": "sha512-eSNXz1XMcGEMHw08NJXSyTHIu6qTCOiN8x9ODACmZpNQpr0aXTBXBnI4xTzQzR+TEpOmLiKowGf9flCuKIzsbw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/formatio": "^5.0.1", + "@sinonjs/samsam": "^5.2.0", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8696,6 +8873,18 @@ "yn": "3.1.1" } }, + "ts-sinon": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-sinon/-/ts-sinon-2.0.1.tgz", + "integrity": "sha512-uI5huDCY6Gw6Yczmyd/Jcu8gZZYtWO0HakPShvDmlrgcywLyFZ7lgWt1y+gd/x79ReHh+rhMAJkhQkGRnPNikw==", + "dev": true, + "requires": { + "@types/node": "^14.6.1", + "@types/sinon": "^9.0.5", + "@types/sinon-chai": "^3.2.4", + "sinon": "^9.0.3" + } + }, "tsconfig-paths": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", @@ -8732,6 +8921,12 @@ "prelude-ls": "^1.2.1" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz", diff --git a/package.json b/package.json index 5c701aa..48a814a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "node": ">=10" }, "dependencies": { - "@bitauth/libauth": "^1.17.1" + "axios": "^0.19.0" }, "devDependencies": { "@ava/typescript": "^1.1.1", @@ -50,6 +50,7 @@ "@typescript-eslint/eslint-plugin": "^4.0.1", "@typescript-eslint/parser": "^4.0.1", "ava": "^3.12.1", + "axios-mock-adapter": "^1.18.2", "codecov": "^3.5.0", "cspell": "^4.1.0", "cz-conventional-changelog": "^3.3.0", @@ -64,6 +65,7 @@ "prettier": "^2.1.1", "standard-version": "^9.0.0", "ts-node": "^9.0.0", + "ts-sinon": "^2.0.1", "typedoc": "^0.19.0", "typescript": "^4.0.2" }, diff --git a/src/index.ts b/src/index.ts index 25b867c..39b8a4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1 @@ -export * from './lib/async'; -export * from './lib/number'; +export { Permanent } from './lib/client'; diff --git a/src/lib/api/api.service.spec.ts b/src/lib/api/api.service.spec.ts new file mode 100644 index 0000000..2037745 --- /dev/null +++ b/src/lib/api/api.service.spec.ts @@ -0,0 +1,38 @@ +import anyTest, { TestInterface } from 'ava'; +import MockAdapter from 'axios-mock-adapter'; + +import { ApiService, MFA_COOKIE, SESSION_COOKIE } from './api.service'; + +const test = anyTest as TestInterface<{ + apiService: ApiService; + mockAxios: MockAdapter; +}>; + +const sessionToken = 'sessionToken'; +const mfaToken = 'mfaToken'; +const apiKey = 'apiKey'; +const baseUrl = 'http://baseurl.com'; + +test.beforeEach('New ApiService', (t) => { + const apiService = new ApiService(sessionToken, mfaToken, apiKey, baseUrl); + const mockAxios = new MockAdapter(apiService.getAxiosInstance()); + + t.context = { + mockAxios, + apiService, + }; +}); + +test('requests are made relative to baseUrl, and with session and mfa cookies set', async (t) => { + const endpoint = '/endpoint'; + const axiosInstance = t.context.apiService.getAxiosInstance(); + + t.context.mockAxios.onPost(`${baseUrl}${endpoint}`).replyOnce((config) => { + const cookies = config.headers.Cookie; + t.assert(cookies.includes(`${SESSION_COOKIE}=${sessionToken}`)); + t.assert(cookies.includes(`${MFA_COOKIE}=${mfaToken}`)); + return [200, {}]; + }); + + await axiosInstance.post(endpoint); +}); diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts new file mode 100644 index 0000000..2fdb91b --- /dev/null +++ b/src/lib/api/api.service.ts @@ -0,0 +1,44 @@ +import axios from 'axios'; + +import { AuthRepo } from './auth.repo'; +import { RepoConstructorConfig } from './base.repo'; +import { CsrfStore } from './csrf'; + +export const SESSION_COOKIE = 'permSession'; +export const MFA_COOKIE = 'permMFA'; + +export class ApiService { + private csrfStore = new CsrfStore(); + private axiosInstance = axios.create(); + + private repoConfig: RepoConstructorConfig = { + csrfStore: this.csrfStore, + axiosInstance: this.axiosInstance, + apiKey: this.apiKey, + }; + + public auth = new AuthRepo(this.repoConfig); + + constructor( + sessionToken: string, + mfaToken: string, + private apiKey: string, + baseUrl = 'https://permanent.org/api' + ) { + this.axiosInstance.defaults.headers = createDefaultHeaders( + sessionToken, + mfaToken + ); + this.axiosInstance.defaults.baseURL = baseUrl; + } + + getAxiosInstance() { + return this.axiosInstance; + } +} + +function createDefaultHeaders(sessionToken: string, mfaToken: string) { + return { + Cookie: `${SESSION_COOKIE}=${sessionToken}; ${MFA_COOKIE}=${mfaToken};`, + }; +} diff --git a/src/lib/api/auth.repo.spec.ts b/src/lib/api/auth.repo.spec.ts new file mode 100644 index 0000000..e7b5f81 --- /dev/null +++ b/src/lib/api/auth.repo.spec.ts @@ -0,0 +1,41 @@ +import anyTest, { TestInterface } from 'ava'; +import axios from 'axios'; +import * as sinon from 'sinon'; + +import { AuthRepo } from './auth.repo'; +import { CsrfStore } from './csrf'; + +const test = anyTest as TestInterface<{ + authRepo: AuthRepo; + csrfStore: CsrfStore; +}>; + +const apiKey = 'apiKey'; + +test.beforeEach('New BaseRepo', (t) => { + const csrfStore = new CsrfStore(); + const axiosInstance = axios.create(); + + t.context = { + csrfStore, + authRepo: new AuthRepo({ + csrfStore, + axiosInstance, + apiKey, + }), + }; +}); + +test('should create', (t) => { + t.assert(t.context.authRepo); +}); + +test('should call loggedIn endpoint', async (t) => { + const requestFake = sinon.fake.resolves(true); + + sinon.replace(t.context.authRepo, 'request', requestFake); + + await t.context.authRepo.isLoggedIn(); + + t.assert(requestFake.calledOnceWith('/auth/loggedIn')); +}); diff --git a/src/lib/api/auth.repo.ts b/src/lib/api/auth.repo.ts new file mode 100644 index 0000000..0bf85f5 --- /dev/null +++ b/src/lib/api/auth.repo.ts @@ -0,0 +1,7 @@ +import { BaseRepo } from './base.repo'; + +export class AuthRepo extends BaseRepo { + public isLoggedIn() { + return this.request('/auth/loggedIn'); + } +} diff --git a/src/lib/api/base.repo.spec.ts b/src/lib/api/base.repo.spec.ts new file mode 100644 index 0000000..65a1c50 --- /dev/null +++ b/src/lib/api/base.repo.spec.ts @@ -0,0 +1,86 @@ +import anyTest, { TestInterface } from 'ava'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; + +import { PermanentApiData } from '../model'; + +import { BaseRepo, PermanentApiRequest } from './base.repo'; +import { CsrfStore } from './csrf'; + +const test = anyTest as TestInterface<{ + baseRepo: BaseRepo; + csrfStore: CsrfStore; + mockAxios: MockAdapter; +}>; +const baseUrl = 'http://test.com'; +const apiKey = 'apiKey'; + +test.beforeEach('New BaseRepo', (t) => { + const csrfStore = new CsrfStore(); + const axiosInstance = axios.create({ + baseURL: baseUrl, + }); + + const mockAxios = new MockAdapter(axiosInstance); + + t.context = { + csrfStore, + mockAxios, + baseRepo: new BaseRepo({ + csrfStore, + axiosInstance, + apiKey, + }), + }; +}); + +test('requests are made using API key', async (t) => { + const endpoint = '/endpoint'; + + t.context.mockAxios.onPost(`${baseUrl}${endpoint}`).replyOnce((config) => { + const requestData = JSON.parse(config.data) as PermanentApiRequest; + t.is(requestData.RequestVO.apiKey, apiKey); + return [200, {}]; + }); + + await t.context.baseRepo.request(endpoint); +}); + +test('requests are made using csrf from CsrfStore', async (t) => { + const endpoint = '/endpoint'; + + t.context.csrfStore.setCsrf('testCsrf'); + t.context.mockAxios.onPost(`${baseUrl}${endpoint}`).replyOnce((config) => { + const requestData = JSON.parse(config.data) as PermanentApiRequest; + t.is(requestData.RequestVO.csrf, t.context.csrfStore.getCsrf()); + return [200, {}]; + }); + + await t.context.baseRepo.request(endpoint); +}); + +test('CsrfStore is updated with csrf from response', async (t) => { + const newCsrf = 'newcsrf'; + const endpoint = '/endpoint'; + t.context.csrfStore.setCsrf('oldcsrf'); + t.context.mockAxios.onPost(`${baseUrl}${endpoint}`).replyOnce(() => { + return [200, { csrf: newCsrf }]; + }); + + await t.context.baseRepo.request(endpoint); + + t.is(t.context.csrfStore.getCsrf(), newCsrf); +}); + +test('requests are made with provided data', async (t) => { + const data: PermanentApiData[] = [{ FolderVO: null }]; + const endpoint = '/endpoint'; + + t.context.mockAxios.onPost(`${baseUrl}${endpoint}`).replyOnce((config) => { + const requestData = JSON.parse(config.data) as PermanentApiRequest; + t.deepEqual(requestData.RequestVO.data, data); + return [200, {}]; + }); + + await t.context.baseRepo.request(endpoint, data); +}); diff --git a/src/lib/api/base.repo.ts b/src/lib/api/base.repo.ts new file mode 100644 index 0000000..0b39945 --- /dev/null +++ b/src/lib/api/base.repo.ts @@ -0,0 +1,55 @@ +import { AxiosInstance } from 'axios'; + +import { PermanentApiData, RequestVO } from '../model'; + +import { CsrfStore } from './csrf'; + +export interface PermanentApiRequest { + RequestVO: RequestVO; +} + +export interface PermanentApiResponse { + isSuccessful: boolean; + isSystemUp: boolean; + Results: { data: PermanentApiData[] }[]; + csrf: string; + sessionId?: string; +} + +export interface RepoConstructorConfig { + csrfStore: CsrfStore; + axiosInstance: AxiosInstance; + apiKey: string; +} + +export class BaseRepo { + private csrfStore: CsrfStore; + private axiosInstance: AxiosInstance; + private apiKey: string; + + constructor(config: RepoConstructorConfig) { + this.csrfStore = config.csrfStore; + this.axiosInstance = config.axiosInstance; + this.apiKey = config.apiKey; + } + + async request( + endpoint: string, + data: PermanentApiData[] = [{}] + ): Promise { + const requestData: PermanentApiRequest = { + RequestVO: { + data, + apiKey: this.apiKey, + csrf: this.csrfStore.getCsrf(), + }, + }; + const response = await this.axiosInstance.post(endpoint, requestData); + + if (response.data.csrf) { + this.csrfStore.setCsrf(response.data.csrf); + } + + return response.data; + } +} diff --git a/src/lib/api/csrf/index.ts b/src/lib/api/csrf/index.ts new file mode 100644 index 0000000..68890d0 --- /dev/null +++ b/src/lib/api/csrf/index.ts @@ -0,0 +1,11 @@ +export class CsrfStore { + private csrf: string | undefined; + + setCsrf(csrf: string) { + this.csrf = csrf; + } + + getCsrf() { + return this.csrf; + } +} diff --git a/src/lib/async.spec.ts b/src/lib/async.spec.ts deleted file mode 100644 index 31bd17d..0000000 --- a/src/lib/async.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import test from 'ava'; - -import { asyncABC } from './async'; - -test('getABC', async (t) => { - t.deepEqual(await asyncABC(), ['a', 'b', 'c']); -}); diff --git a/src/lib/async.ts b/src/lib/async.ts deleted file mode 100644 index 6a38727..0000000 --- a/src/lib/async.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * A sample async function (to demo Typescript's es7 async/await down-leveling). - * - * ### Example (es imports) - * ```js - * import { asyncABC } from 'typescript-starter' - * console.log(await asyncABC()) - * // => ['a','b','c'] - * ``` - * - * ### Example (commonjs) - * ```js - * var double = require('typescript-starter').asyncABC; - * asyncABC().then(console.log); - * // => ['a','b','c'] - * ``` - * - * @returns a Promise which should contain `['a','b','c']` - */ -export const asyncABC = async () => { - const somethingSlow = (index: 0 | 1 | 2) => { - const storage = 'abc'.charAt(index); - return new Promise((resolve) => - // later... - resolve(storage) - ); - }; - const a = await somethingSlow(0); - const b = await somethingSlow(1); - const c = await somethingSlow(2); - return [a, b, c]; -}; diff --git a/src/lib/client.spec.ts b/src/lib/client.spec.ts new file mode 100644 index 0000000..0140073 --- /dev/null +++ b/src/lib/client.spec.ts @@ -0,0 +1,19 @@ +import test from 'ava'; + +import { Permanent, PermanentConstructorConfigI } from './client'; + +test('instance gets config options', (t) => { + const clientOptions: PermanentConstructorConfigI = { + apiKey: 'apiapiapi', + sessionToken: 'sessionsessionsession', + mfaToken: 'mfamfamfa', + archiveId: 555, + }; + + const client = new Permanent(clientOptions); + + t.truthy(client); + t.is(client.getSessionToken(), clientOptions.sessionToken); + t.is(client.getMfaToken(), clientOptions.mfaToken); + t.is(client.getArchiveId(), clientOptions.archiveId); +}); diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..2795da5 --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,42 @@ +import { ApiService } from './api/api.service'; +import { AuthResource } from './resources/auth.resource'; + +export interface PermanentConstructorConfigI { + sessionToken: string; + mfaToken: string; + archiveId: number; + apiKey: string; +} + +export class Permanent { + private apiKey: string; + private sessionToken: string; + private mfaToken: string; + private archiveId: number; + + private api: ApiService; + + public auth: AuthResource; + constructor(config: PermanentConstructorConfigI) { + const { sessionToken, mfaToken, archiveId, apiKey } = config; + + this.sessionToken = sessionToken; + this.mfaToken = mfaToken; + this.archiveId = archiveId; + this.apiKey = apiKey; + this.api = new ApiService(sessionToken, mfaToken, this.apiKey); + this.auth = new AuthResource(this.api); + } + + public getSessionToken() { + return this.sessionToken; + } + + public getMfaToken() { + return this.mfaToken; + } + + public getArchiveId() { + return this.archiveId; + } +} diff --git a/src/lib/model/index.ts b/src/lib/model/index.ts new file mode 100644 index 0000000..e3333c5 --- /dev/null +++ b/src/lib/model/index.ts @@ -0,0 +1 @@ +export * from './request-vo'; diff --git a/src/lib/model/request-vo.ts b/src/lib/model/request-vo.ts new file mode 100644 index 0000000..be90b93 --- /dev/null +++ b/src/lib/model/request-vo.ts @@ -0,0 +1,15 @@ +import { SimpleVO } from './simple-vo'; + +export interface PermanentApiData { + FolderVO?: unknown; + RecordVO?: unknown; + ArchiveVO?: unknown; + AccountVO?: unknown; + SimpleVO?: SimpleVO; +} + +export interface RequestVO { + apiKey: string; + csrf?: string; + data: PermanentApiData[]; +} diff --git a/src/lib/model/simple-vo.ts b/src/lib/model/simple-vo.ts new file mode 100644 index 0000000..b0b1f90 --- /dev/null +++ b/src/lib/model/simple-vo.ts @@ -0,0 +1,4 @@ +export interface SimpleVO { + key: string; + value: string | boolean; +} diff --git a/src/lib/number.spec.ts b/src/lib/number.spec.ts deleted file mode 100644 index 4b2e8dd..0000000 --- a/src/lib/number.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import test from 'ava'; - -import { double, power } from './number'; - -test('double', (t) => { - t.is(double(2), 4); -}); - -test('power', (t) => { - t.is(power(2, 4), 16); -}); diff --git a/src/lib/number.ts b/src/lib/number.ts deleted file mode 100644 index d28ddcc..0000000 --- a/src/lib/number.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Multiplies a value by 2. (Also a full example of TypeDoc's functionality.) - * - * ### Example (es module) - * ```js - * import { double } from 'typescript-starter' - * console.log(double(4)) - * // => 8 - * ``` - * - * ### Example (commonjs) - * ```js - * var double = require('typescript-starter').double; - * console.log(double(4)) - * // => 8 - * ``` - * - * @param value - Comment describing the `value` parameter. - * @returns Comment describing the return type. - * @anotherNote Some other value. - */ -export const double = (value: number) => { - return value * 2; -}; - -/** - * Raise the value of the first parameter to the power of the second using the - * es7 exponentiation operator (`**`). - * - * ### Example (es module) - * ```js - * import { power } from 'typescript-starter' - * console.log(power(2,3)) - * // => 8 - * ``` - * - * ### Example (commonjs) - * ```js - * var power = require('typescript-starter').power; - * console.log(power(2,3)) - * // => 8 - * ``` - * @param base - the base to exponentiate - * @param exponent - the power to which to raise the base - */ -export const power = (base: number, exponent: number) => { - /** - * This es7 exponentiation operator is transpiled by TypeScript - */ - return base ** exponent; -}; diff --git a/src/lib/resources/auth.resource.spec.ts b/src/lib/resources/auth.resource.spec.ts new file mode 100644 index 0000000..9566d19 --- /dev/null +++ b/src/lib/resources/auth.resource.spec.ts @@ -0,0 +1,100 @@ +import anyTest, { TestInterface } from 'ava'; +import * as sinon from 'sinon'; + +import { ApiService } from '../api/api.service'; +import { PermanentApiResponse } from '../api/base.repo'; + +import { AuthResource } from './auth.resource'; + +const test = anyTest as TestInterface<{ + auth: AuthResource; + api: ApiService; +}>; + +test.beforeEach((t) => { + const api = new ApiService('session', 'mfa', 'test'); + t.context = { + api, + auth: new AuthResource(api), + }; +}); + +test('returns true for valid session', async (t) => { + const loggedInResponse: PermanentApiResponse = { + csrf: 'csrf', + isSuccessful: true, + isSystemUp: true, + Results: [ + { + data: [ + { + SimpleVO: { + key: 'bool', + value: true, + }, + }, + ], + }, + ], + }; + const responseFake = sinon.fake.resolves(loggedInResponse); + sinon.replace(t.context.api.auth, 'isLoggedIn', responseFake); + + const isLoggedIn = await t.context.auth.isSessionValid(); + + t.assert(isLoggedIn); +}); + +test('returns false for invalid session', async (t) => { + const loggedInResponse: PermanentApiResponse = { + csrf: 'csrf', + isSuccessful: true, + isSystemUp: true, + Results: [ + { + data: [ + { + SimpleVO: { + key: 'bool', + value: false, + }, + }, + ], + }, + ], + }; + const responseFake = sinon.fake.resolves(loggedInResponse); + sinon.replace(t.context.api.auth, 'isLoggedIn', responseFake); + + const isLoggedIn = await t.context.auth.isSessionValid(); + + t.assert(!isLoggedIn); +}); + +test('returns false for response.isSuccessful = false', async (t) => { + const loggedInResponse: PermanentApiResponse = { + csrf: 'csrf', + isSuccessful: false, + isSystemUp: true, + Results: [ + { + data: [{}], + }, + ], + }; + const responseFake = sinon.fake.resolves(loggedInResponse); + sinon.replace(t.context.api.auth, 'isLoggedIn', responseFake); + + const isLoggedIn = await t.context.auth.isSessionValid(); + + t.assert(!isLoggedIn); +}); + +test('returns false for errored request', async (t) => { + const responseFake = sinon.fake.throws(new Error('error')); + sinon.replace(t.context.api.auth, 'isLoggedIn', responseFake); + + const isLoggedIn = await t.context.auth.isSessionValid(); + + t.assert(!isLoggedIn); +}); diff --git a/src/lib/resources/auth.resource.ts b/src/lib/resources/auth.resource.ts new file mode 100644 index 0000000..32d595e --- /dev/null +++ b/src/lib/resources/auth.resource.ts @@ -0,0 +1,31 @@ +import { BaseResource } from './base.resource'; + +export class AuthResource extends BaseResource { + /** + * Validates the credentials (`sessionToken`, `mfaToken`, etc.) used by the client instance + * + * #### Example + * ```js + * const perm = new Permanent(config); + * if (await perm.auth.isSessionValid()) { + * // session is valid + * } else { + * // session is invalid + * } + * ``` + * + * @returns a Promise that resolves to a boolean representing whether or not the credentials are valid + */ + public async isSessionValid(): Promise { + try { + const response = await this.api.auth.isLoggedIn(); + if (response.isSuccessful) { + return response.Results[0].data[0].SimpleVO?.value === true; + } else { + return false; + } + } catch (err) { + return false; + } + } +} diff --git a/src/lib/resources/base.resource.ts b/src/lib/resources/base.resource.ts new file mode 100644 index 0000000..192b910 --- /dev/null +++ b/src/lib/resources/base.resource.ts @@ -0,0 +1,5 @@ +import { ApiService } from '../api/api.service'; + +export class BaseResource { + constructor(public api: ApiService) {} +}