diff --git a/apps/api/src/certificate/controllers/certificate.controller.ts b/apps/api/src/certificate/controllers/certificate.controller.ts index ca7e100d30..0eb642d833 100644 --- a/apps/api/src/certificate/controllers/certificate.controller.ts +++ b/apps/api/src/certificate/controllers/certificate.controller.ts @@ -1,16 +1,19 @@ import { singleton } from "tsyringe"; -import { Protected } from "@src/auth/services/auth.service"; +import { AuthService, Protected } from "@src/auth/services/auth.service"; import { CreateCertificateResponse } from "../http-schemas/create-certificate.schema"; -import { CertificateService } from "../services/certificate.service"; +import { CertificateService } from "../services/certificate/certificate.service"; @singleton() export class CertificateController { - constructor(private readonly certificateService: CertificateService) {} + constructor( + private readonly certificateService: CertificateService, + private readonly authService: AuthService + ) {} @Protected([{ action: "sign", subject: "UserWallet" }]) async create(): Promise { - const cert = await this.certificateService.create(); + const cert = await this.certificateService.create({ userId: this.authService.currentUser.id }); return { data: cert }; } } diff --git a/apps/api/src/certificate/providers/certificate-manager.provider.ts b/apps/api/src/certificate/providers/certificate-manager.provider.ts new file mode 100644 index 0000000000..7724e70301 --- /dev/null +++ b/apps/api/src/certificate/providers/certificate-manager.provider.ts @@ -0,0 +1,8 @@ +import { CertificateManager, certificateManager } from "@akashnetwork/chain-sdk"; +import { container } from "tsyringe"; + +container.register(CertificateManager, { + useValue: certificateManager +}); + +export { CertificateManager }; diff --git a/apps/api/src/certificate/services/certificate/certificate.service.spec.ts b/apps/api/src/certificate/services/certificate/certificate.service.spec.ts new file mode 100644 index 0000000000..d72e3028f3 --- /dev/null +++ b/apps/api/src/certificate/services/certificate/certificate.service.spec.ts @@ -0,0 +1,118 @@ +import type { CertificateManager, certificateManager } from "@akashnetwork/chain-sdk"; +import type { MongoAbility } from "@casl/ability"; +import { createMongoAbility } from "@casl/ability"; +import { faker } from "@faker-js/faker"; +import { mock } from "jest-mock-extended"; + +import type { AuthService } from "@src/auth/services/auth.service"; +import type { UserWalletRepository } from "@src/billing/repositories"; +import type { RpcMessageService } from "@src/billing/services"; +import type { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; +import { CertificateService } from "./certificate.service"; + +import { UserWalletSeeder } from "@test/seeders/user-wallet.seeder"; + +describe(CertificateService.name, () => { + describe("create", () => { + it("creates certificate successfully when wallet exists", async () => { + const userWallet = UserWalletSeeder.create(); + const certificateData = { + cert: "-----BEGIN CERTIFICATE-----\nMOCK_CERT\n-----END CERTIFICATE-----", + publicKey: "-----BEGIN PUBLIC KEY-----\nMOCK_PUBLIC_KEY\n-----END PUBLIC KEY-----", + privateKey: "encrypted-private-key" + }; + + const createCertificateMsg = { + typeUrl: "/akash.cert.v1beta3.MsgCreateCertificate", + value: {} + }; + + const { service, userWalletRepository, authService, rpcMessageService, managedSignerService, certificateManager } = setup({ + findWallet: jest.fn().mockResolvedValue(userWallet), + getCreateCertificateMsg: jest.fn().mockReturnValue(createCertificateMsg), + executeDecodedTxByUserId: jest.fn().mockResolvedValue({ code: 0, hash: "tx-hash", transactionHash: "tx-hash" }) + }); + + const result = await service.create({ userId: userWallet.userId }); + + expect(userWalletRepository.accessibleBy).toHaveBeenCalledWith(authService.ability, "sign"); + expect(userWalletRepository.findOneByUserId).toHaveBeenCalledWith(userWallet.userId); + expect(certificateManager.generatePEM).toHaveBeenCalledWith(userWallet.address); + expect(rpcMessageService.getCreateCertificateMsg).toHaveBeenCalledWith(userWallet.address, certificateData.cert, certificateData.publicKey); + expect(managedSignerService.executeDecodedTxByUserId).toHaveBeenCalledWith(userWallet.userId, [createCertificateMsg]); + expect(result).toEqual({ + certPem: certificateData.cert, + pubkeyPem: certificateData.publicKey, + encryptedKey: certificateData.privateKey + }); + }); + + it("throws 404 error when user wallet is not found", async () => { + const userId = faker.string.uuid(); + const { service, userWalletRepository, authService } = setup({ + findWallet: jest.fn().mockResolvedValue(null) + }); + + await expect(service.create({ userId })).rejects.toThrow("UserWallet not found"); + + expect(userWalletRepository.accessibleBy).toHaveBeenCalledWith(authService.ability, "sign"); + expect(userWalletRepository.findOneByUserId).toHaveBeenCalledWith(userId); + }); + + it("throws 404 error when user wallet has no address", async () => { + const userWallet = UserWalletSeeder.create(); + userWallet.address = null; + + const { service, userWalletRepository, authService } = setup({ + findWallet: jest.fn().mockResolvedValue(userWallet) + }); + + await expect(service.create({ userId: userWallet.userId })).rejects.toThrow("UserWallet not found"); + + expect(userWalletRepository.accessibleBy).toHaveBeenCalledWith(authService.ability, "sign"); + expect(userWalletRepository.findOneByUserId).toHaveBeenCalledWith(userWallet.userId); + }); + }); + + function setup(input?: { + findWallet?: UserWalletRepository["findOneByUserId"]; + generateCert?: typeof certificateManager.generatePEM; + getCreateCertificateMsg?: RpcMessageService["getCreateCertificateMsg"]; + executeDecodedTxByUserId?: ManagedSignerService["executeDecodedTxByUserId"]; + }) { + const mocks = { + userWalletRepository: mock({ + accessibleBy: jest.fn().mockReturnThis(), + findOneByUserId: input?.findWallet ?? jest.fn() + }), + authService: mock({ + ability: createMongoAbility() + }), + rpcMessageService: mock({ + getCreateCertificateMsg: input?.getCreateCertificateMsg ?? jest.fn() + }), + managedSignerService: mock({ + executeDecodedTxByUserId: input?.executeDecodedTxByUserId ?? jest.fn() + }), + certificateManager: mock({ + generatePEM: + input?.generateCert ?? + jest.fn(async () => ({ + cert: "-----BEGIN CERTIFICATE-----\nMOCK_CERT\n-----END CERTIFICATE-----", + publicKey: "-----BEGIN PUBLIC KEY-----\nMOCK_PUBLIC_KEY\n-----END PUBLIC KEY-----", + privateKey: "encrypted-private-key" + })) + }) + }; + + const service = new CertificateService( + mocks.userWalletRepository, + mocks.authService, + mocks.certificateManager, + mocks.rpcMessageService, + mocks.managedSignerService + ); + + return { service, ...mocks }; + } +}); diff --git a/apps/api/src/certificate/services/certificate.service.ts b/apps/api/src/certificate/services/certificate/certificate.service.ts similarity index 73% rename from apps/api/src/certificate/services/certificate.service.ts rename to apps/api/src/certificate/services/certificate/certificate.service.ts index c3b18fcbe4..ec8eb71785 100644 --- a/apps/api/src/certificate/services/certificate.service.ts +++ b/apps/api/src/certificate/services/certificate/certificate.service.ts @@ -1,11 +1,11 @@ -import { certificateManager } from "@akashnetwork/chain-sdk"; import assert from "http-assert"; import { singleton } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; -import { UserWalletRepository } from "@src/billing/repositories"; +import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; import { RpcMessageService } from "@src/billing/services"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; +import { CertificateManager } from "../../providers/certificate-manager.provider"; interface CertificateOutput { certPem: string; @@ -18,16 +18,16 @@ export class CertificateService { constructor( private readonly userWalletRepository: UserWalletRepository, private readonly authService: AuthService, + private readonly certificateManager: CertificateManager, private readonly rpcMessageService: RpcMessageService, private readonly managedSignerService: ManagedSignerService ) {} - async create(): Promise { - const userWallet = await this.userWalletRepository.accessibleBy(this.authService.ability, "sign").findOneByUserId(this.authService.currentUser.id); - + async create(input: { userId: UserWalletOutput["userId"] }): Promise { + const userWallet = await this.userWalletRepository.accessibleBy(this.authService.ability, "sign").findOneByUserId(input.userId); assert(userWallet?.address, 404, "UserWallet not found"); - const { cert: crtpem, publicKey: pubpem, privateKey: encryptedKey } = await certificateManager.generatePEM(userWallet.address); + const { cert: crtpem, publicKey: pubpem, privateKey: encryptedKey } = await this.certificateManager.generatePEM(userWallet.address); const createCertificateMsg = this.rpcMessageService.getCreateCertificateMsg(userWallet.address, crtpem, pubpem); const messages = [createCertificateMsg]; diff --git a/apps/api/src/transaction/repositories/transaction/transaction.repository.spec.ts b/apps/api/src/transaction/repositories/transaction/transaction.repository.spec.ts new file mode 100644 index 0000000000..c831e92200 --- /dev/null +++ b/apps/api/src/transaction/repositories/transaction/transaction.repository.spec.ts @@ -0,0 +1,101 @@ +import "@test/setup-functional-tests"; + +import { container } from "tsyringe"; + +import { TransactionRepository } from "./transaction.repository"; + +import { createAkashAddress, createAkashBlock, createAkashMessage, createTransaction } from "@test/seeders"; +import { createAddressReferenceInDatabase } from "@test/seeders/address-reference.seeder"; + +describe(TransactionRepository.name, () => { + describe("getTransactions", () => { + it("returns a list of transactions", async () => { + const { transactions, repository } = await setup(); + const transactionsFound = await repository.getTransactions(10); + + expect(transactions).toEqual(expect.arrayContaining(transactionsFound.map(tx => expect.objectContaining({ hash: tx.hash })))); + }); + + it("does not return more than 100 transactions", async () => { + const { transactions, repository } = await setup(); + const transactionsFound = await repository.getTransactions(101); + expect(transactions.length).toBeGreaterThan(100); + expect(transactionsFound.length).toBe(100); + }); + }); + + describe("getTransactionByHash", () => { + it("returns a transaction by hash", async () => { + const { transactions, repository } = await setup(); + const txToFind = transactions[20]; + const transactionFound = await repository.getTransactionByHash(txToFind.hash); + expect(transactionFound).toEqual( + expect.objectContaining({ + height: txToFind.height, + hash: txToFind.hash, + isSuccess: !txToFind.hasProcessingError, + error: txToFind.hasProcessingError && txToFind.log ? txToFind.log : null, + gasUsed: txToFind.gasUsed, + gasWanted: txToFind.gasWanted, + fee: parseInt(txToFind.fee), + memo: txToFind.memo + }) + ); + }); + + it("returns null if the transaction is not found", async () => { + const { repository } = await setup(); + const transactionFound = await repository.getTransactionByHash("unknown-hash"); + expect(transactionFound).toBeNull(); + }); + }); + + describe("getTransactionsByAddress", () => { + it("returns a list of transactions by address", async () => { + const { transactions, repository } = await setup(); + const address = createAkashAddress(); + const transactionsWithAddressRef = transactions.slice(0, 10); + await Promise.all( + transactionsWithAddressRef.map(async trx => { + const message = await createAkashMessage({ + txId: trx.id, + height: trx.height + }); + return createAddressReferenceInDatabase({ + transactionId: trx.id, + address, + type: "sender", + messageId: message.id + }); + }) + ); + const transactionsFound = await repository.getTransactionsByAddress({ address, skip: 0, limit: 10 }); + expect(transactionsFound.count).toBe(transactionsWithAddressRef.length); + expect(transactionsFound.results.map(trx => trx.hash).toSorted()).toEqual(transactionsWithAddressRef.map(trx => trx.hash).toSorted()); + }); + + it("returns an empty list if the address has no transactions", async () => { + const { repository } = await setup(); + const address = createAkashAddress(); + const transactionsFound = await repository.getTransactionsByAddress({ address, skip: 0, limit: 5 }); + expect(transactionsFound.count).toBe(0); + expect(transactionsFound.results).toEqual([]); + }); + }); + + async function setup() { + const repository = container.resolve(TransactionRepository); + const block = await createAkashBlock(); + + const transactions = await Promise.all( + Array.from({ length: 101 }, (_, i) => { + return createTransaction({ + height: block.height, + index: i + 1 + }); + }) + ); + + return { transactions, repository }; + } +}); diff --git a/apps/api/test/functional/certificate.spec.ts b/apps/api/test/functional/certificate.spec.ts deleted file mode 100644 index b612925a83..0000000000 --- a/apps/api/test/functional/certificate.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { certificateManager } from "@akashnetwork/chain-sdk"; -import { faker } from "@faker-js/faker"; -import { container } from "tsyringe"; - -import { ApiKeyRepository } from "@src/auth/repositories/api-key/api-key.repository"; -import { ApiKeyGeneratorService } from "@src/auth/services/api-key/api-key-generator.service"; -import { UserWalletRepository } from "@src/billing/repositories"; -import type { CreateCertificateResponse } from "@src/certificate/http-schemas/create-certificate.schema"; -import type { CoreConfigService } from "@src/core/services/core-config/core-config.service"; -import { app } from "@src/rest-app"; -import { UserRepository } from "@src/user/repositories"; - -import { stub } from "@test/services/stub"; -import { WalletTestingService } from "@test/services/wallet-testing.service"; - -jest.setTimeout(30000); - -describe("Certificate API", () => { - const userRepository = container.resolve(UserRepository); - const apiKeyRepository = container.resolve(ApiKeyRepository); - const walletService = new WalletTestingService(app); - const userWalletRepository = container.resolve(UserWalletRepository); - let apiKeyGenerator: ApiKeyGeneratorService; - let config: jest.Mocked; - - async function createTestUser() { - const { user, token, wallet } = await walletService.createUserAndWallet(); - const userWithId = { ...user, userId: faker.string.uuid() }; - config = stub({ get: jest.fn() }); - config.get.mockReturnValue("test"); - apiKeyGenerator = new ApiKeyGeneratorService(config); - const apiKey = apiKeyGenerator.generateApiKey(); - - jest.spyOn(userRepository, "findById").mockImplementation(async id => { - if (id === userWithId.id) { - return { - ...userWithId, - trial: false, - userWallets: { isTrialing: false } - }; - } - return undefined; - }); - - jest.spyOn(apiKeyRepository, "find").mockImplementation(async () => { - const now = new Date().toISOString(); - return [ - { - id: faker.string.uuid(), - userId: userWithId.id, - key: apiKey, - hashedKey: await apiKeyGenerator.hashApiKey(apiKey), - keyFormat: "sk", - name: "test", - createdAt: now, - updatedAt: now, - expiresAt: null, - lastUsedAt: null - } - ]; - }); - - jest.spyOn(userWalletRepository, "findOneByUserId").mockImplementation(async id => { - if (id === user.id) { - return { - ...wallet, - address: wallet.address - }; - } - }); - - return { user: userWithId, token, apiKey, wallet }; - } - - describe("POST /v1/certificate/create", () => { - it("creates a certificate for the authenticated user", async () => { - const { apiKey, wallet } = await createTestUser(); - - const response = await app.request("/v1/certificates", { - method: "POST", - headers: new Headers({ - "Content-Type": "application/json", - "x-api-key": apiKey - }) - }); - - expect(response.status).toBe(200); - const result = (await response.json()) as CreateCertificateResponse; - - expect(result.data).toMatchObject({ - certPem: expect.any(String), - pubkeyPem: expect.any(String), - encryptedKey: expect.any(String) - }); - const cert = await certificateManager.parsePem(result.data!.certPem!); - expect(cert.sSubject).toContain(wallet.address); - }); - - it("returns 401 for an unauthenticated request", async () => { - const response = await app.request("/v1/certificates", { - method: "POST", - headers: new Headers({ "Content-Type": "application/json" }) - }); - - expect(response.status).toBe(401); - }); - }); -}); diff --git a/apps/api/test/functional/provider-attributes-schema.spec.ts b/apps/api/test/functional/provider-attributes-schema.spec.ts deleted file mode 100644 index 9652174329..0000000000 --- a/apps/api/test/functional/provider-attributes-schema.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { app } from "@src/rest-app"; - -describe("ProviderAttributesSchema", () => { - describe("GET /v1/provider-attributes-schema", () => { - it("returns schema for provider attributes", async () => { - const response = await app.request("/v1/provider-attributes-schema"); - - const data = (await response.json()) as any; - - expect(response.status).toBe(200); - - Object.keys(data).forEach(attributeName => { - expect(data[attributeName].key).toEqual(expect.any(String)); - expect(data[attributeName].type).toEqual(expect.any(String)); - expect(data[attributeName].required).toEqual(expect.any(Boolean)); - expect(data[attributeName].description).toEqual(expect.any(String)); - if (data[attributeName].values) { - expect(Array.isArray(data[attributeName].values)).toBe(true); - } - }); - }); - }); -}); diff --git a/apps/api/test/functional/template-cache.spec.ts b/apps/api/test/functional/template-cache.spec.ts index eb03b65eb4..81688e3458 100644 --- a/apps/api/test/functional/template-cache.spec.ts +++ b/apps/api/test/functional/template-cache.spec.ts @@ -58,11 +58,6 @@ describe("Template cache generation", () => { jest.restoreAllMocks(); }); - const expectCacheFile = (filename: string) => { - const result = fs.readFileSync(path.join(__dirname, "../..", "dist/.data/templates", filename), "utf8"); - expect(result).toMatchSnapshot(); - }; - describe("Generating cache", () => { it("creates files as expected", async () => { const templateGalleryService = new TemplateGalleryService({ @@ -72,9 +67,13 @@ describe("Template cache generation", () => { await templateGalleryService.getTemplateGallery(); - expectCacheFile(`akash-network-awesome-akash-${sha}.json`); - expectCacheFile(`akash-network-cosmos-omnibus-${sha}.json`); - expectCacheFile(`cryptoandcoffee-akash-linuxserver-${sha}.json`); + expect(readTemplate(`akash-network-awesome-akash-${sha}.json`)).toMatchSnapshot(); + expect(readTemplate(`akash-network-cosmos-omnibus-${sha}.json`)).toMatchSnapshot(); + expect(readTemplate(`cryptoandcoffee-akash-linuxserver-${sha}.json`)).toMatchSnapshot(); }); }); + + function readTemplate(filename: string) { + return fs.readFileSync(path.join(__dirname, "../..", "dist/.data/templates", filename), "utf8"); + } }); diff --git a/apps/api/test/functional/transactions.spec.ts b/apps/api/test/functional/transactions.spec.ts deleted file mode 100644 index b07c2fa241..0000000000 --- a/apps/api/test/functional/transactions.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Transaction } from "@akashnetwork/database/dbSchemas/base"; -import { map } from "lodash"; - -import { app } from "@src/rest-app"; - -import { createAkashBlock, createTransaction } from "@test/seeders"; - -describe("Transactions", () => { - const expectTransactions = (transactionsFound: Transaction[], transactionsExpected: Transaction[]) => { - expect(transactionsFound.length).toBe(transactionsExpected.length); - - const hashesFound = map(transactionsFound, "hash"); - transactionsExpected.forEach(transactionExpected => { - expect(hashesFound).toContain(transactionExpected.hash); - }); - }; - - describe("GET /v1/transactions", () => { - it("resolves list of most recent transactions", async () => { - const { transactions } = await setup(); - const response = await app.request("/v1/transactions?limit=2", { - method: "GET", - headers: new Headers({ "Content-Type": "application/json" }) - }); - const transactionsFound = (await response.json()) as any; - - expect(response.status).toBe(200); - expectTransactions(transactionsFound, transactions.slice(0, 2)); - }); - - it("will not resolve more than 100 transactions", async () => { - await setup(); - const response = await app.request("/v1/transactions?limit=101", { - method: "GET", - headers: new Headers({ "Content-Type": "application/json" }) - }); - - expect(response.status).toBe(400); - }); - }); - - describe("GET /v1/transactions/{hash}", () => { - it("resolves transaction by hash", async () => { - const { transactions } = await setup(); - const response = await app.request(`/v1/transactions/${transactions[0].hash}`, { - method: "GET", - headers: new Headers({ "Content-Type": "application/json" }) - }); - const transactionFound = (await response.json()) as any; - - expect(response.status).toBe(200); - expectTransactions([transactions[0]], [transactionFound]); - }); - - it("responds 404 for an unknown hash", async () => { - await setup(); - const response = await app.request("/v1/transactions/unknown-hash", { - method: "GET", - headers: new Headers({ "Content-Type": "application/json" }) - }); - - expect(response.status).toBe(404); - }); - }); - - async function setup() { - const block = await createAkashBlock(); - - const transactions = await Promise.all( - Array.from({ length: 101 }, (_, i) => { - return createTransaction({ - height: block.height, - index: i + 1 - }); - }) - ); - - return { transactions }; - } -});