Skip to content

Commit

Permalink
Fix request tests, OAuthClient tests + base RESTClient
Browse files Browse the repository at this point in the history
  • Loading branch information
hrax committed Aug 3, 2024
1 parent 5c16978 commit 69bb8b5
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 15 deletions.
3 changes: 1 addition & 2 deletions modules/core/OAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
176 changes: 169 additions & 7 deletions modules/core/RESTClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = any> {
result: Array<T>
}

export default class RESTClient {
export class RESTClient {
static readonly NO_PREFERENCE = "No preference";
private instance: InstanceConfig;
private oauthClient: OAuthClient;

Expand All @@ -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<boolean> {
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");
Expand All @@ -55,7 +65,7 @@ export default class RESTClient {
await this.oauthClient.handleAuthentication(options);

const response: Response = await Request.execute(url, options)
.catch<Response>((reason: any) => {
.catch((reason: any) => {
if (reason instanceof Response) {
const response: Response = reason;
if (!response.isEmpty() && response.isUnauthorized()) {
Expand All @@ -73,15 +83,167 @@ export default class RESTClient {
/*requestUpdateXMLByUpdateSetQuery
requestUpdateXMLByUpdateSetIds
*/

private async getTableParentData(): Promise<RESTResponse<TableParentData>> {
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<RESTResponse<TableFieldData>> {
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<TableConfig> {
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<TableConfig> = 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<TableConfig> {
return Promise.reject("To be implemented!");
const config: TableConfig = {
tables: {}
}
const getParentTree = (table: string, tables: {[ key: string]: TableParentData}, tree: Array<string>) => {
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<TableFieldData> = 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<TableConfig> {
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;
}

}
13 changes: 8 additions & 5 deletions modules/core/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

}
Expand All @@ -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;
Expand Down
132 changes: 132 additions & 0 deletions spec/coreSpec/OAuthClientSpec.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
})
2 changes: 1 addition & 1 deletion spec/coreSpec/RequestSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down

0 comments on commit 69bb8b5

Please sign in to comment.