diff --git a/__tests__/unit/core-webhooks/conditions.test.ts b/__tests__/unit/core-webhooks/conditions.test.ts new file mode 100644 index 0000000000..01c623d77c --- /dev/null +++ b/__tests__/unit/core-webhooks/conditions.test.ts @@ -0,0 +1,157 @@ +import "jest-extended"; +import { + between, + contains, + eq, + falsy, + gt, + gte, + lt, + lte, + ne, + notBetween, + regexp, + truthy, +} from "@packages/core-webhooks/src/conditions"; + +describe("Conditions - between", () => { + it("should be true", () => { + expect( + between(1.5, { + min: 1, + max: 2, + }), + ).toBeTrue(); + }); + + it("should be false", () => { + expect( + between(3, { + min: 1, + max: 2, + }), + ).toBeFalse(); + }); +}); + +describe("Conditions - contains", () => { + it("should be true", () => { + expect(contains("Hello World", "Hello")).toBeTrue(); + }); + + it("should be false", () => { + expect(contains("Hello World", "invalid")).toBeFalse(); + }); +}); + +describe("Conditions - equal", () => { + it("should be true", () => { + expect(eq(1, 1)).toBeTrue(); + }); + + it("should be false", () => { + expect(eq(1, 2)).toBeFalse(); + }); +}); + +describe("Conditions - falsy", () => { + it("should be true", () => { + expect(falsy(false)).toBeTrue(); + }); + + it("should be false", () => { + expect(falsy(true)).toBeFalse(); + }); +}); + +describe("Conditions - greater than", () => { + it("should be true", () => { + expect(gt(2, 1)).toBeTrue(); + }); + + it("should be false", () => { + expect(gt(1, 2)).toBeFalse(); + }); +}); + +describe("Conditions - greater than or equal", () => { + it("should be true", () => { + expect(gte(2, 1)).toBeTrue(); + expect(gte(2, 2)).toBeTrue(); + }); + + it("should be false", () => { + expect(gte(1, 2)).toBeFalse(); + }); +}); + +describe("Conditions - less than", () => { + it("should be true", () => { + expect(lt(1, 2)).toBeTrue(); + }); + + it("should be false", () => { + expect(lt(2, 1)).toBeFalse(); + }); +}); + +describe("Conditions - less than or equal", () => { + it("should be true", () => { + expect(lte(1, 2)).toBeTrue(); + expect(lte(1, 1)).toBeTrue(); + }); + + it("should be false", () => { + expect(lte(2, 1)).toBeFalse(); + }); +}); + +describe("Conditions - not equal", () => { + it("should be true", () => { + expect(ne(1, 2)).toBeTrue(); + }); + + it("should be false", () => { + expect(ne(1, 1)).toBeFalse(); + }); +}); + +describe("Conditions - not-between", () => { + it("should be true", () => { + expect( + notBetween(3, { + min: 1, + max: 2, + }), + ).toBeTrue(); + }); + + it("should be false", () => { + expect( + notBetween(1.5, { + min: 1, + max: 2, + }), + ).toBeFalse(); + }); +}); + +describe("Conditions - regexp", () => { + it("should be true", () => { + expect(regexp("hello world!", "hello")).toBeTrue(); + }); + + it("should be false", () => { + expect(regexp(123, "w+")).toBeFalse(); + }); +}); + +describe("Conditions - truthy", () => { + it("should be true", () => { + expect(truthy(true)).toBeTrue(); + }); + + it("should be false", () => { + expect(truthy(false)).toBeFalse(); + }); +}); diff --git a/__tests__/unit/core-webhooks/database.test.ts b/__tests__/unit/core-webhooks/database.test.ts new file mode 100644 index 0000000000..4f8bc0e826 --- /dev/null +++ b/__tests__/unit/core-webhooks/database.test.ts @@ -0,0 +1,94 @@ +import "jest-extended"; + +import { Application } from "@packages/core-kernel/src/application"; +import { Container, interfaces } from "@packages/core-kernel/src/ioc"; +import { Database } from "@packages/core-webhooks/src/database"; +import { Webhook } from "@packages/core-webhooks/src/interfaces"; +import { dirSync, setGracefulCleanup } from "tmp"; + +const dummyWebhook: Webhook = { + id: "id", + token: "token", + event: "event", + target: "target", + enabled: true, + conditions: [ + { + key: "key", + value: "value", + condition: "condition", + }, + ], +}; + +let app: Application; +let container: interfaces.Container; +let database: Database; + +beforeEach(() => { + container = new Container(); + container.snapshot(); + + app = new Application(container); + app.bind("path.cache").toConstantValue(dirSync().name); + + app.bind("webhooks.db") + .to(Database) + .inSingletonScope(); + + database = app.get("webhooks.db"); +}); + +afterEach(() => container.restore()); + +afterAll(() => setGracefulCleanup()); + +describe("Database", () => { + it("should return all webhooks", () => { + database.create(dummyWebhook); + + expect(database.all()).toHaveLength(1); + }); + + it("should find a webhook by its id", () => { + const webhook = database.create(dummyWebhook); + + expect(database.findById(webhook.id)).toEqual(webhook); + }); + + it("should find webhooks by their event", () => { + const webhook: Webhook = database.create(dummyWebhook); + + const rows = database.findByEvent("event"); + + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual(webhook); + }); + + it("should return an empty array if there are no webhooks for an event", () => { + expect(database.findByEvent("event")).toHaveLength(0); + }); + + it("should create a new webhook", () => { + const webhook: Webhook = database.create(dummyWebhook); + + expect(database.create(webhook)).toEqual(webhook); + }); + + it("should update an existing webhook", () => { + const webhook: Webhook = database.create(dummyWebhook); + const updated: Webhook = database.update(webhook.id, dummyWebhook); + + expect(database.findById(webhook.id)).toEqual(updated); + }); + + it("should delete an existing webhook", () => { + const webhook: Webhook = database.create(dummyWebhook); + + expect(database.findById(webhook.id)).toEqual(webhook); + + database.destroy(webhook.id); + + expect(database.findById(webhook.id)).toBeUndefined(); + }); +}); diff --git a/__tests__/unit/core-webhooks/server.test.ts b/__tests__/unit/core-webhooks/server.test.ts new file mode 100644 index 0000000000..90570399c6 --- /dev/null +++ b/__tests__/unit/core-webhooks/server.test.ts @@ -0,0 +1,148 @@ +import "jest-extended"; + +import { Application } from "@packages/core-kernel/src/application"; +import { Container, Identifiers, interfaces } from "@packages/core-kernel/src/ioc"; +import { Enums } from "@packages/core-kernel/src"; +import { Database } from "@packages/core-webhooks/src/database"; +import { dirSync, setGracefulCleanup } from "tmp"; +import { Server } from "@hapi/hapi"; +import { startServer } from "@packages/core-webhooks/src/server"; + +const postData = { + event: Enums.Events.State.BlockForged, + target: "https://httpbin.org/post", + enabled: true, + conditions: [ + { + key: "generatorPublicKey", + condition: "eq", + value: "test-generator", + }, + { + key: "fee", + condition: "gte", + value: "123", + }, + ], +}; + +const request = async (server, method, path, payload = {}) => { + const response = await server.inject({ method, url: `http://localhost:4004/api/${path}`, payload }); + + return { body: response.result, status: response.statusCode }; +}; + +const createWebhook = (server, data?: any) => request(server, "POST", "webhooks", data || postData); + +let server: Server; +let app: Application; +let container: interfaces.Container; + +beforeEach(async () => { + container = new Container(); + container.snapshot(); + + app = new Application(container); + app.bind(Identifiers.LogService).toConstantValue({ info: jest.fn(), debug: jest.fn() }); + app.bind("path.cache").toConstantValue(dirSync().name); + + app.bind("webhooks.db") + .to(Database) + .inSingletonScope(); + + server = await startServer(app, { + host: "0.0.0.0", + port: 4004, + whitelist: ["127.0.0.1", "::ffff:127.0.0.1"], + }); +}); + +afterEach(async () => { + container.restore(); + + await server.stop(); +}); + +afterAll(() => setGracefulCleanup()); + +describe("Webhooks", () => { + it("should GET all the webhooks", async () => { + const response = await request(server, "GET", "webhooks"); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body.data)).toBe(true); + }); + + it("should POST a new webhook with a simple condition", async () => { + const response = await createWebhook(server); + expect(response.status).toBe(201); + expect(response.body.data).toBeObject(); + }); + + it("should POST a new webhook with a complex condition", async () => { + const response = await createWebhook(server, { + event: Enums.Events.State.BlockForged, + target: "https://httpbin.org/post", + enabled: true, + conditions: [ + { + key: "fee", + condition: "between", + value: { + min: 1, + max: 2, + }, + }, + ], + }); + expect(response.status).toBe(201); + expect(response.body.data).toBeObject(); + }); + + it("should POST a new webhook with an empty array as condition", async () => { + const response = await createWebhook(server, { + event: Enums.Events.State.BlockForged, + target: "https://httpbin.org/post", + enabled: true, + conditions: [], + }); + expect(response.status).toBe(201); + expect(response.body.data).toBeObject(); + }); + + it("should GET a webhook by the given id", async () => { + const { body } = await createWebhook(server); + + const response = await request(server, "GET", `webhooks/${body.data.id}`); + expect(response.status).toBe(200); + expect(response.body.data).toBeObject(); + + delete body.data.token; + + expect(response.body.data).toEqual(body.data); + }); + + it("should fail to GET a webhook by the given id", async () => { + expect((await request(server, "GET", `webhooks/123`)).status).toBe(404); + }); + + it("should PUT a webhook by the given id", async () => { + const { body } = await createWebhook(server); + + expect((await request(server, "PUT", `webhooks/${body.data.id}`, postData)).status).toBe(204); + }); + + it("should fail to PUT a webhook by the given id", async () => { + expect((await request(server, "PUT", `webhooks/123`, postData)).status).toBe(404); + }); + + it("should DELETE a webhook by the given id", async () => { + const { body } = await createWebhook(server); + + expect((await request(server, "DELETE", `webhooks/${body.data.id}`)).status).toBe(204); + }); + + it("should fail to DELETE a webhook by the given id", async () => { + expect((await request(server, "DELETE", `webhooks/123`)).status).toBe(404); + }); +}); diff --git a/packages/core-kernel/src/index.ts b/packages/core-kernel/src/index.ts index 2a8c6a412a..7e8b580429 100644 --- a/packages/core-kernel/src/index.ts +++ b/packages/core-kernel/src/index.ts @@ -1,9 +1,9 @@ import { Application } from "./application"; import { container } from "./container"; -import * as Container from "./ioc"; import * as Contracts from "./contracts"; import * as Enums from "./enums"; import * as Exceptions from "./exceptions"; +import * as Container from "./ioc"; import * as Providers from "./providers"; import * as Services from "./services"; import * as Support from "./support"; diff --git a/packages/core-webhooks/src/database.ts b/packages/core-webhooks/src/database.ts index 0f819fe1cc..8a1b17cd0a 100644 --- a/packages/core-webhooks/src/database.ts +++ b/packages/core-webhooks/src/database.ts @@ -1,15 +1,19 @@ -import { ensureFileSync, existsSync, removeSync } from "fs-extra"; +import { Container, Contracts } from "@arkecosystem/core-kernel"; +import { ensureFileSync, existsSync } from "fs-extra"; import lowdb from "lowdb"; import FileSync from "lowdb/adapters/FileSync"; import uuidv4 from "uuid/v4"; import { Webhook } from "./interfaces"; -class Database { - private database: lowdb.LowdbSync; +@Container.injectable() +export class Database { + private readonly database: lowdb.LowdbSync; - public make(): void { - const adapterFile = `${process.env.CORE_PATH_CACHE}/webhooks.json`; + public constructor( + @Container.inject(Container.Identifiers.Application) private readonly app: Contracts.Kernel.Application, + ) { + const adapterFile = this.app.cachePath("webhooks.json"); if (!existsSync(adapterFile)) { ensureFileSync(adapterFile); @@ -70,12 +74,4 @@ class Database { .remove({ id }) .write(); } - - public reset(): void { - removeSync(`${process.env.CORE_PATH_CACHE}/webhooks.json`); - - this.make(); - } } - -export const database = new Database(); diff --git a/packages/core-webhooks/src/index.ts b/packages/core-webhooks/src/index.ts index f6d1aa8587..5ec911b2e4 100644 --- a/packages/core-webhooks/src/index.ts +++ b/packages/core-webhooks/src/index.ts @@ -1,6 +1,6 @@ import { Providers } from "@arkecosystem/core-kernel"; -import { database } from "./database"; +import { Database } from "./database"; import { startListeners } from "./listener"; import { startServer } from "./server"; @@ -11,11 +11,14 @@ export class ServiceProvider extends Providers.ServiceProvider { return; } - database.make(); + this.app + .bind("webhooks.db") + .to(Database) + .inSingletonScope(); - startListeners(); + startListeners(this.app); - this.app.bind("webhooks").toConstantValue(await startServer(this.config().get("server"))); + this.app.bind("webhooks").toConstantValue(await startServer(this.app, this.config().get("server"))); this.app.bind("webhooks.options").toConstantValue(this.config().all()); } diff --git a/packages/core-webhooks/src/listener.ts b/packages/core-webhooks/src/listener.ts index a74e4436a1..9d3f064e3f 100644 --- a/packages/core-webhooks/src/listener.ts +++ b/packages/core-webhooks/src/listener.ts @@ -1,15 +1,16 @@ -import { app, Container, Contracts, Enums, Utils } from "@arkecosystem/core-kernel"; +import { Contracts, Enums, Utils } from "@arkecosystem/core-kernel"; import * as conditions from "./conditions"; -import { database } from "./database"; +import { Database } from "./database"; import { Webhook } from "./interfaces"; -export const startListeners = (): void => { +export const startListeners = (app: Contracts.Kernel.Application): void => { for (const event of Object.values(Enums.Events.State)) { - app.get(Container.Identifiers.EventDispatcherService).listen( - event, - async payload => { - const webhooks: Webhook[] = database.findByEvent(event).filter((webhook: Webhook) => { + app.events.listen(event, async payload => { + const webhooks: Webhook[] = app + .get("webhooks.db") + .findByEvent(event) + .filter((webhook: Webhook) => { if (!webhook.enabled) { return false; } @@ -33,28 +34,29 @@ export const startListeners = (): void => { return false; }); - for (const webhook of webhooks) { - try { - const { status } = await Utils.httpie.post(webhook.target, { - body: { - timestamp: +new Date(), - data: payload, - event: webhook.event, - }, - headers: { - Authorization: webhook.token, - }, - timeout: app.get("webhooks.options").timeout, - }); - - app.log.debug( - `Webhooks Job ${webhook.id} completed! Event [${webhook.event}] has been transmitted to [${webhook.target}] with a status of [${status}].`, - ); - } catch (error) { - app.log.error(`Webhooks Job ${webhook.id} failed: ${error.message}`); - } + for (const webhook of webhooks) { + try { + const { status } = await Utils.httpie.post(webhook.target, { + body: { + timestamp: +new Date(), + data: payload, + event: webhook.event, + }, + headers: { + Authorization: webhook.token, + }, + timeout: app.get("webhooks.options").timeout, + }); + console.log(status); + + // app.log.debug( + // `Webhooks Job ${webhook.id} completed! Event [${webhook.event}] has been transmitted to [${webhook.target}] with a status of [${status}].`, + // ); + } catch (error) { + console.log(error); + // app.log.error(`Webhooks Job ${webhook.id} failed: ${error.message}`); } - }, - ); + } + }); } }; diff --git a/packages/core-webhooks/src/server/index.ts b/packages/core-webhooks/src/server/index.ts index 30dc2b094b..2a7a547156 100644 --- a/packages/core-webhooks/src/server/index.ts +++ b/packages/core-webhooks/src/server/index.ts @@ -1,13 +1,16 @@ -import { createServer, mountServer, plugins } from "@arkecosystem/core-http-utils"; +import { createServer, plugins } from "@arkecosystem/core-http-utils"; +import { Contracts } from "@arkecosystem/core-kernel"; import Boom from "@hapi/boom"; import { randomBytes } from "crypto"; -import { database } from "../database"; +import { Database } from "../database"; import { Webhook } from "../interfaces"; import * as schema from "./schema"; import * as utils from "./utils"; -export const startServer = async config => { +export const startServer = async (app: Contracts.Kernel.Application, config) => { + const database = app.get("webhooks.db"); + const server = await createServer({ host: config.host, port: config.port, @@ -73,7 +76,9 @@ export const startServer = async config => { return Boom.notFound(); } - const webhook: Webhook = { ...database.findById(request.params.id) }; + const webhook: Webhook = { + ...database.findById(request.params.id), + }; delete webhook.token; return utils.respondWithResource(webhook); @@ -117,5 +122,9 @@ export const startServer = async config => { }, }); - return mountServer("Webhook API", server); + await server.start(); + + return server; + + // return mountServer("Webhook API", server); };