diff --git a/modules/core/OAuthClient.ts b/modules/core/OAuthClient.ts index f390389..5e6732d 100644 --- a/modules/core/OAuthClient.ts +++ b/modules/core/OAuthClient.ts @@ -49,10 +49,9 @@ export default class OAuthClient { if (oauth.token == null) { return true; } - const hadTokenFor = Date.now() - oauth.lastRetrieved + 10000; const expiresIn = oauth.token.expires_in * 1000; - return hadTokenFor < expiresIn; + return hadTokenFor > expiresIn; } constructor(config: InstanceConfig) { diff --git a/modules/core/RESTClient.ts b/modules/core/RESTClient.ts index 7848f80..0470859 100644 --- a/modules/core/RESTClient.ts +++ b/modules/core/RESTClient.ts @@ -19,11 +19,22 @@ enum TableAPI { USER_PREFERENCE_PATH = "/api/now/table/sys_user_preference" } -export class RESTTableData { +interface TableFieldData { + name: string; + element: string; +} + +interface TableParentData { + name: string; + "super_class.name": string; +} +export interface RESTResponse { + result: Array } -export default class RESTClient { +export class RESTClient { + static readonly NO_PREFERENCE = "No preference"; private instance: InstanceConfig; private oauthClient: OAuthClient; @@ -37,7 +48,6 @@ export default class RESTClient { * @returns true if connection is succesfull; Rejects promise with an error message if connection fails */ async testConnection(): Promise { - const oauth: InstanceAuthenticationData = this.instance.auth; const url: URL = new URL(TableAPI.USER_PREFERENCE_PATH, this.instance.baseUrl); url.searchParams.set("sysparm_fields", "sys_id"); url.searchParams.set("sysparm_limit", "1"); @@ -55,7 +65,7 @@ export default class RESTClient { await this.oauthClient.handleAuthentication(options); const response: Response = await Request.execute(url, options) - .catch((reason: any) => { + .catch((reason: any) => { if (reason instanceof Response) { const response: Response = reason; if (!response.isEmpty() && response.isUnauthorized()) { @@ -73,15 +83,167 @@ export default class RESTClient { /*requestUpdateXMLByUpdateSetQuery requestUpdateXMLByUpdateSetIds + */ + + private async getTableParentData(): Promise> { + const url: URL = new URL(TableAPI.DB_OBJECT_PATH, this.instance.baseUrl); + url.searchParams.set("sysparm_fields", "sys_id"); + url.searchParams.set("sysparm_limit", "1"); + url.searchParams.set("sysparm_no_count", "true"); + url.searchParams.set("sysparm_suppress_pagination_header", "true"); + + const options: RequestOptions = { + method: "GET", + headers: { + "content-type": "application/json", + "accept": "application/json" + } + }; + + await this.oauthClient.handleAuthentication(options); + + return await Request.json(url, options) + .catch((reason: any) => { + if (reason instanceof Response) { + const response: Response = reason; + if (!response.isEmpty() && response.isUnauthorized()) { + return Promise.reject("Unauthorized"); + } + } + return Promise.reject(reason); + }); + } + + /** + * Load table field data + * Skips tables whos name starts with wf_ or var_ + */ + private async getTableFieldData(): Promise> { + const url: URL = new URL(TableAPI.DICTIONARY_PATH, this.instance.baseUrl); + url.searchParams.set("sysparm_fields", "sys_id"); + url.searchParams.set("sysparm_no_count", "true"); + url.searchParams.set("sysparm_suppress_pagination_header", "true"); + url.searchParams.set("sysparm_fields", "name,element"); + url.searchParams.set("sysparm_query", "nameBETWEEN @varz^ORnameBETWEENvas@wfz^ORnameBETWEENwg@~^internal_type=script^ORinternal_type=script_plain^ORinternal_type=script_server^GROUPBYname^ORDERBYelement"); + + const options: RequestOptions = { + method: "GET", + headers: { + "content-type": "application/json", + "accept": "application/json" + } + }; + + await this.oauthClient.handleAuthentication(options); + + return await Request.json(url, options) + .catch((reason: any) => { + if (reason instanceof Response) { + const response: Response = reason; + if (!response.isEmpty() && response.isUnauthorized()) { + return Promise.reject("Unauthorized"); + } + } + return Promise.reject(reason); + }); + } + + private async getTableConfigurationPreference(): Promise { + const url: URL = new URL(TableAPI.DICTIONARY_PATH, this.instance.baseUrl); + url.searchParams.set("sysparm_fields", "sys_id"); + url.searchParams.set("sysparm_no_count", "true"); + url.searchParams.set("sysparm_suppress_pagination_header", "true"); + url.searchParams.set("sysparm_limit", "2"); + url.searchParams.set("sysparm_fields", "name,value"); + url.searchParams.set("sysparm_query", "nameBETWEEN @varz^ORnameBETWEENvas@wfz^ORnameBETWEENwg@~^internal_type=script^ORinternal_type=script_plain^ORinternal_type=script_server^GROUPBYname^ORDERBYelement"); + + const options: RequestOptions = { + method: "GET", + headers: { + "content-type": "application/json", + "accept": "application/json" + } + }; + + await this.oauthClient.handleAuthentication(options); + + const response: RESTResponse = await Request.json(url, options) + .catch((reason: any) => { + if (reason instanceof Response) { + const response: Response = reason; + if (!response.isEmpty() && response.isUnauthorized()) { + return Promise.reject("Unauthorized"); + } + } + return Promise.reject(reason); + }); + + if (response.result.length === 0) { + return Promise.reject(RESTClient.NO_PREFERENCE); + } - requestTable*/ + return response.result[0]; + } async setupTableConfiguration(): Promise { - return Promise.reject("To be implemented!"); + const config: TableConfig = { + tables: {} + } + const getParentTree = (table: string, tables: {[ key: string]: TableParentData}, tree: Array) => { + if (tables[table] == null) { + return tree; + } + const parent = tables[table]["super_class.name"]; + tree.push(parent); + return getParentTree(parent, tables, tree); + }; + + // Load Table + Table Parent data + const tables = (await this.getTableParentData()).result.reduce((accumulator, value) => { + accumulator[value.name] = value; + return accumulator; + }, <{[ key: string]: TableParentData}>{}); + // Load Table + Table field data + const fields: RESTResponse = await this.getTableFieldData(); + + // Process all loaded fields + fields.result.forEach((data) => { + // If table has not been processed yet + if (config.tables[data.name] == null) { + config.tables[data.name] = { + name: data.name, + fields: {} + } + } + config.tables[data.name].fields[data.element] = { + name: data.element + } + }); + + // For each configured table, find all its parents and merge fields + Object.keys(config.tables).forEach((table) => { + getParentTree(table, tables, []).forEach((parent) => { + if (config.tables[parent] == null) { + return; + } + config.tables[table].fields = Object.assign({}, config.tables[table].fields, config.tables[parent].fields); + }); + }) + + return config; } async getTableConfiguration(): Promise { - return Promise.reject("To be implemented!"); + // Load table configuration from user preference + let pref: TableConfig = await this.getTableConfigurationPreference() + .catch((async (reason) => { + if (reason === RESTClient.NO_PREFERENCE) { + return await this.setupTableConfiguration(); + } + return Promise.reject(reason); + })); + + return pref; } } \ No newline at end of file diff --git a/modules/core/Request.ts b/modules/core/Request.ts index 01bcc9b..866a0c2 100644 --- a/modules/core/Request.ts +++ b/modules/core/Request.ts @@ -102,11 +102,7 @@ export class Request { return Promise.reject(new Error("Response body is empty.")); } - const parsed: any = JSON.parse(response.data); - if (!response.isOK()) { - return Promise.reject(parsed) - } - return parsed; + return response.dataAsJSON(); } } @@ -132,6 +128,13 @@ export class Response { return this.data !== ""; } + dataAsJSON(): any { + if (!this.hasData()) { + return null; + } + return JSON.parse(this.data); + } + private isStatus(message: IncomingMessage | null, status: ResponseStatus) { if (message == null) { return false; diff --git a/spec/coreSpec/OAuthClientSpec.ts b/spec/coreSpec/OAuthClientSpec.ts new file mode 100644 index 0000000..40c42f1 --- /dev/null +++ b/spec/coreSpec/OAuthClientSpec.ts @@ -0,0 +1,132 @@ +import exp from "constants"; +import OAuthClient, { OAuthCodeExpired, OAuthRefreshTokenExpired, OAuthUsernamePasswordIncorrect } from "../../modules/core/OAuthClient"; +import { Request, Response } from "../../modules/core/Request"; +import { IncomingMessage } from "http"; +import { Socket } from "net"; + +describe("OAuthClient", () => { + const config: InstanceConfig = { + name: "test", + baseUrl: "https://example.com", + auth: { + type: "oauth-token", + clientID: "clientID", + clientSecret: "clientSecret", + lastRetrieved: Date.now(), + token: { + access_token: "aaa", + refresh_token: "bbb", + scope: "", + token_type: "Bearer", + // seconds + expires_in: 60 + } + } + }; + + describe("#isTokenExpired", () => { + it("token expired if token null", () => { + const token: InstanceOAuthTokenData = { + clientID: "clientID", + clientSecret: "clientSecret", + lastRetrieved: 0 + }; + + expect(OAuthClient.isTokenExpired(token)).toBeTrue(); + }); + it("token expired", () => { + const token: InstanceOAuthTokenData = { + clientID: "clientID", + clientSecret: "clientSecret", + // current time -1 hour + lastRetrieved: Date.now() - (60 * 60 * 1000), + token: { + access_token: "aaa", + refresh_token: "bbb", + scope: "", + token_type: "Bearer", + // seconds + expires_in: 60 + } + }; + + expect(OAuthClient.isTokenExpired(token)).toBeTrue(); + }); + it("token valid", () => { + const tokenValid: InstanceOAuthTokenData = { + clientID: "clientID", + clientSecret: "clientSecret", + // current time - 10 sec + lastRetrieved: Date.now() - 10000, + token: { + access_token: "aaa", + refresh_token: "bbb", + scope: "", + token_type: "Bearer", + // seconds + expires_in: 60 + } + }; + + expect(OAuthClient.isTokenExpired(tokenValid)).toBeFalse(); + }); + }); + + describe("#requestTokenByUsername", () => { + it("success", async () => { + let response = Response.empty(JSON.stringify(config.auth.token!)); + spyOn(response, "isOK").and.returnValue(true); + spyOn(Request, "execute").and.resolveTo(response); + const client = new OAuthClient(config); + await expectAsync(client.requestTokenByUsername("admin", "admin")).toBeResolvedTo(config.auth.token!); + }); + + it("unauthorized", async () => { + let response = Response.empty(JSON.stringify({})); + spyOn(response, "isEmpty").and.returnValue(false); + spyOn(response, "isUnauthorized").and.returnValue(true); + spyOn(Request, "execute").and.rejectWith(response); + const client = new OAuthClient(config); + await expectAsync(client.requestTokenByUsername("admin", "admin")).toBeRejectedWith(new OAuthUsernamePasswordIncorrect(response.data)); + }); + }); + + describe("#requestTokenByCode", () => { + const code = "1234"; + it("success", async () => { + let response = Response.empty(JSON.stringify(config.auth.token!)); + spyOn(response, "isOK").and.returnValue(true); + spyOn(Request, "execute").and.resolveTo(response); + const client = new OAuthClient(config); + await expectAsync(client.requestTokenByCode(code)).toBeResolvedTo(config.auth.token!); + }); + + it("unauthorized", async () => { + let response = Response.empty("{}"); + spyOn(response, "isEmpty").and.returnValue(false); + spyOn(response, "isUnauthorized").and.returnValue(true); + spyOn(Request, "execute").and.rejectWith(response); + const client = new OAuthClient(config); + await expectAsync(client.requestTokenByCode(code)).toBeRejectedWith(new OAuthCodeExpired(response.data)); + }); + }); + + describe("#refreshToken", () => { + it("success", async () => { + let response = Response.empty(JSON.stringify(config.auth.token!)); + spyOn(response, "isOK").and.returnValue(true); + spyOn(Request, "execute").and.resolveTo(response); + const client = new OAuthClient(config); + await expectAsync(client.refreshToken()).toBeResolvedTo(config.auth.token!); + }); + + it("unauthorized", async () => { + let response = Response.empty("{}"); + spyOn(response, "isEmpty").and.returnValue(false); + spyOn(response, "isUnauthorized").and.returnValue(true); + spyOn(Request, "execute").and.rejectWith(response); + const client = new OAuthClient(config); + await expectAsync(client.refreshToken()).toBeRejectedWith(new OAuthRefreshTokenExpired(response.data)); + }); + }); +}) \ No newline at end of file diff --git a/spec/coreSpec/RequestSpec.ts b/spec/coreSpec/RequestSpec.ts index 28e6120..257313b 100644 --- a/spec/coreSpec/RequestSpec.ts +++ b/spec/coreSpec/RequestSpec.ts @@ -8,7 +8,7 @@ describe("Request", () => { const options: RequestOptions = { method: "GET" } - await expectAsync(Request.execute(url, options)).toBeRejectedWithError("URL protocol must be https!"); + await expectAsync(Request.execute(url, options)).toBeRejectedWith(Response.empty("URL protocol must be https!")); }) it("Returns response on 200 OK status code", async () => {