Skip to content

Commit

Permalink
Feed Authz with approved transfers (#57)
Browse files Browse the repository at this point in the history
Add test coverage for authorization request evaluation

Incorporate domain fixtures supported by the zod-fixture library, ensuring type
safety in mocks and auto-generated data based on schemas.
  • Loading branch information
wcalderipe authored Jan 25, 2024
1 parent aad087d commit ed36881
Show file tree
Hide file tree
Showing 14 changed files with 323 additions and 67 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { addressGenerator, chainIdGenerator, hexGenerator } from '@app/orchestration/__test__/fixture/shared.fixture'
import { Approval, AuthorizationRequest, SignTransaction } from '@app/orchestration/policy-engine/core/type/domain.type'
import { readRequestSchema } from '@app/orchestration/policy-engine/persistence/schema/request.schema'
import { readSignTransactionSchema } from '@app/orchestration/policy-engine/persistence/schema/sign-transaction.schema'
import { signatureSchema } from '@app/orchestration/policy-engine/persistence/schema/signature.schema'
import { Decision, Signature } from '@narval/authz-shared'
import { AuthorizationRequestStatus } from '@prisma/client/orchestration'
import { z } from 'zod'
import { Fixture } from 'zod-fixture'

const approvalSchema = signatureSchema.extend({
id: z.string().uuid(),
createdAt: z.date()
})

const evaluationSchema = z.object({
id: z.string().uuid(),
decision: z.nativeEnum(Decision),
signature: z.string().nullable(),
createdAt: z.date()
})

const authorizationRequestSchema = z.object({
id: z.string().uuid(),
orgId: z.string().uuid(),
status: z.nativeEnum(AuthorizationRequestStatus),
request: readRequestSchema,
authentication: signatureSchema,
approvals: z.array(approvalSchema),
evaluations: z.array(evaluationSchema),
idempotencyKey: z.string().nullish(),
createdAt: z.date(),
updatedAt: z.date()
})

export const generateSignTransactionRequest = (partial?: Partial<SignTransaction>): SignTransaction => {
const fixture = new Fixture()
.extend([hexGenerator, addressGenerator, chainIdGenerator])
.fromSchema(readSignTransactionSchema)

return {
...fixture,
...partial
}
}

export const generateAuthorizationRequest = (partial?: Partial<AuthorizationRequest>): AuthorizationRequest => {
const fixture = new Fixture()
.extend([hexGenerator, addressGenerator, chainIdGenerator])
.fromSchema(authorizationRequestSchema)

return {
...fixture,
...partial
}
}

export const generateApproval = (partial?: Partial<Approval>): Approval => ({
...new Fixture().fromSchema(approvalSchema),
...partial
})

export const generateSignature = (partial?: Partial<Signature>): Signature => ({
...new Fixture().fromSchema(approvalSchema),
...partial
})
24 changes: 24 additions & 0 deletions apps/orchestration/src/__test__/fixture/shared.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
addressSchema,
hexSchema
} from '@app/orchestration/policy-engine/persistence/schema/transaction-request.schema'
import { faker } from '@faker-js/faker'
import { sample } from 'lodash/fp'
import { z } from 'zod'
import { Generator } from 'zod-fixture'

export const hexGenerator = Generator({
schema: hexSchema,
output: () => faker.string.hexadecimal().toLowerCase()
})

export const addressGenerator = Generator({
schema: addressSchema,
output: () => faker.finance.ethereumAddress().toLowerCase()
})

export const chainIdGenerator = Generator({
schema: z.number().min(1),
filter: ({ transform, def }) => transform.utils.checks(def.checks).has('chainId'),
output: () => sample([1, 137])
})
27 changes: 27 additions & 0 deletions apps/orchestration/src/__test__/fixture/transfer-feed.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { addressGenerator, chainIdGenerator } from '@app/orchestration/__test__/fixture/shared.fixture'
import { addressSchema } from '@app/orchestration/policy-engine/persistence/schema/transaction-request.schema'
import { Transfer } from '@app/orchestration/shared/core/type/transfer-feed.type'
import { z } from 'zod'
import { Fixture } from 'zod-fixture'

const transferFeedSchema = z.object({
id: z.string().uuid(),
orgId: z.string().uuid(),
amount: z.bigint(),
from: z.string(),
to: z.string(),
chainId: z.number(),
token: z.string(),
rates: z.record(z.string(), z.number()),
initiatedBy: addressSchema,
createdAt: z.date()
})

export const generateTransferFeed = (partial?: Partial<Transfer>): Transfer => {
const fixture = new Fixture().extend([addressGenerator, chainIdGenerator]).fromSchema(transferFeedSchema)

return {
...fixture,
...partial
}
}
Original file line number Diff line number Diff line change
@@ -1,50 +1,47 @@
import {
generateApproval,
generateAuthorizationRequest,
generateSignTransactionRequest,
generateSignature
} from '@app/orchestration/__test__/fixture/authorization-request.fixture'
import { generateTransferFeed } from '@app/orchestration/__test__/fixture/transfer-feed.fixture'
import { AuthorizationRequestService } from '@app/orchestration/policy-engine/core/service/authorization-request.service'
import { Approval, AuthorizationRequest } from '@app/orchestration/policy-engine/core/type/domain.type'
import { AuthzApplicationClient } from '@app/orchestration/policy-engine/http/client/authz-application.client'
import {
Approval,
AuthorizationRequest,
AuthorizationRequestStatus,
SignTransaction
} from '@app/orchestration/policy-engine/core/type/domain.type'
import {
AuthzApplicationClient,
EvaluationResponse
} from '@app/orchestration/policy-engine/http/client/authz-application.client'
import { AuthorizationRequestRepository } from '@app/orchestration/policy-engine/persistence/repository/authorization-request.repository'
import { AuthorizationRequestProcessingProducer } from '@app/orchestration/policy-engine/queue/producer/authorization-request-processing.producer'
import { Transfer } from '@app/orchestration/shared/core/type/transfer-feed.type'
import { TransferFeedService } from '@app/orchestration/transfer-feed/core/service/transfer-feed.service'
import { Action, Alg } from '@narval/authz-shared'
import { HttpService } from '@nestjs/axios'
import { Action, Decision, Intents } from '@narval/authz-shared'
import { TransferNative } from '@narval/transaction-request-intent'
import { Test, TestingModule } from '@nestjs/testing'
import { AuthorizationRequestStatus } from '@prisma/client/orchestration'
import { mock } from 'jest-mock-extended'
import { times } from 'lodash/fp'
import { Caip10, Caip19 } from 'packages/transaction-request-intent/src/lib/caip'

describe(AuthorizationRequestService.name, () => {
let module: TestingModule
let authzRequestRepositoryMock: AuthorizationRequestRepository
let authzRequestProcessingProducerMock: AuthorizationRequestProcessingProducer
let httpServiceMock: HttpService
let transferFeedServiceMock: TransferFeedService
let authzApplicationClientMock: AuthzApplicationClient
let service: AuthorizationRequestService

const authzRequest: AuthorizationRequest = {
authentication: {
alg: Alg.ES256K,
pubKey: '0xd75D626a116D4a1959fE3bB938B2e7c116A05890',
sig: '0xe24d097cea880a40f8be2cf42f497b9fbda5f9e4a31b596827e051d78dce75c032fa7e5ee3046f7c6f116e5b98cb8d268fa9b9d222ff44719e2ec2a0d9159d0d1c'
},
id: '6c7e92fc-d2b0-4840-8e9b-485393ecdf89',
orgId: '',
status: AuthorizationRequestStatus.PROCESSING,
request: {
action: Action.SIGN_MESSAGE,
nonce: '99',
resourceId: '239bb48b-f708-47ba-97fa-ef336be4dffe',
message: 'Test request'
},
idempotencyKey: null,
approvals: [],
evaluations: [],
createdAt: new Date(),
updatedAt: new Date()
}
const authzRequest: AuthorizationRequest = generateAuthorizationRequest({
request: generateSignTransactionRequest()
})

beforeEach(async () => {
authzRequestRepositoryMock = mock<AuthorizationRequestRepository>()
authzRequestProcessingProducerMock = mock<AuthorizationRequestProcessingProducer>()
httpServiceMock = mock<HttpService>()
transferFeedServiceMock = mock<TransferFeedService>()
authzApplicationClientMock = mock<AuthzApplicationClient>()

Expand All @@ -59,10 +56,6 @@ describe(AuthorizationRequestService.name, () => {
provide: AuthorizationRequestProcessingProducer,
useValue: authzRequestProcessingProducerMock
},
{
provide: HttpService,
useValue: httpServiceMock
},
{
provide: TransferFeedService,
useValue: transferFeedServiceMock
Expand All @@ -78,13 +71,7 @@ describe(AuthorizationRequestService.name, () => {
})

describe('approve', () => {
const approval: Approval = {
id: '3cf9f630-e621-494a-825c-5af917dc3a5e',
sig: '0xcc645f43d8df80c4deeb2e60a8c0c15d58586d2c29ea7c85208cea81d1c47cbd787b1c8473dde70c3a7d49f573e491223107933257b2b99ecc4806b7cc16848d1c',
alg: Alg.ES256K,
pubKey: '0xab88c8785D0C00082dE75D801Fcb1d5066a6311e',
createdAt: new Date()
}
const approval: Approval = generateApproval()

const updatedAuthzRequest: AuthorizationRequest = {
...authzRequest,
Expand All @@ -104,4 +91,86 @@ describe(AuthorizationRequestService.name, () => {
expect(service.evaluate).toHaveBeenCalledWith(updatedAuthzRequest)
})
})

describe('evaluate', () => {
const evaluationResponse: EvaluationResponse = {
decision: Decision.PERMIT,
request: authzRequest.request,
attestation: generateSignature(),
// TODO (@wcalderipe, 25/01/24): Revisit the types of
// @narval/transaction-request-intent with @pierre and start using a
// shared library.
transactionRequestIntent: {
type: Intents.TRANSFER_NATIVE,
amount: '1000000000000000000',
to: 'eip155:137:0x08a08d0504d4f3363a5b7fda1f5fff1c7bca8ad4' as Caip10,
from: 'eip155:137:0x90d03a8971a2faa19a9d7ffdcbca28fe826a289b' as Caip10,
token: 'eip155:137/slip44/966' as Caip19
}
}

const transfers: Transfer[] = times(() => generateTransferFeed({ orgId: authzRequest.orgId }), 2)

beforeEach(() => {
jest.spyOn(authzApplicationClientMock, 'evaluation').mockResolvedValue(evaluationResponse)
jest.spyOn(authzRequestRepositoryMock, 'update').mockResolvedValue(authzRequest)
jest.spyOn(transferFeedServiceMock, 'findByOrgId').mockResolvedValue(transfers)
})

it('calls authz application client', async () => {
await service.evaluate(authzRequest)

expect(authzApplicationClientMock.evaluation).toHaveBeenCalledWith({
host: 'http://localhost:3010',
data: expect.objectContaining({
authentication: authzRequest.authentication,
approvals: authzRequest.approvals,
request: authzRequest.request
})
})
})

it('updates the authorization request with the evaluation response', async () => {
await service.evaluate(authzRequest)

expect(authzRequestRepositoryMock.update).toHaveBeenCalledWith({
id: authzRequest.id,
orgId: authzRequest.orgId,
status: AuthorizationRequestStatus.PERMITTED,
evaluations: [
expect.objectContaining({
id: expect.any(String),
decision: evaluationResponse.decision,
signature: evaluationResponse.attestation?.sig,
createdAt: expect.any(Date)
})
]
})
})

it('tracks approved transfer when signing a transaction', async () => {
await service.evaluate(authzRequest)

const intent = evaluationResponse.transactionRequestIntent as TransferNative
const request = authzRequest.request as SignTransaction

// Ensure the casts above are right.
expect(intent.type).toEqual(Intents.TRANSFER_NATIVE)
expect(request.action).toEqual(Action.SIGN_TRANSACTION)

expect(transferFeedServiceMock.track).toHaveBeenCalledWith({
amount: BigInt(intent.amount),
to: intent.to,
from: intent.from,
token: intent.token,
chainId: request.transactionRequest.chainId,
orgId: authzRequest.orgId,
initiatedBy: authzRequest.authentication.pubKey,
rates: {
'fiat:usd': 0.99
},
createdAt: expect.any(Date)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import {
import { AuthzApplicationClient } from '@app/orchestration/policy-engine/http/client/authz-application.client'
import { AuthorizationRequestRepository } from '@app/orchestration/policy-engine/persistence/repository/authorization-request.repository'
import { AuthorizationRequestProcessingProducer } from '@app/orchestration/policy-engine/queue/producer/authorization-request-processing.producer'
import { Transfer } from '@app/orchestration/shared/core/type/transfer-feed.type'
import { TransferFeedService } from '@app/orchestration/transfer-feed/core/service/transfer-feed.service'
import { Action, Intents } from '@narval/authz-shared'
import { HttpService } from '@nestjs/axios'
import { Action, HistoricalTransfer, Intents } from '@narval/authz-shared'
import { Injectable, Logger } from '@nestjs/common'
import { mapValues, omit } from 'lodash/fp'
import { SetOptional } from 'type-fest'
import { v4 as uuid } from 'uuid'
import { getOkTransfers } from './transfers.mock'

const getStatus = (decision: string): AuthorizationRequestStatus => {
const statuses: Map<string, AuthorizationRequestStatus> = new Map([
Expand All @@ -38,7 +38,6 @@ export class AuthorizationRequestService {
constructor(
private authzRequestRepository: AuthorizationRequestRepository,
private authzRequestProcessingProducer: AuthorizationRequestProcessingProducer,
private httpService: HttpService,
private authzApplicationClient: AuthzApplicationClient,
private transferFeedService: TransferFeedService
) {}
Expand Down Expand Up @@ -105,23 +104,22 @@ export class AuthorizationRequestService {
// TODO (@wcalderipe, 19/01/24): Think how to error the evaluation but
// short-circuit the retry mechanism.

const data = {
authentication: input.authentication,
approvals: input.approvals,
request: input.request,
// transfers: getNotOkTransfers()
transfers: getOkTransfers()
}

const transfers = await this.transferFeedService.findByOrgId(input.orgId)
const evaluation = await this.authzApplicationClient.evaluation({
baseUrl: 'http://localhost:3010',
data
host: 'http://localhost:3010',
data: {
authentication: input.authentication,
approvals: input.approvals,
request: input.request,
transfers: this.toHistoricalTransfers(transfers)
}
})

const status = getStatus(evaluation.decision)

const authzRequest = await this.authzRequestRepository.update({
...input,
id: input.id,
orgId: input.orgId,
status,
evaluations: [
{
Expand Down Expand Up @@ -164,4 +162,13 @@ export class AuthorizationRequestService {

return authzRequest
}

private toHistoricalTransfers(transfers: Transfer[]): HistoricalTransfer[] {
return transfers.map((transfer) => ({
...omit('orgId', transfer),
amount: transfer.amount.toString(),
timestamp: transfer.createdAt.getTime(),
rates: mapValues((value) => value.toString(), transfer.rates)
}))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ export class AuthzApplicationClient {

constructor(private httpService: HttpService) {}

async evaluation(option: { baseUrl: string; data: EvaluationRequest }): Promise<EvaluationResponse> {
async evaluation(option: { host: string; data: EvaluationRequest }): Promise<EvaluationResponse> {
this.logger.log('Sending evaluation request', option)

return lastValueFrom(
this.httpService.post(`${option.baseUrl}/evaluation`, option.data).pipe(
this.httpService.post(`${option.host}/evaluation`, option.data).pipe(
tap((response) => {
this.logger.log('Received evaluation response', {
status: response.status,
Expand Down
Loading

0 comments on commit ed36881

Please sign in to comment.