Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move API key logic from GroupsService to AdminsService #446

Merged
merged 11 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/app/admins/admins.controller.ts
Original file line number Diff line number Diff line change
@@ -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<Admin> {
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<string> {
return this.adminsService.updateApiKey(adminId, dto.action)
}
}
9 changes: 5 additions & 4 deletions apps/api/src/app/admins/admins.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
175 changes: 175 additions & 0 deletions apps/api/src/app/admins/admins.service.test.ts
Original file line number Diff line number Diff line change
@@ -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`
)
})
})
})
56 changes: 54 additions & 2 deletions apps/api/src/app/admins/admins.service.ts
Original file line number Diff line number Diff line change
@@ -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<Admin>
Expand All @@ -29,4 +31,54 @@ export class AdminService {
): Promise<Admin> {
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<string>} 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<string> {
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
}
}
7 changes: 7 additions & 0 deletions apps/api/src/app/admins/dto/update-apikey.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ApiKeyActions } from "@bandada/utils"
import { IsEnum } from "class-validator"

export class UpdateApiKeyDTO {
@IsEnum(ApiKeyActions)
action: ApiKeyActions
}
19 changes: 18 additions & 1 deletion apps/api/src/app/admins/entities/admin.entity.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
6 changes: 3 additions & 3 deletions apps/api/src/app/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const req = context.switchToHttp().getRequest()
Expand All @@ -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 {
Expand Down
Loading
Loading