From 7e27a316b267b57ab5e01565e08a0ef215e15593 Mon Sep 17 00:00:00 2001 From: "Gregor \"hrax\" Magdolen" Date: Sat, 3 Aug 2024 21:41:15 +0200 Subject: [PATCH] OAuthClient test refactor + RESTClient tests --- modules/core/OAuthClient.ts | 4 +- modules/core/RESTClient.ts | 53 ++++++------ spec/coreSpec/OAuthClientSpec.ts | 46 ++++++---- spec/coreSpec/RESTClientSpec.ts | 142 +++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 44 deletions(-) create mode 100644 spec/coreSpec/RESTClientSpec.ts diff --git a/modules/core/OAuthClient.ts b/modules/core/OAuthClient.ts index 5e6732d..3663134 100644 --- a/modules/core/OAuthClient.ts +++ b/modules/core/OAuthClient.ts @@ -36,7 +36,7 @@ export class OAuthUsernamePasswordIncorrect extends Error { } }; -export default class OAuthClient { +export class OAuthClient { private config: InstanceConfig; // create Server for code? @@ -218,6 +218,6 @@ export default class OAuthClient { if (options.headers == null) { options.headers = {}; } - options.headers.authorization = `Bearer ${this.config.auth.token!.access_token}`; + options.headers.authorization = `${this.config.auth.token!.token_type} ${this.config.auth.token!.access_token}`; } } \ No newline at end of file diff --git a/modules/core/RESTClient.ts b/modules/core/RESTClient.ts index 0470859..b58acd2 100644 --- a/modules/core/RESTClient.ts +++ b/modules/core/RESTClient.ts @@ -1,7 +1,8 @@ import { RequestOptions } from "https"; import { URLSearchParams } from "url"; -import OAuthClient from "./OAuthClient"; +import { OAuthClient } from "./OAuthClient"; import { Request, Response } from "./Request"; +import pkg from "../../package.json"; export enum RESPONSE_STATUS { OK = 200, @@ -11,7 +12,7 @@ export enum RESPONSE_STATUS { ERROR = 500 }; -enum TableAPI { +export enum TableAPI { UPDATE_XML_PATH = "/api/now/table/sys_update_xml", UPDATE_SET_PATH = "/api/now/table/sys_update_set", DICTIONARY_PATH = "/api/now/table/sys_dictionary", @@ -19,12 +20,12 @@ enum TableAPI { USER_PREFERENCE_PATH = "/api/now/table/sys_user_preference" } -interface TableFieldData { +export interface TableFieldData { name: string; element: string; } -interface TableParentData { +export interface TableParentData { name: string; "super_class.name": string; } @@ -34,7 +35,7 @@ export interface RESTResponse { } export class RESTClient { - static readonly NO_PREFERENCE = "No preference"; + static readonly NO_TABLE_CONFIG_PREF = "No Table Config preference!"; private instance: InstanceConfig; private oauthClient: OAuthClient; @@ -85,12 +86,12 @@ export class RESTClient { requestUpdateXMLByUpdateSetIds */ - private async getTableParentData(): Promise> { + 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"); + url.searchParams.set("sysparm_fields", "name,super_class.name"); + url.searchParams.set("sysparm_query", "nameBETWEEN @varz^ORnameBETWEENvas@wfz^ORnameBETWEENwg@~^super_class.name!=sys_metadata^ORDERBYname"); const options: RequestOptions = { method: "GET", @@ -102,25 +103,24 @@ export class RESTClient { await this.oauthClient.handleAuthentication(options); - return await Request.json(url, 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(response); } } return Promise.reject(reason); - }); + })).result; } /** * Load table field data * Skips tables whos name starts with wf_ or var_ */ - private async getTableFieldData(): Promise> { + 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"); @@ -136,26 +136,26 @@ export class RESTClient { await this.oauthClient.handleAuthentication(options); - return await Request.json(url, 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(response); } } return Promise.reject(reason); - }); + })).result; } - private async getTableConfigurationPreference(): Promise { - const url: URL = new URL(TableAPI.DICTIONARY_PATH, this.instance.baseUrl); - url.searchParams.set("sysparm_fields", "sys_id"); + async getTableConfigurationPreference(): Promise { + const prefName = `${pkg.name}/table_config`; + const url: URL = new URL(TableAPI.USER_PREFERENCE_PATH, this.instance.baseUrl); 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"); + url.searchParams.set("sysparm_query", `name=${prefName}^userISEMPTY^ORuserDYNAMIC90d1921e5f510100a9ad2572f2b477fe^ORDERBYDESCuser`); const options: RequestOptions = { method: "GET", @@ -172,16 +172,17 @@ export class RESTClient { if (reason instanceof Response) { const response: Response = reason; if (!response.isEmpty() && response.isUnauthorized()) { - return Promise.reject("Unauthorized"); + return Promise.reject(response); } } return Promise.reject(reason); }); if (response.result.length === 0) { - return Promise.reject(RESTClient.NO_PREFERENCE); + return Promise.reject(RESTClient.NO_TABLE_CONFIG_PREF); } + // There should be always at least 1, since we are loading 2 return response.result[0]; } @@ -199,15 +200,15 @@ export class RESTClient { }; // Load Table + Table Parent data - const tables = (await this.getTableParentData()).result.reduce((accumulator, value) => { + const tables = (await this.getTableParentData()).reduce((accumulator, value) => { accumulator[value.name] = value; return accumulator; }, <{[ key: string]: TableParentData}>{}); // Load Table + Table field data - const fields: RESTResponse = await this.getTableFieldData(); + const fields: Array = await this.getTableFieldData(); // Process all loaded fields - fields.result.forEach((data) => { + fields.forEach((data) => { // If table has not been processed yet if (config.tables[data.name] == null) { config.tables[data.name] = { @@ -237,7 +238,7 @@ export class RESTClient { // Load table configuration from user preference let pref: TableConfig = await this.getTableConfigurationPreference() .catch((async (reason) => { - if (reason === RESTClient.NO_PREFERENCE) { + if (reason === RESTClient.NO_TABLE_CONFIG_PREF) { return await this.setupTableConfiguration(); } return Promise.reject(reason); diff --git a/spec/coreSpec/OAuthClientSpec.ts b/spec/coreSpec/OAuthClientSpec.ts index 40c42f1..576bd43 100644 --- a/spec/coreSpec/OAuthClientSpec.ts +++ b/spec/coreSpec/OAuthClientSpec.ts @@ -1,10 +1,11 @@ import exp from "constants"; -import OAuthClient, { OAuthCodeExpired, OAuthRefreshTokenExpired, OAuthUsernamePasswordIncorrect } from "../../modules/core/OAuthClient"; +import { OAuthClient, OAuthCodeExpired, OAuthRefreshTokenExpired, OAuthUsernamePasswordIncorrect } from "../../modules/core/OAuthClient"; import { Request, Response } from "../../modules/core/Request"; import { IncomingMessage } from "http"; import { Socket } from "net"; +import { RequestOptions } from "https"; -describe("OAuthClient", () => { +describe("OAuthClientSpec", () => { const config: InstanceConfig = { name: "test", baseUrl: "https://example.com", @@ -24,8 +25,8 @@ describe("OAuthClient", () => { } }; - describe("#isTokenExpired", () => { - it("token expired if token null", () => { + describe("token validation", () => { + it("should expire if token is null", () => { const token: InstanceOAuthTokenData = { clientID: "clientID", clientSecret: "clientSecret", @@ -34,7 +35,7 @@ describe("OAuthClient", () => { expect(OAuthClient.isTokenExpired(token)).toBeTrue(); }); - it("token expired", () => { + it("should expire", () => { const token: InstanceOAuthTokenData = { clientID: "clientID", clientSecret: "clientSecret", @@ -52,7 +53,7 @@ describe("OAuthClient", () => { expect(OAuthClient.isTokenExpired(token)).toBeTrue(); }); - it("token valid", () => { + it("should be valid", () => { const tokenValid: InstanceOAuthTokenData = { clientID: "clientID", clientSecret: "clientSecret", @@ -72,8 +73,8 @@ describe("OAuthClient", () => { }); }); - describe("#requestTokenByUsername", () => { - it("success", async () => { + describe("request token by username", () => { + it("should resolve on 200", async () => { let response = Response.empty(JSON.stringify(config.auth.token!)); spyOn(response, "isOK").and.returnValue(true); spyOn(Request, "execute").and.resolveTo(response); @@ -81,7 +82,7 @@ describe("OAuthClient", () => { await expectAsync(client.requestTokenByUsername("admin", "admin")).toBeResolvedTo(config.auth.token!); }); - it("unauthorized", async () => { + it("should reject on 401", async () => { let response = Response.empty(JSON.stringify({})); spyOn(response, "isEmpty").and.returnValue(false); spyOn(response, "isUnauthorized").and.returnValue(true); @@ -91,9 +92,9 @@ describe("OAuthClient", () => { }); }); - describe("#requestTokenByCode", () => { + describe("request token by code", () => { const code = "1234"; - it("success", async () => { + it("should resolve on 200", async () => { let response = Response.empty(JSON.stringify(config.auth.token!)); spyOn(response, "isOK").and.returnValue(true); spyOn(Request, "execute").and.resolveTo(response); @@ -101,7 +102,7 @@ describe("OAuthClient", () => { await expectAsync(client.requestTokenByCode(code)).toBeResolvedTo(config.auth.token!); }); - it("unauthorized", async () => { + it("should reject on 401", async () => { let response = Response.empty("{}"); spyOn(response, "isEmpty").and.returnValue(false); spyOn(response, "isUnauthorized").and.returnValue(true); @@ -111,16 +112,16 @@ describe("OAuthClient", () => { }); }); - describe("#refreshToken", () => { - it("success", async () => { + describe("refresh token", () => { + it("should resolve on 200", async () => { let response = Response.empty(JSON.stringify(config.auth.token!)); - spyOn(response, "isOK").and.returnValue(true); + 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 () => { + it("should reject on 401", async () => { let response = Response.empty("{}"); spyOn(response, "isEmpty").and.returnValue(false); spyOn(response, "isUnauthorized").and.returnValue(true); @@ -129,4 +130,17 @@ describe("OAuthClient", () => { await expectAsync(client.refreshToken()).toBeRejectedWith(new OAuthRefreshTokenExpired(response.data)); }); }); + + it("should extend headers with authentication", async() => { + const token: SNOAuthToken = config.auth.token!; + const options: RequestOptions = { + method: "GET" + }; + + const client = new OAuthClient(config); + await client.handleAuthentication(options); + + expect(options.headers).not.toBeUndefined(); + expect(options.headers!.authorization).toBe(`${token.token_type} ${token.access_token}`); + }); }) \ No newline at end of file diff --git a/spec/coreSpec/RESTClientSpec.ts b/spec/coreSpec/RESTClientSpec.ts new file mode 100644 index 0000000..acf56c2 --- /dev/null +++ b/spec/coreSpec/RESTClientSpec.ts @@ -0,0 +1,142 @@ +import { RequestOptions } from "https"; +import { Request, Response } from "../../modules/core/Request"; +import { RESTClient, RESTResponse, TableAPI, TableFieldData, TableParentData } from "../../modules/core/RESTClient"; +import { URLSearchParams } from "url"; + +describe("RESTClientSpec", () => { + 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 + } + } + }; + + const _makeRESTResponse = function(data?: T | Array): RESTResponse { + const response: RESTResponse = { + result: [] + }; + if (data != null) { + if (Array.isArray(data)) { + response.result = data; + return response; + } + response.result.push(data); + } + return response; + } + + describe("loading config from user preference", () => { + const tablePref: TableConfig = { + tables: { + "sys_script_include": { + name: "sys_script_include", + fields: { + "script": { + name: "script" + } + } + } + } + }; + + const url = { + origin: config.baseUrl, + pathname: TableAPI.USER_PREFERENCE_PATH, + search: jasmine.stringContaining(new URLSearchParams("sysparm_query=name=@hrax/now-eslint/table_config^userISEMPTY^ORuserDYNAMIC90d1921e5f510100a9ad2572f2b477fe^ORDERBYDESCuser").toString()) + }; + + it("should succeed if exists", async() => { + const requestExecuteSpy = spyOn(Request, "execute"); + const response = Response.empty(JSON.stringify(_makeRESTResponse(tablePref))); + + spyOn(response, "isEmpty").and.returnValue(false); + spyOn(response, "isOK").and.returnValue(true); + + requestExecuteSpy.withArgs(jasmine.objectContaining(url), jasmine.anything(), undefined).and.resolveTo(response); + + const client = new RESTClient(config); + await expectAsync(client.getTableConfigurationPreference()).toBeResolvedTo(tablePref); + }); + + it("should reject if does not exists", async() => { + const requestExecuteSpy = spyOn(Request, "execute"); + const response = Response.empty(JSON.stringify(_makeRESTResponse())); + + spyOn(response, "isEmpty").and.returnValue(false); + spyOn(response, "isOK").and.returnValue(true); + + requestExecuteSpy.withArgs(jasmine.objectContaining(url), jasmine.anything(), undefined).and.resolveTo(response); + + const client = new RESTClient(config); + await expectAsync(client.getTableConfigurationPreference()).toBeRejectedWith(RESTClient.NO_TABLE_CONFIG_PREF); + }); + }); + + describe("loading table-parent data", () => { + const data: Array = [ + { + name: "sys_script_include", + "super_class.name": "" + }, + { + name: "incident", + "super_class.name": "task" + } + ]; + + const url = { + origin: config.baseUrl, + pathname: TableAPI.DB_OBJECT_PATH, + search: jasmine.stringContaining(new URLSearchParams("sysparm_query=nameBETWEEN @varz^ORnameBETWEENvas@wfz^ORnameBETWEENwg@~^super_class.name!=sys_metadata^ORDERBYname").toString()) + }; + + it("should resolve", async() => { + const response = Response.empty(JSON.stringify(_makeRESTResponse(data))); + spyOn(response, "isEmpty").and.returnValue(false); + spyOn(response, "isOK").and.returnValue(true); + + spyOn(Request, "execute") + .withArgs(jasmine.objectContaining(url), jasmine.anything(), undefined).and.resolveTo(response); + + const client = new RESTClient(config); + await expectAsync(client.getTableParentData()).toBeResolvedTo(data); + }); + }); + + describe("loading table-field data", () => { + const data: Array = [{ + name: "sys_script_include", + element: "script" + }]; + + const url = { + origin: config.baseUrl, + pathname: TableAPI.DICTIONARY_PATH, + search: jasmine.stringContaining(new URLSearchParams("sysparm_query=nameBETWEEN @varz^ORnameBETWEENvas@wfz^ORnameBETWEENwg@~^internal_type=script^ORinternal_type=script_plain^ORinternal_type=script_server^GROUPBYname^ORDERBYelement").toString()) + }; + + it("should resolve", async() => { + const response = Response.empty(JSON.stringify(_makeRESTResponse(data))); + spyOn(response, "isEmpty").and.returnValue(false); + spyOn(response, "isOK").and.returnValue(true); + + spyOn(Request, "execute") + .withArgs(jasmine.objectContaining(url), jasmine.anything(), undefined).and.resolveTo(response); + + const client = new RESTClient(config); + await expectAsync(client.getTableFieldData()).toBeResolvedTo(data); + }); + }); +}); \ No newline at end of file