feat: add missing createInvite api endpoint

This commit is contained in:
Jeeiii
2024-04-04 23:34:01 +02:00
parent 367f7c6b0d
commit 3ebd4de898
16 changed files with 535 additions and 126 deletions

View File

@@ -19,7 +19,7 @@ import { UpdateGroupDto } from "./dto/update-group.dto"
import { Group } from "./entities/group.entity"
import { Member } from "./entities/member.entity"
import { MerkleProof } from "./types"
import { getAndCheckAdmin } from "./groups.utils"
import { getAndCheckAdmin } from "../utils"
@Injectable()
export class GroupsService {

View File

@@ -1,7 +1,6 @@
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"
@@ -11,11 +10,10 @@ 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"
import { mapGroupToResponseDTO } from "./groups.utils"
describe("Groups utils", () => {
let groupsService: GroupsService
let adminsService: AdminsService
beforeAll(async () => {
const module = await Test.createTestingModule({
@@ -37,7 +35,6 @@ describe("Groups utils", () => {
}).compile()
groupsService = await module.resolve(GroupsService)
adminsService = await module.resolve(AdminsService)
await groupsService.initialize()
})
@@ -70,68 +67,4 @@ describe("Groups utils", () => {
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
)
admin = await adminsService.findOne({ id: admin.id })
})
it("Should successfully check and return the admin", async () => {
const checkedAdmin = await getAndCheckAdmin(adminsService, apiKey)
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}'`
)
})
})
})

View File

@@ -1,7 +1,4 @@
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 = "") {
const dto = {
@@ -19,27 +16,3 @@ export function mapGroupToResponseDTO(group: Group, fingerprint: string = "") {
return dto
}
export async function getAndCheckAdmin(
adminService: AdminsService,
apiKey: string,
groupId?: string
): Promise<Admin> {
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`
)
}
if (!admin.apiEnabled || admin.apiKey !== apiKey) {
throw new BadRequestException(
`Invalid API key or API access not enabled for admin '${admin.id}'`
)
}
return admin
}

View File

@@ -2,14 +2,17 @@ import {
Body,
Controller,
Get,
Headers,
NotImplementedException,
Param,
Post,
Req,
UseGuards
} from "@nestjs/common"
import {
ApiBody,
ApiCreatedResponse,
ApiExcludeEndpoint,
ApiHeader,
ApiOperation,
ApiTags
} from "@nestjs/swagger"
@@ -30,17 +33,36 @@ export class InvitesController {
@Post()
@UseGuards(AuthGuard)
@UseGuards(ThrottlerGuard)
@ApiExcludeEndpoint()
@ApiBody({ type: CreateInviteDto })
@ApiHeader({ name: "x-api-key", required: true })
@ApiCreatedResponse({ type: Invite })
@ApiOperation({
description: "Creates a new group invite with a unique code."
})
async createInvite(
@Headers() headers: Headers,
@Req() req: Request,
@Body() dto: CreateInviteDto
): Promise<string> {
const { code } = await this.invitesService.createInvite(
dto,
req.session.adminId
)
): Promise<Invite> {
let invite: Invite
return code
const apiKey = headers["x-api-key"] as string
if (apiKey) {
invite = await this.invitesService.createInviteWithApiKey(
dto,
apiKey
)
} else if (req.session.adminId) {
invite = await this.invitesService.createInviteManually(
dto,
req.session.adminId
)
} else {
throw new NotImplementedException()
}
return invite
}
@Get(":code")

View File

@@ -5,15 +5,17 @@ import { Invite } from "./entities/invite.entity"
import { InvitesController } from "./invites.controller"
import { InvitesService } from "./invites.service"
import { AdminsModule } from "../admins/admins.module"
import { AdminsService } from "../admins/admins.service"
import { Admin } from "../admins/entities/admin.entity"
@Module({
imports: [
forwardRef(() => GroupsModule),
TypeOrmModule.forFeature([Invite]),
TypeOrmModule.forFeature([Invite, Admin]),
AdminsModule
],
controllers: [InvitesController],
providers: [InvitesService],
providers: [InvitesService, AdminsService],
exports: [InvitesService]
})
export class InvitesModule {}

View File

@@ -1,6 +1,7 @@
import { ScheduleModule } from "@nestjs/schedule"
import { Test } from "@nestjs/testing"
import { TypeOrmModule } from "@nestjs/typeorm"
import { ApiKeyActions } from "@bandada/utils"
import { Group } from "../groups/entities/group.entity"
import { Member } from "../groups/entities/member.entity"
import { GroupsService } from "../groups/groups.service"
@@ -8,6 +9,8 @@ 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"
import { AdminsService } from "../admins/admins.service"
import { Admin } from "../admins/entities/admin.entity"
jest.mock("@bandada/utils", () => {
const originalModule = jest.requireActual("@bandada/utils")
@@ -28,7 +31,9 @@ jest.mock("@bandada/utils", () => {
describe("InvitesService", () => {
let invitesService: InvitesService
let groupsService: GroupsService
let adminsService: AdminsService
let groupId: string
let admin: Admin
beforeAll(async () => {
const module = await Test.createTestingModule({
@@ -38,19 +43,29 @@ describe("InvitesService", () => {
type: "sqlite",
database: ":memory:",
dropSchema: true,
entities: [Group, Invite, Member, OAuthAccount],
entities: [Group, Invite, Member, OAuthAccount, Admin],
synchronize: true
})
}),
TypeOrmModule.forFeature([Group, Invite, Member]),
TypeOrmModule.forFeature([Group, Invite, Member, Admin]),
ScheduleModule.forRoot(),
AdminsModule
],
providers: [GroupsService, InvitesService]
providers: [GroupsService, InvitesService, AdminsService]
}).compile()
invitesService = await module.resolve(InvitesService)
groupsService = await module.resolve(GroupsService)
adminsService = await module.resolve(AdminsService)
admin = await adminsService.create({
id: "admin",
address: "0x"
})
await adminsService.updateApiKey(admin.id, ApiKeyActions.Generate)
admin = await adminsService.findOne({ id: admin.id })
const group = await groupsService.createGroup(
{
@@ -59,7 +74,7 @@ describe("InvitesService", () => {
treeDepth: 16,
fingerprintDuration: 3600
},
"admin"
admin.id
)
groupId = group.id
@@ -71,7 +86,7 @@ describe("InvitesService", () => {
group,
code,
isRedeemed: redeemed
} = await invitesService.createInvite({ groupId }, "admin")
} = await invitesService.createInvite({ groupId }, admin.id)
expect(redeemed).toBeFalsy()
expect(code).toHaveLength(8)
@@ -98,12 +113,225 @@ describe("InvitesService", () => {
}
}
},
"admin"
admin.id
)
const fun = invitesService.createInvite(
{ groupId: group.id },
"admin"
admin.id
)
await expect(fun).rejects.toThrow(
"Credential groups cannot be accessed via invites"
)
})
})
describe("# createInviteManually", () => {
it("Should create an invite manually", async () => {
const {
group,
code,
isRedeemed: redeemed
} = await invitesService.createInviteManually({ groupId }, admin.id)
expect(redeemed).toBeFalsy()
expect(code).toHaveLength(8)
expect(group.treeDepth).toBe(16)
})
it("Should not create an invite if the given identifier does not belong to an admin", async () => {
const fun = invitesService.createInviteManually(
{ groupId },
"wrong-admin"
)
await expect(fun).rejects.toThrow("You are not an admin")
})
it("Should not create an invite if the admin is the wrong one", async () => {
const admin2 = await adminsService.create({
id: "admin2",
address: "0x02"
})
await groupsService.createGroup(
{
name: "Group2",
description: "This is a description",
treeDepth: 16,
fingerprintDuration: 3600,
credentials: {
id: "GITHUB_FOLLOWERS",
criteria: {
minFollowers: 12
}
}
},
admin2.id
)
const fun = invitesService.createInviteManually(
{ groupId },
admin2.id
)
await expect(fun).rejects.toThrow("You are not the admin")
})
it("Should not create an invite if the group is a credential group", async () => {
const admin3 = await adminsService.create({
id: "admin3",
address: "0x04"
})
const group = await groupsService.createGroup(
{
name: "Group3",
description: "This is a description",
treeDepth: 16,
fingerprintDuration: 3600,
credentials: {
id: "GITHUB_FOLLOWERS",
criteria: {
minFollowers: 12
}
}
},
admin3.id
)
const fun = invitesService.createInviteManually(
{ groupId: group.id },
admin3.id
)
await expect(fun).rejects.toThrow(
"Credential groups cannot be accessed via invites"
)
})
})
describe("# createInviteWithApiKey", () => {
it("Should create an invite manually", async () => {
const {
group,
code,
isRedeemed: redeemed
} = await invitesService.createInviteWithApiKey(
{ groupId },
admin.apiKey
)
expect(redeemed).toBeFalsy()
expect(code).toHaveLength(8)
expect(group.treeDepth).toBe(16)
})
it("Should not create an invite if the given api key is invalid", async () => {
const fun = invitesService.createInviteWithApiKey(
{ groupId },
"wrong-apikey"
)
await expect(fun).rejects.toThrow(
`Invalid API key or invalid admin for the group '${groupId}'`
)
})
it("Should not create an invite if the given api key does not belong to an admin", async () => {
const oldApiKey = admin.apiKey
await adminsService.updateApiKey(admin.id, ApiKeyActions.Generate)
const fun = invitesService.createInviteWithApiKey(
{ groupId },
oldApiKey
)
await expect(fun).rejects.toThrow(
`Invalid API key or invalid admin for the group '${groupId}'`
)
})
it("Should not create an invite if the given api key is disabled", async () => {
await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable)
admin = await adminsService.findOne({ id: admin.id })
const fun = invitesService.createInviteWithApiKey(
{ groupId },
admin.apiKey
)
await expect(fun).rejects.toThrow(
`Invalid API key or API access not enabled for admin '${admin.id}'`
)
})
it("Should not create an invite if the admin is the wrong one", async () => {
let admin2 = await adminsService.create({
id: "admin2",
address: "0x02"
})
await adminsService.updateApiKey(admin2.id, ApiKeyActions.Generate)
admin2 = await adminsService.findOne({ id: admin2.id })
await groupsService.createGroup(
{
name: "Group2",
description: "This is a description",
treeDepth: 16,
fingerprintDuration: 3600,
credentials: {
id: "GITHUB_FOLLOWERS",
criteria: {
minFollowers: 12
}
}
},
admin2.id
)
const fun = invitesService.createInviteWithApiKey(
{ groupId },
admin2.apiKey
)
await expect(fun).rejects.toThrow("You are not the admin")
})
it("Should not create an invite if the group is a credential group", async () => {
let admin3 = await adminsService.create({
id: "admin3",
address: "0x04"
})
await adminsService.updateApiKey(admin3.id, ApiKeyActions.Generate)
admin3 = await adminsService.findOne({ id: admin3.id })
const group = await groupsService.createGroup(
{
name: "Group3",
description: "This is a description",
treeDepth: 16,
fingerprintDuration: 3600,
credentials: {
id: "GITHUB_FOLLOWERS",
criteria: {
minFollowers: 12
}
}
},
admin3.id
)
const fun = invitesService.createInviteWithApiKey(
{ groupId: group.id },
admin3.apiKey
)
await expect(fun).rejects.toThrow(
@@ -116,7 +344,7 @@ describe("InvitesService", () => {
it("Should get an invite", async () => {
const { code } = await invitesService.createInvite(
{ groupId },
"admin"
admin.id
)
const invite = await invitesService.getInvite(code)
@@ -137,7 +365,7 @@ describe("InvitesService", () => {
let invite: Invite
beforeAll(async () => {
invite = await invitesService.createInvite({ groupId }, "admin")
invite = await invitesService.createInvite({ groupId }, admin.id)
})
it("Should not redeem an invite if group name does not match", async () => {

View File

@@ -11,6 +11,8 @@ import { Repository } from "typeorm"
import { GroupsService } from "../groups/groups.service"
import { CreateInviteDto } from "./dto/create-invite.dto"
import { Invite } from "./entities/invite.entity"
import { getAndCheckAdmin } from "../utils"
import { AdminsService } from "../admins/admins.service"
@Injectable()
export class InvitesService {
@@ -18,9 +20,46 @@ export class InvitesService {
@InjectRepository(Invite)
private readonly inviteRepository: Repository<Invite>,
@Inject(forwardRef(() => GroupsService))
private readonly groupsService: GroupsService
private readonly groupsService: GroupsService,
private readonly adminsService: AdminsService
) {}
/**
* Create a new group invite using API Key.
* @param dto External parameters used to create a new group invite.
* @param apiKey the API Key.
* @returns The group invite.
*/
async createInviteWithApiKey(
dto: CreateInviteDto,
apiKey: string
): Promise<Invite> {
const admin = await getAndCheckAdmin(
this.adminsService,
apiKey,
dto.groupId
)
return this.createInvite(dto, admin.id)
}
/**
* Create a new group invite manually without using API Key.
* @param dto External parameters used to create a new group invite.
* @param adminId Group admin id.
* @returns The group invite.
*/
async createInviteManually(
dto: CreateInviteDto,
adminId: string
): Promise<Invite> {
const admin = await this.adminsService.findOne({ id: adminId })
if (!admin) throw new BadRequestException(`You are not an admin`)
return this.createInvite(dto, adminId)
}
/**
* Creates a new group invite with a unique code. Group invites can only be
* created by group admins.

View File

@@ -0,0 +1,27 @@
import { BadRequestException } from "@nestjs/common"
import { AdminsService } from "../admins/admins.service"
import { Admin } from "../admins/entities/admin.entity"
export default async function getAndCheckAdmin(
adminService: AdminsService,
apiKey: string,
groupId?: string
): Promise<Admin> {
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`
)
}
if (!admin.apiEnabled || admin.apiKey !== apiKey) {
throw new BadRequestException(
`Invalid API key or API access not enabled for admin '${admin.id}'`
)
}
return admin
}

View File

@@ -1,7 +1,49 @@
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 { AdminsService } from "../admins/admins.service"
import { AdminsModule } from "../admins/admins.module"
import { Admin } from "../admins/entities/admin.entity"
import { GroupsService } from "../groups/groups.service"
import { Group } from "../groups/entities/group.entity"
import { Member } from "../groups/entities/member.entity"
import mapEntity from "./mapEntity"
import stringifyJSON from "./stringifyJSON"
import getAndCheckAdmin from "./getAndCheckAdmin"
describe("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("# mapEntity", () => {
it("Should map a DB entity", async () => {
const entity = mapEntity({ id: 1, a: 2 }) as any
@@ -18,4 +60,68 @@ describe("Utils", () => {
expect(entity.a).toBe("143234")
})
})
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
)
admin = await adminsService.findOne({ id: admin.id })
})
it("Should successfully check and return the admin", async () => {
const checkedAdmin = await getAndCheckAdmin(adminsService, apiKey)
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}'`
)
})
})
})

View File

@@ -1,4 +1,5 @@
import mapEntity from "./mapEntity"
import stringifyJSON from "./stringifyJSON"
import getAndCheckAdmin from "./getAndCheckAdmin"
export { mapEntity, stringifyJSON }
export { mapEntity, stringifyJSON, getAndCheckAdmin }