From 7256bcf405b4bdf0bf61f4c93db71a166b887a5d Mon Sep 17 00:00:00 2001 From: Alexey Zorkaltsev Date: Tue, 25 Jul 2023 10:48:55 +0300 Subject: [PATCH] bug: fix metadata-token-service.ts fix: fix fix: fix fix: fix fix: fix fix: fix fix: begining of workm on V2 fix: fix fix: fix fix: fix --- .eslintrc.json | 3 +- .gitignore | 1 + config/jest.coverage.ts | 17 + package.json | 1 + .../metadata-token-service.consts.ts | 30 ++ .../metadata-token-service.test.ts | 339 ++++++++++++++++++ src/token-service/metadata-token-service.ts | 88 +++-- 7 files changed, 451 insertions(+), 28 deletions(-) create mode 100644 config/jest.coverage.ts create mode 100644 src/token-service/metadata-token-service.consts.ts create mode 100644 src/token-service/metadata-token-service.test.ts diff --git a/.eslintrc.json b/.eslintrc.json index 5c40701f..56443bd7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -50,6 +50,7 @@ "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], - "import/no-cycle": "off" + "import/no-cycle": "off", + "linebreak-style": "off" } } diff --git a/.gitignore b/.gitignore index 2fd83a6a..621d577e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea node_modules dist +coverage diff --git a/config/jest.coverage.ts b/config/jest.coverage.ts new file mode 100644 index 00000000..475e2333 --- /dev/null +++ b/config/jest.coverage.ts @@ -0,0 +1,17 @@ +import * as path from 'path'; +import config from './jest'; + +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +export default { + ...config, + collectCoverage: true, + collectCoverageFrom: [ + '**/*.{js,ts}', + '!generated/**', + ], + coverageDirectory: '../coverage', +}; diff --git a/package.json b/package.json index 508bfea2..f75a45b4 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ }, "scripts": { "test": "jest -c config/jest.ts --passWithNoTests", + "coverage": "jest -c config/jest.coverage.ts --passWithNoTests", "lint": "eslint src config", "build": "NODE_OPTIONS=\"--max-old-space-size=4096\" tsc -p .", "generate-code": "ts-node scripts/generate-code.ts", diff --git a/src/token-service/metadata-token-service.consts.ts b/src/token-service/metadata-token-service.consts.ts new file mode 100644 index 00000000..9bf4b1ab --- /dev/null +++ b/src/token-service/metadata-token-service.consts.ts @@ -0,0 +1,30 @@ +/* + +Issuance of tokens shall be in accordance with the following rules + +When accessing APIs of other Cloud services (including the database), services should follow the standard rules of +working with tokens recommended by the documentation: + +- a token is issued in the way most appropriate for this service: for example, it can be a metadata service for SVM, Token Agent + for iron hosts or JWT for services hosted outside the Cloud perimeter + +- the application does not start (does not start accepting requests) until its system SA token has been successfully issued; + the application does not start if the token issued at the moment of start is valid for less than 15 minutes; + +- the token issued at start time is used for a time equal to at least 10% of the difference between expires_at and the time the token was issued; + +- a token that has been used within the time specified in the previous paragraph is subject to update: the application starts + a background process that reissues the token of its system SA, while all current requests continue to be made with the + cached token (thus, in case of any problems with token reissue, 90% of the token's lifetime will be left to notice and + correct the situation); + +- it is recommended that applications have a system SA token usage time monitor, which should be lit if the token lifetime + approaches 20% of the difference between expires_at and the token's expiration time. + +*/ + +export const MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE = 5; + +export const TOKEN_MINIMUM_LIFETIME_MARGIN_MS = 15 * 60 * 1000; + +export const TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT = 90; diff --git a/src/token-service/metadata-token-service.test.ts b/src/token-service/metadata-token-service.test.ts new file mode 100644 index 00000000..4807dec7 --- /dev/null +++ b/src/token-service/metadata-token-service.test.ts @@ -0,0 +1,339 @@ +import axios from 'axios'; +import {MetadataTokenService} from './metadata-token-service'; +import Mock = jest.Mock; +import { + MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE, + TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT, + TOKEN_MINIMUM_LIFETIME_MARGIN_MS, +} from './metadata-token-service.consts'; + +describe('metadata-token-service', () => { + const oldGet = axios.get; + + beforeEach(() => { + axios.get = jest.fn(); + jest.useFakeTimers(); + jest.spyOn(global, 'setTimeout'); + jest.spyOn(global, 'setInterval'); + }); + + afterEach(() => { + axios.get = oldGet; + jest.useRealTimers(); + }); + + it('simple scenario', async () => { + const metadataTokenService = new MetadataTokenService(); + + // set token + (axios.get as Mock).mockReturnValue({ + status: 200, + data: { + access_token: '123', + expires_in: 10 * 60 * 60, // secs + }, + }); + + // first time + const t1 = await metadataTokenService.getToken(); + + expect(t1) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(1); + + // second time - no extra token request + const t2 = await metadataTokenService.getToken(); + + expect(t2) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(1); + }); + + for (const tokenLifetimeSec of [30 * 60 * 60, 60 * 60, 12 * 60 * 60]) { + it(`provider work long time, token gets occasionally updated: period ${tokenLifetimeSec} mins`, async () => { + const metadataTokenService = new MetadataTokenService(); + const nextTokenTimeSec = tokenLifetimeSec * (1 - TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT / 100); + + let expectGetCalls = 0; + + for (const token of ['123', '456', '789']) { + + // set token + (axios.get as Mock).mockReturnValue({ + status: 200, + data: { + access_token: token, + expires_in: tokenLifetimeSec, + }, + }); + + jest.advanceTimersByTime(10); // to overcome numbers rounding mistakes + + // 1st token call in the TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT period + + // eslint-disable-next-line no-await-in-loop + let t = await metadataTokenService.getToken(); + + expect(t) + .toBe(token); + expect((axios.get as Mock).mock.calls) + .toHaveLength(++expectGetCalls); + + // 2nd token call in the same time + + // eslint-disable-next-line no-await-in-loop + t = await metadataTokenService.getToken(); + + expect(t) + .toBe(token); + expect((axios.get as Mock).mock.calls) + .toHaveLength(expectGetCalls); // increase is not expected + + jest.advanceTimersByTime((nextTokenTimeSec / 2) * 1000); // still same token is good + + // 3rd token call in the TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT period with some time advance + + // eslint-disable-next-line no-await-in-loop + t = await metadataTokenService.getToken(); + + expect(t) + .toBe(token); + expect((axios.get as Mock).mock.calls) + .toHaveLength(expectGetCalls); // increase is not expected + + jest.advanceTimersByTime((nextTokenTimeSec - (nextTokenTimeSec / 2)) * 1000); // still same token is good + } + + // due to the specifics of serverless functions - setTimeout and setInterval should not be used + expect(setTimeout) + .toHaveBeenCalledTimes(0); + expect(setInterval) + .toHaveBeenCalledTimes(0); + }); + } + + it('Iam always returns an error', async () => { + const metadataTokenService = new MetadataTokenService(); + + // return an error + (axios.get as Mock).mockReturnValue({ + status: 400, + }); + + await expect(() => metadataTokenService.getToken()) + .rejects + .toThrow(); + }); + + it('Iam occasionally returns an error while .initialize()', async () => { + const metadataTokenService = new MetadataTokenService(); + + // return token on 4th attempt - tests initialize() + const nextResp = (function* () { + for (let i = 0; i < 3; i++) { + yield { + status: 400, + }; + } + + return { + status: 200, + data: { + access_token: '123', + expires_in: 10 * 60 * 60, // secs + }, + }; + }()); + + (axios.get as Mock).mockImplementation(() => nextResp.next().value); + + // first time - return token, even if it was returned only on 4th attempt + let t = await metadataTokenService.getToken(); + + expect(t) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(4); + + // after 1 hour, return on an error use old token and make only one attempt to get token + (axios.get as Mock).mockReturnValue({ + status: 400, + }); + + // after 1 hour, + jest.advanceTimersByTime(60 * 60 * 1000); + + // Iam returns an error on 1st attempt, so we use the old token + t = await metadataTokenService.getToken(); + expect(t) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(5); + + // on next attempt we receive new token, and use this one + (axios.get as Mock).mockReturnValue({ + status: 200, + data: { + access_token: '456', + expires_in: 10 * 60 * 60, // secs + }, + }); + + t = await metadataTokenService.getToken(); + expect(t) + .toBe('456'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(6); + }); + + it('use old token, if .getFetch() return an error', async () => { + const metadataTokenService = new MetadataTokenService(); + const TOKEN_LIFETIME_MINS = 100; + + // set token + (axios.get as Mock).mockReturnValue({ + status: 200, + data: { + access_token: '123', + expires_in: TOKEN_LIFETIME_MINS * 60, + }, + }); + + let t = await metadataTokenService.getToken(); + + jest.advanceTimersByTime(10); // to overcome numbers rounding mistakes + + expect(t) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(1); + + // return an error + (axios.get as Mock).mockReturnValue({ + status: 400, + }); + + jest.advanceTimersByTime(TOKEN_LIFETIME_MINS * 60 * 1000 * (1 - TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT / 100)); + + t = await metadataTokenService.getToken(); + + expect(t) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(2); + }); + + it('parallel fetch token', async () => { + const metadataTokenService = new MetadataTokenService(); + + let responseResolve: () => void; + let responsePromise: Promise; + + (axios.get as Mock).mockImplementation(async () => { + responsePromise = new Promise((resolve) => { + responseResolve = resolve; + }); + + await responsePromise; + + return { + status: 200, + data: { + access_token: '123', + expires_in: 10 * 60 * 60, // secs + }, + }; + }); + + const t1 = metadataTokenService.getToken(); + expect((axios.get as Mock).mock.calls) + .toHaveLength(1); + + const t2 = metadataTokenService.getToken(); + const t3 = metadataTokenService.getToken(); + + // @ts-ignore + responseResolve?.(); + + // @ts-ignore + await responsePromise; + + expect(await t1) + .toBe('123'); + expect(await t2) + .toBe('123'); + expect(await t3) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(1); + }); + + it('if less then TOKEN_MINIMUM_LIFETIME_MARGIN_MS min left use .initialize() not .getToken()', async () => { + const metadataTokenService = new MetadataTokenService(); + const TOKEN_LIFETIME_MINS = 100; + + // set token + (axios.get as Mock).mockReturnValue({ + status: 200, + data: { + access_token: '123', + expires_in: TOKEN_LIFETIME_MINS * 60, + }, + }); + + let t = await metadataTokenService.getToken(); + + jest.advanceTimersByTime(10); // to overcome numbers rounding mistakes + + expect(t) + .toBe('123'); + expect((axios.get as Mock).mock.calls) + .toHaveLength(1); + + // return an error + (axios.get as Mock).mockReturnValue({ + status: 400, + }); + + jest.advanceTimersByTime(TOKEN_LIFETIME_MINS * 60 * 1000); + + await expect(() => metadataTokenService.getToken()) + .rejects + .toThrow(); + expect((axios.get as Mock).mock.calls) + .toHaveLength(1 + MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE); + }); + + it('if always returned token with less then TOKEN_MINIMUM_LIFETIME_MARGIN_MS left', async () => { + const metadataTokenService = new MetadataTokenService(); + + // set token + (axios.get as Mock).mockReturnValue({ + status: 200, + data: { + access_token: '123', + expires_in: (TOKEN_MINIMUM_LIFETIME_MARGIN_MS / 2) / 1000, + }, + }); + + await expect(() => metadataTokenService.getToken()) + .rejects + .toThrow(); + expect((axios.get as Mock).mock.calls) + .toHaveLength(MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE); + + await expect(() => metadataTokenService.getToken()) + .rejects + .toThrow(); + expect((axios.get as Mock).mock.calls) + .toHaveLength(2 * MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE); + + await expect(() => metadataTokenService.getToken()) + .rejects + .toThrow(); + expect((axios.get as Mock).mock.calls) + .toHaveLength(3 * MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE); + }); +}); diff --git a/src/token-service/metadata-token-service.ts b/src/token-service/metadata-token-service.ts index 03a83966..9a344406 100644 --- a/src/token-service/metadata-token-service.ts +++ b/src/token-service/metadata-token-service.ts @@ -1,6 +1,6 @@ -import axios, { AxiosRequestConfig } from 'axios'; +import axios, {AxiosRequestConfig} from 'axios'; -import { TokenService } from '../types'; +import {TokenService} from '../types'; type Options = AxiosRequestConfig; @@ -11,10 +11,19 @@ const DEFAULT_OPTIONS: Options = { }, }; +import { + TOKEN_MINIMUM_LIFETIME_MARGIN_MS, + TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT, + MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE +} from './metadata-token-service.consts' export class MetadataTokenService implements TokenService { private readonly url: string; private readonly opts: Options; private token?: string; + private tokenExpired = 0; + private tokenTimeToRefresh = 0; + private currentFetchToken?: Promise; + private currentInitialize?: Promise; constructor(url: string = DEFAULT_URL, options: Options = DEFAULT_OPTIONS) { this.url = url; @@ -22,57 +31,82 @@ export class MetadataTokenService implements TokenService { } async getToken(): Promise { - if (!this.token) { - await this.initialize(); - if (!this.token) { - throw new Error('Token is empty after MetadataTokenService.initialize'); + if (!this.token || Date.now() >= this.tokenExpired) { + await this.initialize(); // cay throw error + } else if (Date.now() >= this.tokenTimeToRefresh) { + try { + this.token = await this.fetchToken(); + } catch { + // nothing - use old token } - - return this.token; } - return this.token; + return this.token as string; } private async fetchToken(): Promise { - const res = await axios.get<{ access_token: string }>(this.url, this.opts); + + // deduplicate real fetch token requests in any async case + if (!this.currentFetchToken) { + this.currentFetchToken = this._fetchToken().finally(() => { + delete this.currentFetchToken; + }); + } + + return this.currentFetchToken as Promise; + } + + private async _fetchToken(): Promise { + + const res = await axios.get<{ token_type: 'Bearer', access_token: string, expires_in: number, data: {} }>(this.url, this.opts); if (res.status !== 200) { throw new Error(`failed to fetch token from metadata service: ${res.status} ${res.statusText}`); } + const timeLeft = res.data.expires_in * 1000 - TOKEN_MINIMUM_LIFETIME_MARGIN_MS; + + if (timeLeft <= 0) { + throw new Error('failed to fetch token: insufficient lifetime'); + } + + this.tokenExpired = Date.now() + timeLeft; + this.tokenTimeToRefresh = Date.now() + res.data.expires_in * 1000 * (1 - TOKEN_LIFETIME_LEFT_TO_REFRESH_PCT / 100); + return res.data.access_token; } private async initialize() { - if (this.token) { - return; + + // deduplicate initialize requests in any async case + if (!this.currentInitialize) { + this.currentInitialize = this._initialize().finally(() => { + delete this.currentInitialize; + }); } + return this.currentInitialize as Promise; + } + + private async _initialize() { let lastError = null; - for (let i = 0; i < 5; i++) { + delete this.token; + + for (let i = 0; i < MAX_ATTEMPTS_NUMBER_TO_GET_TOKEN_IN_INITIALIZE; i++) { try { // eslint-disable-next-line no-await-in-loop this.token = await this.fetchToken(); - break; + + return; } catch (error) { lastError = error; } } - if (!this.token) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `failed to fetch token from metadata service: ${lastError}`, - ); - } - setTimeout(async () => { - try { - this.token = await this.fetchToken(); - } catch { - // TBD - } - }, 30_000); + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `failed to fetch token from metadata service: ${lastError}`, + ); } }