From 496bbbb532a2e9bc3d4b6c5dd7e95cd6a3dcd96d Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Thu, 21 Mar 2024 23:14:17 +0100 Subject: [PATCH 01/11] fix: add missing types for 'uuid' package --- apps/api/package.json | 1 + yarn.lock | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/apps/api/package.json b/apps/api/package.json index 330c3b5c..ae87a55e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -41,6 +41,7 @@ "@nestjs/testing": "^9.0.0", "@types/express": "^4.17.13", "@types/node": "18.11.18", + "@types/uuid": "^9.0.8", "rimraf": "^5.0.1", "ts-node": "^10.0.0", "typescript": "^4.7.4" diff --git a/yarn.lock b/yarn.lock index e40fb435..53099578 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9053,6 +9053,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.8": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: 10/b8c60b7ba8250356b5088302583d1704a4e1a13558d143c549c408bf8920535602ffc12394ede77f8a8083511b023704bc66d1345792714002bfa261b17c5275 + languageName: node + linkType: hard + "@types/validator@npm:^13.7.10": version: 13.7.12 resolution: "@types/validator@npm:13.7.12" @@ -10708,6 +10715,7 @@ __metadata: "@semaphore-protocol/group": "npm:3.10.0" "@types/express": "npm:^4.17.13" "@types/node": "npm:18.11.18" + "@types/uuid": "npm:^9.0.8" class-transformer: "npm:^0.5.1" class-validator: "npm:^0.14.0" dotenv: "npm:^16.0.3" From 784929c8974b502e089a7edba9ba44113c6d50a2 Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Thu, 21 Mar 2024 23:58:13 +0100 Subject: [PATCH 02/11] refactor: move apikey logic from group to admin --- apps/api/src/app/admins/admins.controller.ts | 23 ++ apps/api/src/app/admins/admins.module.ts | 9 +- .../api/src/app/admins/admins.service.test.ts | 181 ++++++++++++++ apps/api/src/app/admins/admins.service.ts | 59 ++++- .../src/app/admins/dto/update-apikey.dto.ts | 10 + .../src/app/admins/entities/admin.entity.ts | 17 +- apps/api/src/app/auth/auth.guard.ts | 6 +- apps/api/src/app/auth/auth.service.test.ts | 10 +- apps/api/src/app/auth/auth.service.ts | 10 +- .../src/app/credentials/credentials.module.ts | 4 +- .../credentials/credentials.service.test.ts | 4 +- .../src/app/groups/dto/update-group.dto.ts | 5 - .../src/app/groups/entities/group.entity.ts | 6 - apps/api/src/app/groups/groups.controller.ts | 22 +- apps/api/src/app/groups/groups.module.ts | 8 +- .../api/src/app/groups/groups.service.test.ts | 223 +++++++++++------- apps/api/src/app/groups/groups.service.ts | 64 ++--- apps/api/src/app/groups/groups.utils.test.ts | 11 - apps/api/src/app/groups/groups.utils.ts | 15 +- apps/api/src/app/invites/invites.module.ts | 4 +- .../src/app/invites/invites.service.test.ts | 4 +- apps/api/src/types/index.ts | 10 + database/seed.sql | 7 +- 23 files changed, 506 insertions(+), 206 deletions(-) create mode 100644 apps/api/src/app/admins/admins.controller.ts create mode 100644 apps/api/src/app/admins/admins.service.test.ts create mode 100644 apps/api/src/app/admins/dto/update-apikey.dto.ts create mode 100644 apps/api/src/types/index.ts diff --git a/apps/api/src/app/admins/admins.controller.ts b/apps/api/src/app/admins/admins.controller.ts new file mode 100644 index 00000000..b225eb02 --- /dev/null +++ b/apps/api/src/app/admins/admins.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Post, Put } from "@nestjs/common" +import { CreateAdminDTO } from "./dto/create-admin.dto" +import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" +import { AdminsService } from "./admins.service" +import { Admin } from "./entities/admin.entity" + +@Controller("admins") +export class AdminsController { + constructor(private readonly adminsService: AdminsService) {} + + @Post() + async createAdmin(@Body() dto: CreateAdminDTO): Promise { + return this.adminsService.create(dto) + } + + @Put("update-apikey") + async updateApiKey(@Body() dto: UpdateApiKeyDTO): Promise { + return this.adminsService.updateApiKey({ + adminId: dto.adminId, + action: dto.action + }) + } +} diff --git a/apps/api/src/app/admins/admins.module.ts b/apps/api/src/app/admins/admins.module.ts index 307365f6..42dd63b0 100644 --- a/apps/api/src/app/admins/admins.module.ts +++ b/apps/api/src/app/admins/admins.module.ts @@ -1,13 +1,14 @@ import { Global, Module } from "@nestjs/common" import { TypeOrmModule } from "@nestjs/typeorm" import { Admin } from "./entities/admin.entity" -import { AdminService } from "./admins.service" +import { AdminsService } from "./admins.service" +import { AdminsController } from "./admins.controller" @Global() @Module({ imports: [TypeOrmModule.forFeature([Admin])], - exports: [AdminService], - providers: [AdminService], - controllers: [] + exports: [AdminsService], + providers: [AdminsService], + controllers: [AdminsController] }) export class AdminsModule {} diff --git a/apps/api/src/app/admins/admins.service.test.ts b/apps/api/src/app/admins/admins.service.test.ts new file mode 100644 index 00000000..547d1eee --- /dev/null +++ b/apps/api/src/app/admins/admins.service.test.ts @@ -0,0 +1,181 @@ +import { id as idToHash } from "@ethersproject/hash" +import { ScheduleModule } from "@nestjs/schedule" +import { Test } from "@nestjs/testing" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AdminsService } from "./admins.service" +import { Admin } from "./entities/admin.entity" +import { ApiKeyActions } from "../../types" + +describe("AdminsService", () => { + const id = "1" + const hashedId = idToHash(id) + const address = "0x000000" + let admin: Admin + let adminsService: AdminsService + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRootAsync({ + useFactory: () => ({ + type: "sqlite", + database: ":memory:", + dropSchema: true, + entities: [Admin], + synchronize: true + }) + }), + TypeOrmModule.forFeature([Admin]), + ScheduleModule.forRoot() + ], + providers: [AdminsService] + }).compile() + adminsService = await module.resolve(AdminsService) + }) + + describe("# create", () => { + it("Should create an admin", async () => { + admin = await adminsService.create({ id, address }) + + expect(admin.id).toBe(idToHash(id)) + expect(admin.address).toBe(address) + expect(admin.username).toBe(address.slice(-5)) + expect(admin.apiEnabled).toBeFalsy() + expect(admin.apiKey).toBeNull() + }) + + it("Should create an admin given the username", async () => { + const id2 = "2" + const address2 = "0x000002" + const username = "admn2" + + const admin = await adminsService.create({ + id: id2, + address: address2, + username + }) + + expect(admin.id).toBe(idToHash(id2)) + expect(admin.address).toBe(address2) + expect(admin.username).toBe(username) + expect(admin.apiEnabled).toBeFalsy() + expect(admin.apiKey).toBeNull() + }) + }) + + describe("# findOne", () => { + it("Should return the admin given the identifier", async () => { + const found = await adminsService.findOne({ id: hashedId }) + + expect(found.id).toBe(admin.id) + expect(found.address).toBe(admin.address) + expect(found.username).toBe(admin.username) + expect(found.apiEnabled).toBeFalsy() + expect(found.apiKey).toBe(admin.apiKey) + }) + + it("Should return null if the given identifier does not belong to an admin", async () => { + expect(await adminsService.findOne({ id: "3" })).toBeNull() + }) + }) + + describe("# updateApiKey", () => { + it("Should create an apikey for the admin", async () => { + const apiKey = await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Generate + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeTruthy() + expect(admin.apiKey).toBe(apiKey) + }) + + it("Should generate another apikey for the admin", async () => { + const previousApiKey = admin.apiKey + + const apiKey = await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Generate + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeTruthy() + expect(admin.apiKey).toBe(apiKey) + expect(admin.apiKey).not.toBe(previousApiKey) + }) + + it("Should disable the apikey for the admin", async () => { + const { apiKey } = admin + + await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Disable + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeFalsy() + expect(admin.apiKey).toBe(apiKey) + }) + + it("Should enable the apikey for the admin", async () => { + const { apiKey } = admin + + await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Enable + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeTruthy() + expect(admin.apiKey).toBe(apiKey) + }) + + it("Should not create the apikey when the given id does not belog to an admin", async () => { + const wrongId = "wrongId" + + const fun = adminsService.updateApiKey({ + adminId: wrongId, + action: ApiKeyActions.Disable + }) + + await expect(fun).rejects.toThrow( + `The '${wrongId}' does not belong to an admin` + ) + }) + + it("Should not enable the apikey before creation", async () => { + const tempAdmin = await adminsService.create({ + id: "id2", + address: "address2" + }) + + const fun = adminsService.updateApiKey({ + adminId: tempAdmin.id, + action: ApiKeyActions.Enable + }) + + await expect(fun).rejects.toThrow( + `The '${tempAdmin.id}' does not have an apikey` + ) + }) + + it("Shoul throw if the action does not exist", async () => { + const wrongAction = "wrong-action" + + const fun = adminsService.updateApiKey({ + adminId: hashedId, + // @ts-ignore + action: wrongAction + }) + + await expect(fun).rejects.toThrow( + `Unsupported ${wrongAction} apikey` + ) + }) + }) +}) diff --git a/apps/api/src/app/admins/admins.service.ts b/apps/api/src/app/admins/admins.service.ts index 5dfb48dd..08aed512 100644 --- a/apps/api/src/app/admins/admins.service.ts +++ b/apps/api/src/app/admins/admins.service.ts @@ -1,17 +1,24 @@ /* istanbul ignore file */ import { id } from "@ethersproject/hash" -import { Injectable } from "@nestjs/common" +import { + BadRequestException, + Injectable, + Logger +} from "@nestjs/common" import { InjectRepository } from "@nestjs/typeorm" import { FindOptionsWhere, Repository } from "typeorm" +import { v4 } from "uuid" import { CreateAdminDTO } from "./dto/create-admin.dto" import { Admin } from "./entities/admin.entity" +import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" +import { ApiKeyActions } from "../../types" @Injectable() -export class AdminService { +export class AdminsService { constructor( @InjectRepository(Admin) private readonly adminRepository: Repository - ) {} + ) { } public async create(payload: CreateAdminDTO): Promise { const username = payload.username || payload.address.slice(-5) @@ -29,4 +36,50 @@ export class AdminService { ): Promise { return this.adminRepository.findOneBy(payload) } + + /** + * Updates the API key for a given admin based on the specified actions. + * + * @param {UpdateApiKeyDTO} updateApiKeyDTO The DTO containing the admin ID and the action to be performed. + * @returns {Promise} The API key of the admin after the update operation. If the API key is disabled, the return value might not be meaningful. + * @throws {BadRequestException} If the admin ID does not correspond to an existing admin, if the admin does not have an API key when trying to enable it, or if the action is unsupported. + */ + async updateApiKey({ adminId, action }: UpdateApiKeyDTO): Promise { + const admin = await this.findOne({ + id: adminId + }) + + if (!admin) { + throw new BadRequestException( + `The '${adminId}' does not belong to an admin` + ) + } + + switch (action) { + case ApiKeyActions.Generate: + admin.apiKey = v4() + admin.apiEnabled = true + break + case ApiKeyActions.Enable: + if (!admin.apiKey) + throw new BadRequestException( + `The '${adminId}' does not have an apikey` + ) + admin.apiEnabled = true + break + case ApiKeyActions.Disable: + admin.apiEnabled = false + break + default: + throw new BadRequestException(`Unsupported ${action} apikey`) + } + + await this.adminRepository.save(admin) + + Logger.log( + `AdminsService: admin '${admin.id}' api key have been updated` + ) + + return admin.apiKey + } } diff --git a/apps/api/src/app/admins/dto/update-apikey.dto.ts b/apps/api/src/app/admins/dto/update-apikey.dto.ts new file mode 100644 index 00000000..4999a3df --- /dev/null +++ b/apps/api/src/app/admins/dto/update-apikey.dto.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsString } from "class-validator" +import { ApiKeyActions } from "../../../types" + +export class UpdateApiKeyDTO { + @IsString() + adminId: string + + @IsEnum(ApiKeyActions) + action: ApiKeyActions +} \ No newline at end of file diff --git a/apps/api/src/app/admins/entities/admin.entity.ts b/apps/api/src/app/admins/entities/admin.entity.ts index 9eeeaec8..87bea3c3 100644 --- a/apps/api/src/app/admins/entities/admin.entity.ts +++ b/apps/api/src/app/admins/entities/admin.entity.ts @@ -1,4 +1,10 @@ -import { Column, CreateDateColumn, Entity, PrimaryColumn } from "typeorm" +import { + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn +} from "typeorm" @Entity("admins") export class Admin { @@ -12,6 +18,15 @@ export class Admin { @Column({ unique: true }) username: string + @Column({ name: "api_key", nullable: true }) + apiKey: string + + @Column({ name: "api_enabled", default: false }) + apiEnabled: boolean + @CreateDateColumn({ name: "created_at" }) createdAt: Date + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date } diff --git a/apps/api/src/app/auth/auth.guard.ts b/apps/api/src/app/auth/auth.guard.ts index 9bf67766..32f06f78 100644 --- a/apps/api/src/app/auth/auth.guard.ts +++ b/apps/api/src/app/auth/auth.guard.ts @@ -5,11 +5,11 @@ import { Injectable, UnauthorizedException } from "@nestjs/common" -import { AdminService } from "../admins/admins.service" +import { AdminsService } from "../admins/admins.service" @Injectable() export class AuthGuard implements CanActivate { - constructor(private adminService: AdminService) {} + constructor(private adminsService: AdminsService) {} async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest() @@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate { } try { - const admin = await this.adminService.findOne({ id: adminId }) + const admin = await this.adminsService.findOne({ id: adminId }) req["admin"] = admin } catch { diff --git a/apps/api/src/app/auth/auth.service.test.ts b/apps/api/src/app/auth/auth.service.test.ts index 69fcc03c..22043e23 100644 --- a/apps/api/src/app/auth/auth.service.test.ts +++ b/apps/api/src/app/auth/auth.service.test.ts @@ -3,7 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm" import { ethers } from "ethers" import { generateNonce, SiweMessage } from "siwe" import { Admin } from "../admins/entities/admin.entity" -import { AdminService } from "../admins/admins.service" +import { AdminsService } from "../admins/admins.service" import { AuthService } from "./auth.service" jest.mock("@bandada/utils", () => ({ @@ -47,7 +47,7 @@ function createSiweMessage(address: string, statement?: string) { describe("AuthService", () => { let authService: AuthService - let adminService: AdminService + let adminsService: AdminsService let originalApiUrl: string @@ -65,11 +65,11 @@ describe("AuthService", () => { }), TypeOrmModule.forFeature([Admin]) ], - providers: [AuthService, AdminService] + providers: [AuthService, AdminsService] }).compile() authService = await module.resolve(AuthService) - adminService = await module.resolve(AdminService) + adminsService = await module.resolve(AdminsService) // Set API_URL so auth service can validate domain originalApiUrl = process.env.DASHBOARD_URL @@ -169,7 +169,7 @@ describe("AuthService", () => { describe("# isLoggedIn", () => { it("Should return true if the admin exists", async () => { - const admin = await adminService.findOne({ + const admin = await adminsService.findOne({ address: account1.address }) diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index 357b3c5d..003737ae 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -5,12 +5,12 @@ import { } from "@nestjs/common" import { SiweMessage } from "siwe" import { v4 } from "uuid" -import { AdminService } from "../admins/admins.service" +import { AdminsService } from "../admins/admins.service" import { SignInWithEthereumDTO } from "./dto/siwe.dto" @Injectable() export class AuthService { - constructor(private readonly adminService: AdminService) {} + constructor(private readonly adminsService: AdminsService) {} async signIn( { message, signature }: SignInWithEthereumDTO, @@ -37,10 +37,10 @@ export class AuthService { ) } - let admin = await this.adminService.findOne({ address }) + let admin = await this.adminsService.findOne({ address }) if (!admin) { - admin = await this.adminService.create({ + admin = await this.adminsService.create({ id: v4(), address }) @@ -50,6 +50,6 @@ export class AuthService { } async isLoggedIn(adminId: string): Promise { - return !!(await this.adminService.findOne({ id: adminId })) + return !!(await this.adminsService.findOne({ id: adminId })) } } diff --git a/apps/api/src/app/credentials/credentials.module.ts b/apps/api/src/app/credentials/credentials.module.ts index 8182cd3b..4b053f98 100644 --- a/apps/api/src/app/credentials/credentials.module.ts +++ b/apps/api/src/app/credentials/credentials.module.ts @@ -5,12 +5,14 @@ import { GroupsModule } from "../groups/groups.module" import { OAuthAccount } from "./entities/credentials-account.entity" import { CredentialsController } from "./credentials.controller" import { CredentialsService } from "./credentials.service" +import { AdminsModule } from "../admins/admins.module" @Module({ imports: [ ScheduleModule.forRoot(), forwardRef(() => GroupsModule), - TypeOrmModule.forFeature([OAuthAccount]) + TypeOrmModule.forFeature([OAuthAccount]), + AdminsModule ], controllers: [CredentialsController], providers: [CredentialsService], diff --git a/apps/api/src/app/credentials/credentials.service.test.ts b/apps/api/src/app/credentials/credentials.service.test.ts index d48804d2..dd76cb10 100644 --- a/apps/api/src/app/credentials/credentials.service.test.ts +++ b/apps/api/src/app/credentials/credentials.service.test.ts @@ -9,6 +9,7 @@ import { Invite } from "../invites/entities/invite.entity" import { InvitesService } from "../invites/invites.service" import { OAuthAccount } from "./entities/credentials-account.entity" import { CredentialsService } from "./credentials.service" +import { AdminsModule } from "../admins/admins.module" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -59,7 +60,8 @@ describe("CredentialsService", () => { }) }), TypeOrmModule.forFeature([Group, Invite, Member, OAuthAccount]), - ScheduleModule.forRoot() + ScheduleModule.forRoot(), + AdminsModule ], providers: [GroupsService, InvitesService, CredentialsService] }).compile() diff --git a/apps/api/src/app/groups/dto/update-group.dto.ts b/apps/api/src/app/groups/dto/update-group.dto.ts index 6cb2b668..97600b36 100644 --- a/apps/api/src/app/groups/dto/update-group.dto.ts +++ b/apps/api/src/app/groups/dto/update-group.dto.ts @@ -1,5 +1,4 @@ import { - IsBoolean, IsJSON, IsNumber, IsOptional, @@ -21,10 +20,6 @@ export class UpdateGroupDto { @Max(32) readonly treeDepth?: number - @IsOptional() - @IsBoolean() - readonly apiEnabled?: boolean - @IsOptional() @IsNumber() readonly fingerprintDuration?: number diff --git a/apps/api/src/app/groups/entities/group.entity.ts b/apps/api/src/app/groups/entities/group.entity.ts index 4746455e..c390e29d 100644 --- a/apps/api/src/app/groups/entities/group.entity.ts +++ b/apps/api/src/app/groups/entities/group.entity.ts @@ -59,12 +59,6 @@ export class Group { }) credentials: any // TODO: Add correct type for credentials JSON - @Column({ name: "api_enabled", default: false }) - apiEnabled: boolean - - @Column({ name: "api_key", nullable: true }) - apiKey: string - @CreateDateColumn({ name: "created_at" }) createdAt: Date diff --git a/apps/api/src/app/groups/groups.controller.ts b/apps/api/src/app/groups/groups.controller.ts index 5e8d9f80..c12c4367 100644 --- a/apps/api/src/app/groups/groups.controller.ts +++ b/apps/api/src/app/groups/groups.controller.ts @@ -21,7 +21,6 @@ import { ApiQuery, ApiTags } from "@nestjs/swagger" -import { ThrottlerGuard } from "@nestjs/throttler" import { Request } from "express" import { AuthGuard } from "../auth/auth.guard" import { stringifyJSON } from "../utils" @@ -56,14 +55,15 @@ export class GroupsController { @Get(":group") @ApiOperation({ description: "Returns a specific group." }) @ApiCreatedResponse({ type: Group }) - async getGroup(@Param("group") groupId: string, @Req() req: Request) { + async getGroup( + @Param("group") groupId: string + ) { const group = await this.groupsService.getGroup(groupId) const fingerprint = await this.groupsService.getFingerprint(groupId) return mapGroupToResponseDTO( group, - fingerprint, - req.session.adminId === group.adminId + fingerprint ) } @@ -79,8 +79,7 @@ export class GroupsController { return mapGroupToResponseDTO( group, - fingerprint, - req.session.adminId === group.adminId + fingerprint ) } @@ -109,19 +108,10 @@ export class GroupsController { return mapGroupToResponseDTO( group, - fingerprint, - req.session.adminId === group.adminId + fingerprint ) } - @Patch(":group/api-key") - @UseGuards(AuthGuard) - @UseGuards(ThrottlerGuard) - @ApiExcludeEndpoint() - async updateApiKey(@Req() req: Request, @Param("group") groupId: string) { - return this.groupsService.updateApiKey(groupId, req.session.adminId) - } - @Get(":group/members/:member") @ApiOperation({ description: diff --git a/apps/api/src/app/groups/groups.module.ts b/apps/api/src/app/groups/groups.module.ts index f52c58e5..7b0db2f2 100644 --- a/apps/api/src/app/groups/groups.module.ts +++ b/apps/api/src/app/groups/groups.module.ts @@ -6,15 +6,19 @@ import { Group } from "./entities/group.entity" import { Member } from "./entities/member.entity" import { GroupsController } from "./groups.controller" import { GroupsService } from "./groups.service" +import { AdminsModule } from "../admins/admins.module" +import { Admin } from "../admins/entities/admin.entity" +import { AdminsService } from "../admins/admins.service" @Module({ imports: [ ScheduleModule.forRoot(), forwardRef(() => InvitesModule), - TypeOrmModule.forFeature([Member, Group]) + TypeOrmModule.forFeature([Member, Group, Admin]), + AdminsModule ], controllers: [GroupsController], - providers: [GroupsService], + providers: [GroupsService, AdminsService], exports: [GroupsService] }) export class GroupsModule {} diff --git a/apps/api/src/app/groups/groups.service.test.ts b/apps/api/src/app/groups/groups.service.test.ts index dc7f799b..44b0977e 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -7,6 +7,10 @@ import { OAuthAccount } from "../credentials/entities/credentials-account.entity import { Group } from "./entities/group.entity" import { Member } from "./entities/member.entity" import { GroupsService } from "./groups.service" +import { AdminsService } from "../admins/admins.service" +import { AdminsModule } from "../admins/admins.module" +import { Admin } from "../admins/entities/admin.entity" +import { ApiKeyActions } from "../../types" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -23,6 +27,7 @@ jest.mock("@bandada/utils", () => ({ describe("GroupsService", () => { let groupsService: GroupsService let invitesService: InvitesService + let adminsService: AdminsService let groupId: string beforeAll(async () => { @@ -33,18 +38,20 @@ describe("GroupsService", () => { type: "sqlite", database: ":memory:", dropSchema: true, - entities: [Group, Invite, Member, OAuthAccount], + entities: [Group, Invite, Member, OAuthAccount, Admin], synchronize: true }) }), - TypeOrmModule.forFeature([Group, Invite, Member]), - ScheduleModule.forRoot() + TypeOrmModule.forFeature([Group, Invite, Member, Admin]), + ScheduleModule.forRoot(), + AdminsModule ], - providers: [GroupsService, InvitesService] + providers: [GroupsService, InvitesService, AdminsService] }).compile() groupsService = await module.resolve(GroupsService) invitesService = await module.resolve(InvitesService) + adminsService = await module.resolve(AdminsService) await groupsService.initialize() @@ -225,54 +232,6 @@ describe("GroupsService", () => { }) }) - describe("# updateApiKey", () => { - let group: Group - - it("Should enable the API with a new API key", async () => { - group = await groupsService.createGroup( - { - name: "Group2", - description: "This is a new group", - treeDepth: 16, - fingerprintDuration: 3600 - }, - "admin" - ) - - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" - ) - - const { apiKey } = await groupsService.getGroup(group.id) - - expect(apiKey).toHaveLength(36) - }) - - it("Should update the api key of the group", async () => { - const apiKey = await groupsService.updateApiKey(group.id, "admin") - - expect(apiKey).toHaveLength(36) - }) - - it("Should not update the api key if the admin is the wrong one", async () => { - const fun = groupsService.updateApiKey(groupId, "wrong-admin") - - await expect(fun).rejects.toThrow( - `You are not the admin of the group '${groupId}'` - ) - }) - - it("Should not update the api key if the api is not enabled", async () => { - const fun = groupsService.updateApiKey(groupId, "admin") - - await expect(fun).rejects.toThrow( - `Group '${groupId}' API key is not enabled` - ) - }) - }) - describe("# addMember", () => { let invite: Invite @@ -424,10 +383,21 @@ describe("GroupsService", () => { }) describe("# Add and remove member via API", () => { + let admin: Admin let group: Group let apiKey: string beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Generate + }) + group = await groupsService.createGroup( { name: "Group2", @@ -435,16 +405,10 @@ describe("GroupsService", () => { treeDepth: 16, fingerprintDuration: 3600 }, - "admin" + admin.id ) - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" - ) - - apiKey = (await groupsService.getGroup(group.id)).apiKey + admin = await adminsService.findOne({ id: admin.id }) }) it("Should add a member to an existing group via API", async () => { @@ -493,15 +457,62 @@ describe("GroupsService", () => { ) }) - it("Should not add a member to an existing group if API is disabled", async () => { - await groupsService.updateGroup( + it("Should not add a member to an existing group if API belongs to another admin", async () => { + const fun = groupsService.addMemberWithAPIKey( + groupId, + "100002", + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid admin for group '${groupId}'` + ) + }) + + it("Should not remove a member to an existing group if API belongs to another admin", async () => { + const fun = groupsService.removeMemberWithAPIKey( + groupId, + "100001", + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid admin for group '${groupId}'` + ) + }) + + it("Should not add a member to an existing group if API is invalid", async () => { + const fun = groupsService.addMemberWithAPIKey( group.id, - { apiEnabled: false }, - "admin" + "100002", + "apiKey" ) + await expect(fun).rejects.toThrow( + "Invalid API key or API access not enabled for group" + ) + }) + + it("Should not remove a member to an existing group if API is invalid", async () => { + const fun = groupsService.removeMemberWithAPIKey( + group.id, + "100001", + "apiKey" + ) + + await expect(fun).rejects.toThrow( + "Invalid API key or API access not enabled for group" + ) + }) + + it("Should not add a member to an existing group if API is disabled", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + const fun = groupsService.addMemberWithAPIKey( - groupId, + group.id, "100002", apiKey ) @@ -513,7 +524,7 @@ describe("GroupsService", () => { it("Should not remove a member to an existing group if API is disabled", async () => { const fun = groupsService.removeMemberWithAPIKey( - groupId, + group.id, "100001", apiKey ) @@ -525,10 +536,21 @@ describe("GroupsService", () => { }) describe("# Add and remove members via API", () => { + let admin: Admin let group: Group let apiKey: string beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Generate + }) + group = await groupsService.createGroup( { name: "Group2", @@ -536,16 +558,10 @@ describe("GroupsService", () => { treeDepth: 16, fingerprintDuration: 3600 }, - "admin" + admin.id ) - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" - ) - - apiKey = (await groupsService.getGroup(group.id)).apiKey + admin = await adminsService.findOne({ id: admin.id }) }) it("Should add a member to an existing group via API", async () => { @@ -600,15 +616,62 @@ describe("GroupsService", () => { ) }) - it("Should not add a member to an existing group if API is disabled", async () => { - await groupsService.updateGroup( + it("Should not add a member to an existing group if API belongs to another admin", async () => { + const fun = groupsService.addMembersWithAPIKey( + groupId, + ["100002"], + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid admin for group '${groupId}'` + ) + }) + + it("Should not remove a member to an existing group if API belongs to another admin", async () => { + const fun = groupsService.removeMembersWithAPIKey( + groupId, + ["100001"], + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid admin for group '${groupId}'` + ) + }) + + it("Should not add a member to an existing group if API is invalid", async () => { + const fun = groupsService.addMembersWithAPIKey( group.id, - { apiEnabled: false }, - "admin" + ["100002"], + "apiKey" ) + await expect(fun).rejects.toThrow( + "Invalid API key or API access not enabled for group" + ) + }) + + it("Should not remove a member to an existing group if API is invalid", async () => { + const fun = groupsService.removeMembersWithAPIKey( + group.id, + ["100001"], + "apiKey" + ) + + await expect(fun).rejects.toThrow( + "Invalid API key or API access not enabled for group" + ) + }) + + it("Should not add a member to an existing group if API is disabled", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + const fun = groupsService.addMembersWithAPIKey( - groupId, + group.id, ["100002"], apiKey ) @@ -620,7 +683,7 @@ describe("GroupsService", () => { it("Should not remove a member to an existing group if API is disabled", async () => { const fun = groupsService.removeMembersWithAPIKey( - groupId, + group.id, ["100001"], apiKey ) diff --git a/apps/api/src/app/groups/groups.service.ts b/apps/api/src/app/groups/groups.service.ts index 274a130a..866cc331 100644 --- a/apps/api/src/app/groups/groups.service.ts +++ b/apps/api/src/app/groups/groups.service.ts @@ -12,8 +12,8 @@ import { import { InjectRepository } from "@nestjs/typeorm" import { Group as CachedGroup } from "@semaphore-protocol/group" import { Repository } from "typeorm" -import { v4 } from "uuid" import { InvitesService } from "../invites/invites.service" +import { AdminsService } from "../admins/admins.service" import { CreateGroupDto } from "./dto/create-group.dto" import { UpdateGroupDto } from "./dto/update-group.dto" import { Group } from "./entities/group.entity" @@ -31,7 +31,8 @@ export class GroupsService { @InjectRepository(Member) private readonly memberRepository: Repository, @Inject(forwardRef(() => InvitesService)) - private readonly invitesService: InvitesService + private readonly invitesService: InvitesService, + private readonly adminsService: AdminsService ) { this.cachedGroups = new Map() // this.bandadaContract = getBandadaContract( @@ -138,7 +139,6 @@ export class GroupsService { { description, treeDepth, - apiEnabled, credentials, fingerprintDuration }: UpdateGroupDto, @@ -176,15 +176,6 @@ export class GroupsService { group.credentials = credentials } - if (!group.credentials && apiEnabled !== undefined) { - group.apiEnabled = apiEnabled - - // Generate a new API key if it doesn't exist - if (!group.apiKey) { - group.apiKey = v4() - } - } - await this.groupRepository.save(group) Logger.log(`GroupsService: group '${group.name}' has been updated`) @@ -192,37 +183,6 @@ export class GroupsService { return group } - /** - * Updates the group api key. - * @param groupId Group id. - * @param adminId Group admin id. - */ - async updateApiKey(groupId: string, adminId: string): Promise { - const group = await this.getGroup(groupId) - - if (group.adminId !== adminId) { - throw new UnauthorizedException( - `You are not the admin of the group '${groupId}'` - ) - } - - if (!group.apiEnabled) { - throw new UnauthorizedException( - `Group '${groupId}' API key is not enabled` - ) - } - - group.apiKey = v4() - - await this.groupRepository.save(group) - - Logger.log( - `GroupsService: group '${group.name}' APIs have been updated` - ) - - return group.apiKey - } - /** * Join the group by redeeming invite code. * @param groupId Group id. @@ -320,8 +280,15 @@ export class GroupsService { apiKey: string ): Promise { const group = await this.getGroup(groupId) + const admin = await this.adminsService.findOne({ id: group.adminId }) + + if (!admin) { + throw new BadRequestException( + `Invalid admin for group '${groupId}'` + ) + } - if (!group.apiEnabled || group.apiKey !== apiKey) { + if (!admin.apiEnabled || admin.apiKey !== apiKey) { throw new BadRequestException( `Invalid API key or API access not enabled for group '${groupId}'` ) @@ -540,8 +507,15 @@ export class GroupsService { apiKey: string ): Promise { const group = await this.getGroup(groupId) + const admin = await this.adminsService.findOne({ id: group.adminId }) + + if (!admin) { + throw new BadRequestException( + `Invalid admin for group '${groupId}'` + ) + } - if (!group.apiEnabled || group.apiKey !== apiKey) { + if (!admin.apiEnabled || admin.apiKey !== apiKey) { throw new BadRequestException( `Invalid API key or API access not enabled for group '${groupId}'` ) diff --git a/apps/api/src/app/groups/groups.utils.test.ts b/apps/api/src/app/groups/groups.utils.test.ts index d077138d..12a369ed 100644 --- a/apps/api/src/app/groups/groups.utils.test.ts +++ b/apps/api/src/app/groups/groups.utils.test.ts @@ -23,17 +23,6 @@ describe("Groups utils", () => { expect(members).toHaveLength(0) }) - it("Should map the group data with api keys if specified", async () => { - const { apiKey, apiEnabled } = mapGroupToResponseDTO( - { apiEnabled: true, apiKey: "123" } as any, - "12345", - true - ) - - expect(apiEnabled).toBeTruthy() - expect(apiKey).toBe("123") - }) - it("Should map the fingerprint correctly", async () => { const { fingerprint } = mapGroupToResponseDTO({} as any, "12345") diff --git a/apps/api/src/app/groups/groups.utils.ts b/apps/api/src/app/groups/groups.utils.ts index 28d9b567..5c56e519 100644 --- a/apps/api/src/app/groups/groups.utils.ts +++ b/apps/api/src/app/groups/groups.utils.ts @@ -1,10 +1,6 @@ import { Group } from "./entities/group.entity" -export function mapGroupToResponseDTO( - group: Group, - fingerprint: string = "", - includeAPIKey: boolean = false -) { +export function mapGroupToResponseDTO(group: Group, fingerprint: string = "") { const dto = { id: group.id, name: group.name, @@ -15,14 +11,7 @@ export function mapGroupToResponseDTO( fingerprintDuration: group.fingerprintDuration, createdAt: group.createdAt, members: (group.members || []).map((m) => m.id), - credentials: group.credentials, - apiKey: undefined, - apiEnabled: undefined - } - - if (includeAPIKey) { - dto.apiKey = group.apiKey - dto.apiEnabled = group.apiEnabled + credentials: group.credentials } return dto diff --git a/apps/api/src/app/invites/invites.module.ts b/apps/api/src/app/invites/invites.module.ts index c60d65a1..a3eba97e 100644 --- a/apps/api/src/app/invites/invites.module.ts +++ b/apps/api/src/app/invites/invites.module.ts @@ -4,11 +4,13 @@ import { GroupsModule } from "../groups/groups.module" import { Invite } from "./entities/invite.entity" import { InvitesController } from "./invites.controller" import { InvitesService } from "./invites.service" +import { AdminsModule } from "../admins/admins.module" @Module({ imports: [ forwardRef(() => GroupsModule), - TypeOrmModule.forFeature([Invite]) + TypeOrmModule.forFeature([Invite]), + AdminsModule ], controllers: [InvitesController], providers: [InvitesService], diff --git a/apps/api/src/app/invites/invites.service.test.ts b/apps/api/src/app/invites/invites.service.test.ts index 2922ddae..26ab5167 100644 --- a/apps/api/src/app/invites/invites.service.test.ts +++ b/apps/api/src/app/invites/invites.service.test.ts @@ -7,6 +7,7 @@ import { GroupsService } from "../groups/groups.service" import { OAuthAccount } from "../credentials/entities/credentials-account.entity" import { Invite } from "./entities/invite.entity" import { InvitesService } from "./invites.service" +import { AdminsModule } from "../admins/admins.module" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -37,7 +38,8 @@ describe("InvitesService", () => { }) }), TypeOrmModule.forFeature([Group, Invite, Member]), - ScheduleModule.forRoot() + ScheduleModule.forRoot(), + AdminsModule ], providers: [GroupsService, InvitesService] }).compile() diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts new file mode 100644 index 00000000..700be00d --- /dev/null +++ b/apps/api/src/types/index.ts @@ -0,0 +1,10 @@ +/** + * Defines the possible actions that can be performed on an API key. + * This includes generating a new API key, enabling an existing API key, + * and disabling an existing API key. + */ +export enum ApiKeyActions { + Generate = "generate", + Enable = "enable", + Disable = "disable" +} diff --git a/database/seed.sql b/database/seed.sql index 35568a38..9375d265 100644 --- a/database/seed.sql +++ b/database/seed.sql @@ -4,7 +4,10 @@ CREATE TABLE admins ( id character varying PRIMARY KEY, address character varying NOT NULL UNIQUE, username character varying NOT NULL UNIQUE, - created_at timestamp without time zone NOT NULL DEFAULT now() + api_key character varying, + api_enabled boolean NOT NULL DEFAULT false, + created_at timestamp without time zone NOT NULL DEFAULT now(), + updated_at timestamp without time zone NOT NULL DEFAULT now() ); -- Table Definition ---------------------------------------------- @@ -17,8 +20,6 @@ CREATE TABLE groups ( tree_depth integer NOT NULL, fingerprint_duration integer NOT NULL, credentials text, - api_enabled boolean NOT NULL DEFAULT false, - api_key character varying, created_at timestamp without time zone NOT NULL DEFAULT now(), updated_at timestamp without time zone NOT NULL DEFAULT now() ); From 638f57615faa72f765edb9276fd7c4f5efeadd08 Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Fri, 22 Mar 2024 15:31:50 +0100 Subject: [PATCH 03/11] feat: add create / remove groups with api key logic --- apps/api/src/app/admins/admins.service.ts | 10 +- .../src/app/admins/dto/update-apikey.dto.ts | 2 +- apps/api/src/app/groups/groups.controller.ts | 19 +- .../api/src/app/groups/groups.service.test.ts | 412 +++++++++++++++++- apps/api/src/app/groups/groups.service.ts | 147 ++++++- 5 files changed, 557 insertions(+), 33 deletions(-) diff --git a/apps/api/src/app/admins/admins.service.ts b/apps/api/src/app/admins/admins.service.ts index 08aed512..ae98f358 100644 --- a/apps/api/src/app/admins/admins.service.ts +++ b/apps/api/src/app/admins/admins.service.ts @@ -1,10 +1,6 @@ /* istanbul ignore file */ import { id } from "@ethersproject/hash" -import { - BadRequestException, - Injectable, - Logger -} from "@nestjs/common" +import { BadRequestException, Injectable, Logger } from "@nestjs/common" import { InjectRepository } from "@nestjs/typeorm" import { FindOptionsWhere, Repository } from "typeorm" import { v4 } from "uuid" @@ -18,7 +14,7 @@ export class AdminsService { constructor( @InjectRepository(Admin) private readonly adminRepository: Repository - ) { } + ) {} public async create(payload: CreateAdminDTO): Promise { const username = payload.username || payload.address.slice(-5) @@ -39,7 +35,7 @@ export class AdminsService { /** * Updates the API key for a given admin based on the specified actions. - * + * * @param {UpdateApiKeyDTO} updateApiKeyDTO The DTO containing the admin ID and the action to be performed. * @returns {Promise} The API key of the admin after the update operation. If the API key is disabled, the return value might not be meaningful. * @throws {BadRequestException} If the admin ID does not correspond to an existing admin, if the admin does not have an API key when trying to enable it, or if the action is unsupported. diff --git a/apps/api/src/app/admins/dto/update-apikey.dto.ts b/apps/api/src/app/admins/dto/update-apikey.dto.ts index 4999a3df..6539c425 100644 --- a/apps/api/src/app/admins/dto/update-apikey.dto.ts +++ b/apps/api/src/app/admins/dto/update-apikey.dto.ts @@ -7,4 +7,4 @@ export class UpdateApiKeyDTO { @IsEnum(ApiKeyActions) action: ApiKeyActions -} \ No newline at end of file +} diff --git a/apps/api/src/app/groups/groups.controller.ts b/apps/api/src/app/groups/groups.controller.ts index c12c4367..bc8bc954 100644 --- a/apps/api/src/app/groups/groups.controller.ts +++ b/apps/api/src/app/groups/groups.controller.ts @@ -55,16 +55,11 @@ export class GroupsController { @Get(":group") @ApiOperation({ description: "Returns a specific group." }) @ApiCreatedResponse({ type: Group }) - async getGroup( - @Param("group") groupId: string - ) { + async getGroup(@Param("group") groupId: string) { const group = await this.groupsService.getGroup(groupId) const fingerprint = await this.groupsService.getFingerprint(groupId) - return mapGroupToResponseDTO( - group, - fingerprint - ) + return mapGroupToResponseDTO(group, fingerprint) } @Post() @@ -77,10 +72,7 @@ export class GroupsController { ) const fingerprint = await this.groupsService.getFingerprint(group.id) - return mapGroupToResponseDTO( - group, - fingerprint - ) + return mapGroupToResponseDTO(group, fingerprint) } @Delete(":group") @@ -106,10 +98,7 @@ export class GroupsController { const fingerprint = await this.groupsService.getFingerprint(groupId) - return mapGroupToResponseDTO( - group, - fingerprint - ) + return mapGroupToResponseDTO(group, fingerprint) } @Get(":group/members/:member") diff --git a/apps/api/src/app/groups/groups.service.test.ts b/apps/api/src/app/groups/groups.service.test.ts index 44b0977e..9a568165 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -11,6 +11,7 @@ import { AdminsService } from "../admins/admins.service" import { AdminsModule } from "../admins/admins.module" import { Admin } from "../admins/entities/admin.entity" import { ApiKeyActions } from "../../types" +import { CreateGroupDto } from "./dto/create-group.dto" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -382,6 +383,401 @@ describe("GroupsService", () => { }) }) + describe("# Create and remove group via API", () => { + const groupDto: CreateGroupDto = { + name: "Group", + description: "This is a new group", + treeDepth: 16, + fingerprintDuration: 3600 + } + let admin: Admin + let apiKey: string + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Generate + }) + + admin = await adminsService.findOne({ id: admin.id }) + }) + + it("Should create a group via API", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + apiKey + ) + + expect(group.adminId).toBe(admin.id) + expect(group.description).toBe(groupDto.description) + expect(group.name).toBe(groupDto.name) + expect(group.treeDepth).toBe(groupDto.treeDepth) + expect(group.fingerprintDuration).toBe(groupDto.fingerprintDuration) + expect(group.members).toHaveLength(0) + expect(group.credentials).toBeNull() + }) + + it("Should remove a group via API", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + apiKey + ) + + await groupsService.removeGroupWithAPIKey( + group.id, + admin.id, + apiKey + ) + + const fun = groupsService.getGroup(group.id) + + await expect(fun).rejects.toThrow( + `Group with id '${group.id}' does not exist` + ) + }) + + it("Should not create a group if the admin does not exist", async () => { + const fun = groupsService.createGroupWithAPIKey( + groupDto, + "wrong", + apiKey + ) + + await expect(fun).rejects.toThrow(`Invalid admin for new groups`) + }) + + it("Should not remove a group if the admin does not exist", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + apiKey + ) + + const fun = groupsService.removeGroupWithAPIKey( + group.id, + "wrong", + apiKey + ) + + await expect(fun).rejects.toThrow(`Invalid admin for groups`) + }) + + it("Should not create a group if the API key is invalid", async () => { + const fun = groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + "apiKey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not remove a group if the API key is invalid", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + apiKey + ) + + const fun = groupsService.removeGroupWithAPIKey( + group.id, + admin.id, + "wrong" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not create a group if the API key is disabled for the admin", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + + const fun = groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not remove a group if the API key is disabled for the admin", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Enable + }) + + const group = await groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + apiKey + ) + + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + + const fun = groupsService.removeGroupWithAPIKey( + group.id, + admin.id, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not remove a group if the given id does not belong to the group admin", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Enable + }) + + const group = await groupsService.createGroupWithAPIKey( + groupDto, + admin.id, + apiKey + ) + + let anotherAdmin = await adminsService.create({ + id: "admin2", + address: "0x02" + }) + + const anotherApiKey = await adminsService.updateApiKey({ + adminId: anotherAdmin.id, + action: ApiKeyActions.Generate + }) + + anotherAdmin = await adminsService.findOne({ id: anotherAdmin.id }) + + const fun = groupsService.removeGroupWithAPIKey( + group.id, + anotherAdmin.id, + anotherApiKey + ) + + await expect(fun).rejects.toThrow( + `You are not the admin of the group '${group.id}'` + ) + }) + }) + + describe("# Create and remove groups via API", () => { + const groupsDtos: Array = [ + { + id: "1", + name: "Group1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600 + }, + { + id: "2", + name: "Group2", + description: "This is a new group2", + treeDepth: 16, + fingerprintDuration: 3600 + }, + { + id: "3", + name: "Group3", + description: "This is a new group3", + treeDepth: 16, + fingerprintDuration: 3600 + } + ] + const ids = groupsDtos.map((dto) => dto.id) + let admin: Admin + let apiKey: string + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Generate + }) + + admin = await adminsService.findOne({ id: admin.id }) + }) + + it("Should create the groups via API", async () => { + const groups = await groupsService.createGroupsWithAPIKey( + groupsDtos, + admin.id, + apiKey + ) + + groups.forEach((group: Group, i: number) => { + expect(group.id).toBe(groupsDtos[i].id) + expect(group.adminId).toBe(admin.id) + expect(group.description).toBe(groupsDtos[i].description) + expect(group.name).toBe(groupsDtos[i].name) + expect(group.treeDepth).toBe(groupsDtos[i].treeDepth) + expect(group.fingerprintDuration).toBe( + groupsDtos[i].fingerprintDuration + ) + expect(group.members).toHaveLength(0) + expect(group.credentials).toBeNull() + }) + }) + + it("Should remove the groups via API", async () => { + let groups = await groupsService.getGroups({ + adminId: admin.id + }) + + expect(groups).toHaveLength(4) + + await groupsService.removeGroupsWithAPIKey( + [ids[0], ids[1]], + admin.id, + apiKey + ) + + groups = await groupsService.getGroups({ + adminId: admin.id + }) + + expect(groups).toHaveLength(2) + const group = groups.at(1) + const groupDto = groupsDtos.at(2) + + expect(group.id).toBe(groupDto.id) + expect(group.adminId).toBe(admin.id) + expect(group.description).toBe(groupDto.description) + expect(group.name).toBe(groupDto.name) + expect(group.treeDepth).toBe(groupDto.treeDepth) + expect(group.fingerprintDuration).toBe(groupDto.fingerprintDuration) + expect(group.members).toHaveLength(0) + expect(group.credentials).toBeNull() + + const fun = groupsService.getGroup(ids[1]) + + await expect(fun).rejects.toThrow( + `Group with id '${ids[1]}' does not exist` + ) + }) + + it("Should not create the groups if the admin does not exist", async () => { + const fun = groupsService.createGroupsWithAPIKey( + groupsDtos, + "wrong", + apiKey + ) + + await expect(fun).rejects.toThrow(`Invalid admin for new groups`) + }) + + it("Should not remove the groups if the admin does not exist", async () => { + const fun = groupsService.removeGroupsWithAPIKey( + ids, + "wrong", + apiKey + ) + + await expect(fun).rejects.toThrow(`Invalid admin for groups`) + }) + + it("Should not create the groups if the API key is invalid", async () => { + const fun = groupsService.createGroupsWithAPIKey( + groupsDtos, + admin.id, + "apiKey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not remove the groups if the API key is invalid", async () => { + const fun = groupsService.removeGroupsWithAPIKey( + ids, + admin.id, + "apiKey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not create the groups if the API key is disabled for the admin", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + + const fun = groupsService.createGroupsWithAPIKey( + groupsDtos, + admin.id, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not remove the groups if the API key is disabled for the admin", async () => { + const fun = groupsService.removeGroupsWithAPIKey( + ids, + admin.id, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not remove the groups if the given id does not belong to the group admin", async () => { + let anotherAdmin = await adminsService.create({ + id: "admin2", + address: "0x02" + }) + + const anotherApiKey = await adminsService.updateApiKey({ + adminId: anotherAdmin.id, + action: ApiKeyActions.Generate + }) + + anotherAdmin = await adminsService.findOne({ id: anotherAdmin.id }) + + const fun = groupsService.removeGroupsWithAPIKey( + [ids[2]], + anotherAdmin.id, + anotherApiKey + ) + + await expect(fun).rejects.toThrow( + `You are not the admin of the group '${ids[2]}'` + ) + }) + }) + describe("# Add and remove member via API", () => { let admin: Admin let group: Group @@ -489,7 +885,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) @@ -501,7 +897,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) @@ -518,7 +914,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) @@ -530,7 +926,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) }) @@ -648,7 +1044,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) @@ -660,7 +1056,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) @@ -677,7 +1073,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) @@ -689,7 +1085,7 @@ describe("GroupsService", () => { ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `Invalid API key or API access not enabled for admin '${group.adminId}'` ) }) }) diff --git a/apps/api/src/app/groups/groups.service.ts b/apps/api/src/app/groups/groups.service.ts index 866cc331..d122dcd9 100644 --- a/apps/api/src/app/groups/groups.service.ts +++ b/apps/api/src/app/groups/groups.service.ts @@ -106,6 +106,149 @@ export class GroupsService { return group } + /** + * Create a group using API Key. + * @param dto External parameters used to create a new group. + * @param adminId Admin id. + * @param apiKey The API Key. + * @returns Created group. + */ + async createGroupWithAPIKey( + dto: CreateGroupDto, + adminId: string, + apiKey: string + ): Promise { + const groups = await this.createGroupsWithAPIKey([dto], adminId, apiKey) + + return groups.at(0) + } + + /** + * Create groups using API Key. + * @param dtos External parameters used to create new groups. + * @param adminId Admin id. + * @param apiKey The API Key. + * @returns Created groups. + */ + async createGroupsWithAPIKey( + dtos: Array, + adminId: string, + apiKey: string + ): Promise> { + const newGroups: Array = [] + + const admin = await this.adminsService.findOne({ id: adminId }) + + if (!admin) { + throw new BadRequestException(`Invalid admin for new groups`) + } + + if (!admin.apiEnabled || admin.apiKey !== apiKey) { + throw new BadRequestException( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + } + + for await (const dto of dtos) { + const { + id: groupId, + name, + description, + treeDepth, + fingerprintDuration, + credentials + } = dto + + const _groupId = + groupId || + BigInt(id(name + adminId)) + .toString() + .slice(0, 32) + + const group = this.groupRepository.create({ + id: _groupId, + name, + description, + treeDepth, + fingerprintDuration, + credentials, + adminId, + members: [] + }) + + await this.groupRepository.save(group) + + const cachedGroup = new CachedGroup(group.id, group.treeDepth) + + this.cachedGroups.set(_groupId, cachedGroup) + + // this._updateFingerprintDuration(group.id, fingerprintDuration) + + Logger.log( + `GroupsService: group '${name}' has been created with id '${_groupId}'` + ) + + newGroups.push(group) + } + + return newGroups + } + + /** + * Remove a group using API Key. + * @param groupId Group id. + * @param adminId Admin id. + * @param apiKey the api key. + * @returns Created group. + */ + async removeGroupWithAPIKey( + groupId: string, + adminId: string, + apiKey: string + ): Promise { + return this.removeGroupsWithAPIKey([groupId], adminId, apiKey) + } + + /** + * Remove groups using API Key. + * @param groupsIds Groups identifiers. + * @param adminId Admin id. + * @param apiKey the api key. + */ + async removeGroupsWithAPIKey( + groupsIds: Array, + adminId: string, + apiKey: string + ): Promise { + const admin = await this.adminsService.findOne({ id: adminId }) + + if (!admin) { + throw new BadRequestException(`Invalid admin for groups`) + } + + if (!admin.apiEnabled || admin.apiKey !== apiKey) { + throw new BadRequestException( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + } + + for await (const groupId of groupsIds) { + const group = await this.getGroup(groupId) + + if (group.adminId !== adminId) { + throw new UnauthorizedException( + `You are not the admin of the group '${groupId}'` + ) + } + + await this.groupRepository.remove(group) + + this.cachedGroups.delete(groupId) + + Logger.log(`GroupsService: group '${group.name}' has been removed`) + } + } + /** * Removes a group. * @param groupId Group id. @@ -290,7 +433,7 @@ export class GroupsService { if (!admin.apiEnabled || admin.apiKey !== apiKey) { throw new BadRequestException( - `Invalid API key or API access not enabled for group '${groupId}'` + `Invalid API key or API access not enabled for admin '${admin.id}'` ) } @@ -517,7 +660,7 @@ export class GroupsService { if (!admin.apiEnabled || admin.apiKey !== apiKey) { throw new BadRequestException( - `Invalid API key or API access not enabled for group '${groupId}'` + `Invalid API key or API access not enabled for admin '${admin.id}'` ) } From 1bdc04b8b9dc8283b3bf4e51aeb18de16b253dc7 Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Fri, 22 Mar 2024 16:30:22 +0100 Subject: [PATCH 04/11] feat: add update group with api key logic; optimize create / remove with api key --- .../api/src/app/groups/groups.service.test.ts | 120 +++++++++++- apps/api/src/app/groups/groups.service.ts | 178 +++++++----------- apps/api/src/app/groups/groups.utils.ts | 22 +++ 3 files changed, 206 insertions(+), 114 deletions(-) diff --git a/apps/api/src/app/groups/groups.service.test.ts b/apps/api/src/app/groups/groups.service.test.ts index 9a568165..321ce359 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -12,6 +12,7 @@ import { AdminsModule } from "../admins/admins.module" import { Admin } from "../admins/entities/admin.entity" import { ApiKeyActions } from "../../types" import { CreateGroupDto } from "./dto/create-group.dto" +import { UpdateGroupDto } from "./dto/update-group.dto" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -450,7 +451,7 @@ describe("GroupsService", () => { apiKey ) - await expect(fun).rejects.toThrow(`Invalid admin for new groups`) + await expect(fun).rejects.toThrow(`Invalid admin for the groups`) }) it("Should not remove a group if the admin does not exist", async () => { @@ -466,7 +467,7 @@ describe("GroupsService", () => { apiKey ) - await expect(fun).rejects.toThrow(`Invalid admin for groups`) + await expect(fun).rejects.toThrow(`Invalid admin for the groups`) }) it("Should not create a group if the API key is invalid", async () => { @@ -687,7 +688,7 @@ describe("GroupsService", () => { apiKey ) - await expect(fun).rejects.toThrow(`Invalid admin for new groups`) + await expect(fun).rejects.toThrow(`Invalid admin for the groups`) }) it("Should not remove the groups if the admin does not exist", async () => { @@ -697,7 +698,7 @@ describe("GroupsService", () => { apiKey ) - await expect(fun).rejects.toThrow(`Invalid admin for groups`) + await expect(fun).rejects.toThrow(`Invalid admin for the groups`) }) it("Should not create the groups if the API key is invalid", async () => { @@ -778,6 +779,117 @@ describe("GroupsService", () => { }) }) + describe("# Update group via API", () => { + const groupDto: CreateGroupDto = { + id: "1", + name: "Group1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600 + } + + const updateDto: UpdateGroupDto = { + description: "This is a new new group1", + treeDepth: 32, + fingerprintDuration: 7200 + } + let admin: Admin + let apiKey: string + let group: Group + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Generate + }) + + admin = await adminsService.findOne({ id: admin.id }) + group = await groupsService.createGroup(groupDto, admin.id) + }) + + it("Should update the group via API", async () => { + const updatedGroup = await groupsService.updateGroupWithApiKey( + group.id, + updateDto, + admin.id, + apiKey + ) + + expect(updatedGroup.id).toBe(groupDto.id) + expect(updatedGroup.adminId).toBe(admin.id) + expect(updatedGroup.description).toBe(updateDto.description) + expect(updatedGroup.name).toBe(groupDto.name) + expect(updatedGroup.treeDepth).toBe(updateDto.treeDepth) + expect(updatedGroup.fingerprintDuration).toBe( + updateDto.fingerprintDuration + ) + expect(updatedGroup.members).toHaveLength(0) + expect(updatedGroup.credentials).toBeNull() + }) + + it("Should not update a group if the admin is the wrong one", async () => { + const fun = groupsService.updateGroupWithApiKey( + groupId, + groupDto, + "wrong-admin", + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid admin for the group '${groupId}'` + ) + }) + + it("Should not update a group if the group does not exist", async () => { + const fun = groupsService.updateGroupWithApiKey( + "wrong-group", + groupDto, + admin.id, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Group with id 'wrong-group' does not exist` + ) + }) + + it("Should not update a group if the API key is invalid", async () => { + const fun = groupsService.updateGroupWithApiKey( + groupId, + groupDto, + admin.id, + "invalid-apikey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should not update a group if the API key is disabled", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + + const fun = groupsService.updateGroupWithApiKey( + groupId, + groupDto, + admin.id, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + }) + describe("# Add and remove member via API", () => { let admin: Admin let group: Group diff --git a/apps/api/src/app/groups/groups.service.ts b/apps/api/src/app/groups/groups.service.ts index d122dcd9..4d0e60a9 100644 --- a/apps/api/src/app/groups/groups.service.ts +++ b/apps/api/src/app/groups/groups.service.ts @@ -19,6 +19,7 @@ import { UpdateGroupDto } from "./dto/update-group.dto" import { Group } from "./entities/group.entity" import { Member } from "./entities/member.entity" import { MerkleProof } from "./types" +import { adminApiKeyCheck } from "./groups.utils" @Injectable() export class GroupsService { @@ -57,6 +58,50 @@ export class GroupsService { // } } + /** + * Create a group using API Key. + * @param dto External parameters used to create a new group. + * @param adminId Admin id. + * @param apiKey The API Key. + * @returns Created group. + */ + async createGroupWithAPIKey( + dto: CreateGroupDto, + adminId: string, + apiKey: string + ): Promise { + const groups = await this.createGroupsWithAPIKey([dto], adminId, apiKey) + + return groups.at(0) + } + + /** + * Create groups using API Key. + * @param dtos External parameters used to create new groups. + * @param adminId Admin id. + * @param apiKey The API Key. + * @returns Created groups. + */ + async createGroupsWithAPIKey( + dtos: Array, + adminId: string, + apiKey: string + ): Promise> { + const newGroups: Array = [] + + const admin = await this.adminsService.findOne({ id: adminId }) + + await adminApiKeyCheck(admin, apiKey) + + for await (const dto of dtos) { + const group = await this.createGroup(dto, adminId) + + newGroups.push(group) + } + + return newGroups + } + /** * Creates a new group. * @param dto External parameters used to create a new group. @@ -106,94 +151,6 @@ export class GroupsService { return group } - /** - * Create a group using API Key. - * @param dto External parameters used to create a new group. - * @param adminId Admin id. - * @param apiKey The API Key. - * @returns Created group. - */ - async createGroupWithAPIKey( - dto: CreateGroupDto, - adminId: string, - apiKey: string - ): Promise { - const groups = await this.createGroupsWithAPIKey([dto], adminId, apiKey) - - return groups.at(0) - } - - /** - * Create groups using API Key. - * @param dtos External parameters used to create new groups. - * @param adminId Admin id. - * @param apiKey The API Key. - * @returns Created groups. - */ - async createGroupsWithAPIKey( - dtos: Array, - adminId: string, - apiKey: string - ): Promise> { - const newGroups: Array = [] - - const admin = await this.adminsService.findOne({ id: adminId }) - - if (!admin) { - throw new BadRequestException(`Invalid admin for new groups`) - } - - if (!admin.apiEnabled || admin.apiKey !== apiKey) { - throw new BadRequestException( - `Invalid API key or API access not enabled for admin '${admin.id}'` - ) - } - - for await (const dto of dtos) { - const { - id: groupId, - name, - description, - treeDepth, - fingerprintDuration, - credentials - } = dto - - const _groupId = - groupId || - BigInt(id(name + adminId)) - .toString() - .slice(0, 32) - - const group = this.groupRepository.create({ - id: _groupId, - name, - description, - treeDepth, - fingerprintDuration, - credentials, - adminId, - members: [] - }) - - await this.groupRepository.save(group) - - const cachedGroup = new CachedGroup(group.id, group.treeDepth) - - this.cachedGroups.set(_groupId, cachedGroup) - - // this._updateFingerprintDuration(group.id, fingerprintDuration) - - Logger.log( - `GroupsService: group '${name}' has been created with id '${_groupId}'` - ) - - newGroups.push(group) - } - - return newGroups - } - /** * Remove a group using API Key. * @param groupId Group id. @@ -222,30 +179,10 @@ export class GroupsService { ): Promise { const admin = await this.adminsService.findOne({ id: adminId }) - if (!admin) { - throw new BadRequestException(`Invalid admin for groups`) - } - - if (!admin.apiEnabled || admin.apiKey !== apiKey) { - throw new BadRequestException( - `Invalid API key or API access not enabled for admin '${admin.id}'` - ) - } + await adminApiKeyCheck(admin, apiKey) for await (const groupId of groupsIds) { - const group = await this.getGroup(groupId) - - if (group.adminId !== adminId) { - throw new UnauthorizedException( - `You are not the admin of the group '${groupId}'` - ) - } - - await this.groupRepository.remove(group) - - this.cachedGroups.delete(groupId) - - Logger.log(`GroupsService: group '${group.name}' has been removed`) + await this.removeGroup(groupId, adminId) } } @@ -270,6 +207,27 @@ export class GroupsService { Logger.log(`GroupsService: group '${group.name}' has been removed`) } + /** + * Update a group using API Key. + * @param groupId Group id. + * @param dto External parameters used to update a group. + * @param adminId Group admin id. + * @param apiKey the API Key. + * @returns Updated group. + */ + async updateGroupWithApiKey( + groupId: string, + dto: UpdateGroupDto, + adminId: string, + apiKey: string + ): Promise { + const admin = await this.adminsService.findOne({ id: adminId }) + + await adminApiKeyCheck(admin, apiKey, groupId) + + return this.updateGroup(groupId, dto, adminId) + } + /** * Updates some parameters of the group. * @param groupId Group id. diff --git a/apps/api/src/app/groups/groups.utils.ts b/apps/api/src/app/groups/groups.utils.ts index 5c56e519..a85764aa 100644 --- a/apps/api/src/app/groups/groups.utils.ts +++ b/apps/api/src/app/groups/groups.utils.ts @@ -1,4 +1,6 @@ +import { BadRequestException } from "@nestjs/common" import { Group } from "./entities/group.entity" +import { Admin } from "../admins/entities/admin.entity" export function mapGroupToResponseDTO(group: Group, fingerprint: string = "") { const dto = { @@ -16,3 +18,23 @@ export function mapGroupToResponseDTO(group: Group, fingerprint: string = "") { return dto } + +export async function adminApiKeyCheck( + admin: Admin, + apiKey: string, + groupId?: string +) { + if (!admin) { + throw new BadRequestException( + groupId + ? `Invalid admin for the group '${groupId}'` + : `Invalid admin for the groups` + ) + } + + if (!admin.apiEnabled || admin.apiKey !== apiKey) { + throw new BadRequestException( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + } +} From 64268d87c134d2a44fc5fdf69d162f30293c1f1b Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Fri, 22 Mar 2024 16:30:49 +0100 Subject: [PATCH 05/11] chore: comment out dashboard group page api key section to avoid errors n --- apps/dashboard/src/api/bandadaAPI.ts | 56 ++++++++++---------- apps/dashboard/src/pages/group.tsx | 78 +++++++++++++++------------- apps/dashboard/src/types/Group.ts | 4 +- 3 files changed, 73 insertions(+), 65 deletions(-) diff --git a/apps/dashboard/src/api/bandadaAPI.ts b/apps/dashboard/src/api/bandadaAPI.ts index 9103961c..ebc1d5d3 100644 --- a/apps/dashboard/src/api/bandadaAPI.ts +++ b/apps/dashboard/src/api/bandadaAPI.ts @@ -111,39 +111,41 @@ export async function createGroup( * @param group The group id. * @param memberId The group member id. */ -export async function updateGroup( - groupId: string, - { apiEnabled }: { apiEnabled: boolean } -): Promise { - try { - const group = await request(`${API_URL}/groups/${groupId}`, { - method: "PATCH", - data: { apiEnabled } - }) +// @todo needs refactoring to support the new logic. +// export async function updateGroup( +// groupId: string, +// { apiEnabled }: { apiEnabled: boolean } +// ): Promise { +// try { +// const group = await request(`${API_URL}/groups/${groupId}`, { +// method: "PATCH", +// data: { apiEnabled } +// }) - return { ...group, type: "off-chain" } - } catch (error: any) { - console.error(error) - createAlert(error.response.data.message) - return null - } -} +// return { ...group, type: "off-chain" } +// } catch (error: any) { +// console.error(error) +// createAlert(error.response.data.message) +// return null +// } +// } /** * It generates a new API key. * @param group The group id. */ -export async function generateApiKey(groupId: string): Promise { - try { - return await request(`${API_URL}/groups/${groupId}/api-key`, { - method: "PATCH" - }) - } catch (error: any) { - console.error(error) - createAlert(error.response.data.message) - return null - } -} +// @todo needs refactoring to support the new logic. +// export async function generateApiKey(groupId: string): Promise { +// try { +// return await request(`${API_URL}/groups/${groupId}/api-key`, { +// method: "PATCH" +// }) +// } catch (error: any) { +// console.error(error) +// createAlert(error.response.data.message) +// return null +// } +// } /** * It removes a group. diff --git a/apps/dashboard/src/pages/group.tsx b/apps/dashboard/src/pages/group.tsx index ac351a63..ed96dcea 100644 --- a/apps/dashboard/src/pages/group.tsx +++ b/apps/dashboard/src/pages/group.tsx @@ -17,7 +17,7 @@ import { MenuButton, MenuItem, MenuList, - Switch, + // Switch, Text, Tooltip, useClipboard, @@ -49,7 +49,7 @@ export default function GroupPage(): JSX.Element { const toast = useToast() const { groupId, groupType } = useParams() const [_group, setGroup] = useState() - const { hasCopied, setValue: setApiKey, onCopy } = useClipboard("") + // const { hasCopied, setValue: setApiKey, onCopy } = useClipboard("") const { hasCopied: hasCopiedGroupId, onCopy: onCopyGroupId } = useClipboard( groupId || "" ) @@ -77,27 +77,33 @@ export default function GroupPage(): JSX.Element { return } - setApiKey(group.apiKey || "") + // @todo needs refactoring to support the new logic. + // setApiKey(group.apiKey || "") setGroup(group) } })() - }, [groupId, groupType, setApiKey]) - - const onApiAccessToggle = useCallback( - async (apiEnabled: boolean) => { - const group = await bandadaApi.updateGroup(_group!.id as string, { - apiEnabled - }) - - if (group === null) { - return - } - - setApiKey(group.apiKey!) - setGroup(group) - }, - [_group, setApiKey] - ) + }, [ + groupId, + groupType + // setApiKey + ]) + + // const onApiAccessToggle = useCallback( + // async (apiEnabled: boolean) => { + // const group = await bandadaApi.updateGroup(_group!.id as string, { + // apiEnabled + // }) + + // if (group === null) { + // return + // } + + // // @todo needs refactoring to support the new logic. + // // setApiKey(group.apiKey!) + // setGroup(group) + // }, + // [_group, setApiKey] + // ) const addMember = useCallback( (memberIds?: string[]) => { @@ -199,24 +205,24 @@ ${memberIds.join("\n")} navigate("/groups") }, [_group, navigate]) - const generateApiKey = useCallback(async () => { - if ( - !window.confirm("Are you sure you want to generate a new API key?") - ) { - return - } + // const generateApiKey = useCallback(async () => { + // if ( + // !window.confirm("Are you sure you want to generate a new API key?") + // ) { + // return + // } - const apiKey = await bandadaApi.generateApiKey(_group!.id) + // const apiKey = await bandadaApi.generateApiKey(_group!.id) - if (apiKey === null) { - return - } + // if (apiKey === null) { + // return + // } - _group!.apiKey = apiKey + // _group!.apiKey = apiKey - setApiKey(apiKey) - setGroup({ ..._group! }) - }, [_group, setApiKey]) + // setApiKey(apiKey) + // setGroup({ ..._group! }) + // }, [_group, setApiKey]) const toggleMemberSelection = (memberId: string) => { if (_selectedMembers.includes(memberId)) { @@ -398,7 +404,7 @@ ${memberIds.join("\n")} /> )} - {groupType === "off-chain" && + {/* {groupType === "off-chain" && !_group.credentials && isGroupAdmin && ( )} - )} + )} */} {_group.type === "off-chain" && isGroupAdmin && ( Date: Wed, 27 Mar 2024 12:28:03 +0100 Subject: [PATCH 06/11] refactor: add missing getAdmin API, move ApiKeyActions to utils --- apps/api/src/app/admins/admins.controller.ts | 9 ++++++++- apps/api/src/app/admins/admins.service.test.ts | 2 +- apps/api/src/app/admins/admins.service.ts | 2 +- apps/api/src/app/admins/dto/update-apikey.dto.ts | 2 +- apps/api/src/app/groups/groups.service.test.ts | 2 +- apps/api/src/types/index.ts | 10 ---------- libs/utils/src/types/index.ts | 11 +++++++++++ 7 files changed, 23 insertions(+), 15 deletions(-) delete mode 100644 apps/api/src/types/index.ts diff --git a/apps/api/src/app/admins/admins.controller.ts b/apps/api/src/app/admins/admins.controller.ts index b225eb02..bf38b995 100644 --- a/apps/api/src/app/admins/admins.controller.ts +++ b/apps/api/src/app/admins/admins.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Post, Put } from "@nestjs/common" +import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common" +import { ApiCreatedResponse } from "@nestjs/swagger" import { CreateAdminDTO } from "./dto/create-admin.dto" import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" import { AdminsService } from "./admins.service" @@ -13,6 +14,12 @@ export class AdminsController { return this.adminsService.create(dto) } + @Get(":admin") + @ApiCreatedResponse({ type: Admin }) + async getAdmin(@Param("admin") adminId: string) { + return this.adminsService.findOne({ id: adminId }) + } + @Put("update-apikey") async updateApiKey(@Body() dto: UpdateApiKeyDTO): Promise { return this.adminsService.updateApiKey({ diff --git a/apps/api/src/app/admins/admins.service.test.ts b/apps/api/src/app/admins/admins.service.test.ts index 547d1eee..18e36934 100644 --- a/apps/api/src/app/admins/admins.service.test.ts +++ b/apps/api/src/app/admins/admins.service.test.ts @@ -2,9 +2,9 @@ import { id as idToHash } from "@ethersproject/hash" import { ScheduleModule } from "@nestjs/schedule" import { Test } from "@nestjs/testing" import { TypeOrmModule } from "@nestjs/typeorm" +import { ApiKeyActions } from "@bandada/utils" import { AdminsService } from "./admins.service" import { Admin } from "./entities/admin.entity" -import { ApiKeyActions } from "../../types" describe("AdminsService", () => { const id = "1" diff --git a/apps/api/src/app/admins/admins.service.ts b/apps/api/src/app/admins/admins.service.ts index ae98f358..cdf17df9 100644 --- a/apps/api/src/app/admins/admins.service.ts +++ b/apps/api/src/app/admins/admins.service.ts @@ -4,10 +4,10 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common" import { InjectRepository } from "@nestjs/typeorm" import { FindOptionsWhere, Repository } from "typeorm" import { v4 } from "uuid" +import { ApiKeyActions } from "@bandada/utils" import { CreateAdminDTO } from "./dto/create-admin.dto" import { Admin } from "./entities/admin.entity" import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" -import { ApiKeyActions } from "../../types" @Injectable() export class AdminsService { diff --git a/apps/api/src/app/admins/dto/update-apikey.dto.ts b/apps/api/src/app/admins/dto/update-apikey.dto.ts index 6539c425..ecea174d 100644 --- a/apps/api/src/app/admins/dto/update-apikey.dto.ts +++ b/apps/api/src/app/admins/dto/update-apikey.dto.ts @@ -1,5 +1,5 @@ import { IsEnum, IsString } from "class-validator" -import { ApiKeyActions } from "../../../types" +import { ApiKeyActions } from "@bandada/utils" export class UpdateApiKeyDTO { @IsString() diff --git a/apps/api/src/app/groups/groups.service.test.ts b/apps/api/src/app/groups/groups.service.test.ts index 321ce359..dd7beb86 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -1,6 +1,7 @@ import { ScheduleModule } from "@nestjs/schedule" import { Test } from "@nestjs/testing" import { TypeOrmModule } from "@nestjs/typeorm" +import { ApiKeyActions } from "@bandada/utils" import { Invite } from "../invites/entities/invite.entity" import { InvitesService } from "../invites/invites.service" import { OAuthAccount } from "../credentials/entities/credentials-account.entity" @@ -10,7 +11,6 @@ import { GroupsService } from "./groups.service" import { AdminsService } from "../admins/admins.service" import { AdminsModule } from "../admins/admins.module" import { Admin } from "../admins/entities/admin.entity" -import { ApiKeyActions } from "../../types" import { CreateGroupDto } from "./dto/create-group.dto" import { UpdateGroupDto } from "./dto/update-group.dto" diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts deleted file mode 100644 index 700be00d..00000000 --- a/apps/api/src/types/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Defines the possible actions that can be performed on an API key. - * This includes generating a new API key, enabling an existing API key, - * and disabling an existing API key. - */ -export enum ApiKeyActions { - Generate = "generate", - Enable = "enable", - Disable = "disable" -} diff --git a/libs/utils/src/types/index.ts b/libs/utils/src/types/index.ts index 26c845cc..d83f46b1 100644 --- a/libs/utils/src/types/index.ts +++ b/libs/utils/src/types/index.ts @@ -12,3 +12,14 @@ export type BlockchainNetwork = { id: string name: string } + +/** + * Defines the possible actions that can be performed on an API key. + * This includes generating a new API key, enabling an existing API key, + * and disabling an existing API key. + */ +export enum ApiKeyActions { + Generate = "generate", + Enable = "enable", + Disable = "disable" +} From e496fd6fd56124e828844db1da921ef88077678d Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Wed, 27 Mar 2024 13:06:47 +0100 Subject: [PATCH 07/11] refactor: improve apikey admin api and store the admin on db after message verification in UI --- apps/api/src/app/admins/admins.controller.ts | 14 ++-- apps/api/src/app/admins/admins.service.ts | 9 +- .../src/app/admins/dto/update-apikey.dto.ts | 10 --- apps/dashboard/src/api/bandadaAPI.ts | 84 ++++++++++++++----- apps/dashboard/src/context/auth-context.tsx | 15 +++- 5 files changed, 91 insertions(+), 41 deletions(-) delete mode 100644 apps/api/src/app/admins/dto/update-apikey.dto.ts diff --git a/apps/api/src/app/admins/admins.controller.ts b/apps/api/src/app/admins/admins.controller.ts index bf38b995..c22cbd5e 100644 --- a/apps/api/src/app/admins/admins.controller.ts +++ b/apps/api/src/app/admins/admins.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common" import { ApiCreatedResponse } from "@nestjs/swagger" +import { ApiKeyActions } from "@bandada/utils" import { CreateAdminDTO } from "./dto/create-admin.dto" -import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" import { AdminsService } from "./admins.service" import { Admin } from "./entities/admin.entity" @@ -20,11 +20,11 @@ export class AdminsController { return this.adminsService.findOne({ id: adminId }) } - @Put("update-apikey") - async updateApiKey(@Body() dto: UpdateApiKeyDTO): Promise { - return this.adminsService.updateApiKey({ - adminId: dto.adminId, - action: dto.action - }) + @Put(":admin/apikey") + async updateApiKey( + @Param("admin") adminId: string, + @Body() action: ApiKeyActions + ): Promise { + return this.adminsService.updateApiKey(adminId, action) } } diff --git a/apps/api/src/app/admins/admins.service.ts b/apps/api/src/app/admins/admins.service.ts index cdf17df9..3d5fc306 100644 --- a/apps/api/src/app/admins/admins.service.ts +++ b/apps/api/src/app/admins/admins.service.ts @@ -7,7 +7,6 @@ import { v4 } from "uuid" import { ApiKeyActions } from "@bandada/utils" import { CreateAdminDTO } from "./dto/create-admin.dto" import { Admin } from "./entities/admin.entity" -import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" @Injectable() export class AdminsService { @@ -36,11 +35,15 @@ export class AdminsService { /** * Updates the API key for a given admin based on the specified actions. * - * @param {UpdateApiKeyDTO} updateApiKeyDTO The DTO containing the admin ID and the action to be performed. + * @param adminId The identifier of the admin. + * @param action The action to be executed on the API key of the admin. * @returns {Promise} The API key of the admin after the update operation. If the API key is disabled, the return value might not be meaningful. * @throws {BadRequestException} If the admin ID does not correspond to an existing admin, if the admin does not have an API key when trying to enable it, or if the action is unsupported. */ - async updateApiKey({ adminId, action }: UpdateApiKeyDTO): Promise { + async updateApiKey( + adminId: string, + action: ApiKeyActions + ): Promise { const admin = await this.findOne({ id: adminId }) diff --git a/apps/api/src/app/admins/dto/update-apikey.dto.ts b/apps/api/src/app/admins/dto/update-apikey.dto.ts deleted file mode 100644 index ecea174d..00000000 --- a/apps/api/src/app/admins/dto/update-apikey.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IsEnum, IsString } from "class-validator" -import { ApiKeyActions } from "@bandada/utils" - -export class UpdateApiKeyDTO { - @IsString() - adminId: string - - @IsEnum(ApiKeyActions) - action: ApiKeyActions -} diff --git a/apps/dashboard/src/api/bandadaAPI.ts b/apps/dashboard/src/api/bandadaAPI.ts index ebc1d5d3..c690e220 100644 --- a/apps/dashboard/src/api/bandadaAPI.ts +++ b/apps/dashboard/src/api/bandadaAPI.ts @@ -1,6 +1,6 @@ -import { request } from "@bandada/utils" +import { ApiKeyActions, request } from "@bandada/utils" import { SiweMessage } from "siwe" -import { Group } from "../types" +import { Admin, Group } from "../types" import createAlert from "../utils/createAlert" const API_URL = import.meta.env.VITE_API_URL @@ -106,6 +106,69 @@ export async function createGroup( } } +/** + * It creates a new admin. + * @param id The admin id. + * @param address The admin address. + * @returns The Admin. + */ +export async function createAdmin( + id: string, + address: string +): Promise { + try { + return await request(`${API_URL}/admins`, { + method: "POST", + data: { + id, + address + } + }) + } catch (error: any) { + console.error(error) + createAlert(error.response.data.message) + return null + } +} + +/** + * It returns details of a specific admin. + * @param adminId The admin id. + * @returns The admin details. + */ +export async function getAdmin(adminId: string): Promise { + try { + return await request(`${API_URL}/admins/${adminId}`) + } catch (error: any) { + console.error(error) + createAlert(error.response.data.message) + return null + } +} + +/** + * It works with the Admin API key. + * @param adminId The admin id. + * @param action The action to carry on the API key. + */ +export async function updateApiKey( + adminId: string, + action: ApiKeyActions +): Promise { + try { + return await request(`${API_URL}/admins/${adminId}/apikey`, { + method: "PUT", + data: { + action + } + }) + } catch (error: any) { + console.error(error) + createAlert(error.response.data.message) + return null + } +} + /** * It updates the detail of a group. * @param group The group id. @@ -130,23 +193,6 @@ export async function createGroup( // } // } -/** - * It generates a new API key. - * @param group The group id. - */ -// @todo needs refactoring to support the new logic. -// export async function generateApiKey(groupId: string): Promise { -// try { -// return await request(`${API_URL}/groups/${groupId}/api-key`, { -// method: "PATCH" -// }) -// } catch (error: any) { -// console.error(error) -// createAlert(error.response.data.message) -// return null -// } -// } - /** * It removes a group. * @param groupId The group id. diff --git a/apps/dashboard/src/context/auth-context.tsx b/apps/dashboard/src/context/auth-context.tsx index 4cce653b..b3b3e8e5 100644 --- a/apps/dashboard/src/context/auth-context.tsx +++ b/apps/dashboard/src/context/auth-context.tsx @@ -17,7 +17,13 @@ import { SiweMessage } from "siwe" import { configureChains, createClient, WagmiConfig } from "wagmi" import { sepolia } from "wagmi/chains" import { publicProvider } from "wagmi/providers/public" -import { getNonce, logOut, signIn } from "../api/bandadaAPI" +import { + createAdmin, + getAdmin, + getNonce, + logOut, + signIn +} from "../api/bandadaAPI" import useSessionData from "../hooks/use-session-data" import { Admin } from "../types" @@ -59,7 +65,7 @@ export function AuthContextProvider({ children }: { children: ReactNode }) { getMessageBody: ({ message }) => message.prepareMessage(), verify: async ({ message, signature }) => { - const admin = await signIn({ + const admin: Admin = await signIn({ message, signature }) @@ -67,6 +73,11 @@ export function AuthContextProvider({ children }: { children: ReactNode }) { if (admin) { saveAdmin(admin) + const alreadyCreated = await getAdmin(admin.id) + + if (!alreadyCreated) + await createAdmin(admin.id, admin.address) + return true } From fb852d3f10f452c7d845840ac2329aaa0b2b75da Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Wed, 27 Mar 2024 16:31:05 +0100 Subject: [PATCH 08/11] refactor: update admin apikey for dashboard support --- apps/api/src/app/admins/admins.controller.ts | 6 +- .../api/src/app/admins/admins.service.test.ts | 50 ++++--- .../src/app/admins/dto/update-apikey.dto.ts | 7 + .../credentials/credentials.service.test.ts | 35 ++--- .../api/src/app/groups/groups.service.test.ts | 124 ++++++++---------- .../src/app/invites/invites.service.test.ts | 25 ++-- libs/utils/src/index.ts | 7 +- 7 files changed, 125 insertions(+), 129 deletions(-) create mode 100644 apps/api/src/app/admins/dto/update-apikey.dto.ts diff --git a/apps/api/src/app/admins/admins.controller.ts b/apps/api/src/app/admins/admins.controller.ts index c22cbd5e..f8435725 100644 --- a/apps/api/src/app/admins/admins.controller.ts +++ b/apps/api/src/app/admins/admins.controller.ts @@ -1,9 +1,9 @@ import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common" import { ApiCreatedResponse } from "@nestjs/swagger" -import { ApiKeyActions } from "@bandada/utils" import { CreateAdminDTO } from "./dto/create-admin.dto" import { AdminsService } from "./admins.service" import { Admin } from "./entities/admin.entity" +import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" @Controller("admins") export class AdminsController { @@ -23,8 +23,8 @@ export class AdminsController { @Put(":admin/apikey") async updateApiKey( @Param("admin") adminId: string, - @Body() action: ApiKeyActions + @Body() dto: UpdateApiKeyDTO ): Promise { - return this.adminsService.updateApiKey(adminId, action) + return this.adminsService.updateApiKey(adminId, dto.action) } } diff --git a/apps/api/src/app/admins/admins.service.test.ts b/apps/api/src/app/admins/admins.service.test.ts index 18e36934..ca92c669 100644 --- a/apps/api/src/app/admins/admins.service.test.ts +++ b/apps/api/src/app/admins/admins.service.test.ts @@ -81,10 +81,10 @@ describe("AdminsService", () => { describe("# updateApiKey", () => { it("Should create an apikey for the admin", async () => { - const apiKey = await adminsService.updateApiKey({ - adminId: hashedId, - action: ApiKeyActions.Generate - }) + const apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) admin = await adminsService.findOne({ id: hashedId }) @@ -95,10 +95,10 @@ describe("AdminsService", () => { it("Should generate another apikey for the admin", async () => { const previousApiKey = admin.apiKey - const apiKey = await adminsService.updateApiKey({ - adminId: hashedId, - action: ApiKeyActions.Generate - }) + const apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) admin = await adminsService.findOne({ id: hashedId }) @@ -110,10 +110,7 @@ describe("AdminsService", () => { it("Should disable the apikey for the admin", async () => { const { apiKey } = admin - await adminsService.updateApiKey({ - adminId: hashedId, - action: ApiKeyActions.Disable - }) + await adminsService.updateApiKey(hashedId, ApiKeyActions.Disable) admin = await adminsService.findOne({ id: hashedId }) @@ -124,10 +121,7 @@ describe("AdminsService", () => { it("Should enable the apikey for the admin", async () => { const { apiKey } = admin - await adminsService.updateApiKey({ - adminId: hashedId, - action: ApiKeyActions.Enable - }) + await adminsService.updateApiKey(hashedId, ApiKeyActions.Enable) admin = await adminsService.findOne({ id: hashedId }) @@ -138,10 +132,10 @@ describe("AdminsService", () => { it("Should not create the apikey when the given id does not belog to an admin", async () => { const wrongId = "wrongId" - const fun = adminsService.updateApiKey({ - adminId: wrongId, - action: ApiKeyActions.Disable - }) + const fun = adminsService.updateApiKey( + wrongId, + ApiKeyActions.Disable + ) await expect(fun).rejects.toThrow( `The '${wrongId}' does not belong to an admin` @@ -154,10 +148,10 @@ describe("AdminsService", () => { address: "address2" }) - const fun = adminsService.updateApiKey({ - adminId: tempAdmin.id, - action: ApiKeyActions.Enable - }) + const fun = adminsService.updateApiKey( + tempAdmin.id, + ApiKeyActions.Enable + ) await expect(fun).rejects.toThrow( `The '${tempAdmin.id}' does not have an apikey` @@ -167,11 +161,11 @@ describe("AdminsService", () => { it("Shoul throw if the action does not exist", async () => { const wrongAction = "wrong-action" - const fun = adminsService.updateApiKey({ - adminId: hashedId, + const fun = adminsService.updateApiKey( + hashedId, // @ts-ignore - action: wrongAction - }) + wrongAction + ) await expect(fun).rejects.toThrow( `Unsupported ${wrongAction} apikey` diff --git a/apps/api/src/app/admins/dto/update-apikey.dto.ts b/apps/api/src/app/admins/dto/update-apikey.dto.ts new file mode 100644 index 00000000..75c571fc --- /dev/null +++ b/apps/api/src/app/admins/dto/update-apikey.dto.ts @@ -0,0 +1,7 @@ +import { ApiKeyActions } from "@bandada/utils" +import { IsEnum } from "class-validator" + +export class UpdateApiKeyDTO { + @IsEnum(ApiKeyActions) + action: ApiKeyActions +} diff --git a/apps/api/src/app/credentials/credentials.service.test.ts b/apps/api/src/app/credentials/credentials.service.test.ts index dd76cb10..14163aac 100644 --- a/apps/api/src/app/credentials/credentials.service.test.ts +++ b/apps/api/src/app/credentials/credentials.service.test.ts @@ -11,22 +11,27 @@ import { OAuthAccount } from "./entities/credentials-account.entity" import { CredentialsService } from "./credentials.service" import { AdminsModule } from "../admins/admins.module" -jest.mock("@bandada/utils", () => ({ - __esModule: true, - getBandadaContract: () => ({ - updateGroups: () => ({ - status: true, - logs: ["1"] +jest.mock("@bandada/utils", () => { + const originalModule = jest.requireActual("@bandada/utils") + + return { + __esModule: true, + ...originalModule, + getBandadaContract: () => ({ + updateGroups: () => ({ + status: true, + logs: ["1"] + }), + getGroups: () => [] }), - getGroups: () => [] - }), - blockchainCredentialSupportedNetworks: [ - { - id: "sepolia", - name: "Sepolia" - } - ] -})) + blockchainCredentialSupportedNetworks: [ + { + id: "sepolia", + name: "Sepolia" + } + ] + } +}) jest.mock("@bandada/credentials", () => ({ __esModule: true, diff --git a/apps/api/src/app/groups/groups.service.test.ts b/apps/api/src/app/groups/groups.service.test.ts index dd7beb86..ea8db959 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -14,17 +14,22 @@ import { Admin } from "../admins/entities/admin.entity" import { CreateGroupDto } from "./dto/create-group.dto" import { UpdateGroupDto } from "./dto/update-group.dto" -jest.mock("@bandada/utils", () => ({ - __esModule: true, - getBandadaContract: () => ({ - updateGroups: jest.fn(() => ({ - status: true, - logs: ["1"] - })), - getGroups: jest.fn(() => []), - updateFingerprintDuration: jest.fn(() => null) - }) -})) +jest.mock("@bandada/utils", () => { + const originalModule = jest.requireActual("@bandada/utils") + + return { + __esModule: true, + ...originalModule, + getBandadaContract: () => ({ + updateGroups: jest.fn(() => ({ + status: true, + logs: ["1"] + })), + getGroups: jest.fn(() => []), + updateFingerprintDuration: jest.fn(() => null) + }) + } +}) describe("GroupsService", () => { let groupsService: GroupsService @@ -400,10 +405,10 @@ describe("GroupsService", () => { address: "0x" }) - apiKey = await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Generate - }) + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) admin = await adminsService.findOne({ id: admin.id }) }) @@ -501,10 +506,7 @@ describe("GroupsService", () => { }) it("Should not create a group if the API key is disabled for the admin", async () => { - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Disable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) const fun = groupsService.createGroupWithAPIKey( groupDto, @@ -518,10 +520,7 @@ describe("GroupsService", () => { }) it("Should not remove a group if the API key is disabled for the admin", async () => { - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Enable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) const group = await groupsService.createGroupWithAPIKey( groupDto, @@ -529,10 +528,7 @@ describe("GroupsService", () => { apiKey ) - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Disable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) const fun = groupsService.removeGroupWithAPIKey( group.id, @@ -546,10 +542,7 @@ describe("GroupsService", () => { }) it("Should not remove a group if the given id does not belong to the group admin", async () => { - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Enable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) const group = await groupsService.createGroupWithAPIKey( groupDto, @@ -562,10 +555,10 @@ describe("GroupsService", () => { address: "0x02" }) - const anotherApiKey = await adminsService.updateApiKey({ - adminId: anotherAdmin.id, - action: ApiKeyActions.Generate - }) + const anotherApiKey = await adminsService.updateApiKey( + anotherAdmin.id, + ApiKeyActions.Generate + ) anotherAdmin = await adminsService.findOne({ id: anotherAdmin.id }) @@ -615,10 +608,10 @@ describe("GroupsService", () => { address: "0x" }) - apiKey = await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Generate - }) + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) admin = await adminsService.findOne({ id: admin.id }) }) @@ -726,10 +719,7 @@ describe("GroupsService", () => { }) it("Should not create the groups if the API key is disabled for the admin", async () => { - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Disable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) const fun = groupsService.createGroupsWithAPIKey( groupsDtos, @@ -760,10 +750,10 @@ describe("GroupsService", () => { address: "0x02" }) - const anotherApiKey = await adminsService.updateApiKey({ - adminId: anotherAdmin.id, - action: ApiKeyActions.Generate - }) + const anotherApiKey = await adminsService.updateApiKey( + anotherAdmin.id, + ApiKeyActions.Generate + ) anotherAdmin = await adminsService.findOne({ id: anotherAdmin.id }) @@ -803,11 +793,10 @@ describe("GroupsService", () => { address: "0x" }) - apiKey = await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Generate - }) - + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) admin = await adminsService.findOne({ id: admin.id }) group = await groupsService.createGroup(groupDto, admin.id) }) @@ -872,10 +861,7 @@ describe("GroupsService", () => { }) it("Should not update a group if the API key is disabled", async () => { - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Disable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) const fun = groupsService.updateGroupWithApiKey( groupId, @@ -901,10 +887,10 @@ describe("GroupsService", () => { address: "0x" }) - apiKey = await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Generate - }) + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) group = await groupsService.createGroup( { @@ -1014,10 +1000,7 @@ describe("GroupsService", () => { }) it("Should not add a member to an existing group if API is disabled", async () => { - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Disable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) const fun = groupsService.addMemberWithAPIKey( group.id, @@ -1054,10 +1037,10 @@ describe("GroupsService", () => { address: "0x" }) - apiKey = await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Generate - }) + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) group = await groupsService.createGroup( { @@ -1173,10 +1156,7 @@ describe("GroupsService", () => { }) it("Should not add a member to an existing group if API is disabled", async () => { - await adminsService.updateApiKey({ - adminId: admin.id, - action: ApiKeyActions.Disable - }) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) const fun = groupsService.addMembersWithAPIKey( group.id, diff --git a/apps/api/src/app/invites/invites.service.test.ts b/apps/api/src/app/invites/invites.service.test.ts index 26ab5167..8aa4c45b 100644 --- a/apps/api/src/app/invites/invites.service.test.ts +++ b/apps/api/src/app/invites/invites.service.test.ts @@ -9,16 +9,21 @@ import { Invite } from "./entities/invite.entity" import { InvitesService } from "./invites.service" import { AdminsModule } from "../admins/admins.module" -jest.mock("@bandada/utils", () => ({ - __esModule: true, - getBandadaContract: () => ({ - updateGroups: jest.fn(() => ({ - status: true, - logs: ["1"] - })), - getGroups: jest.fn(() => []) - }) -})) +jest.mock("@bandada/utils", () => { + const originalModule = jest.requireActual("@bandada/utils") + + return { + __esModule: true, + ...originalModule, + getBandadaContract: () => ({ + updateGroups: jest.fn(() => ({ + status: true, + logs: ["1"] + })), + getGroups: jest.fn(() => []) + }) + } +}) describe("InvitesService", () => { let invitesService: InvitesService diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index fd919137..a1881042 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -10,7 +10,12 @@ import request from "./request" import shortenAddress from "./shortenAddress" import { blockchainCredentialSupportedNetworks } from "./getSupportedNetworks" -export * from "./types/index" +export { + ApiKeyActions, + Network, + ContractName, + OnchainBandadaGroup +} from "./types/index" export { request, shortenAddress, From 011f7b9fd5285022b68d97ab88db21f0a467972e Mon Sep 17 00:00:00 2001 From: Jeeiii Date: Wed, 27 Mar 2024 16:31:27 +0100 Subject: [PATCH 09/11] feat: add UI components to support admin apikey interactions --- apps/dashboard/package.json | 1 + apps/dashboard/src/components/api-key.tsx | 188 ++++++++++++++++++++++ apps/dashboard/src/pages/groups.tsx | 3 + apps/dashboard/src/types/Admin.ts | 4 +- yarn.lock | 25 +++ 5 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/components/api-key.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index d367d085..dbf47777 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -10,6 +10,7 @@ "dependencies": { "@bandada/credentials": "2.1.1", "@bandada/utils": "2.1.1", + "@chakra-ui/icons": "^2.1.1", "@chakra-ui/react": "^2.5.1", "@chakra-ui/styled-system": "^2.0.0", "@chakra-ui/theme-tools": "^2.0.16", diff --git a/apps/dashboard/src/components/api-key.tsx b/apps/dashboard/src/components/api-key.tsx new file mode 100644 index 00000000..0fc841ae --- /dev/null +++ b/apps/dashboard/src/components/api-key.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from "react" +import { + Box, + Flex, + Switch, + useClipboard, + useToast, + IconButton, + Tooltip, + Text +} from "@chakra-ui/react" +import { ViewIcon, CopyIcon, RepeatIcon, CheckIcon } from "@chakra-ui/icons" +import { ApiKeyActions } from "@bandada/utils" +import { Admin } from "../types" +import { getAdmin, updateApiKey } from "../api/bandadaAPI" + +export default function ApiKeyComponent({ + adminId +}: { + adminId: string +}): JSX.Element { + const [admin, setAdmin] = useState() + const [apiKey, setApiKey] = useState("") + const [isEnabled, setIsEnabled] = useState(false) + const [isCopied, setIsCopied] = useState(false) + const { onCopy } = useClipboard(apiKey) + const toast = useToast() + + useEffect(() => { + getAdmin(adminId).then((admin) => { + if (admin) { + setAdmin(admin) + setApiKey(!admin.apiKey ? "" : admin.apiKey) + setIsEnabled(admin.apiEnabled) + } + }) + }) + + useEffect(() => { + if (isCopied) { + const timer = setTimeout(() => { + setIsCopied(false) + }, 2000) + return () => clearTimeout(timer) + } + }, [isCopied]) + + const showToast = ( + title: string, + description: string, + status: "info" | "warning" | "success" | "error", + duration = 2000, + position: "top" | "bottom" = "top" + ) => { + toast({ + title, + description, + status, + duration, + isClosable: true, + position + }) + } + + const handleCopy = () => { + onCopy() + setIsCopied(true) + } + + const handleRefresh = async () => { + if (admin) { + const newApiKey = await updateApiKey( + admin.id, + ApiKeyActions.Generate + ) + if (!newApiKey) { + showToast( + "Something went wrong", + "API Key has not been refreshed", + "error" + ) + } else { + showToast( + "API Key refresh", + "Successfully refreshed", + "success" + ) + setApiKey(newApiKey) + } + } + } + + const showApiKey = () => { + if (admin && admin.apiKey) { + showToast( + "API Key", + `Your API key is: ${admin.apiKey}`, + "info", + 2500, + "top" + ) + } + } + + const toggleIsEnabled = async () => { + if (admin) { + let toastTitle = "" + let toastDescription = "" + let action = ApiKeyActions.Enable + + if (!admin.apiKey) { + await updateApiKey(admin.id, ApiKeyActions.Generate) + toastTitle = "API Key Generated" + toastDescription = "A new API key has been generated." + } else { + action = isEnabled + ? ApiKeyActions.Disable + : ApiKeyActions.Enable + await updateApiKey(admin.id, action) + toastTitle = + action === ApiKeyActions.Enable + ? "API Key Enabled" + : "API Key Disabled" + toastDescription = + action === ApiKeyActions.Enable + ? "API key has been enabled." + : "API key has been disabled." + } + + showToast(toastTitle, toastDescription, "success") + setIsEnabled((prevState) => !prevState) + } + } + + return ( + + + + + API Key + + + + + {isEnabled && ( + + + } + onClick={showApiKey} + aria-label="View API Key" + /> + + : } + onClick={handleCopy} + ml={2} + aria-label="Copy API Key" + isDisabled={!isEnabled} + /> + } + onClick={handleRefresh} + ml={2} + aria-label="Refresh API Key" + isDisabled={!isEnabled} + /> + + )} + + + ) +} diff --git a/apps/dashboard/src/pages/groups.tsx b/apps/dashboard/src/pages/groups.tsx index 1f810443..877105d2 100644 --- a/apps/dashboard/src/pages/groups.tsx +++ b/apps/dashboard/src/pages/groups.tsx @@ -27,6 +27,7 @@ import GroupCard from "../components/group-card" import { AuthContext } from "../context/auth-context" import { Group } from "../types" import GoerliGroupCard from "../components/goerli-group" +import ApiKeyComponent from "../components/api-key" export default function GroupsPage(): JSX.Element { const { admin } = useContext(AuthContext) @@ -115,6 +116,8 @@ export default function GroupsPage(): JSX.Element { + +