diff --git a/packages/application/src/api/http/controllers/authutils.test.ts b/packages/application/src/api/http/controllers/authutils.test.ts new file mode 100644 index 0000000..3cb53bc --- /dev/null +++ b/packages/application/src/api/http/controllers/authutils.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { getPublicKey, etc } from '@noble/secp256k1'; +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha256'; +import cbor from 'cbor'; +import { verifyLedgerPoP } from './authutils'; +import { FilecoinTxBuilder } from '@src/testing/mocks/builders'; + +// Ensure noble-secp256k1 HMAC is set in case setup didn't run yet +if (!etc.hmacSha256Sync) { + etc.hmacSha256Sync = (key: Uint8Array, ...msgs: Uint8Array[]) => + hmac(sha256, key, etc.concatBytes(...msgs)); +} + +describe('verifyLedgerPoP (integration)', () => { + it('returns true for a valid signed transaction and matching challenge', async () => { + const challenge = 'challenge'; + const { address, pubKeyBase64, transaction } = await new FilecoinTxBuilder() + .withChallenge(challenge) + .build(); + + const ok = await verifyLedgerPoP(address, pubKeyBase64, transaction, challenge); + expect(ok).toBe(true); + }); + + it("throws when To/From/Nonce don't match expected (replay guard)", async () => { + const challenge = 'challenge'; + const { address, pubKeyBase64, transaction } = await new FilecoinTxBuilder() + .withChallenge(challenge) + .build(); + + const parsed = JSON.parse(transaction); + parsed.Message.From = 'f1different'; + + await expect( + verifyLedgerPoP(address, pubKeyBase64, JSON.stringify(parsed), challenge), + ).rejects.toThrow("addresses don't match"); + }); + + it('throws when derived address from pubkey does not match provided address', async () => { + const challenge = 'challenge'; + const { address, transaction } = await new FilecoinTxBuilder().withChallenge(challenge).build(); + + // Provide a mismatching pubkey (different private key) + const otherPriv = Uint8Array.from( + Buffer.from('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'hex'), + ); + const otherPub = getPublicKey(otherPriv, false); + const otherPubB64 = Buffer.from(otherPub).toString('base64'); + + await expect(verifyLedgerPoP(address, otherPubB64, transaction, challenge)).rejects.toThrow( + 'wrong key for address', + ); + }); + + it("throws when pre-image doesn't match", async () => { + const challenge = 'challenge'; + const { address, pubKeyBase64, transaction } = await new FilecoinTxBuilder() + .withChallenge(challenge) + .build(); + await expect( + verifyLedgerPoP(address, pubKeyBase64, transaction, 'different-challenge'), + ).rejects.toThrow("pre-images don't match"); + }); + + it("throws when signature doesn't exist", async () => { + const challenge = 'challenge'; + const { address, pubKeyBase64, transaction } = await new FilecoinTxBuilder() + .withChallenge(challenge) + .build(); + const parsed = JSON.parse(transaction); + parsed.Signature.Data = ''; + + await expect( + verifyLedgerPoP(address, pubKeyBase64, JSON.stringify(parsed), challenge), + ).rejects.toThrow("signature doesn't exist"); + }); + + it('throws when signature has wrong length', async () => { + const challenge = 'challenge'; + const { address, pubKeyBase64, transaction } = await new FilecoinTxBuilder() + .withChallenge(challenge) + .build(); + const parsed = JSON.parse(transaction); + const sigBytes = Buffer.from(parsed.Signature.Data, 'base64'); + // Drop recovery byte to make it 64 + const wrongLen = sigBytes.subarray(0, 64); + parsed.Signature.Data = Buffer.from(wrongLen).toString('base64'); + + await expect( + verifyLedgerPoP(address, pubKeyBase64, JSON.stringify(parsed), challenge), + ).rejects.toThrow('Bad signature length: 64'); + }); + + it('should throw "addresses don\'t match" when nonce is not 0', async () => { + const challenge = 'challenge'; + const { address, pubKeyBase64, transaction } = await new FilecoinTxBuilder() + .withCustomMessage({ Nonce: 1 }) + .withChallenge(challenge) + .build(); + + await expect(verifyLedgerPoP(address, pubKeyBase64, transaction, challenge)).rejects.toThrow( + "addresses don't match", + ); + }); + + it('should throw "addresses don\'t match" when address does not match', async () => { + const challenge = 'challenge'; + const { address, pubKeyBase64, transaction } = await new FilecoinTxBuilder() + .withChallenge(challenge) + .build(); + + const parsed = JSON.parse(transaction); + parsed.Message.To = 'f1evc3p45ke4apzvi5ix25mniemuva6umggusmdif'; + + await expect( + verifyLedgerPoP(address, pubKeyBase64, JSON.stringify(parsed), challenge), + ).rejects.toThrow("addresses don't match"); + }); +}); diff --git a/packages/application/src/api/http/controllers/refresh.controller.ts b/packages/application/src/api/http/controllers/refresh.controller.ts index 1bac9f7..506fb04 100644 --- a/packages/application/src/api/http/controllers/refresh.controller.ts +++ b/packages/application/src/api/http/controllers/refresh.controller.ts @@ -9,10 +9,11 @@ import { httpPut, request, requestBody, + requestParam, response, } from 'inversify-express-utils'; -import { badRequest, ok } from '@src/api/http/processors/response'; +import { badPermissions, badRequest, ok } from '@src/api/http/processors/response'; import { TYPES } from '@src/types'; import { RefreshIssuesCommand } from '@src/application/use-cases/refresh-issues/refresh-issues.command'; import { GetRefreshesQuery } from '@src/application/queries/get-refreshes/get-refreshes.query'; @@ -21,6 +22,14 @@ import { UpsertIssueCommand } from '@src/application/use-cases/refresh-issues/up import { IssuesWebhookPayload } from '@src/infrastructure/clients/github'; import { validateIssueUpsert, validateRefreshesQuery } from '@src/api/http/validators'; import { RESPONSE_MESSAGES } from '@src/constants'; +import { validateGovernanceReview } from '../validators'; +import { validateRequest } from '../middleware/validate-request.middleware'; +import { GovernanceReviewDto } from '@src/application/dtos/GovernanceReviewDto'; +import { RoleService } from '@src/application/services/role.service'; +import { SignatureType } from '@src/patterns/decorators/signature-guard.decorator'; +import { SignatureGuard } from '@src/patterns/decorators/signature-guard.decorator'; +import { RejectRefreshCommand } from '@src/application/use-cases/refresh-issues/reject-refesh.command'; +import { ApproveRefreshCommand } from '@src/application/use-cases/refresh-issues/approve-refresh.command'; const RES = RESPONSE_MESSAGES.REFRESH_CONTROLLER; @@ -30,6 +39,7 @@ export class RefreshController { @inject(TYPES.QueryBus) private readonly _queryBus: IQueryBus, @inject(TYPES.CommandBus) private readonly _commandBus: ICommandBus, @inject(TYPES.IssueMapper) private readonly _issueMapper: IIssueMapper, + @inject(TYPES.RoleService) private readonly _roleService: RoleService, ) {} @httpGet('', ...validateRefreshesQuery) @@ -83,4 +93,36 @@ export class RefreshController { return res.json(ok(RES.REFRESH_SUCCESS, result)); } + + @httpPost('/:githubIssueNumber/review', validateRequest(validateGovernanceReview)) + @SignatureGuard(SignatureType.RefreshReview) + async approveRefresh( + @requestParam('githubIssueNumber') githubIssueNumber: string, + @requestBody() approveRefreshDto: GovernanceReviewDto, + @response() res: Response, + ) { + const id = parseInt(githubIssueNumber); + const address = approveRefreshDto.details.reviewerAddress; + const role = this._roleService.getRole(address); + if (role !== 'GOVERNANCE_TEAM') { + console.log(`Not a governance team member: ${role}`); + return res.status(403).json(badPermissions()); + } + + const { result } = approveRefreshDto; + + const command = + result === 'approve' + ? new ApproveRefreshCommand(id, approveRefreshDto.details.finalDataCap) + : new RejectRefreshCommand(id); + + const refreshResult = await this._commandBus.send(command); + if (!refreshResult.success) { + return res + .status(400) + .json(badRequest(RES.FAILED_TO_UPSERT_ISSUE, [refreshResult.error.message])); + } + + return res.json(ok(RES.REFRESH_SUCCESS, result)); + } } diff --git a/packages/application/src/api/http/middleware/validate-request.middleware.ts b/packages/application/src/api/http/middleware/validate-request.middleware.ts new file mode 100644 index 0000000..f3b21d5 --- /dev/null +++ b/packages/application/src/api/http/middleware/validate-request.middleware.ts @@ -0,0 +1,18 @@ +import { Request, Response, NextFunction } from 'express'; +import { validationResult, ValidationChain } from 'express-validator'; +import { badRequest } from '@src/api/http/processors/response'; + +export function validateRequest(validators: ValidationChain[]) { + return async (req: Request, res: Response, next: NextFunction) => { + await Promise.all(validators.map(validator => validator.run(req))); + + const errors = validationResult(req); + + if (!errors.isEmpty()) { + const errorMessages = errors.array().map(error => error.msg); + return res.status(400).json(badRequest('Validation failed', errorMessages)); + } + + next(); + }; +} diff --git a/packages/application/src/api/http/processors/response.ts b/packages/application/src/api/http/processors/response.ts index 6b94150..b384634 100644 --- a/packages/application/src/api/http/processors/response.ts +++ b/packages/application/src/api/http/processors/response.ts @@ -4,14 +4,14 @@ export const ok = (message: string, data?: any) => ({ data, }); -export const badRequest = (message: string, errors: any) => ({ +export const badRequest = (message: string, errors?: any) => ({ status: '400', message: message || 'Bad Request', errors, }); export const badPermissions = (message?: string) => ({ - status: '400', + status: '403', message: message || 'Bad Permissions', }); diff --git a/packages/application/src/api/http/validators/governance-review.validator.ts b/packages/application/src/api/http/validators/governance-review.validator.ts new file mode 100644 index 0000000..e953748 --- /dev/null +++ b/packages/application/src/api/http/validators/governance-review.validator.ts @@ -0,0 +1,65 @@ +import { VALIDATION_MESSAGES } from '@src/constants/validation-messages'; +import { body, param } from 'express-validator'; + +export const validateGovernanceReview = [ + param('githubIssueNumber') + .isInt({ min: 1, max: Number.MAX_SAFE_INTEGER }) + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_GITHUB_ISSUE_NUMBER.INVALID) + .bail(), + + body('result') + .exists() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_RESULT.REQUIRED) + .bail() + .isIn(['approve', 'reject']) + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_RESULT.INVALID) + .bail(), + + body('details') + .exists() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS.REQUIRED) + .bail() + .isObject() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS.INVALID), + + body('details.reviewerAddress') + .exists() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_REVIEWER_ADDRESS.REQUIRED) + .bail() + .isString() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_REVIEWER_ADDRESS.INVALID) + .bail(), + + body('details.reviewerPublicKey') + .exists() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_REVIEWER_PUBLIC_KEY.REQUIRED) + .bail() + .isString() + .bail() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_REVIEWER_PUBLIC_KEY.INVALID) + .bail(), + + body('details.finalDataCap') + .exists() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_FINAL_DATACAP.REQUIRED) + .bail() + .isNumeric() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_FINAL_DATACAP.INVALID) + .bail(), + + body('details.allocatorType') + .exists() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_ALLOCATOR_TYPE.REQUIRED) + .bail() + .isString() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_ALLOCATOR_TYPE.INVALID) + .bail(), + + body('signature') + .exists() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_SIGNATURE.REQUIRED) + .bail() + .isString() + .withMessage(VALIDATION_MESSAGES.GOVERNANCE_REVIEW_DETAILS_SIGNATURE.INVALID) + .bail(), +]; diff --git a/packages/application/src/api/http/validators/index.ts b/packages/application/src/api/http/validators/index.ts index e8c56ab..ad8c9c0 100644 --- a/packages/application/src/api/http/validators/index.ts +++ b/packages/application/src/api/http/validators/index.ts @@ -1 +1,2 @@ export * from './github-issue.validator'; +export * from './governance-review.validator'; diff --git a/packages/application/src/application/dtos/GovernanceReviewDto.ts b/packages/application/src/application/dtos/GovernanceReviewDto.ts new file mode 100644 index 0000000..3b34eba --- /dev/null +++ b/packages/application/src/application/dtos/GovernanceReviewDto.ts @@ -0,0 +1,12 @@ +export interface GovernanceReviewDetailsDto { + reviewerAddress: string; + reviewerPublicKey: string; + finalDataCap: number; + allocatorType: string; +} + +export interface GovernanceReviewDto { + result: string; + details: GovernanceReviewDetailsDto; + signature: string; +} diff --git a/packages/application/src/application/publishers/refresh-audit-publisher.test.ts b/packages/application/src/application/publishers/refresh-audit-publisher.test.ts index ff06d60..9c854c7 100644 --- a/packages/application/src/application/publishers/refresh-audit-publisher.test.ts +++ b/packages/application/src/application/publishers/refresh-audit-publisher.test.ts @@ -7,7 +7,8 @@ import { GithubClient } from '@src/infrastructure/clients/github'; import { TYPES } from '@src/types'; import { GithubConfig } from '@src/domain/types'; import { ICommandBus } from '@filecoin-plus/core/src/interfaces/ICommandBus'; -import { AuditMapper, IAuditMapper } from '@src/infrastructure/mappers/audit-mapper'; +import { IAuditMapper } from '@src/infrastructure/mappers/audit-mapper'; +import { Logger } from '@filecoin-plus/core'; describe('RefreshAuditPublisher', () => { let container: Container; @@ -37,6 +38,7 @@ describe('RefreshAuditPublisher', () => { datacap_amount: a.datacapAmount as number, })), }; + const loggerMock = { info: vi.fn(), error: vi.fn() }; const baseAllocator = { application_number: 123, @@ -66,6 +68,7 @@ describe('RefreshAuditPublisher', () => { container .bind(TYPES.AuditMapper) .toConstantValue(auditMapperMock as unknown as IAuditMapper); + container.bind(TYPES.Logger).toConstantValue(loggerMock as unknown as Logger); container .bind(TYPES.AllocatorRegistryConfig) .toConstantValue(configMock as unknown as GithubConfig); @@ -82,7 +85,7 @@ describe('RefreshAuditPublisher', () => { githubMock.mergePullRequest.mockResolvedValue({}); githubMock.deleteBranch.mockResolvedValue({}); - commandBusMock.send.mockResolvedValue({ success: true, data: structuredClone(baseAllocator) }); + commandBusMock.send.mockResolvedValue({ success: true, data: baseAllocator }); }); it('newAudit creates a new pending audit and publishes PR', async () => { @@ -103,7 +106,7 @@ describe('RefreshAuditPublisher', () => { it.each([AuditOutcome.PENDING, AuditOutcome.APPROVED])( 'newAudit throws when last audit is %s', async outcome => { - const pendingAllocator = structuredClone(baseAllocator); + const pendingAllocator = baseAllocator; pendingAllocator.audits[pendingAllocator.audits.length - 1].outcome = outcome; commandBusMock.send.mockResolvedValueOnce({ success: true, data: pendingAllocator }); diff --git a/packages/application/src/application/publishers/refresh-audit-publisher.ts b/packages/application/src/application/publishers/refresh-audit-publisher.ts index 689c8ec..cc3add3 100644 --- a/packages/application/src/application/publishers/refresh-audit-publisher.ts +++ b/packages/application/src/application/publishers/refresh-audit-publisher.ts @@ -12,6 +12,9 @@ import { import { GithubConfig } from '@src/domain/types'; import { IAuditMapper } from '@src/infrastructure/mappers/audit-mapper'; import { nanoid } from 'nanoid'; +import { LOG_MESSAGES } from '@src/constants'; + +const LOG = LOG_MESSAGES.REFRESH_AUDIT_PUBLISHER; export interface IRefreshAuditPublisher { newAudit(jsonHash: string): Promise<{ @@ -40,6 +43,7 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { @inject(TYPES.AllocatorRegistryConfig) private readonly _allocatorRegistryConfig: GithubConfig, @inject(TYPES.CommandBus) private readonly _commandBus: ICommandBus, @inject(TYPES.AuditMapper) private readonly _auditMapper: IAuditMapper, + @inject(TYPES.Logger) private readonly _logger: Logger, ) {} async newAudit(jsonHash: string): Promise<{ @@ -65,6 +69,8 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { const result = await this.publish(jsonHash, allocator); + this._logger.info(`${LOG.NEW_AUDIT_PUBLISHED} jsonHash: ${jsonHash}`); + return { auditChange: newAudit, ...result, @@ -74,6 +80,7 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { async updateAudit( jsonHash: string, auditData: Partial | ((jsonA: ApplicationPullRequestFile) => Partial), + expectedPreviousAuditOutcome?: AuditOutcome[], ): Promise<{ auditChange: Partial; branchName: string; @@ -83,6 +90,14 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { }> { const { allocator } = await this.getAllocatorJsonDetails(jsonHash); + if ( + expectedPreviousAuditOutcome && + !expectedPreviousAuditOutcome.includes(allocator.audits.at(-1)?.outcome as AuditOutcome) + ) + throw new Error( + `Previous status not allowed. Expected: ${expectedPreviousAuditOutcome.join(', ')} but got: ${allocator.audits.at(-1)?.outcome}`, + ); + const auditDataToUpdate = typeof auditData === 'function' ? auditData(allocator!) : auditData; Object.entries(this._auditMapper.partialFromAuditDataToDomain(auditDataToUpdate)).forEach( @@ -93,6 +108,10 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { const result = await this.publish(jsonHash, allocator); + this._logger.info( + `${LOG.AUDIT_UPDATED} jsonHash: ${jsonHash} outcome: ${auditDataToUpdate.outcome}`, + ); + return { auditChange: auditDataToUpdate, ...result, @@ -116,7 +135,7 @@ export class RefreshAuditPublisher implements IRefreshAuditPublisher { private async publish(jsonHash: string, allocator: ApplicationPullRequestFile) { const branchName = `refresh-audit-${jsonHash}-${allocator.audits.length}-${nanoid()}`; - const prTitle = `Refresh Audit ${allocator.application_number} - ${allocator.audits.length}`; + const prTitle = `Refresh Audit ${allocator.application_number} - ${allocator.audits.length} ${allocator.audits.at(-1)?.outcome}`; const prBody = `This PR is a refresh audit for the application ${allocator.application_number}.`; const branch = await this._github.createBranch( diff --git a/packages/application/src/application/resolvers/audit-outcome-resolver.ts b/packages/application/src/application/resolvers/audit-outcome-resolver.ts index cd2d636..94222b2 100644 --- a/packages/application/src/application/resolvers/audit-outcome-resolver.ts +++ b/packages/application/src/application/resolvers/audit-outcome-resolver.ts @@ -4,7 +4,9 @@ import { AuditCycle } from '../services/pull-request.types'; @injectable() export class AuditOutcomeResolver { - resolve(prevAudit: AuditCycle, currentAudit: AuditCycle): AuditOutcome { + resolve(prevAudit?: AuditCycle, currentAudit?: AuditCycle): AuditOutcome { + if (!prevAudit || !currentAudit) return AuditOutcome.UNKNOWN; + const prevDatacap = prevAudit.datacap_amount; const currentDatacap = currentAudit.datacap_amount; diff --git a/packages/application/src/application/services/pull-request.types.ts b/packages/application/src/application/services/pull-request.types.ts index 29f7d0e..ca7e428 100644 --- a/packages/application/src/application/services/pull-request.types.ts +++ b/packages/application/src/application/services/pull-request.types.ts @@ -9,7 +9,7 @@ export type AuditCycle = { ended: string; dc_allocated: string; outcome: string; //"PENDING" | "APPROVED" | "REJECTED" | "DOUBLE" | "THROTTLE" | "MATCH", - datacap_amount: number; + datacap_amount: number | ''; }; export type ApplicationPullRequestFile = { diff --git a/packages/application/src/application/services/refresh-audit.service.test.ts b/packages/application/src/application/services/refresh-audit.service.test.ts index 6432345..a133cb6 100644 --- a/packages/application/src/application/services/refresh-audit.service.test.ts +++ b/packages/application/src/application/services/refresh-audit.service.test.ts @@ -43,6 +43,7 @@ describe('RefreshAuditService', () => { }); it('approveAudit sets ended and APPROVED outcome', async () => { + const datacapAmount = 10; const expectedChange = { ended: new Date().toISOString(), outcome: AuditOutcome.APPROVED, @@ -55,9 +56,17 @@ describe('RefreshAuditService', () => { prUrl: 'u', }); - const result = await service.approveAudit(jsonHash); + const result = await service.approveAudit(jsonHash, datacapAmount); - expect(refreshAuditPublisherMock.updateAudit).toHaveBeenCalledWith(jsonHash, expectedChange); + expect(refreshAuditPublisherMock.updateAudit).toHaveBeenCalledWith( + jsonHash, + { + datacapAmount, + ended: new Date().toISOString(), + outcome: AuditOutcome.APPROVED, + }, + [AuditOutcome.PENDING], + ); expect(result.auditChange).toEqual(expectedChange); }); @@ -76,32 +85,14 @@ describe('RefreshAuditService', () => { const result = await service.rejectAudit(jsonHash); - expect(refreshAuditPublisherMock.updateAudit).toHaveBeenCalledWith(jsonHash, expectedChange); - expect(result.auditChange).toEqual(expectedChange); - }); - - it('finishAudit computes outcome via resolver and sets dcAllocated', async () => { - const expectedOutcome = AuditOutcome.MATCH; - auditOutcomeResolverMock.resolve.mockReturnValue(expectedOutcome); - - const allocatorMock = { audits: [{}, {}] } as any; - - refreshAuditPublisherMock.updateAudit.mockImplementation( - async (_hash: string, updater: any) => { - const change = updater(allocatorMock); - return { - auditChange: change, - branchName: 'b', - commitSha: 'c', - prNumber: 1, - prUrl: 'u', - }; + expect(refreshAuditPublisherMock.updateAudit).toHaveBeenCalledWith( + jsonHash, + { + ended: new Date().toISOString(), + outcome: AuditOutcome.REJECTED, }, + [AuditOutcome.PENDING], ); - - const result = await service.finishAudit(jsonHash); - - expect(refreshAuditPublisherMock.updateAudit).toHaveBeenCalled(); - expect(result.auditChange.outcome).toBe(expectedOutcome); + expect(result.auditChange).toEqual(expectedChange); }); }); diff --git a/packages/application/src/application/services/refresh-audit.service.ts b/packages/application/src/application/services/refresh-audit.service.ts index 3361b70..9e846fb 100644 --- a/packages/application/src/application/services/refresh-audit.service.ts +++ b/packages/application/src/application/services/refresh-audit.service.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'inversify'; import { AuditData, AuditOutcome } from '@src/infrastructure/repositories/issue-details'; import { RefreshAuditPublisher } from '../publishers/refresh-audit-publisher'; import { AuditOutcomeResolver } from '../resolvers/audit-outcome-resolver'; +import { ApplicationPullRequestFile } from './pull-request.types'; type UpdateAuditResult = { auditChange: Partial; @@ -14,7 +15,7 @@ type UpdateAuditResult = { export interface IRefreshAuditService { startAudit(jsonHash: string): Promise; - approveAudit(jsonHash: string): Promise; + approveAudit(jsonHash: string, datacapAmount: number): Promise; rejectAudit(jsonHash: string): Promise; finishAudit(jsonHash: string): Promise; } @@ -32,28 +33,42 @@ export class RefreshAuditService implements IRefreshAuditService { return this._refreshAuditPublisher.newAudit(jsonHash); } - async approveAudit(jsonHash: string): Promise { - return this._refreshAuditPublisher.updateAudit(jsonHash, { - ended: new Date().toISOString(), - outcome: AuditOutcome.APPROVED, - }); + async approveAudit(jsonHash: string, datacapAmount: number): Promise { + return this._refreshAuditPublisher.updateAudit( + jsonHash, + { + ended: new Date().toISOString(), + outcome: AuditOutcome.APPROVED, + datacapAmount, + }, + [AuditOutcome.PENDING], + ); } async rejectAudit(jsonHash: string): Promise { - return this._refreshAuditPublisher.updateAudit(jsonHash, { - ended: new Date().toISOString(), - outcome: AuditOutcome.REJECTED, - }); + return this._refreshAuditPublisher.updateAudit( + jsonHash, + { + ended: new Date().toISOString(), + outcome: AuditOutcome.REJECTED, + }, + [AuditOutcome.PENDING], + ); } async finishAudit(jsonHash: string): Promise { - return this._refreshAuditPublisher.updateAudit(jsonHash, allocator => { - const [prevAudit, currentAudit] = allocator.audits.slice(-2); - - return { - dcAllocated: new Date().toISOString(), - outcome: this._auditOutcomeResolver.resolve(prevAudit, currentAudit), - }; - }); + return this._refreshAuditPublisher.updateAudit( + jsonHash, + allocator => { + const prevAudit = allocator.audits.at(-2); + const currentAudit = allocator.audits.at(-1); + + return { + dcAllocated: new Date().toISOString(), + outcome: this._auditOutcomeResolver.resolve(prevAudit, currentAudit), + }; + }, + [AuditOutcome.APPROVED], + ); } } diff --git a/packages/application/src/application/services/role.service.ts b/packages/application/src/application/services/role.service.ts index 8c0b980..306c77e 100644 --- a/packages/application/src/application/services/role.service.ts +++ b/packages/application/src/application/services/role.service.ts @@ -1,15 +1,19 @@ -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import config from '@src/config'; +import { GovernanceConfig } from '@src/infrastructure/interfaces'; +import { TYPES } from '@src/types'; -const GOVERNANCE_REVIEW_ADDRESSES = config.GOVERNANCE_REVIEW_ADDRESSES; const RKH_ADDRESSES = config.RKH_ADDRESSES; const MA_ADDRESSES = config.MA_ADDRESSES; @injectable() export class RoleService { + constructor( + @inject(TYPES.GovernanceConfig) private readonly governanceConfig: GovernanceConfig, + ) {} getRole(address: string): string { let role = 'USER'; - if (GOVERNANCE_REVIEW_ADDRESSES.includes(address.toLowerCase())) { + if (this.governanceConfig.addresses.includes(address.toLowerCase())) { role = 'GOVERNANCE_TEAM'; } else if (RKH_ADDRESSES.includes(address.toLowerCase())) { role = 'ROOT_KEY_HOLDER'; diff --git a/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.ts b/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.ts new file mode 100644 index 0000000..45aaf6a --- /dev/null +++ b/packages/application/src/application/use-cases/refresh-issues/approve-refresh.command.ts @@ -0,0 +1,88 @@ +import { Command, ICommandBus, ICommandHandler, Logger } from '@filecoin-plus/core'; +import { inject, injectable } from 'inversify'; +import { TYPES } from '@src/types'; +import { RefreshAuditService } from '@src/application/services/refresh-audit.service'; +import { LOG_MESSAGES } from '@src/constants'; +import { + AuditHistory, + IssueDetails, + RefreshStatus, +} from '@src/infrastructure/repositories/issue-details'; +import { SaveIssueCommand } from './save-issue.command'; +import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; + +const LOG = LOG_MESSAGES.UPSERT_ISSUE_COMMAND; + +export class ApproveRefreshCommand extends Command { + constructor( + public readonly githubIssueNumber: number, + public readonly datacapAmount: number, + ) { + super(); + } +} + +@injectable() +export class ApproveRefreshCommandHandler implements ICommandHandler { + commandToHandle: string = ApproveRefreshCommand.name; + + constructor( + @inject(TYPES.Logger) private readonly logger: Logger, + @inject(TYPES.CommandBus) private readonly commandBus: ICommandBus, + @inject(TYPES.RefreshAuditService) private readonly refreshAuditService: RefreshAuditService, + @inject(TYPES.IssueDetailsRepository) + private readonly issueDetailsRepository: IIssueDetailsRepository, + ) {} + + async handle(command: ApproveRefreshCommand) { + this.logger.info(LOG.UPSERTING_ISSUE); + + try { + const issueDetails = await this.getIssueDetailsOrThrow(command.githubIssueNumber); + const auditResult = await this.refreshAuditService.approveAudit( + issueDetails.jsonNumber, + command.datacapAmount, + ); + const issueDetailsWithUpdatedAudit = this.updateAudit(issueDetails, auditResult); + await this.saveIssueOrThrow(issueDetailsWithUpdatedAudit); + + this.logger.info(LOG.ISSUE_UPSERTED); + + return { + success: true, + }; + } catch (error) { + this.logger.error(LOG.FAILED_TO_UPSERT_ISSUE, error); + return { + success: false, + error: error, + }; + } + } + + private async getIssueDetailsOrThrow(githubIssueNumber: number): Promise { + const issueDetails = await this.issueDetailsRepository.findPendingBy({ githubIssueNumber }); + if (!issueDetails) { + throw new Error( + `Cannot approve audit refresh because it is not in the correct status. GithubIssueNumber: ${githubIssueNumber}`, + ); + } + return issueDetails; + } + + private updateAudit(issueDetails: IssueDetails, auditResult: AuditHistory): IssueDetails { + const auditHistory = issueDetails.auditHistory || []; + auditHistory.push(auditResult); + + return { + ...issueDetails, + refreshStatus: RefreshStatus.APPROVED, + auditHistory, + }; + } + + private async saveIssueOrThrow(issueDetails: IssueDetails) { + const result = await this.commandBus.send(new SaveIssueCommand(issueDetails)); + if (!result.success) throw result.error; + } +} diff --git a/packages/application/src/application/use-cases/refresh-issues/reject-refesh.command.ts b/packages/application/src/application/use-cases/refresh-issues/reject-refesh.command.ts new file mode 100644 index 0000000..b9ee36a --- /dev/null +++ b/packages/application/src/application/use-cases/refresh-issues/reject-refesh.command.ts @@ -0,0 +1,87 @@ +import { Command, ICommandBus, ICommandHandler, Logger } from '@filecoin-plus/core'; +import { inject, injectable } from 'inversify'; +import { TYPES } from '@src/types'; +import { RefreshAuditService } from '@src/application/services/refresh-audit.service'; +import { LOG_MESSAGES } from '@src/constants'; +import { + AuditHistory, + IssueDetails, + RefreshStatus, +} from '@src/infrastructure/repositories/issue-details'; +import { SaveIssueCommand } from './save-issue.command'; +import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; + +const LOG = LOG_MESSAGES.UPSERT_ISSUE_COMMAND; + +export class RejectRefreshCommand extends Command { + constructor(public readonly githubIssueNumber: number) { + super(); + } +} + +@injectable() +export class RejectRefreshCommandHandler implements ICommandHandler { + commandToHandle: string = RejectRefreshCommand.name; + + constructor( + @inject(TYPES.Logger) private readonly logger: Logger, + @inject(TYPES.CommandBus) private readonly commandBus: ICommandBus, + @inject(TYPES.RefreshAuditService) private readonly refreshAuditService: RefreshAuditService, + @inject(TYPES.IssueDetailsRepository) + private readonly issueDetailsRepository: IIssueDetailsRepository, + ) {} + + async handle(command: RejectRefreshCommand) { + this.logger.info(LOG.UPSERTING_ISSUE); + + try { + const issueDetails = await this.getIssueDetailsOrThrow(command.githubIssueNumber); + const auditResult = await this.refreshAuditService.rejectAudit(issueDetails.jsonNumber); + const issueDetailsWithUpdatedAudit = this.updateAudit(issueDetails, auditResult); + + await this.saveIssueOrThrow(issueDetailsWithUpdatedAudit); + this.logger.info(LOG.ISSUE_UPSERTED); + + return { + success: true, + }; + } catch (error) { + this.logger.error(LOG.FAILED_TO_UPSERT_ISSUE, error); + return { + success: false, + error: error, + }; + } + } + + private async getIssueDetailsOrThrow(githubIssueNumber: number): Promise { + const issueDetails = await this.issueDetailsRepository.findPendingBy({ githubIssueNumber }); + + if (!issueDetails) { + throw new Error( + `Cannot reject audit refresh because it is not in the correct status. GithubIssueNumber: ${githubIssueNumber}`, + ); + } + + return issueDetails; + } + + private updateAudit(issueDetails: IssueDetails, auditResult: AuditHistory): IssueDetails { + const auditHistory = issueDetails.auditHistory || []; + auditHistory.push(auditResult); + + return { + ...issueDetails, + refreshStatus: RefreshStatus.REJECTED, + auditHistory, + }; + } + + private async saveIssueOrThrow(issueDetails: IssueDetails) { + const result = await this.commandBus.send(new SaveIssueCommand(issueDetails)); + if (!result.success) { + throw result.error; + } + return result; + } +} diff --git a/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.test.ts b/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.test.ts index ed11b99..16694b0 100644 --- a/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.test.ts +++ b/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.test.ts @@ -50,8 +50,8 @@ describe('SaveIssueWithNewAuditCommand', () => { SaveIssueWithNewAuditCommandHandler, ); - (refreshAuditServiceMock.startAudit as any).mockResolvedValue(auditResult); - (commandBusMock.send as any).mockResolvedValue({ success: true }); + refreshAuditServiceMock.startAudit.mockResolvedValue(auditResult); + commandBusMock.send.mockResolvedValue({ success: true }); vi.clearAllMocks(); }); @@ -61,16 +61,18 @@ describe('SaveIssueWithNewAuditCommand', () => { expect(refreshAuditServiceMock.startAudit).toHaveBeenCalledWith(issue.jsonNumber); expect(commandBusMock.send).toHaveBeenCalled(); - const sentCommand = (commandBusMock.send as any).mock.calls[0][0] as SaveIssueCommand; + const sentCommand = commandBusMock.send.mock.calls[0][0] as SaveIssueCommand; expect(sentCommand).toBeInstanceOf(SaveIssueCommand); - expect(sentCommand.issueDetails.currentAudit?.started).toBe(auditResult.auditChange.started); + expect(sentCommand.issueDetails.auditHistory?.at(-1)?.auditChange.started).toBe( + auditResult.auditChange.started, + ); expect(result).toStrictEqual({ success: true }); }); it('returns failure when startAudit throws', async () => { const error = new Error('audit failed'); - (refreshAuditServiceMock.startAudit as any).mockRejectedValueOnce(error); + refreshAuditServiceMock.startAudit.mockRejectedValueOnce(error); const result = await handler.handle(new SaveIssueWithNewAuditCommand(issue)); expect(result).toStrictEqual({ success: false, error }); diff --git a/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.ts b/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.ts index f012821..55156ba 100644 --- a/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.ts +++ b/packages/application/src/application/use-cases/refresh-issues/save-issue-with-new-audit.command.ts @@ -38,10 +38,6 @@ export class SaveIssueWithNewAuditCommandHandler const issueWithAudit = { ...command.issueDetails, - currentAudit: { - ...command.issueDetails.currentAudit, - ...auditResult.auditChange, - }, auditHistory, }; diff --git a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.test.ts b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.test.ts index 2b0b765..23a2217 100644 --- a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.test.ts +++ b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.test.ts @@ -11,6 +11,7 @@ import { IIssueMapper } from '@src/infrastructure/mappers/issue-mapper'; import { IAuditMapper } from '@src/infrastructure/mappers/audit-mapper'; import { IUpsertStrategy } from './upsert-issue.strategy'; import { SaveIssueCommand } from './save-issue.command'; +import { SaveIssueWithNewAuditCommand } from './save-issue-with-new-audit.command'; vi.mock('nanoid', () => ({ nanoid: vi.fn().mockReturnValue('guid'), @@ -166,7 +167,9 @@ describe('UpsertIssueCommand', () => { const command = new UpsertIssueCommand({ ...fixtureIssueDetails, jsonNumber: '' }); const result = await handler.handle(command); - expect(commandBusMock.send).not.toHaveBeenCalled(); + expect(commandBusMock.send).toHaveBeenCalledWith(expect.any(FetchAllocatorCommand)); + expect(commandBusMock.send).not.toHaveBeenCalledWith(expect.any(SaveIssueCommand)); + expect(commandBusMock.send).not.toHaveBeenCalledWith(expect.any(SaveIssueWithNewAuditCommand)); expect(result).toStrictEqual({ success: false, error: expect.objectContaining({ diff --git a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts index a657dcb..325b0e2 100644 --- a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts +++ b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.command.ts @@ -10,8 +10,9 @@ import { ApplicationPullRequestFile, AuditCycle, } from '@src/application/services/pull-request.types'; -import { IAuditMapper } from '@src/infrastructure/mappers/audit-mapper'; -import { IUpsertStrategy, UpsertIssueStrategyResolver } from './upsert-issue.strategy'; + +import { UpsertIssueStrategyResolver } from './upsert-issue.strategy'; +import { a } from 'vitest/dist/chunks/suite.d.FvehnV49'; const LOG = LOG_MESSAGES.UPSERT_ISSUE_COMMAND; const RES = RESPONSE_MESSAGES.UPSERT_ISSUE_COMMAND; @@ -30,7 +31,6 @@ export class UpsertIssueCommandCommandHandler implements ICommandHandler { - this.logger.info(LOG.CONNECTING_ALLOCATOR_TO_ISSUE); - - if (!issueDetails.jsonNumber) throw new Error(RES.JSON_HASH_IS_NOT_FOUND_OR_INCORRECT); - + private async fetchAllocatorData( + issueDetails: IssueDetails, + ): Promise { const commandResponse = await this.commandBus.send( new FetchAllocatorCommand(issueDetails.jsonNumber), ); - this.logger.info(LOG.ALLOCATOR_CONNECTED_TO_ISSUE); - - if (commandResponse.error) throw commandResponse.error; - const data = commandResponse.data as ApplicationPullRequestFile; + if (!commandResponse.success) throw commandResponse.error; - const withAllocator = this.issueMapper.extendWithAllocatorData(issueDetails, data); - const withAudit = this.extendIssueWithAuditData(withAllocator, data.audits); - - return withAudit; + return commandResponse.data as ApplicationPullRequestFile; } - private extendIssueWithAuditData( + private async connectAllocatorToIssue( issueDetails: IssueDetails, - audits: ApplicationPullRequestFile['audits'], - ): IssueDetails { - const currentAudit = audits.at(-1); - const previousAudit = audits.at(-2); - - if (this.isPendingOrApproved(currentAudit!)) { - issueDetails.lastAudit = this.auditMapper.fromDomainToAuditData(previousAudit!); - issueDetails.currentAudit = this.auditMapper.fromDomainToAuditData(currentAudit!); - } else { - issueDetails.lastAudit = this.auditMapper.fromDomainToAuditData(currentAudit!); - } + allocatorData: ApplicationPullRequestFile, + ): Promise { + this.logger.info(LOG.CONNECTING_ALLOCATOR_TO_ISSUE); - return issueDetails; - } + if (!issueDetails.jsonNumber) throw new Error(RES.JSON_HASH_IS_NOT_FOUND_OR_INCORRECT); + + const extendedIssueDetails = this.issueMapper.extendWithAllocatorData( + issueDetails, + allocatorData, + ); + this.logger.info(LOG.ALLOCATOR_CONNECTED_TO_ISSUE); - private isPendingOrApproved(audit: AuditCycle): boolean { - return [AuditOutcome.PENDING, AuditOutcome.APPROVED].includes(audit.outcome as AuditOutcome); + return extendedIssueDetails; } } diff --git a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.test.ts b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.test.ts index 301e2e1..ba6130b 100644 --- a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.test.ts +++ b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.test.ts @@ -2,12 +2,13 @@ import 'reflect-metadata'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Container } from 'inversify'; import { TYPES } from '@src/types'; -import { UpsertIssueStrategyResolver, UpsertStrategyKey } from './upsert-issue.strategy'; +import { UpsertIssueStrategyResolver } from './upsert-issue.strategy'; import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; import { Logger } from '@filecoin-plus/core'; import { SaveIssueCommand } from './save-issue.command'; import { SaveIssueWithNewAuditCommand } from './save-issue-with-new-audit.command'; +import { AuditCycle } from '@src/application/services/pull-request.types'; describe('UpsertStrategy', () => { let container: Container; @@ -15,7 +16,7 @@ describe('UpsertStrategy', () => { const repoMock = { findBy: vi.fn(), - findWithLatestAuditBy: vi.fn(), + findLatestBy: vi.fn(), }; const loggerMock = { info: vi.fn() }; @@ -35,6 +36,22 @@ describe('UpsertStrategy', () => { refreshStatus: 'PENDING', }; + const fixturePendingAudit: AuditCycle = { + started: '2024-01-01T00:00:00.000Z', + ended: '', + dc_allocated: '', + outcome: 'PENDING', + datacap_amount: '', + }; + + const fixtureGantedAudit: AuditCycle = { + started: '2024-01-01T00:00:00.000Z', + ended: '2024-01-02T00:00:00.000Z', + dc_allocated: '2024-01-03T00:00:00.000Z', + outcome: 'GRANTED', + datacap_amount: 1, + }; + beforeEach(() => { vi.clearAllMocks(); container = new Container(); @@ -48,47 +65,39 @@ describe('UpsertStrategy', () => { it('returns SaveIssueWithNewAuditCommand when no issue by github id and no pending latest audit', async () => { repoMock.findBy.mockResolvedValue(null); - repoMock.findWithLatestAuditBy.mockResolvedValue(null); + repoMock.findLatestBy.mockResolvedValue(null); - const cmd = await strategy.resolveAndExecute(fixtureIssue); + const cmd = await strategy.resolveAndExecute(fixtureIssue, [fixtureGantedAudit]); expect(cmd).toBeInstanceOf(SaveIssueWithNewAuditCommand); }); it('returns SaveIssueCommand when issue exists and no pending latest audit', async () => { - repoMock.findBy.mockResolvedValue({ + repoMock.findBy.mockResolvedValue(fixtureIssue); + repoMock.findLatestBy.mockResolvedValue({ ...fixtureIssue, - _id: 'x', - refreshStatus: 'PENDING', - } as any); - repoMock.findWithLatestAuditBy.mockResolvedValue(null); + refreshStatus: 'DC_ALLOCATED', + githubIssueId: 2, + githubIssueNumber: 200, + }); - const cmd = await strategy.resolveAndExecute(fixtureIssue); - expect(cmd).toBeInstanceOf(SaveIssueCommand); + const cmd = await strategy.resolveAndExecute(fixtureIssue, [fixtureGantedAudit]); + expect(cmd).toBeInstanceOf(SaveIssueWithNewAuditCommand); }); - it('should update issue when issueByGithubId is the same as issueWithLatestAuditByJsonNumber', async () => { - const githubIssueId = 1; + it('should save issue with new github audit, when issue is related to last pending audit and audit is finished', async () => { + repoMock.findBy.mockResolvedValue(fixtureIssue); + repoMock.findLatestBy.mockResolvedValue(fixtureIssue); - repoMock.findBy.mockResolvedValue({ - ...fixtureIssue, - githubIssueId, - }); - repoMock.findWithLatestAuditBy.mockResolvedValue({ - ...fixtureIssue, - githubIssueId, - }); - - const cmd = await strategy.resolveAndExecute(fixtureIssue); + const cmd = await strategy.resolveAndExecute(fixtureIssue, [fixturePendingAudit]); expect(cmd).toBeInstanceOf(SaveIssueCommand); }); - it('returns SaveIssueCommand when issues are related', async () => { - const dbIssue = { ...fixtureIssue, _id: 'x', refreshStatus: 'PENDING' } as any; - repoMock.findBy.mockResolvedValue(dbIssue); - repoMock.findWithLatestAuditBy.mockResolvedValue(dbIssue); + it('should save issue with new github audit, when issue is related to last pending audit and audit is finished', async () => { + repoMock.findBy.mockResolvedValue(fixtureIssue); + repoMock.findLatestBy.mockResolvedValue(fixtureIssue); - const cmd = await strategy.resolveAndExecute(fixtureIssue); - expect(cmd).toBeInstanceOf(SaveIssueCommand); + const cmd = await strategy.resolveAndExecute(fixtureIssue, [fixtureGantedAudit]); + expect(cmd).toBeInstanceOf(SaveIssueWithNewAuditCommand); }); it.each` @@ -101,10 +110,10 @@ describe('UpsertStrategy', () => { repoMock.findBy.mockResolvedValue({ ...fixtureIssue, refreshStatus, - } as any); - repoMock.findWithLatestAuditBy.mockResolvedValue(null); + }); + repoMock.findLatestBy.mockResolvedValue(null); - await expect(strategy.resolveAndExecute(fixtureIssue)).rejects.toThrow( + await expect(strategy.resolveAndExecute(fixtureIssue, [fixtureGantedAudit])).rejects.toThrow( `${fixtureIssue.githubIssueNumber} ${expectedError}`, ); }, @@ -112,13 +121,13 @@ describe('UpsertStrategy', () => { it('throws when latest audit by jsonNumber is pending and not related', async () => { repoMock.findBy.mockResolvedValue(null); - repoMock.findWithLatestAuditBy.mockResolvedValue({ + repoMock.findLatestBy.mockResolvedValue({ ...fixtureIssue, refreshStatus: 'PENDING', - } as any); + }); - await expect(strategy.resolveAndExecute(fixtureIssue)).rejects.toThrow( - 'has pending audit, finish existing audit before creating a new one', + await expect(strategy.resolveAndExecute(fixtureIssue, [fixturePendingAudit])).rejects.toThrow( + `${fixtureIssue.jsonNumber} has pending audit, finish existing refresh before creating a new one`, ); }); }); diff --git a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.ts b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.ts index 0ad942b..be8554b 100644 --- a/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.ts +++ b/packages/application/src/application/use-cases/refresh-issues/upsert-issue.strategy.ts @@ -2,8 +2,10 @@ import { Command, Logger } from '@filecoin-plus/core'; import { inject, injectable } from 'inversify'; import { WithId } from 'mongodb'; import { + AuditOutcome, FINISHED_REFRESH_STATUSES, IssueDetails, + PENDING_AUDIT_OUTCOMES, PENDING_REFRESH_STATUSES, RefreshStatus, } from '@src/infrastructure/repositories/issue-details'; @@ -12,19 +14,21 @@ import { SaveIssueWithNewAuditCommand } from './save-issue-with-new-audit.comman import { TYPES } from '@src/types'; import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; import { LOG_MESSAGES, RESPONSE_MESSAGES } from '@src/constants'; +import { ApplicationPullRequestFile } from '@src/application/services/pull-request.types'; const LOG = LOG_MESSAGES.UPSERT_ISSUE_STRATEGY_RESOLVER; const RES = RESPONSE_MESSAGES.UPSERT_ISSUE_STRATEGY_RESOLVER; export enum UpsertStrategyKey { SAVE_WITH_NEW_AUDIT = 'save-with-new-audit', - SAVE_WITHOUT_GITHUB_UPDATE = 'update-existing-without-github-update', + SAVE_WITHOUT_GITHUB_UPDATE = 'save-without-github-update', } interface AuditStateAnalysis { - issueByGithubIdExists: boolean; + isCurrentAuditNotFinished: boolean; + issueByGithubIdExistsInDb: boolean; isIssueByGithubIdFinished: boolean; - isLatestAuditByJsonNumberPending: boolean; + currentAllocatorHasPendingRefresh: boolean; areTheSameIssue: boolean; } @@ -54,9 +58,12 @@ export class UpsertIssueStrategyResolver { private readonly repository: IIssueDetailsRepository, ) {} - public async resolveAndExecute(mappedIsuueFromGithub: IssueDetails): Promise { + public async resolveAndExecute( + mappedIsuueFromGithub: IssueDetails, + audits: ApplicationPullRequestFile['audits'], + ): Promise { this.logger.info(LOG.RESOLVING_UPSERT_ISSUE_STRATEGY); - const strategyKey = await this.getStrategyKey(mappedIsuueFromGithub); + const strategyKey = await this.getStrategyKey(mappedIsuueFromGithub, audits); this.logger.info(LOG.STRATEGY_SELECTED + strategyKey); @@ -75,62 +82,83 @@ export class UpsertIssueStrategyResolver { } } - private async getStrategyKey(mappedIsuueFromGithub: IssueDetails): Promise { + private async getStrategyKey( + mappedIsuueFromGithub: IssueDetails, + audits: ApplicationPullRequestFile['audits'], + ): Promise { const [issueByGithubId, issueWithLatestAuditByJsonNumber] = await this.getRelatedIssues(mappedIsuueFromGithub); const { - issueByGithubIdExists, + isCurrentAuditNotFinished, + issueByGithubIdExistsInDb, isIssueByGithubIdFinished, - isLatestAuditByJsonNumberPending, + currentAllocatorHasPendingRefresh, areTheSameIssue, - } = this.analyzeAuditState(issueByGithubId, issueWithLatestAuditByJsonNumber); + } = this.analyzeAuditState(issueByGithubId, issueWithLatestAuditByJsonNumber, audits); + // if refresh is already finished its not necessary to update the issue if (isIssueByGithubIdFinished) throw new Error( `${mappedIsuueFromGithub.githubIssueNumber} ${RES.ISSUE_REFRESH_ALREADY_FINISHED}`, ); - if (areTheSameIssue) return UpsertStrategyKey.SAVE_WITHOUT_GITHUB_UPDATE; + // if issue related to event is same as latest opened refresh, its allowed to update refresh with fresh data + if (areTheSameIssue && isCurrentAuditNotFinished) + return UpsertStrategyKey.SAVE_WITHOUT_GITHUB_UPDATE; + + // if issue related to event is same as latest opened refresh, its allowed to update refresh with fresh data + if (areTheSameIssue && !isCurrentAuditNotFinished) return UpsertStrategyKey.SAVE_WITH_NEW_AUDIT; - if (isLatestAuditByJsonNumberPending) + // database records are different, but allocator has pending refresh, its not allowed to create new refresh + if (currentAllocatorHasPendingRefresh) throw new Error(`${mappedIsuueFromGithub.jsonNumber} ${RES.PENDING_AUDIT}`); - if (issueByGithubIdExists && !isLatestAuditByJsonNumberPending) - return UpsertStrategyKey.SAVE_WITHOUT_GITHUB_UPDATE; + // if issue is not in database yet and there is pending audit, its not allowed to create new refresh + if (!issueByGithubIdExistsInDb && isCurrentAuditNotFinished) + throw new Error(`${mappedIsuueFromGithub.jsonNumber} ${RES.PENDING_AUDIT}`); - if (!issueByGithubIdExists && !isLatestAuditByJsonNumberPending) + // if issue is not in database yet and there is no pending audit, its allowed to create new refresh with new audit + if (!issueByGithubIdExistsInDb && !isCurrentAuditNotFinished) return UpsertStrategyKey.SAVE_WITH_NEW_AUDIT; - if (!areTheSameIssue && isLatestAuditByJsonNumberPending) - throw new Error(`${RES.CANNOT_RESOLVE_UPSERT_STRATEGY} ${mappedIsuueFromGithub.jsonNumber}`); + if (issueByGithubIdExistsInDb && !isCurrentAuditNotFinished) + return UpsertStrategyKey.SAVE_WITH_NEW_AUDIT; - throw new Error(`${RES.CANNOT_RESOLVE_UPSERT_STRATEGY} ${mappedIsuueFromGithub.jsonNumber}`); + throw new Error(`${mappedIsuueFromGithub.jsonNumber} ${RES.CANNOT_RESOLVE_UPSERT_STRATEGY}`); } private analyzeAuditState( issueByGithubId: WithId | null, - issueWithLatestAuditByJsonNumber: WithId | null, + latestIssueByJsonNumber: WithId | null, + audits: ApplicationPullRequestFile['audits'], ): AuditStateAnalysis { - const issueByGithubIdExists = !!issueByGithubId; + const currentAudit = audits?.at(-1); + + const isCurrentAuditNotFinished = PENDING_AUDIT_OUTCOMES.includes( + currentAudit?.outcome as AuditOutcome, + ); + + const issueByGithubIdExistsInDb = !!issueByGithubId; const isIssueByGithubIdFinished = - !!issueByGithubId && + issueByGithubIdExistsInDb && FINISHED_REFRESH_STATUSES.includes(issueByGithubId.refreshStatus as RefreshStatus); - const isLatestAuditByJsonNumberPending = PENDING_REFRESH_STATUSES.includes( - issueWithLatestAuditByJsonNumber?.refreshStatus as RefreshStatus, + const currentAllocatorHasPendingRefresh = PENDING_REFRESH_STATUSES.includes( + latestIssueByJsonNumber?.refreshStatus as RefreshStatus, ); const areTheSameIssue = - !!issueByGithubId && - !!issueWithLatestAuditByJsonNumber && - issueByGithubId.githubIssueId === issueWithLatestAuditByJsonNumber.githubIssueId; + !!issueByGithubIdExistsInDb && + !!latestIssueByJsonNumber && + issueByGithubId.githubIssueId === latestIssueByJsonNumber.githubIssueId; return { - issueByGithubIdExists, + isCurrentAuditNotFinished, + issueByGithubIdExistsInDb, isIssueByGithubIdFinished, - isLatestAuditByJsonNumberPending, + currentAllocatorHasPendingRefresh, areTheSameIssue, }; } @@ -140,7 +168,7 @@ export class UpsertIssueStrategyResolver { ): Promise<[WithId | null, WithId | null]> { return await Promise.all([ this.repository.findBy('githubIssueId', issueDetails.githubIssueId), - this.repository.findWithLatestAuditBy('jsonNumber', issueDetails.jsonNumber), + this.repository.findLatestBy('jsonNumber', issueDetails.jsonNumber), ]); } } diff --git a/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.test.ts b/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.test.ts index e9c53fa..707c355 100644 --- a/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.test.ts +++ b/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.test.ts @@ -10,10 +10,10 @@ import { import { DatabaseRefreshFactory } from '@mocks/factories'; import { faker } from '@faker-js/faker'; import { Approval } from '@src/infrastructure/clients/lotus'; -import { IDataCapMapper } from '@src/infrastructure/mappers/data-cap-mapper'; import { CommandBus } from '@src/infrastructure/command-bus'; import { RefreshAuditService } from '@src/application/services/refresh-audit.service'; import { SaveIssueCommand } from '../refresh-issues/save-issue.command'; +import { RefreshStatus } from '@src/infrastructure/repositories/issue-details'; vi.mock('nanoid', () => ({ nanoid: vi.fn().mockReturnValue('guid'), @@ -24,8 +24,7 @@ describe('ApproveRefreshByMaCommand', () => { let handler: ApproveRefreshByMaCommandHandler; const loggerMock = { info: vi.fn(), error: vi.fn() }; const commandBusMock = { send: vi.fn() }; - const datacapMapperMock = { fromBigIntBytesToPiBNumber: vi.fn() }; - const refreshAuditServiceMock = { approveAudit: vi.fn() }; + const refreshAuditServiceMock = { finishAudit: vi.fn() }; const fixtureAuditResult = { auditChange: { @@ -45,10 +44,9 @@ describe('ApproveRefreshByMaCommand', () => { txHash: faker.string.alphanumeric(66), contractAddress: faker.string.alphanumeric(42), allocatorAddress: faker.string.alphanumeric(42), - allowanceBefore: '1125899906842624', // 1 PiB in bytes - allowanceAfter: '2251799813685248', // 2 PiB in bytes + allowanceBefore: '1125899906842624', + allowanceAfter: '2251799813685248', }; - const fixtureMappedDatacap = 1; beforeEach(() => { container = new Container(); @@ -57,9 +55,7 @@ describe('ApproveRefreshByMaCommand', () => { container .bind(TYPES.CommandBus) .toConstantValue(commandBusMock as unknown as CommandBus); - container - .bind(TYPES.DataCapMapper) - .toConstantValue(datacapMapperMock as unknown as IDataCapMapper); + container .bind(TYPES.RefreshAuditService) .toConstantValue(refreshAuditServiceMock as unknown as RefreshAuditService); @@ -67,8 +63,7 @@ describe('ApproveRefreshByMaCommand', () => { handler = container.get(ApproveRefreshByMaCommandHandler); - datacapMapperMock.fromBigIntBytesToPiBNumber.mockReturnValue(fixtureMappedDatacap); - refreshAuditServiceMock.approveAudit.mockResolvedValue(fixtureAuditResult); + refreshAuditServiceMock.finishAudit.mockResolvedValue(fixtureAuditResult); commandBusMock.send.mockResolvedValue({ success: true }); }); @@ -80,25 +75,20 @@ describe('ApproveRefreshByMaCommand', () => { const command = new ApproveRefreshByMaCommand(fixtureIssueDetails, fixtureApproval); const result = await handler.handle(command); - expect(datacapMapperMock.fromBigIntBytesToPiBNumber).toHaveBeenCalledWith( - BigInt('1125899906842624'), - ); - expect(refreshAuditServiceMock.approveAudit).toHaveBeenCalledWith( + expect(refreshAuditServiceMock.finishAudit).toHaveBeenCalledWith( fixtureIssueDetails.jsonNumber, ); expect(commandBusMock.send).toHaveBeenCalledWith({ guid: 'guid', issueDetails: { ...fixtureIssueDetails, - refreshStatus: 'DC_ALLOCATED', + refreshStatus: RefreshStatus.DC_ALLOCATED, transactionCid: fixtureApproval.txHash, blockNumber: fixtureApproval.blockNumber, - currentAudit: fixtureAuditResult.auditChange, auditHistory: [fixtureAuditResult], metaAllocator: { blockNumber: fixtureApproval.blockNumber, }, - dataCap: fixtureMappedDatacap, }, }); expect(loggerMock.info).toHaveBeenCalledTimes(2); @@ -114,25 +104,20 @@ describe('ApproveRefreshByMaCommand', () => { const command = new ApproveRefreshByMaCommand(fixtureIssueDetails, fixtureApproval); const result = await handler.handle(command); - expect(datacapMapperMock.fromBigIntBytesToPiBNumber).toHaveBeenCalledWith( - BigInt('1125899906842624'), - ); - expect(refreshAuditServiceMock.approveAudit).toHaveBeenCalledWith( + expect(refreshAuditServiceMock.finishAudit).toHaveBeenCalledWith( fixtureIssueDetails.jsonNumber, ); expect(commandBusMock.send).toHaveBeenCalledWith({ guid: 'guid', issueDetails: { ...fixtureIssueDetails, - refreshStatus: 'DC_ALLOCATED', + refreshStatus: RefreshStatus.DC_ALLOCATED, transactionCid: fixtureApproval.txHash, blockNumber: fixtureApproval.blockNumber, - currentAudit: fixtureAuditResult.auditChange, auditHistory: [fixtureAuditResult], metaAllocator: { blockNumber: fixtureApproval.blockNumber, }, - dataCap: fixtureMappedDatacap, }, }); expect(commandBusMock.send).toHaveBeenCalledWith(expect.any(SaveIssueCommand)); diff --git a/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.ts b/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.ts index 39e8b0a..67cdf3f 100644 --- a/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.ts +++ b/packages/application/src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command.ts @@ -2,7 +2,11 @@ import { Command, ICommandHandler, Logger } from '@filecoin-plus/core'; import { inject, injectable } from 'inversify'; import { TYPES } from '@src/types'; -import { IssueDetails, RefreshStatus } from '@src/infrastructure/repositories/issue-details'; +import { + AuditHistory, + IssueDetails, + RefreshStatus, +} from '@src/infrastructure/repositories/issue-details'; import { LOG_MESSAGES } from '@src/constants'; import { Approval } from '@src/infrastructure/clients/lotus'; import { DataCapMapper } from '@src/infrastructure/mappers/data-cap-mapper'; @@ -32,8 +36,6 @@ export class ApproveRefreshByMaCommandHandler private readonly _logger: Logger, @inject(TYPES.CommandBus) private readonly _commandBus: CommandBus, - @inject(TYPES.DataCapMapper) - private readonly _dataCapMapper: DataCapMapper, @inject(TYPES.RefreshAuditService) private readonly _refreshAuditService: RefreshAuditService, ) {} @@ -42,35 +44,18 @@ export class ApproveRefreshByMaCommandHandler try { this._logger.info(LOG.APPROVE_REFRESH_BY_MA); - const dataCap = this._dataCapMapper.fromBigIntBytesToPiBNumber( - BigInt(command.approval.allowanceAfter) - BigInt(command.approval.allowanceBefore), - ); - const auditResult = await this._refreshAuditService.approveAudit( + const auditResult = await this._refreshAuditService.finishAudit( command.issueDetails.jsonNumber, ); - - const auditHistory = command.issueDetails.auditHistory || []; - auditHistory?.push(auditResult); - - const issueWithApprovedStatus: IssueDetails = { - ...command.issueDetails, - refreshStatus: RefreshStatus.DC_ALLOCATED, - transactionCid: command.approval.txHash, - blockNumber: command.approval.blockNumber, - metaAllocator: { - blockNumber: command.approval.blockNumber, - }, - currentAudit: { - ...command.issueDetails.currentAudit, - ...auditResult.auditChange, - }, - dataCap, - auditHistory, - }; + const issueWithApprovedStatus = this.updateIssue( + command.issueDetails, + auditResult, + command.approval, + ); await this._commandBus.send(new SaveIssueCommand(issueWithApprovedStatus)); - this._logger.info(LOG.REFRESH_APPROVED); + return { success: true, }; @@ -82,4 +67,24 @@ export class ApproveRefreshByMaCommandHandler }; } } + + private updateIssue( + issueDetails: IssueDetails, + auditResult: AuditHistory, + approval: Approval, + ): IssueDetails { + const auditHistory = issueDetails.auditHistory || []; + auditHistory?.push(auditResult); + + return { + ...issueDetails, + refreshStatus: RefreshStatus.DC_ALLOCATED, + transactionCid: approval.txHash, + blockNumber: approval.blockNumber, + metaAllocator: { + blockNumber: approval.blockNumber, + }, + auditHistory, + }; + } } diff --git a/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.test.ts b/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.test.ts index 336e8b8..b9f9555 100644 --- a/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.test.ts +++ b/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.test.ts @@ -77,7 +77,6 @@ describe('ApproveRefreshByRKHCommand', () => { ...fixtureIssueDetails, refreshStatus: 'DC_ALLOCATED', transactionCid: fixtureApprovedTx.cid, - currentAudit: fixtureAuditResult.auditChange, auditHistory: [fixtureAuditResult], }, }), @@ -100,7 +99,6 @@ describe('ApproveRefreshByRKHCommand', () => { ...fixtureIssueDetails, refreshStatus: 'DC_ALLOCATED', transactionCid: fixtureApprovedTx.cid, - currentAudit: fixtureAuditResult.auditChange, auditHistory: [fixtureAuditResult], }, }), diff --git a/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.ts b/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.ts index 5d60f8e..c8ffec0 100644 --- a/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.ts +++ b/packages/application/src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command.ts @@ -2,7 +2,11 @@ import { Command, ICommandHandler, Logger } from '@filecoin-plus/core'; import { inject, injectable } from 'inversify'; import { TYPES } from '@src/types'; -import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; +import { + AuditHistory, + IssueDetails, + RefreshStatus, +} from '@src/infrastructure/repositories/issue-details'; import { ApprovedTx } from '@src/infrastructure/clients/lotus'; import { LOG_MESSAGES } from '@src/constants'; import { RefreshAuditService } from '@src/application/services/refresh-audit.service'; @@ -43,20 +47,11 @@ export class ApproveRefreshByRKHCommandHandler command.issueDetails.jsonNumber, ); - const auditHistory = command.issueDetails.auditHistory || []; - auditHistory?.push(auditResult); - - const issueWithApprovedStatus: IssueDetails = { - ...command.issueDetails, - refreshStatus: 'DC_ALLOCATED', - transactionCid: command.tx.cid, - currentAudit: { - ...command.issueDetails.currentAudit, - ...auditResult.auditChange, - }, - auditHistory, - }; - + const issueWithApprovedStatus = this.updateIssue( + command.issueDetails, + auditResult, + command.tx, + ); await this._commandBus.send(new SaveIssueCommand(issueWithApprovedStatus)); this._logger.info(LOG.REFRESH_APPROVED); @@ -71,4 +66,20 @@ export class ApproveRefreshByRKHCommandHandler }; } } + + private updateIssue( + issueDetails: IssueDetails, + auditResult: AuditHistory, + tx: ApprovedTx, + ): IssueDetails { + const auditHistory = issueDetails.auditHistory || []; + auditHistory?.push(auditResult); + + return { + ...issueDetails, + refreshStatus: RefreshStatus.DC_ALLOCATED, + transactionCid: tx.cid, + auditHistory, + }; + } } diff --git a/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.test.ts b/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.test.ts index 1edd9eb..6f7e08b 100644 --- a/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.test.ts +++ b/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.test.ts @@ -11,15 +11,8 @@ import { DatabaseRefreshFactory } from '@mocks/factories'; import { faker } from '@faker-js/faker'; import { PendingTx } from '@src/infrastructure/clients/lotus'; import cbor from 'cbor'; -import { IDataCapMapper } from '@src/infrastructure/mappers/data-cap-mapper'; import { CommandBus } from '@src/infrastructure/command-bus'; -vi.mock('cbor', () => ({ - default: { - decode: vi.fn(), - }, -})); - vi.mock('nanoid', () => ({ nanoid: vi.fn().mockReturnValue('guid'), })); @@ -28,7 +21,6 @@ describe('SignRefreshByRKHCommand', () => { let container: Container; let handler: SignRefreshByRKHCommandHandler; const loggerMock = { info: vi.fn(), error: vi.fn() }; - const dataCapMapperMock = { fromBufferBytesToPiBNumber: vi.fn() }; const commandBusMock = { send: vi.fn() }; const fixtureIssueDetails = DatabaseRefreshFactory.create(); @@ -40,7 +32,6 @@ describe('SignRefreshByRKHCommand', () => { value: '0', approved: [faker.string.alphanumeric(40)], }; - const fixtureDataCap = 5; beforeEach(() => { container = new Container(); @@ -49,14 +40,9 @@ describe('SignRefreshByRKHCommand', () => { container .bind(TYPES.CommandBus) .toConstantValue(commandBusMock as unknown as CommandBus); - container - .bind(TYPES.DataCapMapper) - .toConstantValue(dataCapMapperMock as unknown as IDataCapMapper); container.bind(SignRefreshByRKHCommandHandler).toSelf(); handler = container.get(SignRefreshByRKHCommandHandler); - - dataCapMapperMock.fromBufferBytesToPiBNumber.mockReturnValue(fixtureDataCap); }); afterEach(() => { @@ -64,18 +50,13 @@ describe('SignRefreshByRKHCommand', () => { }); it('should successfully sign refresh by RKH', async () => { - const mockDatacap = Buffer.from('1688849860263936'); // 1.5 PiB in hex - (cbor.decode as any).mockReturnValue(['param1', mockDatacap]); - const command = new SignRefreshByRKHCommand(fixtureIssueDetails, fixturePendingTx); const result = await handler.handle(command); - expect(dataCapMapperMock.fromBufferBytesToPiBNumber).toHaveBeenCalledWith(mockDatacap); expect(commandBusMock.send).toHaveBeenCalledWith({ guid: 'guid', issueDetails: { ...fixtureIssueDetails, - dataCap: fixtureDataCap, refreshStatus: 'SIGNED_BY_RKH', rkhPhase: { messageId: fixturePendingTx.id, @@ -90,101 +71,16 @@ describe('SignRefreshByRKHCommand', () => { }); it('should handle error during repository update', async () => { - const mockDatacap = Buffer.from('1125899906842624'); - (cbor.decode as any).mockReturnValue(['param1', mockDatacap]); - const error = new Error('Failed to update repository'); commandBusMock.send.mockRejectedValue(error); const command = new SignRefreshByRKHCommand(fixtureIssueDetails, fixturePendingTx); const result = await handler.handle(command); - expect(dataCapMapperMock.fromBufferBytesToPiBNumber).toHaveBeenCalledWith(mockDatacap); expect(loggerMock.error).toHaveBeenCalled(); expect(result).toStrictEqual({ success: false, error, }); }); - - it('should return error when CBOR fails with error', async () => { - const error = new Error('Invalid CBOR'); - (cbor.decode as any).mockImplementation(() => { - throw error; - }); - - const command = new SignRefreshByRKHCommand(fixtureIssueDetails, fixturePendingTx); - const result = await handler.handle(command); - - expect(dataCapMapperMock.fromBufferBytesToPiBNumber).not.toHaveBeenCalled(); - expect(commandBusMock.send).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - success: false, - error, - }); - }); - - it('should set dataCap to 0 when params array is invalid', async () => { - (cbor.decode as any).mockReturnValue(['only-one-param']); - - const command = new SignRefreshByRKHCommand(fixtureIssueDetails, fixturePendingTx); - await handler.handle(command); - - expect(dataCapMapperMock.fromBufferBytesToPiBNumber).not.toHaveBeenCalled(); - expect(commandBusMock.send).toHaveBeenCalledWith( - expect.objectContaining({ - guid: 'guid', - issueDetails: expect.objectContaining({ - dataCap: 0, - }), - }), - ); - }); - - describe('CBOR parsing edge cases', () => { - it('should handle non-array CBOR result', async () => { - (cbor.decode as any).mockReturnValue({ not: 'an array' }); - - const command = new SignRefreshByRKHCommand(fixtureIssueDetails, fixturePendingTx); - await handler.handle(command); - - expect(commandBusMock.send).toHaveBeenCalledWith({ - guid: 'guid', - issueDetails: expect.objectContaining({ - dataCap: 0, - }), - }); - }); - - it('should handle null CBOR result', async () => { - (cbor.decode as any).mockReturnValue(null); - - const command = new SignRefreshByRKHCommand(fixtureIssueDetails, fixturePendingTx); - await handler.handle(command); - - expect(commandBusMock.send).toHaveBeenCalledWith({ - guid: 'guid', - issueDetails: expect.objectContaining({ - dataCap: 0, - }), - }); - }); - - it('should handle empty params string', async () => { - const txWithEmptyParams: PendingTx = { - ...fixturePendingTx, - params: '', - }; - - const command = new SignRefreshByRKHCommand(fixtureIssueDetails, txWithEmptyParams); - await handler.handle(command); - - expect(commandBusMock.send).toHaveBeenCalledWith({ - guid: 'guid', - issueDetails: expect.objectContaining({ - dataCap: 0, - }), - }); - }); - }); }); diff --git a/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.ts b/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.ts index d554cea..7682372 100644 --- a/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.ts +++ b/packages/application/src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command.ts @@ -2,7 +2,7 @@ import { Command, ICommandHandler, Logger } from '@filecoin-plus/core'; import { inject, injectable } from 'inversify'; import { TYPES } from '@src/types'; import { IIssueDetailsRepository } from '@src/infrastructure/repositories/issue-details.repository'; -import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; +import { IssueDetails, RefreshStatus } from '@src/infrastructure/repositories/issue-details'; import { PendingTx } from '@src/infrastructure/clients/lotus'; import { LOG_MESSAGES } from '@src/constants'; import cbor from 'cbor'; @@ -30,23 +30,13 @@ export class SignRefreshByRKHCommandHandler implements ICommandHandler ({ + createBranch: vi.fn(), + createPullRequest: vi.fn(), + mergePullRequest: vi.fn(), + deleteBranch: vi.fn(), + getFile: vi.fn(), +})); + +vi.mock('nanoid', () => ({ + nanoid: vi.fn().mockReturnValue('nanoid-id'), +})); + +describe('POST /api/v1/refreshes/:githubIssueId/review', () => { + let app: Application; + let container: Container; + let db: Db; + let transactions: { + approve: FilecoinTx; + reject: FilecoinTx; + nonGovernanceTeamMember: FilecoinTx; + invalidChallenge: FilecoinTx; + incorrectAuditStatusForApprove: FilecoinTx; + notExistingRefresh: FilecoinTx; + }; + let fixturePendingRefresh: IssueDetails; + let databasePendingRefresh: InsertOneResult; + let fixtureRefreshWithIncorrectAuditStatusForApprove: IssueDetails; + + const messageFactory = messageFactoryByType[SignatureType.RefreshReview]; + + const fixtureChallengeProps = { id: '123', finalDataCap: 1024, allocatorType: 'RKH' }; + const fixtureGithubPendingAudit = GithubAuditFactory.create(AuditOutcome.PENDING); + const fixtureGithubGantedAudit = GithubAuditFactory.create(AuditOutcome.GRANTED); + const fixtureAllocatorJsonMock = { + pathway_addresses: { msig: 'f2abcdef1234567890' }, + ma_address: 'f4', + metapathway_type: 'AMA', + allocator_id: '1', + audits: [fixtureGithubGantedAudit, fixtureGithubPendingAudit], + }; + const fixtureFileContent = { + content: JSON.stringify(fixtureAllocatorJsonMock), + }; + const fixtureCreatePullRequestResponse = { + number: 10, + head: { sha: 'abc' }, + html_url: 'url', + }; + + beforeAll(async () => { + const [ + correctApprove, + correctReject, + nonGovernanceTeamMember, + invalidChallenge, + incorrectAuditStatusForApprove, + notExistingRefresh, + ] = await Promise.all([ + new FilecoinTxBuilder() + .withChallenge(messageFactory({ result: 'approve', ...fixtureChallengeProps })) + .build(), + new FilecoinTxBuilder() + .withChallenge(messageFactory({ result: 'reject', ...fixtureChallengeProps })) + .build(), + new FilecoinTxBuilder() + .withPrivateKeyHex('8f6a1c0d3b2e9f71c4d2a1b0e9f8c7d6b5a49382716f5e4d3c2b1a0918273645') + .withChallenge(messageFactory({ result: 'approve', ...fixtureChallengeProps })) + .build(), + new FilecoinTxBuilder().withChallenge('invalid-challenge').build(), + new FilecoinTxBuilder() + .withChallenge( + messageFactory({ result: 'approve', ...{ ...fixtureChallengeProps, id: '456' } }), + ) + .build(), + new FilecoinTxBuilder() + .withChallenge( + messageFactory({ result: 'approve', ...{ ...fixtureChallengeProps, id: '789' } }), + ) + .build(), + ]); + + transactions = { + approve: correctApprove, + reject: correctReject, + nonGovernanceTeamMember, + invalidChallenge, + incorrectAuditStatusForApprove, + notExistingRefresh, + }; + + const testBuilder = new TestContainerBuilder(); + await testBuilder.withDatabase(); + const testSetup = testBuilder + .withLogger() + .withConfig(TYPES.AllocatorGovernanceConfig, { owner: 'owner', repo: 'repo' }) + .withConfig(TYPES.AllocatorRegistryConfig, { owner: 'owner', repo: 'repo' }) + .withConfig(TYPES.GovernanceConfig, { + addresses: [correctApprove.address], + }) + .withEventBus() + .withCommandBus() + .withQueryBus() + .withMappers() + .withResolvers() + .withPublishers() + .withServices() + .withGithubClient(githubMock as unknown as IGithubClient) + .withRepositories() + .withCommandHandlers() + .withQueryHandlers() + .registerHandlers() + .build(); + + container = testSetup.container; + db = testSetup.db; + + const server = new InversifyExpressServer(container); + server.setConfig((app: Application) => { + app.use(urlencoded({ extended: true })); + app.use(json()); + }); + + app = server.build(); + app.listen(); + + console.log(`Test setup complete. Database: ${db.databaseName}`); + }); + + beforeEach(async () => { + fixturePendingRefresh = DatabaseRefreshFactory.create({ + githubIssueNumber: 123, + refreshStatus: RefreshStatus.PENDING, + }); + fixtureRefreshWithIncorrectAuditStatusForApprove = DatabaseRefreshFactory.create({ + githubIssueNumber: 456, + refreshStatus: RefreshStatus.DC_ALLOCATED, + }); + + databasePendingRefresh = await db + .collection('issueDetails') + .insertOne(fixturePendingRefresh); + + await db + .collection('issueDetails') + .insertOne(fixtureRefreshWithIncorrectAuditStatusForApprove); + + githubMock.getFile.mockResolvedValue(fixtureFileContent); + githubMock.createBranch.mockResolvedValue({ ref: 'refs/heads/b' }); + githubMock.createPullRequest.mockResolvedValue(fixtureCreatePullRequestResponse); + githubMock.mergePullRequest.mockResolvedValue({}); + githubMock.deleteBranch.mockResolvedValue({}); + }); + + afterEach(async () => { + await db.collection('issueDetails').deleteMany({}); + await db.collection('refreshDetails').deleteMany({}); + await db.collection('applicationDetails').deleteMany({}); + }); + + function prepareGovernanceReviewPayload({ result }: { result: 'approve' | 'reject' }) { + return { + result, + details: { + reviewerAddress: transactions[result].address, + reviewerPublicKey: transactions[result].pubKeyBase64, + finalDataCap: fixtureChallengeProps.finalDataCap, + allocatorType: fixtureChallengeProps.allocatorType, + }, + signature: transactions[result].transaction, + }; + } + + it('should approve refresh with valid governance team signature', async () => { + vi.useFakeTimers(); + const now = new Date('2024-01-01T00:00:00.000Z'); + + vi.setSystemTime(now); + + const response = await request(app) + .post('/api/v1/refreshes/123/review') + .send( + prepareGovernanceReviewPayload({ + result: 'approve', + }), + ) + .expect(200); + + expect(response.body).toStrictEqual({ + status: '200', + message: expect.stringContaining('success'), + data: 'approve', + }); + + const refresh = await db.collection('issueDetails').findOne({ githubIssueNumber: 123 }); + + expect(refresh).toStrictEqual({ + ...fixturePendingRefresh, + _id: databasePendingRefresh.insertedId, + refreshStatus: RefreshStatus.APPROVED, + auditHistory: [ + ...(fixturePendingRefresh.auditHistory || []), + { + auditChange: { + outcome: AuditOutcome.APPROVED, + ended: now.toISOString(), + datacapAmount: fixtureChallengeProps.finalDataCap, + }, + branchName: `refresh-audit-${fixturePendingRefresh.jsonNumber}-2-nanoid-id`, + commitSha: fixtureCreatePullRequestResponse.head.sha, + prNumber: fixtureCreatePullRequestResponse.number, + prUrl: fixtureCreatePullRequestResponse.html_url, + }, + ], + }); + vi.useRealTimers(); + }); + + it('should reject refresh with valid governance team signature', async () => { + vi.useFakeTimers(); + const now = new Date('2024-01-01T00:00:00.000Z'); + + vi.setSystemTime(now); + + const response = await request(app) + .post('/api/v1/refreshes/123/review') + .send( + prepareGovernanceReviewPayload({ + result: 'reject', + }), + ); + + expect(response.body).toStrictEqual({ + status: '200', + message: expect.stringContaining('success'), + data: 'reject', + }); + + const refresh = await db.collection('issueDetails').findOne({ githubIssueNumber: 123 }); + + expect(refresh).toStrictEqual({ + ...fixturePendingRefresh, + _id: databasePendingRefresh.insertedId, + refreshStatus: RefreshStatus.REJECTED, + auditHistory: [ + ...(fixturePendingRefresh.auditHistory || []), + { + auditChange: { + outcome: AuditOutcome.REJECTED, + ended: now.toISOString(), + }, + branchName: `refresh-audit-${fixturePendingRefresh.jsonNumber}-2-nanoid-id`, + commitSha: fixtureCreatePullRequestResponse.head.sha, + prNumber: fixtureCreatePullRequestResponse.number, + prUrl: fixtureCreatePullRequestResponse.html_url, + }, + ], + }); + vi.useRealTimers(); + }); + + it('should return 400 for inalid id', async () => { + const response = await request(app) + .post('/api/v1/refreshes/invalid/review') + .send( + prepareGovernanceReviewPayload({ + result: 'approve', + }), + ) + .expect(400); + + expect(response.body).toStrictEqual({ + status: '400', + message: 'Validation failed', + errors: ['Governance review github issue number must be a positive integer'], + }); + }); + + it('should return 400 for invalid request body', async () => { + const response = await request(app) + .post('/api/v1/refreshes/123/review') + .send({ + result: 'approve', + }) + .expect(400); + + expect(response.body).toStrictEqual({ + status: '400', + message: 'Validation failed', + errors: [ + 'Governance review details is required', + 'Governance review details reviewer address is required', + 'Governance review details final data cap is required', + 'Governance review details allocator type is required', + 'Governance review details signature is required', + 'Governance review details reviewer public key is required', + ], + }); + }); + + it('should return 400 for invalid result value', async () => { + const payload = prepareGovernanceReviewPayload({ + result: 'reject', + }); + payload.result = 'invalid' as any; + + const response = await request(app) + .post('/api/v1/refreshes/123/review') + .send(payload) + .expect(400); + + expect(response.body).toStrictEqual({ + status: '400', + message: 'Validation failed', + errors: ['Governance review result must be either approve or reject'], + }); + }); + + it('should return 403 for non-governance team member', async () => { + const response = await request(app) + .post('/api/v1/refreshes/123/review') + .send({ + result: 'approve', + details: { + reviewerAddress: transactions.nonGovernanceTeamMember.address, + reviewerPublicKey: transactions.nonGovernanceTeamMember.pubKeyBase64, + finalDataCap: fixtureChallengeProps.finalDataCap, + allocatorType: fixtureChallengeProps.allocatorType, + }, + signature: transactions.nonGovernanceTeamMember.transaction, + }) + .expect(403); + + expect(response.body).toStrictEqual({ + status: '403', + message: 'Bad Permissions', + }); + }); + + it('should throw error when signature is invalid', async () => { + const payload = prepareGovernanceReviewPayload({ + result: 'approve', + }); + + payload.signature = JSON.stringify({ + Message: 'invalid-message-base64', + Signature: { Data: 'invalid-signature-base64' }, + }); + + const response = await request(app) + .post('/api/v1/refreshes/789/review') + .send(payload) + .expect(400); + + expect(response.body).toStrictEqual({ + status: '400', + message: "addresses don't match", + }); + }); + + it('should return 400 for signature with wrong challenge content', async () => { + const response = await request(app) + .post('/api/v1/refreshes/123/review') + .send({ + result: 'approve', + details: { + reviewerAddress: transactions.invalidChallenge.address, + reviewerPublicKey: transactions.invalidChallenge.pubKeyBase64, + finalDataCap: fixtureChallengeProps.finalDataCap, + allocatorType: fixtureChallengeProps.allocatorType, + }, + signature: transactions.invalidChallenge.transaction, + }) + .expect(400); + + expect(response.body).toStrictEqual({ + status: '400', + message: "pre-images don't match", + }); + }); + + it('should return 400 for refresh with incorrect audit status for approve', async () => { + const response = await request(app) + .post('/api/v1/refreshes/456/review') + .send({ + result: 'approve', + details: { + reviewerAddress: transactions.incorrectAuditStatusForApprove.address, + reviewerPublicKey: transactions.incorrectAuditStatusForApprove.pubKeyBase64, + finalDataCap: fixtureChallengeProps.finalDataCap, + allocatorType: fixtureChallengeProps.allocatorType, + }, + signature: transactions.incorrectAuditStatusForApprove.transaction, + }) + .expect(400); + + expect(response.body).toStrictEqual({ + status: '400', + message: 'Failed to upsert issue', + errors: [ + 'Cannot approve audit refresh because it is not in the correct status. GithubIssueNumber: 456', + ], + }); + }); + + it('should throw error when trying to approve not existing refresh', async () => { + const response = await request(app) + .post('/api/v1/refreshes/789/review') + .send({ + result: 'approve', + details: { + reviewerAddress: transactions.notExistingRefresh.address, + reviewerPublicKey: transactions.notExistingRefresh.pubKeyBase64, + finalDataCap: fixtureChallengeProps.finalDataCap, + allocatorType: fixtureChallengeProps.allocatorType, + }, + signature: transactions.notExistingRefresh.transaction, + }) + .expect(400); + + expect(response.body).toStrictEqual({ + status: '400', + message: 'Failed to upsert issue', + errors: [ + 'Cannot approve audit refresh because it is not in the correct status. GithubIssueNumber: 789', + ], + }); + }); +}); diff --git a/packages/application/src/e2e/api/refresh/post--sync-issues.e2e.test.ts b/packages/application/src/e2e/api/refresh/post--sync-issues.e2e.test.ts index 0a1ae84..81da054 100644 --- a/packages/application/src/e2e/api/refresh/post--sync-issues.e2e.test.ts +++ b/packages/application/src/e2e/api/refresh/post--sync-issues.e2e.test.ts @@ -34,6 +34,7 @@ describe('POST /api/v1/refreshes/sync/issues', () => { .withCommandBus() .withQueryBus() .withGithubClient(githubMock as unknown as IGithubClient) + .withConfig(TYPES.AllocatorGovernanceConfig, { owner: 'owner', repo: 'repo' }) .withMappers() .withServices() .withRepositories() @@ -45,13 +46,6 @@ describe('POST /api/v1/refreshes/sync/issues', () => { container = testSetup.container; db = testSetup.db; - // Provide AllocatorGovernanceConfig if not present - if (!container.isBound(TYPES.AllocatorGovernanceConfig)) { - container - .bind(TYPES.AllocatorGovernanceConfig) - .toConstantValue({ owner: 'owner', repo: 'repo' }); - } - const server = new InversifyExpressServer(container); server.setConfig((app: Application) => { app.use(urlencoded({ extended: true })); diff --git a/packages/application/src/e2e/api/refresh/put--upsert-from-issue.e2e.test.ts b/packages/application/src/e2e/api/refresh/put--upsert-from-issue.e2e.test.ts index f358b43..6b037e6 100644 --- a/packages/application/src/e2e/api/refresh/put--upsert-from-issue.e2e.test.ts +++ b/packages/application/src/e2e/api/refresh/put--upsert-from-issue.e2e.test.ts @@ -6,14 +6,21 @@ import { Container } from 'inversify'; import { Db } from 'mongodb'; import { InversifyExpressServer } from 'inversify-express-utils'; import * as dotenv from 'dotenv'; -import { DatabaseRefreshFactory, GithubIssueFactory } from '@mocks/factories'; +import { DatabaseRefreshFactory, GithubAuditFactory, GithubIssueFactory } from '@mocks/factories'; import { TestContainerBuilder } from '@mocks/builders'; import '@src/api/http/controllers/refresh.controller'; import { IGithubClient } from '@src/infrastructure/clients/github'; +import { TYPES } from '@src/types'; +import { AuditOutcome, RefreshStatus } from '@src/infrastructure/repositories/issue-details'; +import { faker } from '@faker-js/faker'; process.env.NODE_ENV = 'test'; dotenv.config({ path: '.env.test' }); +vi.mock('nanoid', () => ({ + nanoid: vi.fn().mockReturnValue('nanoid-id'), +})); + describe('Refresh from Issue E2E', () => { let app: Application; let container: Container; @@ -23,17 +30,36 @@ describe('Refresh from Issue E2E', () => { getIssues: vi.fn(), getIssue: vi.fn(), getFile: vi.fn(), + createBranch: vi.fn(), + createPullRequest: vi.fn(), + mergePullRequest: vi.fn(), + deleteBranch: vi.fn(), })); - const fixtureAllocatorJsonMock = { - pathway_addresses: { msig: 'f2abcdef1234567890' }, - ma_address: 'f4', + const fixtureGithubPendingAudit = GithubAuditFactory.create(AuditOutcome.PENDING); + const fixtureGithubGantedAudit = GithubAuditFactory.create(AuditOutcome.GRANTED); + const fixturePendingAllocatorJson = { + pathway_addresses: { msig: `f2${faker.string.alphanumeric(36)}` }, + application_number: 10000, + ma_address: `f4${faker.string.alphanumeric(36)}`, + metapathway_type: 'RKH', + allocator_id: `f0${faker.string.numeric(7)}`, + audits: [fixtureGithubGantedAudit, fixtureGithubPendingAudit], + }; + const fixtureGantedAllocatorJson = { + pathway_addresses: { msig: `f2${faker.string.alphanumeric(36)}` }, + application_number: 11000, + ma_address: `f4${faker.string.alphanumeric(36)}`, metapathway_type: 'AMA', - allocator_id: '1', + allocator_id: `f0${faker.string.numeric(7)}`, + audits: [fixtureGithubGantedAudit], }; - - const mockFileContent = { - content: JSON.stringify(fixtureAllocatorJsonMock), + const fixturePendingFileContent = { content: JSON.stringify(fixturePendingAllocatorJson) }; + const fixtureGantedFileContent = { content: JSON.stringify(fixtureGantedAllocatorJson) }; + const fixtureCreatePullRequestResponse = { + number: 10, + head: { sha: 'abc' }, + html_url: 'url', }; beforeAll(async () => { @@ -45,8 +71,13 @@ describe('Refresh from Issue E2E', () => { .withCommandBus() .withQueryBus() .withGithubClient(githubMock as unknown as IGithubClient) - .withMappers() + .withConfig(TYPES.AllocatorGovernanceConfig, { owner: 'owner', repo: 'repo' }) + .withConfig(TYPES.AllocatorRegistryConfig, { owner: 'owner', repo: 'repo' }) + .withConfig(TYPES.GovernanceConfig, { addresses: ['0x123'] }) + .withResolvers() .withServices() + .withPublishers() + .withMappers() .withRepositories() .withCommandHandlers() .withQueryHandlers() @@ -67,18 +98,41 @@ describe('Refresh from Issue E2E', () => { }); beforeEach(() => { - githubMock.getFile.mockResolvedValue(mockFileContent); + githubMock.getFile.mockResolvedValue(fixturePendingFileContent); + githubMock.createBranch.mockResolvedValue({ ref: 'refs/heads/b' }); + githubMock.createPullRequest.mockResolvedValue(fixtureCreatePullRequestResponse); + githubMock.mergePullRequest.mockResolvedValue({}); + githubMock.deleteBranch.mockResolvedValue({}); }); afterEach(async () => { await db.collection('issueDetails').deleteMany({}); await db.collection('refreshDetails').deleteMany({}); await db.collection('applicationDetails').deleteMany({}); + + vi.clearAllMocks(); }); describe('PUT /api/v1/refreshes/upsert-from-issue', () => { - it('should add refresh by issue to database', async () => { - const fixtureOpenedGithubEvent = GithubIssueFactory.createOpened(); + /** + * when: + * - allocator doesn't have pending audit on github + * - database described by @jsonNumber doesn't have pending audit + * - refresh for given github issue id does not exist in database + * then: + * - should add refresh by @githubIssueId to database + * - should call publisher with new audit + */ + it('should add refresh by issue to database and call publisher with new audit', async () => { + vi.useFakeTimers(); + const now = new Date('2024-01-01T00:00:00.000Z'); + const fixtureJsonHash = `rec${faker.string.alphanumeric(12)}`; + const fixtureOpenedGithubEvent = GithubIssueFactory.createOpened({ + allocator: { jsonNumber: fixtureJsonHash }, + }); + + vi.setSystemTime(now); + githubMock.getFile.mockResolvedValue(fixtureGantedFileContent); const response = await request(app) .put('/api/v1/refreshes/upsert-from-issue') @@ -100,10 +154,10 @@ describe('Refresh from Issue E2E', () => { name: assignee.login, userId: assignee.id, })), - actorId: fixtureAllocatorJsonMock.allocator_id, - msigAddress: fixtureAllocatorJsonMock.pathway_addresses.msig, - maAddress: fixtureAllocatorJsonMock.ma_address, - metapathwayType: fixtureAllocatorJsonMock.metapathway_type, + actorId: fixtureGantedAllocatorJson.allocator_id, + msigAddress: fixtureGantedAllocatorJson.pathway_addresses.msig, + maAddress: fixtureGantedAllocatorJson.ma_address, + metapathwayType: fixtureGantedAllocatorJson.metapathway_type, githubIssueNumber: fixtureOpenedGithubEvent.issue.number, closedAt: null, createdAt: new Date(fixtureOpenedGithubEvent.issue.created_at), @@ -113,21 +167,62 @@ describe('Refresh from Issue E2E', () => { userId: fixtureOpenedGithubEvent.issue.user?.id, }, githubIssueId: fixtureOpenedGithubEvent.issue.id, - jsonNumber: expect.any(String), + jsonNumber: fixtureJsonHash, labels: fixtureOpenedGithubEvent.issue.labels.map(label => typeof label === 'string' ? label : label.name, ), state: fixtureOpenedGithubEvent.issue.state, title: fixtureOpenedGithubEvent.issue.title.replace('[DataCap Refresh] ', ''), refreshStatus: 'PENDING', + auditHistory: [ + { + auditChange: { + dcAllocated: '', + datacapAmount: '', + ended: '', + outcome: AuditOutcome.PENDING, + started: now.toISOString(), + }, + branchName: `refresh-audit-${fixtureJsonHash}-2-nanoid-id`, + commitSha: fixtureCreatePullRequestResponse.head.sha, + prNumber: fixtureCreatePullRequestResponse.number, + prUrl: fixtureCreatePullRequestResponse.html_url, + }, + ], }); + + expect(githubMock.createBranch).toHaveBeenCalled(); + expect(githubMock.createPullRequest).toHaveBeenCalled(); + expect(githubMock.mergePullRequest).toHaveBeenCalled(); + expect(githubMock.deleteBranch).toHaveBeenCalled(); }); - it('should update refresh in database by github issue id', async () => { - const fixtureEditedGithubEvent = GithubIssueFactory.createEdited(); - const fixtureDatabaseIssue = DatabaseRefreshFactory.create(); + /** + * when: + * - allocator has pending audit on github + * - database described by @jsonNumber has pending audit + * - refresh for given github issue id exists and has pending audit + * - refresh and issue matches + * then: + * - should update refresh by @githubIssueId in database + * - should skip publisher since audit is already pending + */ + it('should update refresh in database by github issue id and skip publisher since audit is already pending ', async () => { + vi.useFakeTimers(); + const now = new Date('2024-01-01T00:00:00.000Z'); + const fixtureJsonHash = `rec${faker.string.alphanumeric(12)}`; + const fixtureEditedGithubEvent = GithubIssueFactory.createEdited({ + allocator: { jsonNumber: fixtureJsonHash }, + }); + const fixtureDatabaseIssue = DatabaseRefreshFactory.create({ + refreshStatus: RefreshStatus.PENDING, + jsonNumber: fixtureJsonHash, + }); + vi.setSystemTime(now); fixtureEditedGithubEvent.issue.id = fixtureDatabaseIssue.githubIssueId; + fixtureDatabaseIssue; + githubMock.getFile.mockResolvedValue(fixturePendingFileContent); await db.collection('issueDetails').insertOne(fixtureDatabaseIssue); @@ -149,18 +244,26 @@ describe('Refresh from Issue E2E', () => { .collection('issueDetails') .findOne({ githubIssueId: fixtureEditedGithubEvent.issue.id }); + expect(githubMock.getFile).toHaveBeenCalledWith( + 'threesigmaxyz', + 'Allocator-Registry', + `Allocators/${fixtureJsonHash}.json`, + ); + expect(updatedIssue).toStrictEqual({ - _id: savedIssue?._id, - actorId: fixtureAllocatorJsonMock.allocator_id, - msigAddress: fixtureAllocatorJsonMock.pathway_addresses.msig, - maAddress: fixtureAllocatorJsonMock.ma_address, + // udates iherited from allocator json + actorId: fixturePendingAllocatorJson.allocator_id, + msigAddress: fixturePendingAllocatorJson.pathway_addresses.msig, + maAddress: fixturePendingAllocatorJson.ma_address, + metapathwayType: fixturePendingAllocatorJson.metapathway_type, + // fields updated by github issue githubIssueNumber: fixtureEditedGithubEvent.issue.number, - metapathwayType: fixtureAllocatorJsonMock.metapathway_type, - assignees: fixtureEditedGithubEvent.issue.assignees?.map(assignee => ({ - name: assignee.login, - userId: assignee.id, - })), - dataCap: fixtureDatabaseIssue.dataCap, + state: fixtureEditedGithubEvent.issue.state, + title: fixtureEditedGithubEvent.issue.title.replace('[DataCap Refresh] ', ''), + jsonNumber: fixtureJsonHash, + labels: fixtureEditedGithubEvent.issue.labels.map(label => + typeof label === 'string' ? label : label.name, + ), closedAt: null, createdAt: new Date(fixtureEditedGithubEvent.issue.created_at), updatedAt: new Date(fixtureEditedGithubEvent.issue.updated_at), @@ -168,15 +271,259 @@ describe('Refresh from Issue E2E', () => { name: fixtureEditedGithubEvent.issue.user?.login, userId: fixtureEditedGithubEvent.issue.user?.id, }, - githubIssueId: fixtureEditedGithubEvent.issue.id, - jsonNumber: expect.any(String), - labels: fixtureEditedGithubEvent.issue.labels.map(label => + assignees: fixtureEditedGithubEvent.issue.assignees?.map(assignee => ({ + name: assignee.login, + userId: assignee.id, + })), + // fields preserved + _id: savedIssue?._id, + dataCap: savedIssue?.dataCap, + githubIssueId: savedIssue?.githubIssueId, + refreshStatus: 'PENDING', + }); + + expect(githubMock.createBranch).not.toHaveBeenCalled(); + expect(githubMock.createPullRequest).not.toHaveBeenCalled(); + expect(githubMock.mergePullRequest).not.toHaveBeenCalled(); + expect(githubMock.deleteBranch).not.toHaveBeenCalled(); + }); + + /** + * when: + * - allocator has pending audit on github and database described by @jsonNumber + * - refresh for given github issue doesn't exist in database + * then: + * - should throw error when upserting new issue but allocator has pending audit + */ + it('should throw error when upserting new issue but allocator has pending audit', async () => { + const fixtureJsonHash = `rec${faker.string.alphanumeric(12)}`; + const fixtureOpenedGithubEvent = GithubIssueFactory.createOpened({ + allocator: { jsonNumber: fixtureJsonHash }, + }); + const fixtureDatabaseIssue = DatabaseRefreshFactory.create({ + jsonNumber: fixtureJsonHash, + }); + await db.collection('issueDetails').insertOne(fixtureDatabaseIssue); + + const response = await request(app) + .put('/api/v1/refreshes/upsert-from-issue') + .send(fixtureOpenedGithubEvent) + .expect(400); + + expect(response.body).toStrictEqual({ + errors: [ + `${fixtureJsonHash} has pending audit, finish existing refresh before creating a new one`, + ], + message: 'Failed to upsert issue', + status: '400', + }); + }); + + it('should throw error when upserting issue but its already finished', async () => { + const fixtureOpenedGithubEvent = GithubIssueFactory.createOpened(); + const fixtureDatabaseIssue = DatabaseRefreshFactory.create({ + refreshStatus: RefreshStatus.DC_ALLOCATED, + }); + + await db.collection('issueDetails').insertOne(fixtureDatabaseIssue); + fixtureOpenedGithubEvent.issue.number = fixtureDatabaseIssue.githubIssueNumber; + fixtureOpenedGithubEvent.issue.id = fixtureDatabaseIssue.githubIssueId; + + githubMock.getFile.mockResolvedValue(fixtureGantedFileContent); + + const response = await request(app) + .put('/api/v1/refreshes/upsert-from-issue') + .send(fixtureOpenedGithubEvent) + .expect(400); + + expect(response.body).toStrictEqual({ + errors: [ + `${fixtureDatabaseIssue.githubIssueNumber} This issue refresh is already finished`, + ], + message: 'Failed to upsert issue', + status: '400', + }); + }); + + /** + * when: + * - allocator does not have pending audit on github + * - refresh for given github issue exists in database + * - current allocator does not have pending audit + * - refresh and issue doesn't match + * then: + * - should create new refresh and audit + */ + it('should update refresh and create new audit ', async () => { + vi.useFakeTimers(); + const now = new Date('2024-01-01T00:00:00.000Z'); + const fixtureJsonHash = `rec${faker.string.alphanumeric(12)}`; + const fixtureOpenedGithubEvent = GithubIssueFactory.createOpened({ + allocator: { jsonNumber: fixtureJsonHash }, + }); + const fixtureDatabaseIssue = DatabaseRefreshFactory.create({ + githubIssueNumber: fixtureOpenedGithubEvent.issue.number, + githubIssueId: fixtureOpenedGithubEvent.issue.id, + jsonNumber: fixtureJsonHash, + refreshStatus: RefreshStatus.PENDING, + }); + + githubMock.getFile.mockResolvedValue(fixtureGantedFileContent); + vi.setSystemTime(now); + await db.collection('issueDetails').insertOne(fixtureDatabaseIssue); + + const savedIssue = await db + .collection('issueDetails') + .findOne({ githubIssueId: fixtureDatabaseIssue.githubIssueId }); + + const response = await request(app) + .put('/api/v1/refreshes/upsert-from-issue') + .send(fixtureOpenedGithubEvent) + .expect(200); + + expect(response.body).toStrictEqual({ + message: 'Issue upserted successfully', + status: '200', + }); + + const updatedIssue = await db + .collection('issueDetails') + .findOne({ githubIssueId: fixtureOpenedGithubEvent.issue.id }); + + expect(githubMock.createBranch).toHaveBeenCalled(); + expect(githubMock.createPullRequest).toHaveBeenCalled(); + expect(githubMock.mergePullRequest).toHaveBeenCalled(); + expect(githubMock.deleteBranch).toHaveBeenCalled(); + + expect(updatedIssue).toStrictEqual({ + ...savedIssue, + assignees: fixtureOpenedGithubEvent.issue.assignees?.map(assignee => ({ + name: assignee.login, + userId: assignee.id, + })), + githubIssueNumber: fixtureOpenedGithubEvent.issue.number, + githubIssueId: fixtureOpenedGithubEvent.issue.id, + creator: { + name: fixtureOpenedGithubEvent.issue.user?.login, + userId: fixtureOpenedGithubEvent.issue.user?.id, + }, + actorId: fixtureGantedAllocatorJson.allocator_id, + msigAddress: fixtureGantedAllocatorJson.pathway_addresses.msig, + maAddress: fixtureGantedAllocatorJson.ma_address, + metapathwayType: fixtureGantedAllocatorJson.metapathway_type, + createdAt: new Date(fixtureOpenedGithubEvent.issue.created_at), + updatedAt: new Date(fixtureOpenedGithubEvent.issue.updated_at), + closedAt: null, + title: fixtureOpenedGithubEvent.issue.title.replace('[DataCap Refresh] ', ''), + state: fixtureOpenedGithubEvent.issue.state, + labels: fixtureOpenedGithubEvent.issue.labels.map(label => typeof label === 'string' ? label : label.name, ), - state: fixtureEditedGithubEvent.issue.state, - title: fixtureEditedGithubEvent.issue.title.replace('[DataCap Refresh] ', ''), - refreshStatus: 'PENDING', + jsonNumber: fixtureDatabaseIssue.jsonNumber, + refreshStatus: RefreshStatus.PENDING, + auditHistory: [ + { + auditChange: { + dcAllocated: '', + datacapAmount: '', + ended: '', + outcome: AuditOutcome.PENDING, + started: now.toISOString(), + }, + branchName: `refresh-audit-${fixtureDatabaseIssue.jsonNumber}-2-nanoid-id`, + commitSha: fixtureCreatePullRequestResponse.head.sha, + prNumber: fixtureCreatePullRequestResponse.number, + prUrl: fixtureCreatePullRequestResponse.html_url, + }, + ], + }); + + expect(githubMock.createBranch).toHaveBeenCalled(); + expect(githubMock.createPullRequest).toHaveBeenCalled(); + expect(githubMock.mergePullRequest).toHaveBeenCalled(); + expect(githubMock.deleteBranch).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + /** + * when: + * - allocator has pending audit on github + * - database refresh described by @jsonNumber is pending + * - refresh and issue matches + * then: + * - should update refresh in database by github issue id + * - should skip publisher since audit is already pending + */ + it('should update refresh and skip creation of new audit ', async () => { + vi.useFakeTimers(); + const now = new Date('2024-01-01T00:00:00.000Z'); + const fixtureJsonHash = `rec${faker.string.alphanumeric(12)}`; + const fixtureOpenedGithubEvent = GithubIssueFactory.createOpened({ + allocator: { jsonNumber: fixtureJsonHash }, + }); + const fixturePendingDatabaseIssue = DatabaseRefreshFactory.create({ + githubIssueNumber: fixtureOpenedGithubEvent.issue.number, + githubIssueId: fixtureOpenedGithubEvent.issue.id, + jsonNumber: fixtureJsonHash, + refreshStatus: RefreshStatus.PENDING, }); + githubMock.getFile.mockResolvedValue(fixturePendingFileContent); + vi.setSystemTime(now); + await db.collection('issueDetails').insertOne(fixturePendingDatabaseIssue); + + const savedIssue = await db + .collection('issueDetails') + .findOne({ githubIssueId: fixturePendingDatabaseIssue.githubIssueId }); + + const response = await request(app) + .put('/api/v1/refreshes/upsert-from-issue') + .send(fixtureOpenedGithubEvent) + .expect(200); + + expect(response.body).toStrictEqual({ + message: 'Issue upserted successfully', + status: '200', + }); + + const updatedIssue = await db + .collection('issueDetails') + .findOne({ githubIssueId: fixtureOpenedGithubEvent.issue.id }); + + expect(updatedIssue).toStrictEqual({ + ...savedIssue, + assignees: fixtureOpenedGithubEvent.issue.assignees?.map(assignee => ({ + name: assignee.login, + userId: assignee.id, + })), + githubIssueNumber: fixtureOpenedGithubEvent.issue.number, + githubIssueId: fixtureOpenedGithubEvent.issue.id, + creator: { + name: fixtureOpenedGithubEvent.issue.user?.login, + userId: fixtureOpenedGithubEvent.issue.user?.id, + }, + actorId: fixturePendingAllocatorJson.allocator_id, + msigAddress: fixturePendingAllocatorJson.pathway_addresses.msig, + maAddress: fixturePendingAllocatorJson.ma_address, + metapathwayType: fixturePendingAllocatorJson.metapathway_type, + createdAt: new Date(fixtureOpenedGithubEvent.issue.created_at), + updatedAt: new Date(fixtureOpenedGithubEvent.issue.updated_at), + closedAt: null, + title: fixtureOpenedGithubEvent.issue.title.replace('[DataCap Refresh] ', ''), + state: fixtureOpenedGithubEvent.issue.state, + labels: fixtureOpenedGithubEvent.issue.labels.map(label => + typeof label === 'string' ? label : label.name, + ), + jsonNumber: fixturePendingDatabaseIssue.jsonNumber, + refreshStatus: RefreshStatus.PENDING, + }); + + expect(githubMock.createBranch).not.toHaveBeenCalled(); + expect(githubMock.createPullRequest).not.toHaveBeenCalled(); + expect(githubMock.mergePullRequest).not.toHaveBeenCalled(); + expect(githubMock.deleteBranch).not.toHaveBeenCalled(); + + vi.useRealTimers(); }); it('should throw validation error when issue is missing', async () => { diff --git a/packages/application/src/infrastructure/interfaces.ts b/packages/application/src/infrastructure/interfaces.ts index 4057f9f..a48aebb 100644 --- a/packages/application/src/infrastructure/interfaces.ts +++ b/packages/application/src/infrastructure/interfaces.ts @@ -17,3 +17,7 @@ export interface LotusClientConfig { rpcUrl: string; authToken: string; } + +export interface GovernanceConfig { + addresses: string[]; +} diff --git a/packages/application/src/infrastructure/module.ts b/packages/application/src/infrastructure/module.ts index a62b65b..82357c8 100644 --- a/packages/application/src/infrastructure/module.ts +++ b/packages/application/src/infrastructure/module.ts @@ -33,7 +33,7 @@ import { IMetaAllocatorRepository, MetaAllocatorRepository, } from './repositories/meta-allocator.repository'; -import { LotusClientConfig, RkhConfig, RpcProviderConfig } from './interfaces'; +import { GovernanceConfig, LotusClientConfig, RkhConfig, RpcProviderConfig } from './interfaces'; import { GithubConfig } from '@src/domain/types'; import { AuditMapper, IAuditMapper } from './mappers/audit-mapper'; import { @@ -111,6 +111,11 @@ export const infrastructureModule = new AsyncContainerModule(async (bind: interf }; bind(TYPES.RkhConfig).toConstantValue(rkhConfig); + const governanceConfig = { + addresses: config.GOVERNANCE_REVIEW_ADDRESSES, + }; + bind(TYPES.GovernanceConfig).toConstantValue(governanceConfig); + // Bindings bind(TYPES.DatacapAllocatorEventStore) .to(DatacapAllocatorEventStore) diff --git a/packages/application/src/infrastructure/repositories/issue-details.repository.ts b/packages/application/src/infrastructure/repositories/issue-details.repository.ts index 3098093..850194e 100644 --- a/packages/application/src/infrastructure/repositories/issue-details.repository.ts +++ b/packages/application/src/infrastructure/repositories/issue-details.repository.ts @@ -1,8 +1,8 @@ import { IRepository } from '@filecoin-plus/core'; import { inject, injectable } from 'inversify'; -import { BulkWriteResult, Db, Filter, WithId } from 'mongodb'; +import { BulkWriteResult, Db, Filter, FindOptions, UpdateFilter, WithId } from 'mongodb'; import { TYPES } from '@src/types'; -import { AuditOutcome, IssueDetails } from '@src/infrastructure/repositories/issue-details'; +import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; type PaginatedResults = { results: T[]; @@ -38,14 +38,18 @@ export interface IIssueDetailsRepository extends IRepository { findSignedBy(filter: Filter): Promise; + findApprovedBy(filter: Filter): Promise; + findBy( key: K, value: IssueDetails[K], + options?: FindOptions, ): Promise | null>; - findWithLatestAuditBy( + findLatestBy( key: K, value: IssueDetails[K], + options?: FindOptions, ): Promise | null>; } @@ -60,16 +64,22 @@ class IssueDetailsRepository implements IIssueDetailsRepository { } async save(issueDetails: IssueDetails): Promise { - await this._db.collection('issueDetails').updateOne( - { githubIssueId: issueDetails.githubIssueId }, - { - $set: issueDetails, - $setOnInsert: { - refreshStatus: 'PENDING' as const, - }, - }, - { upsert: true }, - ); + const { refreshStatus } = issueDetails; + + const updateFilter: UpdateFilter = { + $set: issueDetails, + ...(!refreshStatus + ? { + $setOnInsert: { + refreshStatus: 'PENDING' as const, + }, + } + : {}), + }; + + await this._db + .collection('issueDetails') + .updateOne({ githubIssueId: issueDetails.githubIssueId }, updateFilter, { upsert: true }); } async bulkUpsertByField( @@ -150,11 +160,12 @@ class IssueDetailsRepository implements IIssueDetailsRepository { async findBy( key: K, value: IssueDetails[K], + options?: FindOptions, ): Promise | null> { - return this._db.collection('issueDetails').findOne({ [key]: value }); + return this._db.collection('issueDetails').findOne({ [key]: value }, options); } - async findWithLatestAuditBy( + async findLatestBy( key: K, value: IssueDetails[K], ): Promise | null> { @@ -163,7 +174,7 @@ class IssueDetailsRepository implements IIssueDetailsRepository { [key]: value, }, { - sort: { 'currentAudit.started': -1 }, + sort: { createdAt: -1 }, }, ); } @@ -172,6 +183,13 @@ class IssueDetailsRepository implements IIssueDetailsRepository { return this._db.collection('issueDetails').find({}).toArray(); } + async findApprovedBy(filter: Filter): Promise { + return this._db.collection('issueDetails').findOne({ + ...filter, + refreshStatus: 'APPROVED', + }); + } + async findPendingBy(filter: Filter): Promise { return this._db.collection('issueDetails').findOne({ ...filter, diff --git a/packages/application/src/infrastructure/repositories/issue-details.ts b/packages/application/src/infrastructure/repositories/issue-details.ts index 13b110e..810350f 100644 --- a/packages/application/src/infrastructure/repositories/issue-details.ts +++ b/packages/application/src/infrastructure/repositories/issue-details.ts @@ -86,7 +86,5 @@ export interface IssueDetails { dataCap?: number; rkhPhase?: RkhPhase; metaAllocator?: MetaAllocator; - lastAudit?: AuditData; - currentAudit?: Partial; auditHistory?: AuditHistory[]; } diff --git a/packages/application/src/infrastructure/repositories/meta-allocator.repository.ts b/packages/application/src/infrastructure/repositories/meta-allocator.repository.ts index 5fcd2be..4a3e903 100644 --- a/packages/application/src/infrastructure/repositories/meta-allocator.repository.ts +++ b/packages/application/src/infrastructure/repositories/meta-allocator.repository.ts @@ -11,6 +11,8 @@ export interface MetaAllocator { export interface IMetaAllocatorRepository { getAll(): readonly MetaAllocator[]; + + getByName(name: MetaAllocatorName): MetaAllocator; } // TODO - rework this and relate to AllocatorType and Pathway enums diff --git a/packages/application/src/patterns/decorators/signature-guard.decorator.test.ts b/packages/application/src/patterns/decorators/signature-guard.decorator.test.ts new file mode 100644 index 0000000..9317a54 --- /dev/null +++ b/packages/application/src/patterns/decorators/signature-guard.decorator.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Response } from 'express'; +import { messageFactoryByType, SignatureGuard, SignatureType } from './signature-guard.decorator'; + +const mocks = vi.hoisted(() => ({ + mockVerifyLedgerPoP: vi.fn(), + mockBadRequest: vi.fn(), + mockResponse: { + res: { + status: vi.fn(() => mocks.mockResponse.res), + json: vi.fn(() => mocks.mockResponse.res), + }, + }, + mockOriginal: vi.fn(), +})); + +vi.mock('@src/api/http/controllers/authutils', () => ({ + verifyLedgerPoP: mocks.mockVerifyLedgerPoP, +})); + +vi.mock('@src/api/http/processors/response', () => ({ + badRequest: mocks.mockBadRequest, +})); + +describe('SignatureGuard', () => { + const fixtureId = 'refresh-123'; + const fixtureDto = { + result: 'Approved', + details: { + reviewerAddress: 'f1address', + reviewerPublicKey: '04abcdef', + signature: '0xsig', + finalDataCap: 1024, + allocatorType: 'RKH', + }, + }; + class TestController { + @SignatureGuard(SignatureType.RefreshReview) + handler(...args: unknown[]) { + return mocks.mockOriginal(...args); + } + } + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('calls original method when signature verification succeeds', async () => { + mocks.mockVerifyLedgerPoP.mockResolvedValue(true); + mocks.mockOriginal.mockResolvedValue('ok'); + + const controller = new TestController(); + + const result = await controller.handler( + fixtureId, + fixtureDto, + mocks.mockResponse.res as unknown as Response, + 'rest', + ); + + expect(mocks.mockVerifyLedgerPoP).toHaveBeenCalledOnce(); + expect(mocks.mockOriginal).toHaveBeenCalledOnce(); + expect(result).toBe('ok'); + }); + + it('returns 403 when verification returns false', async () => { + mocks.mockVerifyLedgerPoP.mockResolvedValue(false); + mocks.mockOriginal.mockResolvedValue('ok'); + + const controller = new TestController(); + + await controller.handler(fixtureId, fixtureDto, mocks.mockResponse.res, 'rest'); + + expect(mocks.mockVerifyLedgerPoP).toHaveBeenCalledOnce(); + expect(mocks.mockOriginal).not.toHaveBeenCalled(); + expect(mocks.mockResponse.res.status).toHaveBeenCalledWith(403); + }); + + it('returns 400 when verifyLedgerPoP throws Error', async () => { + mocks.mockVerifyLedgerPoP.mockRejectedValue(new Error('Error')); + mocks.mockOriginal.mockResolvedValue('ok'); + + const controller = new TestController(); + + await controller.handler(fixtureId, fixtureDto, mocks.mockResponse.res, 'rest'); + + expect(mocks.mockVerifyLedgerPoP).toHaveBeenCalledOnce(); + expect(mocks.mockOriginal).not.toHaveBeenCalled(); + expect(mocks.mockResponse.res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when verifyLedgerPoP throws non-Error', async () => { + mocks.mockVerifyLedgerPoP.mockRejectedValue('non-Error'); + + const controller = new TestController(); + + await controller.handler(fixtureId, fixtureDto, mocks.mockResponse.res, 'rest'); + + expect(mocks.mockVerifyLedgerPoP).toHaveBeenCalledOnce(); + expect(mocks.mockOriginal).not.toHaveBeenCalled(); + expect(mocks.mockResponse.res.status).toHaveBeenCalledWith(400); + }); +}); + +describe('SignatureGuard messageFactoryByType', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const props = { + result: 'Approved', + id: '123', + finalDataCap: 1024, + allocatorType: 'RKH', + }; + + it.each` + signatureType | expectedMessage + ${SignatureType.RefreshReview} | ${'Governance refresh Approved 123 1024 RKH'} + ${SignatureType.ApproveGovernanceReview} | ${'Governance Approved 123 1024 RKH'} + ${SignatureType.KycOverride} | ${'KYC Override for 123'} + ${SignatureType.KycRevoke} | ${'KYC Revoke for 123'} + `('returns the correct message for $signatureType', ({ signatureType, expectedMessage }) => { + expect(messageFactoryByType[signatureType](props)).toBe(expectedMessage); + }); +}); diff --git a/packages/application/src/patterns/decorators/signature-guard.decorator.ts b/packages/application/src/patterns/decorators/signature-guard.decorator.ts new file mode 100644 index 0000000..5f01cf8 --- /dev/null +++ b/packages/application/src/patterns/decorators/signature-guard.decorator.ts @@ -0,0 +1,80 @@ +import { Response } from 'express'; +import { badRequest } from '@src/api/http/processors/response'; +import { verifyLedgerPoP } from '@src/api/http/controllers/authutils'; +import { HandlerDecorator } from 'inversify-express-utils'; +import { GovernanceReviewDto } from '@src/application/dtos/GovernanceReviewDto'; + +interface MessageFactoryProps { + result: string; + id: string; + finalDataCap: number; + allocatorType: string; +} + +export enum SignatureType { + RefreshReview = 'refreshReview', + ApproveGovernanceReview = 'approveGovernanceReview', + KycOverride = 'kycOverride', + KycRevoke = 'kycRevoke', +} + +export const messageFactoryByType = { + [SignatureType.RefreshReview]: ({ + result, + id, + finalDataCap, + allocatorType, + }: MessageFactoryProps) => `Governance refresh ${result} ${id} ${finalDataCap} ${allocatorType}`, + [SignatureType.ApproveGovernanceReview]: ({ + result, + id, + finalDataCap, + allocatorType, + }: MessageFactoryProps) => `Governance ${result} ${id} ${finalDataCap} ${allocatorType}`, + [SignatureType.KycOverride]: ({ id }: MessageFactoryProps) => `KYC Override for ${id}`, + [SignatureType.KycRevoke]: ({ id }: MessageFactoryProps) => `KYC Revoke for ${id}`, +}; + +export function SignatureGuard(signatureType: SignatureType): HandlerDecorator { + return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = async function ( + id: string, + governanceReviewDto: GovernanceReviewDto, + res: Response, + ...rest: unknown[] + ) { + const { result, details, signature } = governanceReviewDto; + const { reviewerPublicKey, reviewerAddress, finalDataCap, allocatorType } = details; + + const expectedPreImage = messageFactoryByType[signatureType]({ + result, + id, + finalDataCap, + allocatorType, + }); + + try { + const verified = await verifyLedgerPoP( + reviewerAddress, + reviewerPublicKey, + signature, + expectedPreImage, + ); + + if (!verified) { + return res.status(403).json(badRequest('Signature verification failure.')); + } + } catch (e) { + if (e instanceof Error) { + return res.status(400).json(badRequest(e.message)); + } + + return res.status(400).json(badRequest('Unknown error in signature validation')); + } + + return originalMethod.apply(this, [id, governanceReviewDto, res, ...rest]); + }; + }; +} diff --git a/packages/application/src/startup.ts b/packages/application/src/startup.ts index bca2312..5b1d78a 100644 --- a/packages/application/src/startup.ts +++ b/packages/application/src/startup.ts @@ -81,6 +81,8 @@ import { AllocationPathResolver } from './application/resolvers/allocation-path- import { AuditOutcomeResolver } from './application/resolvers/audit-outcome-resolver'; import { SaveIssueWithNewAuditCommandHandler } from './application/use-cases/refresh-issues/save-issue-with-new-audit.command'; import { SaveIssueCommandHandler } from './application/use-cases/refresh-issues/save-issue.command'; +import { ApproveRefreshCommandHandler } from './application/use-cases/refresh-issues/approve-refresh.command'; +import { RejectRefreshCommandHandler } from './application/use-cases/refresh-issues/reject-refesh.command'; export const initialize = async (): Promise => { const container = new Container(); @@ -191,6 +193,8 @@ export const initialize = async (): Promise => { .bind>(TYPES.CommandHandler) .to(SaveIssueWithNewAuditCommandHandler); container.bind>(TYPES.CommandHandler).to(SaveIssueCommandHandler); + container.bind>(TYPES.CommandHandler).to(ApproveRefreshCommandHandler); + container.bind>(TYPES.CommandHandler).to(RejectRefreshCommandHandler); const commandBus = container.get(TYPES.CommandBus); container diff --git a/packages/application/src/testing/mocks/builders/filecoin-tx-builder.ts b/packages/application/src/testing/mocks/builders/filecoin-tx-builder.ts new file mode 100644 index 0000000..9175be9 --- /dev/null +++ b/packages/application/src/testing/mocks/builders/filecoin-tx-builder.ts @@ -0,0 +1,83 @@ +import { transactionSerialize } from '@zondax/filecoin-signing-tools'; +import { AddressSecp256k1 } from 'iso-filecoin/address'; +import { getPublicKey, signAsync } from '@noble/secp256k1'; +import { blake2b } from '@noble/hashes/blake2'; +import { hexToBytes } from '@noble/hashes/utils'; +import cbor from 'cbor'; + +export interface FilecoinTx { + address: string; + pubKeyBase64: string; + transaction: string; +} + +export class FilecoinTxBuilder { + challenge: string = 'challenge'; + privateKeyHex: string = '4f9c4ea3f3ee2a26d1f6a4a2f0f0b8f8e0e9a3d9e5a4b1c2d3e4f5a6b7c8d9e0'; + customMessage: object = {}; + address: string = ''; + + withChallenge(challenge: string) { + this.challenge = challenge; + return this; + } + + withPrivateKeyHex(privateKeyHex: string) { + this.privateKeyHex = privateKeyHex; + return this; + } + + withAddress(address: string) { + this.address = address; + return this; + } + + withCustomMessage(customMessage: object) { + this.customMessage = customMessage; + return this; + } + + async build(): Promise { + const privKey = Uint8Array.from(Buffer.from(this.privateKeyHex, 'hex')); + const pubKeyUncompressed = getPublicKey(privKey, false); + const address = + this.address || AddressSecp256k1.fromPublicKey(pubKeyUncompressed, 'mainnet').toString(); + + const message = { + Version: 0, + To: address, + From: address, + Nonce: 0, + Value: '0', + GasFeeCap: '0', + GasPremium: '0', + GasLimit: 1000000, + Method: 0, + Params: this.encodeChallengeBase64(), + ...this.customMessage, + }; + + const serialized = transactionSerialize(message); + const serializedBytes = hexToBytes(serialized); + const CID_PREFIX = Uint8Array.from([0x01, 0x71, 0xa0, 0xe4, 0x02, 0x20]); + const digestInner = blake2b(serializedBytes, { dkLen: 32 }); + const digestMiddle = Uint8Array.from(Buffer.concat([CID_PREFIX, digestInner])); + const digest = blake2b(digestMiddle, { dkLen: 32 }); + + const sig = await signAsync(digest, privKey); + const compact = sig.toCompactRawBytes(); + const recovery = sig.recovery; + const sig65 = Uint8Array.from([...compact, recovery]); + const signatureData = Buffer.from(sig65).toString('base64'); + + const transaction = JSON.stringify({ Message: message, Signature: { Data: signatureData } }); + const pubKeyBase64 = Buffer.from(pubKeyUncompressed).toString('base64'); + + return { address, pubKeyBase64, transaction }; + } + + private encodeChallengeBase64() { + const cborBytes = cbor.encode(this.challenge); + return Buffer.from(cborBytes).toString('base64'); + } +} diff --git a/packages/application/src/testing/mocks/builders/index.ts b/packages/application/src/testing/mocks/builders/index.ts index ff914af..e8abb8d 100644 --- a/packages/application/src/testing/mocks/builders/index.ts +++ b/packages/application/src/testing/mocks/builders/index.ts @@ -1 +1,2 @@ export * from './test-container-builder'; +export * from './filecoin-tx-builder'; diff --git a/packages/application/src/testing/mocks/builders/test-container-builder.ts b/packages/application/src/testing/mocks/builders/test-container-builder.ts index dad4620..7a81a29 100644 --- a/packages/application/src/testing/mocks/builders/test-container-builder.ts +++ b/packages/application/src/testing/mocks/builders/test-container-builder.ts @@ -36,6 +36,22 @@ import { MetaAllocatorService, IMetaAllocatorService, } from '@src/application/services/meta-allocator.service'; +import { RefreshAuditService } from '@src/application/services/refresh-audit.service'; +import { AuditMapper, IAuditMapper } from '@src/infrastructure/mappers/audit-mapper'; +import { AllocationPathResolver } from '@src/application/resolvers/allocation-path-resolver'; +import { AuditOutcomeResolver } from '@src/application/resolvers/audit-outcome-resolver'; +import { UpsertIssueStrategyResolver } from '@src/application/use-cases/refresh-issues/upsert-issue.strategy'; +import { RefreshAuditPublisher } from '@src/application/publishers/refresh-audit-publisher'; +import { SignRefreshByRKHCommandHandler } from '@src/application/use-cases/update-rkh-approvals/sign-refresh-by-rkh.command'; +import { ApproveRefreshByRKHCommandHandler } from '@src/application/use-cases/update-rkh-approvals/approve-refresh-by-rkh.command'; +import { ApproveRefreshByMaCommandHandler } from '@src/application/use-cases/update-ma-approvals/approve-refresh-by-ma.command'; +import { SaveIssueWithNewAuditCommandHandler } from '@src/application/use-cases/refresh-issues/save-issue-with-new-audit.command'; +import { SaveIssueCommandHandler } from '@src/application/use-cases/refresh-issues/save-issue.command'; +import { MessageService } from '@src/application/services/message.service'; +import { PullRequestService } from '@src/application/services/pull-request.service'; +import { RoleService } from '@src/application/services/role.service'; +import { ApproveRefreshCommandHandler } from '@src/application/use-cases/refresh-issues/approve-refresh.command'; +import { RejectRefreshCommandHandler } from '@src/application/use-cases/refresh-issues/reject-refesh.command'; export class TestContainerBuilder { private container: Container; @@ -56,6 +72,11 @@ export class TestContainerBuilder { return this; } + withConfig(type: symbol, config = {}) { + this.container.bind(type).toConstantValue(config); + return this; + } + withLogger(name = 'test-refresh-e2e') { const logger = createWinstonLogger(name); this.container.bind(TYPES.Logger).toConstantValue(logger); @@ -85,6 +106,7 @@ export class TestContainerBuilder { withMappers() { this.container.bind(TYPES.IssueMapper).to(IssueMapper).inSingletonScope(); this.container.bind(TYPES.DataCapMapper).to(DataCapMapper).inSingletonScope(); + this.container.bind(TYPES.AuditMapper).to(AuditMapper).inSingletonScope(); return this; } @@ -104,6 +126,10 @@ export class TestContainerBuilder { withServices() { this.container.bind(TYPES.MetaAllocatorService).to(MetaAllocatorService); + this.container.bind(TYPES.RefreshAuditService).to(RefreshAuditService); + this.container.bind(TYPES.MessageService).to(MessageService); + this.container.bind(TYPES.PullRequestService).to(PullRequestService); + this.container.bind(TYPES.RoleService).to(RoleService); return this; } @@ -113,6 +139,13 @@ export class TestContainerBuilder { this.container.bind(TYPES.CommandHandler).to(BulkCreateIssueCommandHandler); this.container.bind(TYPES.CommandHandler).to(UpsertIssueCommandCommandHandler); this.container.bind(TYPES.CommandHandler).to(FetchAllocatorCommandHandler); + this.container.bind(TYPES.CommandHandler).to(SignRefreshByRKHCommandHandler); + this.container.bind(TYPES.CommandHandler).to(ApproveRefreshByRKHCommandHandler); + this.container.bind(TYPES.CommandHandler).to(ApproveRefreshByMaCommandHandler); + this.container.bind(TYPES.CommandHandler).to(SaveIssueWithNewAuditCommandHandler); + this.container.bind(TYPES.CommandHandler).to(SaveIssueCommandHandler); + this.container.bind(TYPES.CommandHandler).to(ApproveRefreshCommandHandler); + this.container.bind(TYPES.CommandHandler).to(RejectRefreshCommandHandler); return this; } @@ -137,6 +170,27 @@ export class TestContainerBuilder { return this; } + withResolvers() { + this.container + .bind(TYPES.UpsertIssueStrategyResolver) + .to(UpsertIssueStrategyResolver); + + this.container.bind(TYPES.AuditOutcomeResolver).to(AuditOutcomeResolver); + + this.container + .bind(TYPES.AllocationPathResolver) + .to(AllocationPathResolver); + + return this; + } + + withPublishers() { + this.container + .bind(TYPES.RefreshAuditPublisher) + .to(RefreshAuditPublisher); + return this; + } + build() { return { container: this.container, db: this.db! }; } diff --git a/packages/application/src/testing/mocks/factories/database-refresh-factory.ts b/packages/application/src/testing/mocks/factories/database-refresh-factory.ts index 5a71dc7..c3c1846 100644 --- a/packages/application/src/testing/mocks/factories/database-refresh-factory.ts +++ b/packages/application/src/testing/mocks/factories/database-refresh-factory.ts @@ -1,5 +1,10 @@ import { faker } from '@faker-js/faker'; -import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; +import { + AuditData, + AuditHistory, + AuditOutcome, + IssueDetails, +} from '@src/infrastructure/repositories/issue-details'; export class DatabaseRefreshFactory { static create(overrides: Partial = {}): IssueDetails { diff --git a/packages/application/src/testing/mocks/factories/github-audit-factory.ts b/packages/application/src/testing/mocks/factories/github-audit-factory.ts new file mode 100644 index 0000000..c8af777 --- /dev/null +++ b/packages/application/src/testing/mocks/factories/github-audit-factory.ts @@ -0,0 +1,59 @@ +import { faker } from '@faker-js/faker'; + +import { AuditCycle } from '@src/application/services/pull-request.types'; +import { AuditOutcome } from '@src/infrastructure/repositories/issue-details'; + +export class GithubAuditFactory { + static create(outcome: AuditOutcome): AuditCycle { + const map = { + [AuditOutcome.PENDING]: this.newAudit(), + [AuditOutcome.APPROVED]: this.approvedAudit(), + [AuditOutcome.REJECTED]: this.rejectedAudit(), + [AuditOutcome.GRANTED]: this.finishedAudit(AuditOutcome.GRANTED), + [AuditOutcome.DOUBLE]: this.finishedAudit(AuditOutcome.DOUBLE), + [AuditOutcome.THROTTLE]: this.finishedAudit(AuditOutcome.THROTTLE), + [AuditOutcome.MATCH]: this.finishedAudit(AuditOutcome.MATCH), + [AuditOutcome.UNKNOWN]: this.finishedAudit(AuditOutcome.UNKNOWN), + }; + + return map[outcome]; + } + + static newAudit(): AuditCycle { + return { + started: faker.date.past().toISOString(), + ended: '', + dc_allocated: '', + outcome: AuditOutcome.PENDING, + datacap_amount: '', + }; + } + + static approvedAudit(): AuditCycle { + return { + ...this.newAudit(), + ended: faker.date.recent().toISOString(), + outcome: AuditOutcome.APPROVED, + datacap_amount: faker.number.int({ min: 1, max: 50 }), + }; + } + + static rejectedAudit(): AuditCycle { + return { + ...this.newAudit(), + ended: faker.date.recent().toISOString(), + outcome: AuditOutcome.REJECTED, + datacap_amount: '', + }; + } + + static finishedAudit(outcome: AuditOutcome): AuditCycle { + return { + ...this.newAudit(), + ...this.approvedAudit(), + dc_allocated: faker.date.recent().toISOString(), + datacap_amount: faker.number.int({ min: 1, max: 50 }), + outcome, + }; + } +} diff --git a/packages/application/src/testing/mocks/factories/github-issue-factory.ts b/packages/application/src/testing/mocks/factories/github-issue-factory.ts index 458fb3c..ebea853 100644 --- a/packages/application/src/testing/mocks/factories/github-issue-factory.ts +++ b/packages/application/src/testing/mocks/factories/github-issue-factory.ts @@ -1,14 +1,22 @@ import { faker } from '@faker-js/faker'; import { IssuesWebhookPayload } from '@src/infrastructure/clients/github'; +import { IssueDetails } from '@src/infrastructure/repositories/issue-details'; + +interface GithubIssueFactoryOverrides { + event?: Partial; + allocator?: Partial; +} export class GithubIssueFactory { - static createOpened(overrides: Partial = {}): IssuesWebhookPayload { + static createOpened( + overrides: GithubIssueFactoryOverrides = { event: {}, allocator: {} }, + ): IssuesWebhookPayload { const userId = faker.number.int({ min: 10000000, max: 999999999 }); const issueId = faker.number.int({ min: 100000000, max: 999999999 }); const issueNumber = faker.number.int({ min: 1000, max: 9999 }); const repoId = faker.number.int({ min: 10000000, max: 999999999 }); const userLogin = faker.internet.username(); - const recHash = `rec${faker.string.alphanumeric(15)}`; + const recHash = overrides?.allocator?.jsonNumber || `rec${faker.string.alphanumeric(15)}`; const createdDate = faker.date.past({ years: 1 }); const updatedDate = faker.date.between({ from: createdDate, to: new Date() }); @@ -162,12 +170,14 @@ export class GithubIssueFactory { type: 'User', site_admin: faker.datatype.boolean({ probability: 0.1 }), } as IssuesWebhookPayload['sender'], - ...overrides, + ...overrides.event, }; } - static createEdited(overrides: Partial = {}): IssuesWebhookPayload { - const baseIssue = this.createOpened(); + static createEdited( + overrides: GithubIssueFactoryOverrides = { event: {}, allocator: {} }, + ): IssuesWebhookPayload { + const baseIssue = this.createOpened(overrides); const originalCreatedDate = new Date(baseIssue.issue.created_at); const recentUpdatedDate = faker.date.between({ from: originalCreatedDate, diff --git a/packages/application/src/testing/mocks/factories/index.ts b/packages/application/src/testing/mocks/factories/index.ts index a35c456..dd2afd7 100644 --- a/packages/application/src/testing/mocks/factories/index.ts +++ b/packages/application/src/testing/mocks/factories/index.ts @@ -1,2 +1,3 @@ export * from './github-issue-factory'; export * from './database-refresh-factory'; +export * from './github-audit-factory'; diff --git a/packages/application/src/types.ts b/packages/application/src/types.ts index 7089d2d..d998ed8 100644 --- a/packages/application/src/types.ts +++ b/packages/application/src/types.ts @@ -40,4 +40,7 @@ export const TYPES = { AuditsChangesEventStore: Symbol('AuditsChangesEventStore'), AuditOutcomeResolver: Symbol('AuditOutcomeResolver'), UpsertIssueStrategyResolver: Symbol('UpsertIssueStrategyResolver'), + GovernanceConfig: Symbol('GovernanceConfig'), }; + +export type ConfigTypes = typeof TYPES;