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/apps/api/src/app/admins/admins.controller.ts b/apps/api/src/app/admins/admins.controller.ts new file mode 100644 index 00000000..f8435725 --- /dev/null +++ b/apps/api/src/app/admins/admins.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common" +import { ApiCreatedResponse } from "@nestjs/swagger" +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 { + constructor(private readonly adminsService: AdminsService) {} + + @Post() + async createAdmin(@Body() dto: CreateAdminDTO): Promise { + return this.adminsService.create(dto) + } + + @Get(":admin") + @ApiCreatedResponse({ type: Admin }) + async getAdmin(@Param("admin") adminId: string) { + return this.adminsService.findOne({ id: adminId }) + } + + @Put(":admin/apikey") + async updateApiKey( + @Param("admin") adminId: string, + @Body() dto: UpdateApiKeyDTO + ): Promise { + return this.adminsService.updateApiKey(adminId, 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..ca92c669 --- /dev/null +++ b/apps/api/src/app/admins/admins.service.test.ts @@ -0,0 +1,175 @@ +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" + +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( + admin.id, + 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( + admin.id, + 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(hashedId, 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(hashedId, 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( + wrongId, + 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( + tempAdmin.id, + 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( + hashedId, + // @ts-ignore + 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..3d5fc306 100644 --- a/apps/api/src/app/admins/admins.service.ts +++ b/apps/api/src/app/admins/admins.service.ts @@ -1,13 +1,15 @@ /* 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 { ApiKeyActions } from "@bandada/utils" import { CreateAdminDTO } from "./dto/create-admin.dto" import { Admin } from "./entities/admin.entity" @Injectable() -export class AdminService { +export class AdminsService { constructor( @InjectRepository(Admin) private readonly adminRepository: Repository @@ -29,4 +31,54 @@ export class AdminService { ): Promise { return this.adminRepository.findOneBy(payload) } + + /** + * Updates the API key for a given admin based on the specified actions. + * + * @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: string, + action: ApiKeyActions + ): 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..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/admins/entities/admin.entity.ts b/apps/api/src/app/admins/entities/admin.entity.ts index 9eeeaec8..732c134b 100644 --- a/apps/api/src/app/admins/entities/admin.entity.ts +++ b/apps/api/src/app/admins/entities/admin.entity.ts @@ -1,6 +1,14 @@ -import { Column, CreateDateColumn, Entity, PrimaryColumn } from "typeorm" +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn +} from "typeorm" @Entity("admins") +@Index(["apiKey"], { unique: true }) export class Admin { @PrimaryColumn({ unique: true }) id: string @@ -12,6 +20,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..14163aac 100644 --- a/apps/api/src/app/credentials/credentials.service.test.ts +++ b/apps/api/src/app/credentials/credentials.service.test.ts @@ -9,23 +9,29 @@ 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" - -jest.mock("@bandada/utils", () => ({ - __esModule: true, - getBandadaContract: () => ({ - updateGroups: () => ({ - status: true, - logs: ["1"] +import { AdminsModule } from "../admins/admins.module" + +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, @@ -59,7 +65,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..0148913b 100644 --- a/apps/api/src/app/groups/groups.controller.ts +++ b/apps/api/src/app/groups/groups.controller.ts @@ -15,13 +15,11 @@ import { import { ApiBody, ApiCreatedResponse, - ApiExcludeEndpoint, ApiHeader, ApiOperation, 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,70 +54,189 @@ 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 - ) + return mapGroupToResponseDTO(group, fingerprint) } @Post() @UseGuards(AuthGuard) - @ApiExcludeEndpoint() - async createGroup(@Req() req: Request, @Body() dto: CreateGroupDto) { - const group = await this.groupsService.createGroup( - dto, - req.session.adminId - ) - const fingerprint = await this.groupsService.getFingerprint(group.id) + @ApiBody({ required: false, type: Array }) + @ApiHeader({ name: "x-api-key", required: false }) + @ApiCreatedResponse({ type: Group }) + @ApiOperation({ + description: "Create one or more groups using an API Key or manually." + }) + async createGroups( + @Body() dtos: Array, + @Headers() headers: Headers, + @Req() req: Request + ) { + let groups = [] + const groupsToResponseDTO = [] - return mapGroupToResponseDTO( - group, - fingerprint, - req.session.adminId === group.adminId - ) + const apiKey = headers["x-api-key"] as string + + if (apiKey) { + groups = await this.groupsService.createGroupsWithAPIKey( + dtos, + apiKey + ) + } + + if (req.session.adminId) { + groups = await this.groupsService.createGroupsManually( + dtos, + req.session.adminId + ) + } + + for await (const group of groups) { + const fingerprint = await this.groupsService.getFingerprint( + group.id + ) + + groupsToResponseDTO.push(mapGroupToResponseDTO(group, fingerprint)) + } + + return groupsToResponseDTO + } + + @Delete() + @UseGuards(AuthGuard) + @ApiBody({ required: false, type: Array }) + @ApiHeader({ name: "x-api-key", required: false }) + @ApiOperation({ + description: "Remove one or more groups using an API Key or manually." + }) + async removeGroups( + @Body() groupsIds: Array, + @Headers() headers: Headers, + @Req() req: Request + ) { + const apiKey = headers["x-api-key"] as string + + if (apiKey) { + await this.groupsService.removeGroupsWithAPIKey(groupsIds, apiKey) + } + + if (req.session.adminId) { + await this.groupsService.removeGroupsManually( + groupsIds, + req.session.adminId + ) + } } @Delete(":group") @UseGuards(AuthGuard) - @ApiExcludeEndpoint() - async removeGroup(@Req() req: Request, @Param("group") groupId: string) { - await this.groupsService.removeGroup(groupId, req.session.adminId) + @ApiHeader({ name: "x-api-key", required: false }) + @ApiOperation({ + description: "Remove a specific group using an API Key or manually" + }) + async removeGroup( + @Param("group") groupId: string, + @Headers() headers: Headers, + @Req() req: Request + ) { + const apiKey = headers["x-api-key"] as string + + if (apiKey) { + await this.groupsService.removeGroupWithAPIKey(groupId, apiKey) + } + + if (req.session.adminId) { + await this.groupsService.removeGroupManually( + groupId, + req.session.adminId + ) + } + } + + @Patch() + @UseGuards(AuthGuard) + @ApiBody({ required: false, type: Array }) + @ApiCreatedResponse({ type: Array }) + @ApiHeader({ name: "x-api-key", required: false }) + @ApiOperation({ + description: "Update one or more groups using an API Key or manually." + }) + async updateGroups( + @Headers() headers: Headers, + @Body() groupsIds: Array, + @Body() dtos: Array, + @Req() req: Request + ) { + let groups = [] + const groupsToResponseDTO = [] + + const apiKey = headers["x-api-key"] as string + + if (apiKey) { + groups = await this.groupsService.updateGroupsWithApiKey( + groupsIds, + dtos, + apiKey + ) + } + + if (req.session.adminId) { + groups = await this.groupsService.updateGroupsManually( + groupsIds, + dtos, + req.session.adminId + ) + } + + for await (const group of groups) { + const fingerprint = await this.groupsService.getFingerprint( + group.id + ) + + groupsToResponseDTO.push(mapGroupToResponseDTO(group, fingerprint)) + } + + return groupsToResponseDTO } @Patch(":group") @UseGuards(AuthGuard) - @ApiExcludeEndpoint() + @ApiHeader({ name: "x-api-key", required: false }) + @ApiBody({ required: false, type: UpdateGroupDto }) + @ApiCreatedResponse({ type: Group }) + @ApiOperation({ + description: "Update a specific group using an API Key or manually." + }) async updateGroup( - @Req() req: Request, @Param("group") groupId: string, - @Body() dto: UpdateGroupDto + @Headers() headers: Headers, + @Body() dto: UpdateGroupDto, + @Req() req: Request ) { - const group = await this.groupsService.updateGroup( - groupId, - dto, - req.session.adminId - ) + let group: any + const apiKey = headers["x-api-key"] as string - const fingerprint = await this.groupsService.getFingerprint(groupId) + if (apiKey) { + group = await this.groupsService.updateGroupWithApiKey( + groupId, + dto, + apiKey + ) + } - return mapGroupToResponseDTO( - group, - fingerprint, - req.session.adminId === group.adminId - ) - } + if (req.session.adminId) { + group = await this.groupsService.updateGroupManually( + groupId, + dto, + req.session.adminId + ) + } - @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) + const fingerprint = await this.groupsService.getFingerprint(groupId) + + return mapGroupToResponseDTO(group, fingerprint) } @Get(":group/members/:member") 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..3e2f11fb 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -1,28 +1,40 @@ 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" import { Group } from "./entities/group.entity" import { Member } from "./entities/member.entity" import { GroupsService } from "./groups.service" - -jest.mock("@bandada/utils", () => ({ - __esModule: true, - getBandadaContract: () => ({ - updateGroups: jest.fn(() => ({ - status: true, - logs: ["1"] - })), - getGroups: jest.fn(() => []), - updateFingerprintDuration: jest.fn(() => null) - }) -})) +import { AdminsService } from "../admins/admins.service" +import { AdminsModule } from "../admins/admins.module" +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", () => { + 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 let invitesService: InvitesService + let adminsService: AdminsService let groupId: string beforeAll(async () => { @@ -33,18 +45,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 +239,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 @@ -423,268 +389,923 @@ describe("GroupsService", () => { }) }) - describe("# Add and remove member via API", () => { - let group: Group + 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 () => { - group = await groupsService.createGroup( - { - name: "Group2", - description: "This is a new group", - treeDepth: 16, - fingerprintDuration: 3600 - }, - "admin" - ) + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate ) - 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 () => { - const { members } = await groupsService.addMemberWithAPIKey( - group.id, - "123123", + it("Should create a group via API", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, apiKey ) - expect(members).toHaveLength(1) + 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 not add a member if they already exist", async () => { - const fun = groupsService.addMemberWithAPIKey( - group.id, - "123123", + it("Should remove a group via API", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, apiKey ) + await groupsService.removeGroupWithAPIKey(group.id, apiKey) + + const fun = groupsService.getGroup(group.id) + await expect(fun).rejects.toThrow( - `Member '123123' already exists in the group '${group.id}'` + `Group with id '${group.id}' does not exist` ) }) - it("Should remove a member from an existing group via API", async () => { - await groupsService.addMemberWithAPIKey(group.id, "100001", apiKey) + it("Should not create a group if the admin does not exist", async () => { + const fun = groupsService.createGroupWithAPIKey(groupDto, "wrong") - const { members } = await groupsService.removeMemberWithAPIKey( - group.id, - "100001", + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should not remove a group if the admin does not exist", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, apiKey ) - expect(members.map((m) => m.id)).not.toContain("100001") + const fun = groupsService.removeGroupWithAPIKey(group.id, "wrong") + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) }) - it("Should not remove a member if they does not exist", async () => { - const fun = groupsService.removeMemberWithAPIKey( - group.id, - "100001", + it("Should not create a group if the API key is invalid", async () => { + const fun = groupsService.createGroupWithAPIKey(groupDto, "apiKey") + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should not remove a group if the API key is invalid", async () => { + const group = await groupsService.createGroupWithAPIKey( + groupDto, apiKey ) + const fun = groupsService.removeGroupWithAPIKey(group.id, "wrong") + await expect(fun).rejects.toThrow( - `Member '100001' is not a member of group '${group.id}'` + `Invalid API key or invalid admin for the groups` ) }) - it("Should not add a member to an existing group if API is disabled", async () => { - await groupsService.updateGroup( - group.id, - { apiEnabled: false }, - "admin" + it("Should not create a group if the API key is disabled for the admin", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.createGroupWithAPIKey(groupDto, apiKey) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` ) + }) - const fun = groupsService.addMemberWithAPIKey( - groupId, - "100002", + it("Should not remove a group if the API key is disabled for the admin", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) + + const group = await groupsService.createGroupWithAPIKey( + groupDto, apiKey ) + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.removeGroupWithAPIKey(group.id, apiKey) + 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 '${admin.id}'` ) }) - it("Should not remove a member to an existing group if API is disabled", async () => { - const fun = groupsService.removeMemberWithAPIKey( - groupId, - "100001", + it("Should not remove a group if the given id does not belong to the group admin", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) + + const group = await groupsService.createGroupWithAPIKey( + groupDto, apiKey ) + let anotherAdmin = await adminsService.create({ + id: "admin2", + address: "0x02" + }) + + const anotherApiKey = await adminsService.updateApiKey( + anotherAdmin.id, + ApiKeyActions.Generate + ) + + anotherAdmin = await adminsService.findOne({ id: anotherAdmin.id }) + + const fun = groupsService.removeGroupWithAPIKey( + group.id, + anotherApiKey + ) + await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `You are not the admin of the group '${group.id}'` ) }) }) - describe("# Add and remove members via API", () => { - let group: Group + 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 () => { - group = await groupsService.createGroup( - { - name: "Group2", - description: "This is a new group", - treeDepth: 16, - fingerprintDuration: 3600 - }, - "admin" - ) + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate ) - 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 () => { - const { members } = await groupsService.addMembersWithAPIKey( - group.id, - ["123123", "456456", "789789"], + it("Should create the groups via API", async () => { + const groups = await groupsService.createGroupsWithAPIKey( + groupsDtos, apiKey ) - expect(members).toHaveLength(3) + 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 not add a member if they already exist", async () => { - const fun = groupsService.addMembersWithAPIKey( - group.id, - ["123123", "456456", "789789"], - apiKey - ) + 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]], 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( - `Member '123123' already exists in the group '${group.id}'` + `Group with id '${ids[1]}' does not exist` ) }) - it("Should remove members from an existing group via API", async () => { - await groupsService.addMembersWithAPIKey( - group.id, - ["100001", "100002", "100003"], - apiKey + it("Should not create the groups if the admin does not exist", async () => { + const fun = groupsService.createGroupsWithAPIKey( + groupsDtos, + "wrong" ) - const { members } = await groupsService.removeMembersWithAPIKey( - group.id, - ["100001", "100002", "100003"], - apiKey + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` ) + }) - expect(members.map((m) => m.id)).not.toContain("100001") - expect(members.map((m) => m.id)).not.toContain("100002") - expect(members.map((m) => m.id)).not.toContain("100003") + it("Should not remove the groups if the admin does not exist", async () => { + const fun = groupsService.removeGroupsWithAPIKey(ids, "wrong") + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) }) - it("Should not remove a member if they does not exist", async () => { - const fun = groupsService.removeMembersWithAPIKey( - group.id, - ["100001"], - apiKey + it("Should not create the groups if the API key is invalid", async () => { + const fun = groupsService.createGroupsWithAPIKey( + groupsDtos, + "wrong" ) await expect(fun).rejects.toThrow( - `Member '100001' is not a member of group '${group.id}'` + `Invalid API key or invalid admin for the groups` ) }) - it("Should not add a member to an existing group if API is disabled", async () => { - await groupsService.updateGroup( - group.id, - { apiEnabled: false }, - "admin" + it("Should not remove the groups if the API key is invalid", async () => { + const fun = groupsService.removeGroupsWithAPIKey(ids, "wrong") + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` ) + }) - const fun = groupsService.addMembersWithAPIKey( - groupId, - ["100002"], - apiKey + it("Should not create the groups if the API key is disabled for the admin", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.createGroupsWithAPIKey(groupsDtos, 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, apiKey) 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 '${admin.id}'` ) }) - it("Should not remove a member to an existing group if API is disabled", async () => { - const fun = groupsService.removeMembersWithAPIKey( - groupId, - ["100001"], - apiKey + 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( + anotherAdmin.id, + ApiKeyActions.Generate + ) + + anotherAdmin = await adminsService.findOne({ id: anotherAdmin.id }) + + const fun = groupsService.removeGroupsWithAPIKey( + [ids[2]], + anotherApiKey ) await expect(fun).rejects.toThrow( - "Invalid API key or API access not enabled for group" + `You are not the admin of the group '${ids[2]}'` ) }) }) - describe("# addMemberManually", () => { + 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 () => { - group = await groupsService.createGroup( - { - name: "Group2", - description: "This is a new group", - treeDepth: 16, - fingerprintDuration: 3600 - }, - "admin" + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate ) + admin = await adminsService.findOne({ id: admin.id }) + group = await groupsService.createGroup(groupDto, admin.id) }) - it("Should add a member to an existing group manually", async () => { - const { members } = await groupsService.addMemberManually( + it("Should update the group via API", async () => { + const updatedGroup = await groupsService.updateGroupWithApiKey( group.id, - "123123", - "admin" + updateDto, + apiKey ) - expect(members).toHaveLength(1) + 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 add a member if they already exists", async () => { - const fun = groupsService.addMemberManually( - group.id, - "123123", - "admin" + it("Should not update a group if the admin is the wrong one", async () => { + const fun = groupsService.updateGroupWithApiKey( + groupId, + groupDto, + apiKey ) await expect(fun).rejects.toThrow( - `Member '123123' already exists in the group '${group.id}'` + `You are not the admin of the group '${groupId}'` ) }) - it("Should not add a member if the admin is the wrong admin", async () => { - const fun = groupsService.addMemberManually( - group.id, - "123123", - "wrong-admin" + it("Should not update a group if the group does not exist", async () => { + const fun = groupsService.updateGroupWithApiKey( + "wrong", + groupDto, + apiKey ) - await expect(fun).rejects.toThrow("You are not the admin") + await expect(fun).rejects.toThrow( + `Group with id 'wrong' does not exist` + ) }) - }) - describe("# addMembersManually", () => { - let group: Group + it("Should not update a group if the API key is invalid", async () => { + const fun = groupsService.updateGroupWithApiKey( + groupId, + groupDto, + "invalid-apikey" + ) - beforeAll(async () => { - group = await groupsService.createGroup( - { + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should not update a group if the API key is disabled", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.updateGroupWithApiKey( + groupId, + groupDto, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + }) + + describe("# Update 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: 32, + fingerprintDuration: 7200 + } + ] + + const updateDtos: Array = [ + { + description: "This is a new new group1", + treeDepth: 32, + fingerprintDuration: 7200 + }, + { + description: "This is a new new group2", + treeDepth: 32, + fingerprintDuration: 14400 + } + ] + let admin: Admin + let apiKey: string + let groups: Array + let groupId1: string + let groupId2: string + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + admin = await adminsService.findOne({ id: admin.id }) + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) + groups = await groupsService.createGroupsManually( + groupsDtos, + admin.id + ) + groupId1 = groups[0].id + groupId2 = groups[1].id + }) + + it("Should update the groups via API", async () => { + const updatedGroups = await groupsService.updateGroupsWithApiKey( + [groupId1, groupId2], + updateDtos, + apiKey + ) + + updatedGroups.forEach((updatedGroup: Group, i: number) => { + expect(updatedGroup.id).toBe(groupsDtos[i].id) + expect(updatedGroup.adminId).toBe(admin.id) + expect(updatedGroup.description).toBe(updateDtos[i].description) + expect(updatedGroup.name).toBe(groupsDtos[i].name) + expect(updatedGroup.treeDepth).toBe(updateDtos[i].treeDepth) + expect(updatedGroup.fingerprintDuration).toBe( + updateDtos[i].fingerprintDuration + ) + expect(updatedGroup.members).toHaveLength(0) + }) + }) + + it("Should not update the groups if the admin is the wrong one", async () => { + const fun = groupsService.updateGroupsWithApiKey( + [groupId1, groupId2], + groupsDtos, + "wrong" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should not update the groups if the group does not exist", async () => { + const fun = groupsService.updateGroupsWithApiKey( + ["wrong"], + groupsDtos, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Group with id 'wrong' does not exist` + ) + }) + + it("Should not update the groups if the API key is invalid", async () => { + const fun = groupsService.updateGroupsWithApiKey( + [groupId1, groupId2], + groupsDtos, + "invalid-apikey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should not update the groups if the API key is disabled", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.updateGroupsWithApiKey( + [groupId1, groupId2], + groupsDtos, + 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 + let apiKey: string + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) + + group = await groupsService.createGroup( + { + name: "Group2", + description: "This is a new group", + treeDepth: 16, + fingerprintDuration: 3600 + }, + admin.id + ) + + admin = await adminsService.findOne({ id: admin.id }) + }) + + it("Should add a member to an existing group via API", async () => { + const { members } = await groupsService.addMemberWithAPIKey( + group.id, + "123123", + apiKey + ) + + expect(members).toHaveLength(1) + }) + + it("Should not add a member if they already exist", async () => { + const fun = groupsService.addMemberWithAPIKey( + group.id, + "123123", + apiKey + ) + + await expect(fun).rejects.toThrow( + `Member '123123' already exists in the group '${group.id}'` + ) + }) + + it("Should remove a member from an existing group via API", async () => { + await groupsService.addMemberWithAPIKey(group.id, "100001", apiKey) + + const { members } = await groupsService.removeMemberWithAPIKey( + group.id, + "100001", + apiKey + ) + + expect(members.map((m) => m.id)).not.toContain("100001") + }) + + it("Should not remove a member if they does not exist", async () => { + const fun = groupsService.removeMemberWithAPIKey( + group.id, + "100001", + apiKey + ) + + await expect(fun).rejects.toThrow( + `Member '100001' is not a member of group '${group.id}'` + ) + }) + + 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, + "100002", + "apiKey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${group.adminId}'` + ) + }) + + 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 admin '${group.adminId}'` + ) + }) + + it("Should not add a member to an existing group if API is disabled", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.addMemberWithAPIKey( + group.id, + "100002", + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${group.adminId}'` + ) + }) + + it("Should not remove a member to an existing group if API is disabled", async () => { + const fun = groupsService.removeMemberWithAPIKey( + group.id, + "100001", + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${group.adminId}'` + ) + }) + }) + + 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( + admin.id, + ApiKeyActions.Generate + ) + + group = await groupsService.createGroup( + { + name: "Group2", + description: "This is a new group", + treeDepth: 16, + fingerprintDuration: 3600 + }, + admin.id + ) + + admin = await adminsService.findOne({ id: admin.id }) + }) + + it("Should add a member to an existing group via API", async () => { + const { members } = await groupsService.addMembersWithAPIKey( + group.id, + ["123123", "456456", "789789"], + apiKey + ) + + expect(members).toHaveLength(3) + }) + + it("Should not add a member if they already exist", async () => { + const fun = groupsService.addMembersWithAPIKey( + group.id, + ["123123", "456456", "789789"], + apiKey + ) + + await expect(fun).rejects.toThrow( + `Member '123123' already exists in the group '${group.id}'` + ) + }) + + it("Should remove members from an existing group via API", async () => { + await groupsService.addMembersWithAPIKey( + group.id, + ["100001", "100002", "100003"], + apiKey + ) + + const { members } = await groupsService.removeMembersWithAPIKey( + group.id, + ["100001", "100002", "100003"], + apiKey + ) + + expect(members.map((m) => m.id)).not.toContain("100001") + expect(members.map((m) => m.id)).not.toContain("100002") + expect(members.map((m) => m.id)).not.toContain("100003") + }) + + it("Should not remove a member if they does not exist", async () => { + const fun = groupsService.removeMembersWithAPIKey( + group.id, + ["100001"], + apiKey + ) + + await expect(fun).rejects.toThrow( + `Member '100001' is not a member of group '${group.id}'` + ) + }) + + 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, + ["100002"], + "apiKey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${group.adminId}'` + ) + }) + + 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 admin '${group.adminId}'` + ) + }) + + it("Should not add a member to an existing group if API is disabled", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.addMembersWithAPIKey( + group.id, + ["100002"], + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${group.adminId}'` + ) + }) + + it("Should not remove a member to an existing group if API is disabled", async () => { + const fun = groupsService.removeMembersWithAPIKey( + group.id, + ["100001"], + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${group.adminId}'` + ) + }) + }) + + describe("# addMemberManually", () => { + let group: Group + + beforeAll(async () => { + group = await groupsService.createGroup( + { + name: "Group2", + description: "This is a new group", + treeDepth: 16, + fingerprintDuration: 3600 + }, + "admin" + ) + }) + + it("Should add a member to an existing group manually", async () => { + const { members } = await groupsService.addMemberManually( + group.id, + "123123", + "admin" + ) + + expect(members).toHaveLength(1) + }) + + it("Should not add a member if they already exists", async () => { + const fun = groupsService.addMemberManually( + group.id, + "123123", + "admin" + ) + + await expect(fun).rejects.toThrow( + `Member '123123' already exists in the group '${group.id}'` + ) + }) + + it("Should not add a member if the admin is the wrong admin", async () => { + const fun = groupsService.addMemberManually( + group.id, + "123123", + "wrong-admin" + ) + + await expect(fun).rejects.toThrow("You are not the admin") + }) + }) + + describe("# addMembersManually", () => { + let group: Group + + beforeAll(async () => { + group = await groupsService.createGroup( + { name: "Group2", description: "This is a new group", treeDepth: 16, @@ -732,6 +1353,402 @@ describe("GroupsService", () => { }) }) + describe("# createGroupManually", () => { + const groupDto: CreateGroupDto = { + id: "1", + name: "Group1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600 + } + let admin: Admin + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + admin = await adminsService.findOne({ id: admin.id }) + }) + + it("Should create a group manually", async () => { + const group = await groupsService.createGroupManually( + groupDto, + admin.id + ) + + 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) + }) + + it("Should not create a group manually if the admin doesn't exist", async () => { + const fun = groupsService.createGroupManually(groupDto, "wrong") + + await expect(fun).rejects.toThrow(`You are not an admin`) + }) + }) + + describe("# createGroupsManually", () => { + 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: 32, + fingerprintDuration: 7200 + } + ] + let admin: Admin + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + admin = await adminsService.findOne({ id: admin.id }) + }) + + it("Should create a group manually", async () => { + const groups = await groupsService.createGroupsManually( + groupsDtos, + admin.id + ) + + 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) + }) + }) + + it("Should not create a group manually if the admin doesn't exist", async () => { + const fun = groupsService.createGroupsManually(groupsDtos, "wrong") + + await expect(fun).rejects.toThrow(`You are not an admin`) + }) + }) + + describe("# removeGroupManually", () => { + const groupDto: CreateGroupDto = { + id: "1", + name: "Group1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600 + } + let admin: Admin + let group: Group + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + admin = await adminsService.findOne({ id: admin.id }) + + group = await groupsService.createGroupManually(groupDto, admin.id) + }) + + it("Should remove a group manually", async () => { + await groupsService.removeGroupManually(group.id, admin.id) + + const fun = groupsService.getGroup(group.id) + + await expect(fun).rejects.toThrow( + `Group with id '${group.id}' does not exist` + ) + }) + + it("Should not remove a group manually if the group doesn't exist", async () => { + const fun = groupsService.removeGroupManually(group.id, admin.id) + + await expect(fun).rejects.toThrow( + `Group with id '${group.id}' does not exist` + ) + }) + + it("Should not remove a group manually if the admin doesn't exist or is not the admin of the group", async () => { + group = await groupsService.createGroupManually(groupDto, admin.id) + + const fun = groupsService.removeGroupManually(group.id, "wrong") + + await expect(fun).rejects.toThrow( + `You are not the admin of the group '${group.id}'` + ) + }) + }) + + describe("# removeGroupsManually", () => { + 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: 32, + fingerprintDuration: 7200 + } + ] + let admin: Admin + let groups: Array + let groupId1: string + let groupId2: string + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + admin = await adminsService.findOne({ id: admin.id }) + + groups = await groupsService.createGroupsManually( + groupsDtos, + admin.id + ) + + groupId1 = groups[0].id + groupId2 = groups[1].id + }) + + it("Should remove the groups manually", async () => { + await groupsService.removeGroupsManually( + [groupId1, groupId2], + admin.id + ) + + const fun1 = groupsService.getGroup(groupId1) + const fun2 = groupsService.getGroup(groupId2) + + await expect(fun1).rejects.toThrow( + `Group with id '${groupId1}' does not exist` + ) + await expect(fun2).rejects.toThrow( + `Group with id '${groupId2}' does not exist` + ) + }) + + it("Should not remove the groups manually if one or more group doesn't exist", async () => { + const fun = groupsService.removeGroupsManually( + [groupId1, groupId2], + admin.id + ) + + await expect(fun).rejects.toThrow( + `Group with id '${groupId1}' does not exist` + ) + }) + + it("Should not remove the groups manually if the admin doesn't exist or is not the admin of the group", async () => { + groups = await groupsService.createGroupsManually( + groupsDtos, + admin.id + ) + + const fun = groupsService.removeGroupsManually( + [groupId1, groupId2], + "wrong" + ) + + await expect(fun).rejects.toThrow( + `You are not the admin of the group '${groupId1}'` + ) + }) + }) + + describe("# updateGroupManually", () => { + 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 group: Group + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + admin = await adminsService.findOne({ id: admin.id }) + group = await groupsService.createGroupManually(groupDto, admin.id) + }) + + it("Should update a group manually", async () => { + const updatedGroup = await groupsService.updateGroupManually( + group.id, + updateDto, + admin.id + ) + + 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 manually if the group doesn't exist", async () => { + const fun = groupsService.updateGroupManually( + "wrong", + updateDto, + admin.id + ) + + await expect(fun).rejects.toThrow( + `Group with id 'wrong' does not exist` + ) + }) + + it("Should not update a group manually if the admin doesn't exist or is not the admin of the group", async () => { + const fun = groupsService.updateGroupManually( + group.id, + updateDto, + "wrong" + ) + + await expect(fun).rejects.toThrow( + `You are not the admin of the group '${group.id}'` + ) + }) + }) + + describe("# updateGroupsManually", () => { + 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: 32, + fingerprintDuration: 7200 + } + ] + + const updateDtos: Array = [ + { + description: "This is a new new group1", + treeDepth: 32, + fingerprintDuration: 7200 + }, + { + description: "This is a new new group2", + treeDepth: 32, + fingerprintDuration: 14400 + } + ] + let admin: Admin + let groups: Array + let groupId1: string + let groupId2: string + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + admin = await adminsService.findOne({ id: admin.id }) + groups = await groupsService.createGroupsManually( + groupsDtos, + admin.id + ) + groupId1 = groups[0].id + groupId2 = groups[1].id + }) + + it("Should update the groups manually", async () => { + const updatedGroups = await groupsService.updateGroupsManually( + [groupId1, groupId2], + updateDtos, + admin.id + ) + + updatedGroups.forEach((updatedGroup: Group, i: number) => { + expect(updatedGroup.id).toBe(groupsDtos[i].id) + expect(updatedGroup.adminId).toBe(admin.id) + expect(updatedGroup.description).toBe(updateDtos[i].description) + expect(updatedGroup.name).toBe(groupsDtos[i].name) + expect(updatedGroup.treeDepth).toBe(updateDtos[i].treeDepth) + expect(updatedGroup.fingerprintDuration).toBe( + updateDtos[i].fingerprintDuration + ) + expect(updatedGroup.members).toHaveLength(0) + }) + }) + + it("Should not update the groups manually if one or more groups doesn't exist", async () => { + const fun = groupsService.updateGroupsManually( + ["wrong"], + updateDtos, + admin.id + ) + + await expect(fun).rejects.toThrow( + `Group with id 'wrong' does not exist` + ) + }) + + it("Should not update the groups manually if the admin doesn't exist or is not the admin of the groups", async () => { + const fun = groupsService.updateGroupsManually( + [groupId1, groupId2], + updateDtos, + "wrong" + ) + + await expect(fun).rejects.toThrow( + `You are not the admin of the group '${groupId1}'` + ) + }) + }) + describe("# initialize", () => { it("Should initialize the cached groups", async () => { const currentCachedGroups = await groupsService.getGroups() diff --git a/apps/api/src/app/groups/groups.service.ts b/apps/api/src/app/groups/groups.service.ts index 274a130a..aedd530c 100644 --- a/apps/api/src/app/groups/groups.service.ts +++ b/apps/api/src/app/groups/groups.service.ts @@ -12,13 +12,14 @@ 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" import { Member } from "./entities/member.entity" import { MerkleProof } from "./types" +import { getAndCheckAdmin } from "./groups.utils" @Injectable() export class GroupsService { @@ -31,7 +32,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( @@ -56,6 +58,86 @@ export class GroupsService { // } } + /** + * Create a group using API Key. + * @param dto External parameters used to create a new group. + * @param apiKey The API Key. + * @returns Created group. + */ + async createGroupWithAPIKey( + dto: CreateGroupDto, + apiKey: string + ): Promise { + const groups = await this.createGroupsWithAPIKey([dto], apiKey) + + return groups.at(0) + } + + /** + * Create groups using API Key. + * @param dtos External parameters used to create new groups. + * @param apiKey The API Key. + * @returns Created groups. + */ + async createGroupsWithAPIKey( + dtos: Array, + apiKey: string + ): Promise> { + const newGroups: Array = [] + + const admin = await getAndCheckAdmin(this.adminsService, apiKey) + + for await (const dto of dtos) { + const group = await this.createGroup(dto, admin.id) + + newGroups.push(group) + } + + return newGroups + } + + /** + * Create group manually without using API Key. + * @param dto External parameters used to create a new group. + * @param adminId Admin id. + * @returns Created group. + */ + async createGroupManually( + dto: CreateGroupDto, + adminId: string + ): Promise { + const admin = await this.adminsService.findOne({ id: adminId }) + + if (!admin) throw new BadRequestException(`You are not an admin`) + + return this.createGroup(dto, adminId) + } + + /** + * Create groups manually without using API Key. + * @param dtos External parameters used to create new groups. + * @param adminId Admin id. + * @returns Created groups. + */ + async createGroupsManually( + dtos: Array, + adminId: string + ): Promise> { + const admin = await this.adminsService.findOne({ id: adminId }) + + if (!admin) throw new BadRequestException(`You are not an admin`) + + const newGroups: Array = [] + + 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. @@ -105,6 +187,59 @@ export class GroupsService { return group } + /** + * 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, + apiKey: string + ): Promise { + return this.removeGroupsWithAPIKey([groupId], apiKey) + } + + /** + * Remove groups using API Key. + * @param groupsIds Groups identifiers. + * @param apiKey the api key. + */ + async removeGroupsWithAPIKey( + groupsIds: Array, + apiKey: string + ): Promise { + const admin = await getAndCheckAdmin(this.adminsService, apiKey) + + for await (const groupId of groupsIds) { + await this.removeGroup(groupId, admin.id) + } + } + + /** + * Remove a group manually without using API Key. + * @param groupId Group id. + * @param adminId Admin id. + */ + async removeGroupManually(groupId: string, adminId: string): Promise { + return this.removeGroup(groupId, adminId) + } + + /** + * Remove groups manually without using API Key. + * @param groupsIds Groups identifiers. + * @param adminId Admin id. + */ + async removeGroupsManually( + groupsIds: Array, + adminId: string + ): Promise { + for await (const groupId of groupsIds) { + await this.removeGroup(groupId, adminId) + } + } + /** * Removes a group. * @param groupId Group id. @@ -113,6 +248,11 @@ export class GroupsService { async removeGroup(groupId: string, adminId: string): Promise { const group = await this.getGroup(groupId) + if (!group) + throw new BadRequestException( + `The group '${groupId}' does not exists` + ) + if (group.adminId !== adminId) { throw new UnauthorizedException( `You are not the admin of the group '${groupId}'` @@ -126,6 +266,86 @@ 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 apiKey the API Key. + * @returns Updated group. + */ + async updateGroupWithApiKey( + groupId: string, + dto: UpdateGroupDto, + apiKey: string + ): Promise { + const admin = await getAndCheckAdmin(this.adminsService, apiKey) + + return this.updateGroup(groupId, dto, admin.id) + } + + /** + * Update groups using API Key. + * @param groupsIds Groups ids. + * @param dtos External parameters used to update groups. + * @param apiKey the API Key. + * @returns Updated group. + */ + async updateGroupsWithApiKey( + groupsIds: Array, + dtos: Array, + apiKey: string + ): Promise> { + const updatedGroups: Array = [] + + const admin = await getAndCheckAdmin(this.adminsService, apiKey) + + for await (const [index, groupId] of groupsIds.entries()) { + const dto = dtos[index] + const group = await this.updateGroup(groupId, dto, admin.id) + updatedGroups.push(group) + } + + return updatedGroups + } + + /** + * Update a group manually without using API Key. + * @param groupId Group id. + * @param dto External parameters used to update a group. + * @param adminId Group admin id. + * @returns Updated group. + */ + async updateGroupManually( + groupId: string, + dto: UpdateGroupDto, + adminId: string + ): Promise { + return this.updateGroup(groupId, dto, adminId) + } + + /** + * Update groups manually without using API Key. + * @param groupsIds Groups ids. + * @param dtos External parameters used to update groups. + * @param adminId Group admin id. + * @returns Updated groups. + */ + async updateGroupsManually( + groupsIds: Array, + dtos: Array, + adminId: string + ): Promise> { + const updatedGroups: Array = [] + + for await (const [index, groupId] of groupsIds.entries()) { + const dto = dtos[index] + const group = await this.updateGroup(groupId, dto, adminId) + updatedGroups.push(group) + } + + return updatedGroups + } + /** * Updates some parameters of the group. * @param groupId Group id. @@ -138,7 +358,6 @@ export class GroupsService { { description, treeDepth, - apiEnabled, credentials, fingerprintDuration }: UpdateGroupDto, @@ -146,6 +365,11 @@ export class GroupsService { ): Promise { const group = await this.getGroup(groupId) + if (!group) + throw new BadRequestException( + `The group '${groupId}' does not exists` + ) + if (group.adminId !== adminId) { throw new UnauthorizedException( `You are not the admin of the group '${groupId}'` @@ -176,15 +400,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 +407,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,10 +504,17 @@ 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}'` + `Invalid API key or API access not enabled for admin '${admin.id}'` ) } @@ -540,10 +731,17 @@ 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}'` + `Invalid API key or API access not enabled for admin '${admin.id}'` ) } diff --git a/apps/api/src/app/groups/groups.utils.test.ts b/apps/api/src/app/groups/groups.utils.test.ts index d077138d..a764a298 100644 --- a/apps/api/src/app/groups/groups.utils.test.ts +++ b/apps/api/src/app/groups/groups.utils.test.ts @@ -1,6 +1,47 @@ -import { mapGroupToResponseDTO } from "./groups.utils" +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" +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 { mapGroupToResponseDTO, getAndCheckAdmin } from "./groups.utils" describe("Groups utils", () => { + let groupsService: GroupsService + let adminsService: AdminsService + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRootAsync({ + useFactory: () => ({ + type: "sqlite", + database: ":memory:", + dropSchema: true, + entities: [Group, Invite, Member, OAuthAccount, Admin], + synchronize: true + }) + }), + TypeOrmModule.forFeature([Group, Invite, Member, Admin]), + ScheduleModule.forRoot(), + AdminsModule + ], + providers: [GroupsService, InvitesService, AdminsService] + }).compile() + + groupsService = await module.resolve(GroupsService) + adminsService = await module.resolve(AdminsService) + + await groupsService.initialize() + }) + describe("# mapGroupToResponseDTO", () => { it("Should map the group data", async () => { const group = { @@ -23,21 +64,74 @@ 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 + it("Should map the fingerprint correctly", async () => { + const { fingerprint } = mapGroupToResponseDTO({} as any, "12345") + + expect(fingerprint).toBe("12345") + }) + }) + + describe("# getAndCheckAdmin", () => { + const groupId = "1" + let apiKey = "" + let admin: Admin = {} as any + + beforeAll(async () => { + admin = await adminsService.create({ + id: groupId, + address: "0x00" + }) + + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate ) - expect(apiEnabled).toBeTruthy() - expect(apiKey).toBe("123") + admin = await adminsService.findOne({ id: admin.id }) }) - it("Should map the fingerprint correctly", async () => { - const { fingerprint } = mapGroupToResponseDTO({} as any, "12345") + it("Should successfully check and return the admin", async () => { + const checkedAdmin = await getAndCheckAdmin(adminsService, apiKey) - expect(fingerprint).toBe("12345") + expect(checkedAdmin.id).toBe(admin.id) + expect(checkedAdmin.address).toBe(admin.address) + expect(checkedAdmin.apiKey).toBe(admin.apiKey) + expect(checkedAdmin.apiEnabled).toBe(admin.apiEnabled) + expect(checkedAdmin.username).toBe(admin.username) + }) + + it("Should throw if the API Key or admin is invalid", async () => { + const fun = getAndCheckAdmin(adminsService, "wrong") + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should throw if the API Key or admin is invalid (w/ group identifier)", async () => { + const fun = getAndCheckAdmin(adminsService, "wrong", groupId) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the group '${groupId}'` + ) + }) + + it("Should throw if the API Key is invalid or API access is disabled", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = getAndCheckAdmin(adminsService, apiKey) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + + it("Should throw if the API Key is invalid or API access is disabled (w/ group identifier)", async () => { + const fun = getAndCheckAdmin(adminsService, apiKey, groupId) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) }) }) }) diff --git a/apps/api/src/app/groups/groups.utils.ts b/apps/api/src/app/groups/groups.utils.ts index 28d9b567..2bc95e7c 100644 --- a/apps/api/src/app/groups/groups.utils.ts +++ b/apps/api/src/app/groups/groups.utils.ts @@ -1,10 +1,9 @@ +import { BadRequestException } from "@nestjs/common" import { Group } from "./entities/group.entity" +import { Admin } from "../admins/entities/admin.entity" +import { AdminsService } from "../admins/admins.service" -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,15 +14,32 @@ export function mapGroupToResponseDTO( fingerprintDuration: group.fingerprintDuration, createdAt: group.createdAt, members: (group.members || []).map((m) => m.id), - credentials: group.credentials, - apiKey: undefined, - apiEnabled: undefined + credentials: group.credentials } - if (includeAPIKey) { - dto.apiKey = group.apiKey - dto.apiEnabled = group.apiEnabled + return dto +} + +export async function getAndCheckAdmin( + adminService: AdminsService, + apiKey: string, + groupId?: string +): Promise { + const admin = await adminService.findOne({ apiKey }) + + if (!apiKey || !admin) { + throw new BadRequestException( + groupId + ? `Invalid API key or invalid admin for the group '${groupId}'` + : `Invalid API key or invalid admin for the groups` + ) } - return dto + if (!admin.apiEnabled || admin.apiKey !== apiKey) { + throw new BadRequestException( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + } + + return admin } 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..8aa4c45b 100644 --- a/apps/api/src/app/invites/invites.service.test.ts +++ b/apps/api/src/app/invites/invites.service.test.ts @@ -7,17 +7,23 @@ 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" - -jest.mock("@bandada/utils", () => ({ - __esModule: true, - getBandadaContract: () => ({ - updateGroups: jest.fn(() => ({ - status: true, - logs: ["1"] - })), - getGroups: jest.fn(() => []) - }) -})) +import { AdminsModule } from "../admins/admins.module" + +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 @@ -37,7 +43,8 @@ describe("InvitesService", () => { }) }), TypeOrmModule.forFeature([Group, Invite, Member]), - ScheduleModule.forRoot() + ScheduleModule.forRoot(), + AdminsModule ], providers: [GroupsService, InvitesService] }).compile() 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/api/bandadaAPI.ts b/apps/dashboard/src/api/bandadaAPI.ts index 9103961c..b5675e71 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 @@ -87,18 +87,20 @@ export async function createGroup( credentials?: any ): Promise { try { - const group = await request(`${API_URL}/groups`, { + const groups = await request(`${API_URL}/groups`, { method: "POST", - data: { - name, - description, - treeDepth, - fingerprintDuration, - credentials: JSON.stringify(credentials) - } + data: [ + { + name, + description, + treeDepth, + fingerprintDuration, + credentials: JSON.stringify(credentials) + } + ] }) - return { ...group, type: "off-chain" } + return { ...groups.at(0), type: "off-chain" } } catch (error: any) { console.error(error) createAlert(error.response.data.message) @@ -107,21 +109,38 @@ export async function createGroup( } /** - * It updates the detail of a group. - * @param group The group id. - * @param memberId The group member id. + * It creates a new admin. + * @param id The admin id. + * @param address The admin address. + * @returns The Admin. */ -export async function updateGroup( - groupId: string, - { apiEnabled }: { apiEnabled: boolean } -): Promise { +export async function createAdmin( + id: string, + address: string +): Promise { try { - const group = await request(`${API_URL}/groups/${groupId}`, { - method: "PATCH", - data: { apiEnabled } + return await request(`${API_URL}/admins`, { + method: "POST", + data: { + id, + address + } }) + } catch (error: any) { + console.error(error) + createAlert(error.response.data.message) + return null + } +} - return { ...group, type: "off-chain" } +/** + * 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) @@ -130,13 +149,20 @@ export async function updateGroup( } /** - * It generates a new API key. - * @param group The group id. + * 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 generateApiKey(groupId: string): Promise { +export async function updateApiKey( + adminId: string, + action: ApiKeyActions +): Promise { try { - return await request(`${API_URL}/groups/${groupId}/api-key`, { - method: "PATCH" + return await request(`${API_URL}/admins/${adminId}/apikey`, { + method: "PUT", + data: { + action + } }) } catch (error: any) { console.error(error) 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/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 } 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 && ( + +