From e2f888def286b4ffc96e03bc41b04664fa9ebfb6 Mon Sep 17 00:00:00 2001 From: Sebastian Quek Date: Mon, 2 Dec 2019 15:12:17 +0800 Subject: [PATCH] feat: add verifyWithIndividualChecks (#72) --- src/hash/hash.test.ts | 10 +- src/hash/hash.ts | 4 +- src/index.ts | 77 ++++++++++++-- src/verify.integration.test.ts | 177 ++++++++++++++++++++++++++++++++- src/verify.test.ts | 94 +++++++++++------ 5 files changed, 315 insertions(+), 47 deletions(-) diff --git a/src/hash/hash.test.ts b/src/hash/hash.test.ts index fc25ccb5..c3c9381d 100644 --- a/src/hash/hash.test.ts +++ b/src/hash/hash.test.ts @@ -4,11 +4,13 @@ import { documentTampered } from "../../test/fixtures/tampered-document"; describe("verify/hash", () => { describe("verifyHash", () => { - it("should return true for untampered document", () => { - expect(verifyHash(document)).toEqual({ checksumMatch: true }); + it("should return true for untampered document", async () => { + expect(await verifyHash(document)).toEqual({ checksumMatch: true }); }); - it("should return false for tampered document", () => { - expect(verifyHash(documentTampered)).toEqual({ checksumMatch: false }); + it("should return false for tampered document", async () => { + expect(await verifyHash(documentTampered)).toEqual({ + checksumMatch: false + }); }); }); }); diff --git a/src/hash/hash.ts b/src/hash/hash.ts index 265bd41d..599b41b2 100644 --- a/src/hash/hash.ts +++ b/src/hash/hash.ts @@ -3,6 +3,6 @@ import { SchematisedDocument } from "@govtechsg/open-attestation"; -export const verifyHash = (document: SchematisedDocument) => ({ - checksumMatch: verifySignature(document) +export const verifyHash = async (document: SchematisedDocument) => ({ + checksumMatch: await verifySignature(document) }); diff --git a/src/index.ts b/src/index.ts index 1c2047f8..87687b17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,43 @@ import { verifyHash } from "./hash/hash"; import { verifyIssued } from "./issued/verify"; import { verifyRevoked } from "./revoked/verify"; import { documentToSmartContracts } from "./common/smartContract/documentToSmartContracts"; +import { OpenAttestationContract } from "./types"; + +/** + * Unwraps the resolve type of promises + * e.g. + * type foo = () => Promise; + * type bar = ResolveType; // bar is a boolean + */ +type ResolveType = T extends (...args: any[]) => Promise ? U : T; + +type VerificationChecks = [ + ReturnType, + ReturnType, + ReturnType +]; + +type VerificationChecksWithValidity = [ + ReturnType, + ReturnType, + ReturnType, + Promise +]; + +const getAllChecks = ( + document: SignedDocument, + smartContracts: OpenAttestationContract[] +): VerificationChecks => [ + verifyHash(document), + verifyIssued(document, smartContracts), + verifyRevoked(document, smartContracts) +]; + +const isDocumentValid = ( + hash: ResolveType, + issued: ResolveType, + revoked: ResolveType +) => hash.checksumMatch && issued.issuedOnAll && !revoked.revokedOnAny; /** * @param {object} document Entire document object to be validated @@ -14,18 +51,46 @@ export const verify = async ( network = "homestead" ) => { const smartContracts = documentToSmartContracts(document, network); - const [hash, issued, revoked] = await Promise.all([ - verifyHash(document), - verifyIssued(document, smartContracts), - verifyRevoked(document, smartContracts) - ]); + const checks = getAllChecks(document, smartContracts); + const [hash, issued, revoked] = await Promise.all(checks); return { hash, issued, revoked, - valid: hash.checksumMatch && issued.issuedOnAll && !revoked.revokedOnAny + valid: isDocumentValid(hash, issued, revoked) }; }; +/** + * @param {object} document Entire document object to be validated + * @param {string} network Network to check against, defaults to "homestead". Other valid choices: "ropsten", "kovan", etc + * @returns {array} Array of promises, each promise corresponds to a verification check. + * The last promise resolves to the overall validity based on all the checks. + */ +export const verifyWithIndividualChecks = ( + document: SignedDocument, + network = "homestead" +): VerificationChecksWithValidity => { + const smartContracts = documentToSmartContracts(document, network); + const [hash, issued, revoked] = getAllChecks(document, smartContracts); + + // If any of the checks are invalid, resolve the overall validity early + const valid = Promise.all([ + new Promise(async (resolve, reject) => + (await hash).checksumMatch ? resolve() : reject() + ), + new Promise(async (resolve, reject) => + (await issued).issuedOnAll ? resolve() : reject() + ), + new Promise(async (resolve, reject) => + (await revoked).revokedOnAny ? reject() : resolve() + ) + ]) + .then(() => true) + .catch(() => false); + + return [hash, issued, revoked, valid]; +}; + export default verify; // backward compatible diff --git a/src/verify.integration.test.ts b/src/verify.integration.test.ts index 70b8d0f9..3f4fa506 100644 --- a/src/verify.integration.test.ts +++ b/src/verify.integration.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ -import verify from "./index"; +import verify, { verifyWithIndividualChecks } from "./index"; import { documentMainnetValid } from "../test/fixtures/documentMainnetValid"; import { documentTampered } from "../test/fixtures/tampered-document"; import { documentRopstenValid } from "../test/fixtures/documentRopstenValid"; @@ -10,7 +10,7 @@ import { tokenRopstenValid } from "../test/fixtures/tokenRopstenValid"; import { tokenRopstenInvalid } from "../test/fixtures/tokenRopstenInvalid"; describe("verify(integration)", () => { - it("returns false if document is invalid", async () => { + it("returns false if document's hash is invalid and was not issued", async () => { const results = await verify(documentTampered, "ropsten"); expect(results).toEqual( @@ -121,7 +121,7 @@ describe("verify(integration)", () => { }); }); - it("returns false if Ropsten token is invalid", async () => { + it("returns false if Ropsten token was not issued and was revoked", async () => { const results = await verify(tokenRopstenInvalid, "ropsten"); expect(results).toEqual( @@ -152,3 +152,174 @@ describe("verify(integration)", () => { ); }); }); + +describe("verifyWithIndividualChecks(integration)", () => { + it("returns false if document's hash is invalid and was not issued", async () => { + const checkPromises = verifyWithIndividualChecks( + documentTampered, + "ropsten" + ); + const [hash, issued, revoked, valid] = await Promise.all(checkPromises); + + const results = { hash, issued, revoked, valid }; + + expect(results).toEqual( + expect.objectContaining({ + hash: { checksumMatch: false }, + issued: { + issuedOnAll: false, + details: [ + { + address: "0x20bc9C354A18C8178A713B9BcCFFaC2152b53990", + error: expect.stringMatching("exception"), + issued: false + } + ] + }, + revoked: { + revokedOnAny: false, + details: [ + { + address: "0x20bc9C354A18C8178A713B9BcCFFaC2152b53990", + revoked: false + } + ] + }, + valid: false + }) + ); + }); + + it("returns true if Mainnet document is valid", async () => { + const checkPromises = verifyWithIndividualChecks(documentMainnetValid); + const [hash, issued, revoked, valid] = await Promise.all(checkPromises); + + const results = { hash, issued, revoked, valid }; + + expect(results).toEqual({ + hash: { checksumMatch: true }, + issued: { + issuedOnAll: true, + details: [ + { + address: "0x007d40224f6562461633ccfbaffd359ebb2fc9ba", + issued: true + } + ] + }, + revoked: { + revokedOnAny: false, + details: [ + { + address: "0x007d40224f6562461633ccfbaffd359ebb2fc9ba", + revoked: false + } + ] + }, + valid: true + }); + }); + + it("returns true if Ropsten document is valid", async () => { + const checkPromises = verifyWithIndividualChecks( + documentRopstenValid, + "ropsten" + ); + const [hash, issued, revoked, valid] = await Promise.all(checkPromises); + + const results = { hash, issued, revoked, valid }; + + expect(results).toEqual({ + hash: { checksumMatch: true }, + issued: { + issuedOnAll: true, + details: [ + { + address: "0xc36484efa1544c32ffed2e80a1ea9f0dfc517495", + issued: true + } + ] + }, + revoked: { + revokedOnAny: false, + details: [ + { + address: "0xc36484efa1544c32ffed2e80a1ea9f0dfc517495", + revoked: false + } + ] + }, + valid: true + }); + }); + + it("returns true if Ropsten token is valid", async () => { + const checkPromises = verifyWithIndividualChecks( + tokenRopstenValid, + "ropsten" + ); + const [hash, issued, revoked, valid] = await Promise.all(checkPromises); + + const results = { hash, issued, revoked, valid }; + + expect(results).toEqual({ + hash: { checksumMatch: true }, + issued: { + issuedOnAll: true, + details: [ + { + address: "0x48399Fb88bcD031C556F53e93F690EEC07963Af3", + issued: true + } + ] + }, + revoked: { + revokedOnAny: false, + details: [ + { + address: "0x48399Fb88bcD031C556F53e93F690EEC07963Af3", + revoked: false + } + ] + }, + valid: true + }); + }); + + it("returns false if Ropsten token was not issued and was revoked", async () => { + const checkPromises = verifyWithIndividualChecks( + tokenRopstenInvalid, + "ropsten" + ); + const [hash, issued, revoked, valid] = await Promise.all(checkPromises); + + const results = { hash, issued, revoked, valid }; + + expect(results).toEqual( + expect.objectContaining({ + hash: { checksumMatch: true }, + issued: { + issuedOnAll: false, + details: [ + { + address: "0x48399Fb88bcD031C556F53e93F690EEC07963Af3", + error: expect.stringMatching("revert"), + issued: false + } + ] + }, + revoked: { + revokedOnAny: true, + details: [ + { + address: "0x48399Fb88bcD031C556F53e93F690EEC07963Af3", + error: expect.stringMatching("revert"), + revoked: true + } + ] + }, + valid: false + }) + ); + }); +}); diff --git a/src/verify.test.ts b/src/verify.test.ts index 95425999..99540ee2 100644 --- a/src/verify.test.ts +++ b/src/verify.test.ts @@ -3,6 +3,8 @@ const mockVerifyHash = jest.fn(); const mockVerifyIssued = jest.fn(); const mockVerifyRevoked = jest.fn(); +jest.useFakeTimers(); + jest.doMock("./hash/hash", () => ({ verifyHash: mockVerifyHash })); @@ -15,49 +17,77 @@ jest.doMock("./revoked/verify", () => ({ verifyRevoked: mockVerifyRevoked })); -import verify from "./index"; +import { verifyWithIndividualChecks } from "./index"; import { document } from "../test/fixtures/document"; -const whenAllTestPasses = () => { - const valid = true; - mockVerifyHash.mockResolvedValue({ checksumMatch: valid }); - mockVerifyIssued.mockResolvedValue({ issuedOnAll: valid }); - mockVerifyRevoked.mockResolvedValue({ revokedOnAny: !valid }); -}; - -const whenIssueTestFail = () => { - const valid = true; - mockVerifyHash.mockResolvedValue({ checksumMatch: valid }); - mockVerifyIssued.mockResolvedValue({ issuedOnAll: false }); - mockVerifyRevoked.mockResolvedValue({ revokedOnAny: !valid }); -}; - -describe("verify", () => { +describe("verifyWithIndividualChecks", () => { beforeEach(() => { mockVerifyHash.mockReset(); mockVerifyIssued.mockReset(); mockVerifyRevoked.mockReset(); }); - it("returns valid as true when all test passes", async () => { - whenAllTestPasses(); - const summary = await verify(document); - expect(summary).toEqual({ - hash: { checksumMatch: true }, - issued: { issuedOnAll: true }, - revoked: { revokedOnAny: false }, - valid: true + it("returns valid as true only after all tests have passed", async () => { + mockVerifyHash.mockResolvedValue({ checksumMatch: true }); + mockVerifyIssued.mockImplementation( + () => + new Promise(res => setTimeout(() => res({ issuedOnAll: true }), 1000)) + ); + mockVerifyRevoked.mockImplementation( + () => + new Promise(res => setTimeout(() => res({ revokedOnAny: false }), 1500)) + ); + + let hasResolvedValid = false; + const [hash, issued, revoked, valid] = verifyWithIndividualChecks(document); + valid.then(() => { + hasResolvedValid = true; }); + + expect(await hash).toEqual({ checksumMatch: true }); + expect(hasResolvedValid).toBe(false); + + jest.advanceTimersByTime(1000); + + expect(await issued).toEqual({ issuedOnAll: true }); + expect(hasResolvedValid).toBe(false); + + jest.runAllTimers(); + + expect(await revoked).toEqual({ revokedOnAny: false }); + expect(await valid).toBe(true); + expect(hasResolvedValid).toBe(true); }); - it("returns valid as false when any test passes", async () => { - whenIssueTestFail(); - const summary = await verify(document); - expect(summary).toEqual({ - hash: { checksumMatch: true }, - issued: { issuedOnAll: false }, - revoked: { revokedOnAny: false }, - valid: false + it("returns valid as false immediately when any test fails", async () => { + mockVerifyHash.mockResolvedValue({ checksumMatch: true }); + mockVerifyIssued.mockImplementation( + () => + new Promise(res => setTimeout(() => res({ issuedOnAll: false }), 1000)) + ); + mockVerifyRevoked.mockImplementation( + () => + new Promise(res => setTimeout(() => res({ revokedOnAny: false }), 1500)) + ); + + let hasResolvedRevoked = false; + const [hash, issued, revoked, valid] = verifyWithIndividualChecks(document); + revoked.then(() => { + hasResolvedRevoked = true; }); + + expect(await hash).toEqual({ checksumMatch: true }); + expect(hasResolvedRevoked).toBe(false); + + jest.advanceTimersByTime(1000); + + expect(await issued).toEqual({ issuedOnAll: false }); // Since issued check is falsy, document is overall invalid + expect(await valid).toBe(false); // Return the overall validity early + expect(hasResolvedRevoked).toBe(false); // The result of this is inconsequential to the overal validity + + jest.runAllTimers(); + + expect(await revoked).toEqual({ revokedOnAny: false }); + expect(hasResolvedRevoked).toBe(true); }); });