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 0000000..b225eb0 --- /dev/null +++ b/apps/api/src/app/admins/admins.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Post, Put } from "@nestjs/common" +import { CreateAdminDTO } from "./dto/create-admin.dto" +import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" +import { AdminsService } from "./admins.service" +import { Admin } from "./entities/admin.entity" + +@Controller("admins") +export class AdminsController { + constructor(private readonly adminsService: AdminsService) {} + + @Post() + async createAdmin(@Body() dto: CreateAdminDTO): Promise { + return this.adminsService.create(dto) + } + + @Put("update-apikey") + async updateApiKey(@Body() dto: UpdateApiKeyDTO): Promise { + return this.adminsService.updateApiKey({ + adminId: dto.adminId, + action: dto.action + }) + } +} diff --git a/apps/api/src/app/admins/admins.module.ts b/apps/api/src/app/admins/admins.module.ts index 307365f..42dd63b 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 0000000..547d1ee --- /dev/null +++ b/apps/api/src/app/admins/admins.service.test.ts @@ -0,0 +1,181 @@ +import { id as idToHash } from "@ethersproject/hash" +import { ScheduleModule } from "@nestjs/schedule" +import { Test } from "@nestjs/testing" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AdminsService } from "./admins.service" +import { Admin } from "./entities/admin.entity" +import { ApiKeyActions } from "../../types" + +describe("AdminsService", () => { + const id = "1" + const hashedId = idToHash(id) + const address = "0x000000" + let admin: Admin + let adminsService: AdminsService + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRootAsync({ + useFactory: () => ({ + type: "sqlite", + database: ":memory:", + dropSchema: true, + entities: [Admin], + synchronize: true + }) + }), + TypeOrmModule.forFeature([Admin]), + ScheduleModule.forRoot() + ], + providers: [AdminsService] + }).compile() + adminsService = await module.resolve(AdminsService) + }) + + describe("# create", () => { + it("Should create an admin", async () => { + admin = await adminsService.create({ id, address }) + + expect(admin.id).toBe(idToHash(id)) + expect(admin.address).toBe(address) + expect(admin.username).toBe(address.slice(-5)) + expect(admin.apiEnabled).toBeFalsy() + expect(admin.apiKey).toBeNull() + }) + + it("Should create an admin given the username", async () => { + const id2 = "2" + const address2 = "0x000002" + const username = "admn2" + + const admin = await adminsService.create({ + id: id2, + address: address2, + username + }) + + expect(admin.id).toBe(idToHash(id2)) + expect(admin.address).toBe(address2) + expect(admin.username).toBe(username) + expect(admin.apiEnabled).toBeFalsy() + expect(admin.apiKey).toBeNull() + }) + }) + + describe("# findOne", () => { + it("Should return the admin given the identifier", async () => { + const found = await adminsService.findOne({ id: hashedId }) + + expect(found.id).toBe(admin.id) + expect(found.address).toBe(admin.address) + expect(found.username).toBe(admin.username) + expect(found.apiEnabled).toBeFalsy() + expect(found.apiKey).toBe(admin.apiKey) + }) + + it("Should return null if the given identifier does not belong to an admin", async () => { + expect(await adminsService.findOne({ id: "3" })).toBeNull() + }) + }) + + describe("# updateApiKey", () => { + it("Should create an apikey for the admin", async () => { + const apiKey = await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Generate + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeTruthy() + expect(admin.apiKey).toBe(apiKey) + }) + + it("Should generate another apikey for the admin", async () => { + const previousApiKey = admin.apiKey + + const apiKey = await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Generate + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeTruthy() + expect(admin.apiKey).toBe(apiKey) + expect(admin.apiKey).not.toBe(previousApiKey) + }) + + it("Should disable the apikey for the admin", async () => { + const { apiKey } = admin + + await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Disable + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeFalsy() + expect(admin.apiKey).toBe(apiKey) + }) + + it("Should enable the apikey for the admin", async () => { + const { apiKey } = admin + + await adminsService.updateApiKey({ + adminId: hashedId, + action: ApiKeyActions.Enable + }) + + admin = await adminsService.findOne({ id: hashedId }) + + expect(admin.apiEnabled).toBeTruthy() + expect(admin.apiKey).toBe(apiKey) + }) + + it("Should not create the apikey when the given id does not belog to an admin", async () => { + const wrongId = "wrongId" + + const fun = adminsService.updateApiKey({ + adminId: wrongId, + action: ApiKeyActions.Disable + }) + + await expect(fun).rejects.toThrow( + `The '${wrongId}' does not belong to an admin` + ) + }) + + it("Should not enable the apikey before creation", async () => { + const tempAdmin = await adminsService.create({ + id: "id2", + address: "address2" + }) + + const fun = adminsService.updateApiKey({ + adminId: tempAdmin.id, + action: ApiKeyActions.Enable + }) + + await expect(fun).rejects.toThrow( + `The '${tempAdmin.id}' does not have an apikey` + ) + }) + + it("Shoul throw if the action does not exist", async () => { + const wrongAction = "wrong-action" + + const fun = adminsService.updateApiKey({ + adminId: hashedId, + // @ts-ignore + action: wrongAction + }) + + await expect(fun).rejects.toThrow( + `Unsupported ${wrongAction} apikey` + ) + }) + }) +}) diff --git a/apps/api/src/app/admins/admins.service.ts b/apps/api/src/app/admins/admins.service.ts index 5dfb48d..08aed51 100644 --- a/apps/api/src/app/admins/admins.service.ts +++ b/apps/api/src/app/admins/admins.service.ts @@ -1,17 +1,24 @@ /* istanbul ignore file */ import { id } from "@ethersproject/hash" -import { Injectable } from "@nestjs/common" +import { + BadRequestException, + Injectable, + Logger +} from "@nestjs/common" import { InjectRepository } from "@nestjs/typeorm" import { FindOptionsWhere, Repository } from "typeorm" +import { v4 } from "uuid" import { CreateAdminDTO } from "./dto/create-admin.dto" import { Admin } from "./entities/admin.entity" +import { UpdateApiKeyDTO } from "./dto/update-apikey.dto" +import { ApiKeyActions } from "../../types" @Injectable() -export class AdminService { +export class AdminsService { constructor( @InjectRepository(Admin) private readonly adminRepository: Repository - ) {} + ) { } public async create(payload: CreateAdminDTO): Promise { const username = payload.username || payload.address.slice(-5) @@ -29,4 +36,50 @@ export class AdminService { ): Promise { return this.adminRepository.findOneBy(payload) } + + /** + * Updates the API key for a given admin based on the specified actions. + * + * @param {UpdateApiKeyDTO} updateApiKeyDTO The DTO containing the admin ID and the action to be performed. + * @returns {Promise} The API key of the admin after the update operation. If the API key is disabled, the return value might not be meaningful. + * @throws {BadRequestException} If the admin ID does not correspond to an existing admin, if the admin does not have an API key when trying to enable it, or if the action is unsupported. + */ + async updateApiKey({ adminId, action }: UpdateApiKeyDTO): Promise { + const admin = await this.findOne({ + id: adminId + }) + + if (!admin) { + throw new BadRequestException( + `The '${adminId}' does not belong to an admin` + ) + } + + switch (action) { + case ApiKeyActions.Generate: + admin.apiKey = v4() + admin.apiEnabled = true + break + case ApiKeyActions.Enable: + if (!admin.apiKey) + throw new BadRequestException( + `The '${adminId}' does not have an apikey` + ) + admin.apiEnabled = true + break + case ApiKeyActions.Disable: + admin.apiEnabled = false + break + default: + throw new BadRequestException(`Unsupported ${action} apikey`) + } + + await this.adminRepository.save(admin) + + Logger.log( + `AdminsService: admin '${admin.id}' api key have been updated` + ) + + return admin.apiKey + } } diff --git a/apps/api/src/app/admins/dto/update-apikey.dto.ts b/apps/api/src/app/admins/dto/update-apikey.dto.ts new file mode 100644 index 0000000..4999a3d --- /dev/null +++ b/apps/api/src/app/admins/dto/update-apikey.dto.ts @@ -0,0 +1,10 @@ +import { IsEnum, IsString } from "class-validator" +import { ApiKeyActions } from "../../../types" + +export class UpdateApiKeyDTO { + @IsString() + adminId: string + + @IsEnum(ApiKeyActions) + action: ApiKeyActions +} \ No newline at end of file diff --git a/apps/api/src/app/admins/entities/admin.entity.ts b/apps/api/src/app/admins/entities/admin.entity.ts index 9eeeaec..87bea3c 100644 --- a/apps/api/src/app/admins/entities/admin.entity.ts +++ b/apps/api/src/app/admins/entities/admin.entity.ts @@ -1,4 +1,10 @@ -import { Column, CreateDateColumn, Entity, PrimaryColumn } from "typeorm" +import { + Column, + CreateDateColumn, + Entity, + PrimaryColumn, + UpdateDateColumn +} from "typeorm" @Entity("admins") export class Admin { @@ -12,6 +18,15 @@ export class Admin { @Column({ unique: true }) username: string + @Column({ name: "api_key", nullable: true }) + apiKey: string + + @Column({ name: "api_enabled", default: false }) + apiEnabled: boolean + @CreateDateColumn({ name: "created_at" }) createdAt: Date + + @UpdateDateColumn({ name: "updated_at" }) + updatedAt: Date } diff --git a/apps/api/src/app/auth/auth.guard.ts b/apps/api/src/app/auth/auth.guard.ts index 9bf6776..32f06f7 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 69fcc03..22043e2 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 357b3c5..003737a 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 8182cd3..4b053f9 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 d48804d..dd76cb1 100644 --- a/apps/api/src/app/credentials/credentials.service.test.ts +++ b/apps/api/src/app/credentials/credentials.service.test.ts @@ -9,6 +9,7 @@ import { Invite } from "../invites/entities/invite.entity" import { InvitesService } from "../invites/invites.service" import { OAuthAccount } from "./entities/credentials-account.entity" import { CredentialsService } from "./credentials.service" +import { AdminsModule } from "../admins/admins.module" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -59,7 +60,8 @@ describe("CredentialsService", () => { }) }), TypeOrmModule.forFeature([Group, Invite, Member, OAuthAccount]), - ScheduleModule.forRoot() + ScheduleModule.forRoot(), + AdminsModule ], providers: [GroupsService, InvitesService, CredentialsService] }).compile() diff --git a/apps/api/src/app/groups/dto/update-group.dto.ts b/apps/api/src/app/groups/dto/update-group.dto.ts index 6cb2b66..97600b3 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 4746455..c390e29 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 5e8d9f8..c12c436 100644 --- a/apps/api/src/app/groups/groups.controller.ts +++ b/apps/api/src/app/groups/groups.controller.ts @@ -21,7 +21,6 @@ import { ApiQuery, ApiTags } from "@nestjs/swagger" -import { ThrottlerGuard } from "@nestjs/throttler" import { Request } from "express" import { AuthGuard } from "../auth/auth.guard" import { stringifyJSON } from "../utils" @@ -56,14 +55,15 @@ export class GroupsController { @Get(":group") @ApiOperation({ description: "Returns a specific group." }) @ApiCreatedResponse({ type: Group }) - async getGroup(@Param("group") groupId: string, @Req() req: Request) { + async getGroup( + @Param("group") groupId: string + ) { const group = await this.groupsService.getGroup(groupId) const fingerprint = await this.groupsService.getFingerprint(groupId) return mapGroupToResponseDTO( group, - fingerprint, - req.session.adminId === group.adminId + fingerprint ) } @@ -79,8 +79,7 @@ export class GroupsController { return mapGroupToResponseDTO( group, - fingerprint, - req.session.adminId === group.adminId + fingerprint ) } @@ -109,19 +108,10 @@ export class GroupsController { return mapGroupToResponseDTO( group, - fingerprint, - req.session.adminId === group.adminId + fingerprint ) } - @Patch(":group/api-key") - @UseGuards(AuthGuard) - @UseGuards(ThrottlerGuard) - @ApiExcludeEndpoint() - async updateApiKey(@Req() req: Request, @Param("group") groupId: string) { - return this.groupsService.updateApiKey(groupId, req.session.adminId) - } - @Get(":group/members/:member") @ApiOperation({ description: diff --git a/apps/api/src/app/groups/groups.module.ts b/apps/api/src/app/groups/groups.module.ts index f52c58e..7b0db2f 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 dc7f799..44b0977 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -7,6 +7,10 @@ import { OAuthAccount } from "../credentials/entities/credentials-account.entity import { Group } from "./entities/group.entity" import { Member } from "./entities/member.entity" import { GroupsService } from "./groups.service" +import { AdminsService } from "../admins/admins.service" +import { AdminsModule } from "../admins/admins.module" +import { Admin } from "../admins/entities/admin.entity" +import { ApiKeyActions } from "../../types" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -23,6 +27,7 @@ jest.mock("@bandada/utils", () => ({ describe("GroupsService", () => { let groupsService: GroupsService let invitesService: InvitesService + let adminsService: AdminsService let groupId: string beforeAll(async () => { @@ -33,18 +38,20 @@ describe("GroupsService", () => { type: "sqlite", database: ":memory:", dropSchema: true, - entities: [Group, Invite, Member, OAuthAccount], + entities: [Group, Invite, Member, OAuthAccount, Admin], synchronize: true }) }), - TypeOrmModule.forFeature([Group, Invite, Member]), - ScheduleModule.forRoot() + TypeOrmModule.forFeature([Group, Invite, Member, Admin]), + ScheduleModule.forRoot(), + AdminsModule ], - providers: [GroupsService, InvitesService] + providers: [GroupsService, InvitesService, AdminsService] }).compile() groupsService = await module.resolve(GroupsService) invitesService = await module.resolve(InvitesService) + adminsService = await module.resolve(AdminsService) await groupsService.initialize() @@ -225,54 +232,6 @@ describe("GroupsService", () => { }) }) - describe("# updateApiKey", () => { - let group: Group - - it("Should enable the API with a new API key", async () => { - group = await groupsService.createGroup( - { - name: "Group2", - description: "This is a new group", - treeDepth: 16, - fingerprintDuration: 3600 - }, - "admin" - ) - - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" - ) - - const { apiKey } = await groupsService.getGroup(group.id) - - expect(apiKey).toHaveLength(36) - }) - - it("Should update the api key of the group", async () => { - const apiKey = await groupsService.updateApiKey(group.id, "admin") - - expect(apiKey).toHaveLength(36) - }) - - it("Should not update the api key if the admin is the wrong one", async () => { - const fun = groupsService.updateApiKey(groupId, "wrong-admin") - - await expect(fun).rejects.toThrow( - `You are not the admin of the group '${groupId}'` - ) - }) - - it("Should not update the api key if the api is not enabled", async () => { - const fun = groupsService.updateApiKey(groupId, "admin") - - await expect(fun).rejects.toThrow( - `Group '${groupId}' API key is not enabled` - ) - }) - }) - describe("# addMember", () => { let invite: Invite @@ -424,10 +383,21 @@ describe("GroupsService", () => { }) describe("# Add and remove member via API", () => { + let admin: Admin let group: Group let apiKey: string beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Generate + }) + group = await groupsService.createGroup( { name: "Group2", @@ -435,16 +405,10 @@ describe("GroupsService", () => { treeDepth: 16, fingerprintDuration: 3600 }, - "admin" + admin.id ) - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" - ) - - apiKey = (await groupsService.getGroup(group.id)).apiKey + admin = await adminsService.findOne({ id: admin.id }) }) it("Should add a member to an existing group via API", async () => { @@ -493,19 +457,66 @@ describe("GroupsService", () => { ) }) - 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 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 group" + ) + }) + + it("Should not remove a member to an existing group if API is invalid", async () => { + const fun = groupsService.removeMemberWithAPIKey( + group.id, + "100001", + "apiKey" + ) + + await expect(fun).rejects.toThrow( + "Invalid API key or API access not enabled for group" + ) + }) + + it("Should not add a member to an existing group if API is disabled", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + + const fun = groupsService.addMemberWithAPIKey( + group.id, + "100002", + apiKey + ) + await expect(fun).rejects.toThrow( "Invalid API key or API access not enabled for group" ) @@ -513,7 +524,7 @@ describe("GroupsService", () => { it("Should not remove a member to an existing group if API is disabled", async () => { const fun = groupsService.removeMemberWithAPIKey( - groupId, + group.id, "100001", apiKey ) @@ -525,10 +536,21 @@ describe("GroupsService", () => { }) describe("# Add and remove members via API", () => { + let admin: Admin let group: Group let apiKey: string beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Generate + }) + group = await groupsService.createGroup( { name: "Group2", @@ -536,16 +558,10 @@ describe("GroupsService", () => { treeDepth: 16, fingerprintDuration: 3600 }, - "admin" + admin.id ) - await groupsService.updateGroup( - group.id, - { apiEnabled: true }, - "admin" - ) - - apiKey = (await groupsService.getGroup(group.id)).apiKey + admin = await adminsService.findOne({ id: admin.id }) }) it("Should add a member to an existing group via API", async () => { @@ -600,19 +616,66 @@ describe("GroupsService", () => { ) }) - 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 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 group" + ) + }) + + it("Should not remove a member to an existing group if API is invalid", async () => { + const fun = groupsService.removeMembersWithAPIKey( + group.id, + ["100001"], + "apiKey" + ) + + await expect(fun).rejects.toThrow( + "Invalid API key or API access not enabled for group" + ) + }) + + it("Should not add a member to an existing group if API is disabled", async () => { + await adminsService.updateApiKey({ + adminId: admin.id, + action: ApiKeyActions.Disable + }) + + const fun = groupsService.addMembersWithAPIKey( + group.id, + ["100002"], + apiKey + ) + await expect(fun).rejects.toThrow( "Invalid API key or API access not enabled for group" ) @@ -620,7 +683,7 @@ describe("GroupsService", () => { it("Should not remove a member to an existing group if API is disabled", async () => { const fun = groupsService.removeMembersWithAPIKey( - groupId, + group.id, ["100001"], apiKey ) diff --git a/apps/api/src/app/groups/groups.service.ts b/apps/api/src/app/groups/groups.service.ts index 274a130..866cc33 100644 --- a/apps/api/src/app/groups/groups.service.ts +++ b/apps/api/src/app/groups/groups.service.ts @@ -12,8 +12,8 @@ import { import { InjectRepository } from "@nestjs/typeorm" import { Group as CachedGroup } from "@semaphore-protocol/group" import { Repository } from "typeorm" -import { v4 } from "uuid" import { InvitesService } from "../invites/invites.service" +import { AdminsService } from "../admins/admins.service" import { CreateGroupDto } from "./dto/create-group.dto" import { UpdateGroupDto } from "./dto/update-group.dto" import { Group } from "./entities/group.entity" @@ -31,7 +31,8 @@ export class GroupsService { @InjectRepository(Member) private readonly memberRepository: Repository, @Inject(forwardRef(() => InvitesService)) - private readonly invitesService: InvitesService + private readonly invitesService: InvitesService, + private readonly adminsService: AdminsService ) { this.cachedGroups = new Map() // this.bandadaContract = getBandadaContract( @@ -138,7 +139,6 @@ export class GroupsService { { description, treeDepth, - apiEnabled, credentials, fingerprintDuration }: UpdateGroupDto, @@ -176,15 +176,6 @@ export class GroupsService { group.credentials = credentials } - if (!group.credentials && apiEnabled !== undefined) { - group.apiEnabled = apiEnabled - - // Generate a new API key if it doesn't exist - if (!group.apiKey) { - group.apiKey = v4() - } - } - await this.groupRepository.save(group) Logger.log(`GroupsService: group '${group.name}' has been updated`) @@ -192,37 +183,6 @@ export class GroupsService { return group } - /** - * Updates the group api key. - * @param groupId Group id. - * @param adminId Group admin id. - */ - async updateApiKey(groupId: string, adminId: string): Promise { - const group = await this.getGroup(groupId) - - if (group.adminId !== adminId) { - throw new UnauthorizedException( - `You are not the admin of the group '${groupId}'` - ) - } - - if (!group.apiEnabled) { - throw new UnauthorizedException( - `Group '${groupId}' API key is not enabled` - ) - } - - group.apiKey = v4() - - await this.groupRepository.save(group) - - Logger.log( - `GroupsService: group '${group.name}' APIs have been updated` - ) - - return group.apiKey - } - /** * Join the group by redeeming invite code. * @param groupId Group id. @@ -320,8 +280,15 @@ export class GroupsService { apiKey: string ): Promise { const group = await this.getGroup(groupId) + const admin = await this.adminsService.findOne({ id: group.adminId }) - if (!group.apiEnabled || group.apiKey !== apiKey) { + if (!admin) { + throw new BadRequestException( + `Invalid admin for group '${groupId}'` + ) + } + + if (!admin.apiEnabled || admin.apiKey !== apiKey) { throw new BadRequestException( `Invalid API key or API access not enabled for group '${groupId}'` ) @@ -540,8 +507,15 @@ export class GroupsService { apiKey: string ): Promise { const group = await this.getGroup(groupId) + const admin = await this.adminsService.findOne({ id: group.adminId }) - if (!group.apiEnabled || group.apiKey !== apiKey) { + if (!admin) { + throw new BadRequestException( + `Invalid admin for group '${groupId}'` + ) + } + + if (!admin.apiEnabled || admin.apiKey !== apiKey) { throw new BadRequestException( `Invalid API key or API access not enabled for group '${groupId}'` ) diff --git a/apps/api/src/app/groups/groups.utils.test.ts b/apps/api/src/app/groups/groups.utils.test.ts index d077138..12a369e 100644 --- a/apps/api/src/app/groups/groups.utils.test.ts +++ b/apps/api/src/app/groups/groups.utils.test.ts @@ -23,17 +23,6 @@ describe("Groups utils", () => { expect(members).toHaveLength(0) }) - it("Should map the group data with api keys if specified", async () => { - const { apiKey, apiEnabled } = mapGroupToResponseDTO( - { apiEnabled: true, apiKey: "123" } as any, - "12345", - true - ) - - expect(apiEnabled).toBeTruthy() - expect(apiKey).toBe("123") - }) - it("Should map the fingerprint correctly", async () => { const { fingerprint } = mapGroupToResponseDTO({} as any, "12345") diff --git a/apps/api/src/app/groups/groups.utils.ts b/apps/api/src/app/groups/groups.utils.ts index 28d9b56..5c56e51 100644 --- a/apps/api/src/app/groups/groups.utils.ts +++ b/apps/api/src/app/groups/groups.utils.ts @@ -1,10 +1,6 @@ import { Group } from "./entities/group.entity" -export function mapGroupToResponseDTO( - group: Group, - fingerprint: string = "", - includeAPIKey: boolean = false -) { +export function mapGroupToResponseDTO(group: Group, fingerprint: string = "") { const dto = { id: group.id, name: group.name, @@ -15,14 +11,7 @@ export function mapGroupToResponseDTO( fingerprintDuration: group.fingerprintDuration, createdAt: group.createdAt, members: (group.members || []).map((m) => m.id), - credentials: group.credentials, - apiKey: undefined, - apiEnabled: undefined - } - - if (includeAPIKey) { - dto.apiKey = group.apiKey - dto.apiEnabled = group.apiEnabled + credentials: group.credentials } return dto diff --git a/apps/api/src/app/invites/invites.module.ts b/apps/api/src/app/invites/invites.module.ts index c60d65a..a3eba97 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 2922dda..26ab516 100644 --- a/apps/api/src/app/invites/invites.service.test.ts +++ b/apps/api/src/app/invites/invites.service.test.ts @@ -7,6 +7,7 @@ import { GroupsService } from "../groups/groups.service" import { OAuthAccount } from "../credentials/entities/credentials-account.entity" import { Invite } from "./entities/invite.entity" import { InvitesService } from "./invites.service" +import { AdminsModule } from "../admins/admins.module" jest.mock("@bandada/utils", () => ({ __esModule: true, @@ -37,7 +38,8 @@ describe("InvitesService", () => { }) }), TypeOrmModule.forFeature([Group, Invite, Member]), - ScheduleModule.forRoot() + ScheduleModule.forRoot(), + AdminsModule ], providers: [GroupsService, InvitesService] }).compile() diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts new file mode 100644 index 0000000..700be00 --- /dev/null +++ b/apps/api/src/types/index.ts @@ -0,0 +1,10 @@ +/** + * Defines the possible actions that can be performed on an API key. + * This includes generating a new API key, enabling an existing API key, + * and disabling an existing API key. + */ +export enum ApiKeyActions { + Generate = "generate", + Enable = "enable", + Disable = "disable" +} diff --git a/database/seed.sql b/database/seed.sql index 35568a3..9375d26 100644 --- a/database/seed.sql +++ b/database/seed.sql @@ -4,7 +4,10 @@ CREATE TABLE admins ( id character varying PRIMARY KEY, address character varying NOT NULL UNIQUE, username character varying NOT NULL UNIQUE, - created_at timestamp without time zone NOT NULL DEFAULT now() + api_key character varying, + api_enabled boolean NOT NULL DEFAULT false, + created_at timestamp without time zone NOT NULL DEFAULT now(), + updated_at timestamp without time zone NOT NULL DEFAULT now() ); -- Table Definition ---------------------------------------------- @@ -17,8 +20,6 @@ CREATE TABLE groups ( tree_depth integer NOT NULL, fingerprint_duration integer NOT NULL, credentials text, - api_enabled boolean NOT NULL DEFAULT false, - api_key character varying, created_at timestamp without time zone NOT NULL DEFAULT now(), updated_at timestamp without time zone NOT NULL DEFAULT now() );