Merge pull request #11001 from Budibase/feature/custom-role-readable-ids

Custom roles - readable IDs
This commit is contained in:
Michael Drury 2023-06-27 16:17:08 +01:00 committed by GitHub
commit f7cdf5f2bc
11 changed files with 131 additions and 56 deletions

View File

@ -81,8 +81,19 @@ export function generateAppUserID(prodAppId: string, userId: string) {
* Generates a new role ID. * Generates a new role ID.
* @returns {string} The new role ID which the role doc can be stored under. * @returns {string} The new role ID which the role doc can be stored under.
*/ */
export function generateRoleID(id?: any) { export function generateRoleID(name: string) {
return `${DocumentType.ROLE}${SEPARATOR}${id || newid()}` const prefix = `${DocumentType.ROLE}${SEPARATOR}`
if (name.startsWith(prefix)) {
return name
}
return `${prefix}${name}`
}
/**
* Utility function to be more verbose.
*/
export function prefixRoleID(name: string) {
return generateRoleID(name)
} }
/** /**

View File

@ -1,5 +1,5 @@
import { BuiltinPermissionID, PermissionLevel } from "./permissions" import { BuiltinPermissionID, PermissionLevel } from "./permissions"
import { generateRoleID, getRoleParams, DocumentType, SEPARATOR } from "../db" import { prefixRoleID, getRoleParams, DocumentType, SEPARATOR } from "../db"
import { getAppDB } from "../context" import { getAppDB } from "../context"
import { doWithDB } from "../db" import { doWithDB } from "../db"
import { Screen, Role as RoleDoc } from "@budibase/types" import { Screen, Role as RoleDoc } from "@budibase/types"
@ -25,18 +25,28 @@ const EXTERNAL_BUILTIN_ROLE_IDS = [
BUILTIN_IDS.PUBLIC, BUILTIN_IDS.PUBLIC,
] ]
export const RoleIDVersion = {
// original version, with a UUID based ID
UUID: undefined,
// new version - with name based ID
NAME: "name",
}
export class Role implements RoleDoc { export class Role implements RoleDoc {
_id: string _id: string
_rev?: string _rev?: string
name: string name: string
permissionId: string permissionId: string
inherits?: string inherits?: string
version?: string
permissions = {} permissions = {}
constructor(id: string, name: string, permissionId: string) { constructor(id: string, name: string, permissionId: string) {
this._id = id this._id = id
this.name = name this.name = name
this.permissionId = permissionId this.permissionId = permissionId
// version for managing the ID - removing the role_ when responding
this.version = RoleIDVersion.NAME
} }
addInheritance(inherits: string) { addInheritance(inherits: string) {
@ -157,13 +167,16 @@ export async function getRole(
role = cloneDeep( role = cloneDeep(
Object.values(BUILTIN_ROLES).find(role => role._id === roleId) Object.values(BUILTIN_ROLES).find(role => role._id === roleId)
) )
} else {
// make sure has the prefix (if it has it then it won't be added)
roleId = prefixRoleID(roleId)
} }
try { try {
const db = getAppDB() const db = getAppDB()
const dbRole = await db.get(getDBRoleID(roleId)) const dbRole = await db.get(getDBRoleID(roleId))
role = Object.assign(role, dbRole) role = Object.assign(role, dbRole)
// finalise the ID // finalise the ID
role._id = getExternalRoleID(role._id) role._id = getExternalRoleID(role._id, role.version)
} catch (err) { } catch (err) {
if (!isBuiltin(roleId) && opts?.defaultPublic) { if (!isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC) return cloneDeep(BUILTIN_ROLES.PUBLIC)
@ -261,6 +274,9 @@ export async function getAllRoles(appId?: string) {
}) })
) )
roles = body.rows.map((row: any) => row.doc) roles = body.rows.map((row: any) => row.doc)
roles.forEach(
role => (role._id = getExternalRoleID(role._id!, role.version))
)
} }
const builtinRoles = getBuiltinRoles() const builtinRoles = getBuiltinRoles()
@ -268,14 +284,15 @@ export async function getAllRoles(appId?: string) {
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) { for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
const builtinRole = builtinRoles[builtinRoleId] const builtinRole = builtinRoles[builtinRoleId]
const dbBuiltin = roles.filter( const dbBuiltin = roles.filter(
dbRole => getExternalRoleID(dbRole._id) === builtinRoleId dbRole =>
getExternalRoleID(dbRole._id!, dbRole.version) === builtinRoleId
)[0] )[0]
if (dbBuiltin == null) { if (dbBuiltin == null) {
roles.push(builtinRole || builtinRoles.BASIC) roles.push(builtinRole || builtinRoles.BASIC)
} else { } else {
// remove role and all back after combining with the builtin // remove role and all back after combining with the builtin
roles = roles.filter(role => role._id !== dbBuiltin._id) roles = roles.filter(role => role._id !== dbBuiltin._id)
dbBuiltin._id = getExternalRoleID(dbBuiltin._id) dbBuiltin._id = getExternalRoleID(dbBuiltin._id!, dbBuiltin.version)
roles.push(Object.assign(builtinRole, dbBuiltin)) roles.push(Object.assign(builtinRole, dbBuiltin))
} }
} }
@ -381,19 +398,22 @@ export class AccessController {
/** /**
* Adds the "role_" for builtin role IDs which are to be written to the DB (for permissions). * Adds the "role_" for builtin role IDs which are to be written to the DB (for permissions).
*/ */
export function getDBRoleID(roleId?: string) { export function getDBRoleID(roleName: string) {
if (roleId?.startsWith(DocumentType.ROLE)) { if (roleName?.startsWith(DocumentType.ROLE)) {
return roleId return roleName
} }
return generateRoleID(roleId) return prefixRoleID(roleName)
} }
/** /**
* Remove the "role_" from builtin role IDs that have been written to the DB (for permissions). * Remove the "role_" from builtin role IDs that have been written to the DB (for permissions).
*/ */
export function getExternalRoleID(roleId?: string) { export function getExternalRoleID(roleId: string, version?: string) {
// for built-in roles we want to remove the DB role ID element (role_) // for built-in roles we want to remove the DB role ID element (role_)
if (roleId?.startsWith(DocumentType.ROLE) && isBuiltin(roleId)) { if (
(roleId.startsWith(DocumentType.ROLE) && isBuiltin(roleId)) ||
version === RoleIDVersion.NAME
) {
return roleId.split(`${DocumentType.ROLE}${SEPARATOR}`)[1] return roleId.split(`${DocumentType.ROLE}${SEPARATOR}`)[1]
} }
return roleId return roleId

View File

@ -12,15 +12,14 @@
let selectedRole = BASE_ROLE let selectedRole = BASE_ROLE
let errors = [] let errors = []
let builtInRoles = ["Admin", "Power", "Basic", "Public"] let builtInRoles = ["Admin", "Power", "Basic", "Public"]
let validRegex = /^[a-zA-Z0-9_]*$/
// Don't allow editing of public role // Don't allow editing of public role
$: editableRoles = $roles.filter(role => role._id !== "PUBLIC") $: editableRoles = $roles.filter(role => role._id !== "PUBLIC")
$: selectedRoleId = selectedRole._id $: selectedRoleId = selectedRole._id
$: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId) $: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId)
$: isCreating = selectedRoleId == null || selectedRoleId === "" $: isCreating = selectedRoleId == null || selectedRoleId === ""
$: hasUniqueRoleName = !otherRoles $: roleNameError = getRoleNameError(selectedRole.name)
?.map(role => role.name)
?.includes(selectedRole.name)
$: valid = $: valid =
selectedRole.name && selectedRole.name &&
@ -101,6 +100,18 @@
} }
} }
const getRoleNameError = name => {
const hasUniqueRoleName = !otherRoles
?.map(role => role.name)
?.includes(name)
const invalidRoleName = !validRegex.test(name)
if (!hasUniqueRoleName) {
return "Select a unique role name."
} else if (invalidRoleName) {
return "Please enter a role name consisting of only alphanumeric symbols and underscores"
}
}
onMount(fetchBasePermissions) onMount(fetchBasePermissions)
</script> </script>
@ -108,7 +119,7 @@
title="Edit Roles" title="Edit Roles"
confirmText={isCreating ? "Create" : "Save"} confirmText={isCreating ? "Create" : "Save"}
onConfirm={saveRole} onConfirm={saveRole}
disabled={!valid || !hasUniqueRoleName} disabled={!valid || roleNameError}
> >
{#if errors.length} {#if errors.length}
<ErrorsBox {errors} /> <ErrorsBox {errors} />
@ -129,7 +140,7 @@
label="Name" label="Name"
bind:value={selectedRole.name} bind:value={selectedRole.name}
disabled={shouldDisableRoleInput} disabled={shouldDisableRoleInput}
error={!hasUniqueRoleName ? "Select a unique role name." : null} error={roleNameError}
/> />
<Select <Select
label="Inherits Role" label="Inherits Role"

View File

@ -5,7 +5,7 @@ import {
getBasePermissions, getBasePermissions,
} from "../../utilities/security" } from "../../utilities/security"
import { removeFromArray } from "../../utilities" import { removeFromArray } from "../../utilities"
import { BBContext, Database, Role } from "@budibase/types" import { UserCtx, Database, Role } from "@budibase/types"
const PermissionUpdateType = { const PermissionUpdateType = {
REMOVE: "remove", REMOVE: "remove",
@ -38,12 +38,12 @@ async function updatePermissionOnRole(
const isABuiltin = roles.isBuiltin(roleId) const isABuiltin = roles.isBuiltin(roleId)
const dbRoleId = roles.getDBRoleID(roleId) const dbRoleId = roles.getDBRoleID(roleId)
const dbRoles = await getAllDBRoles(db) const dbRoles = await getAllDBRoles(db)
const docUpdates = [] const docUpdates: Role[] = []
// the permission is for a built in, make sure it exists // the permission is for a built in, make sure it exists
if (isABuiltin && !dbRoles.some(role => role._id === dbRoleId)) { if (isABuiltin && !dbRoles.some(role => role._id === dbRoleId)) {
const builtin = roles.getBuiltinRoles()[roleId] const builtin = roles.getBuiltinRoles()[roleId]
builtin._id = roles.getDBRoleID(builtin._id) builtin._id = roles.getDBRoleID(builtin._id!)
dbRoles.push(builtin) dbRoles.push(builtin)
} }
@ -88,22 +88,23 @@ async function updatePermissionOnRole(
const response = await db.bulkDocs(docUpdates) const response = await db.bulkDocs(docUpdates)
return response.map((resp: any) => { return response.map((resp: any) => {
resp._id = roles.getExternalRoleID(resp.id) const version = docUpdates.find(role => role._id === resp.id)?.version
resp._id = roles.getExternalRoleID(resp.id, version)
delete resp.id delete resp.id
return resp return resp
}) })
} }
export function fetchBuiltin(ctx: BBContext) { export function fetchBuiltin(ctx: UserCtx) {
ctx.body = Object.values(permissions.getBuiltinPermissions()) ctx.body = Object.values(permissions.getBuiltinPermissions())
} }
export function fetchLevels(ctx: BBContext) { export function fetchLevels(ctx: UserCtx) {
// for now only provide the read/write perms externally // for now only provide the read/write perms externally
ctx.body = SUPPORTED_LEVELS ctx.body = SUPPORTED_LEVELS
} }
export async function fetch(ctx: BBContext) { export async function fetch(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const dbRoles: Role[] = await getAllDBRoles(db) const dbRoles: Role[] = await getAllDBRoles(db)
let permissions: any = {} let permissions: any = {}
@ -112,7 +113,7 @@ export async function fetch(ctx: BBContext) {
if (!role.permissions) { if (!role.permissions) {
continue continue
} }
const roleId = roles.getExternalRoleID(role._id) const roleId = roles.getExternalRoleID(role._id!, role.version)
if (!roleId) { if (!roleId) {
ctx.throw(400, "Unable to retrieve role") ctx.throw(400, "Unable to retrieve role")
} }
@ -132,7 +133,7 @@ export async function fetch(ctx: BBContext) {
ctx.body = finalPermissions ctx.body = finalPermissions
} }
export async function getResourcePerms(ctx: BBContext) { export async function getResourcePerms(ctx: UserCtx) {
const resourceId = ctx.params.resourceId const resourceId = ctx.params.resourceId
const db = context.getAppDB() const db = context.getAppDB()
const body = await db.allDocs( const body = await db.allDocs(
@ -154,14 +155,14 @@ export async function getResourcePerms(ctx: BBContext) {
rolePerms[resourceId] && rolePerms[resourceId] &&
rolePerms[resourceId].indexOf(level) !== -1 rolePerms[resourceId].indexOf(level) !== -1
) { ) {
permissions[level] = roles.getExternalRoleID(role._id)! permissions[level] = roles.getExternalRoleID(role._id, role.version)!
} }
} }
} }
ctx.body = Object.assign(getBasePermissions(resourceId), permissions) ctx.body = Object.assign(getBasePermissions(resourceId), permissions)
} }
export async function addPermission(ctx: BBContext) { export async function addPermission(ctx: UserCtx) {
ctx.body = await updatePermissionOnRole( ctx.body = await updatePermissionOnRole(
ctx.appId, ctx.appId,
ctx.params, ctx.params,
@ -169,7 +170,7 @@ export async function addPermission(ctx: BBContext) {
) )
} }
export async function removePermission(ctx: BBContext) { export async function removePermission(ctx: UserCtx) {
ctx.body = await updatePermissionOnRole( ctx.body = await updatePermissionOnRole(
ctx.appId, ctx.appId,
ctx.params, ctx.params,

View File

@ -14,7 +14,8 @@ const UpdateRolesOptions = {
async function updateRolesOnUserTable( async function updateRolesOnUserTable(
db: Database, db: Database,
roleId: string, roleId: string,
updateOption: string updateOption: string,
roleVersion: string | undefined
) { ) {
const table = await db.get(InternalTables.USER_METADATA) const table = await db.get(InternalTables.USER_METADATA)
const schema = table.schema const schema = table.schema
@ -24,11 +25,15 @@ async function updateRolesOnUserTable(
if (prop === "roleId") { if (prop === "roleId") {
updated = true updated = true
const constraints = schema[prop].constraints const constraints = schema[prop].constraints
const indexOf = constraints.inclusion.indexOf(roleId) const updatedRoleId =
if (remove && indexOf !== -1) { roleVersion === roles.RoleIDVersion.NAME
constraints.inclusion.splice(indexOf, 1) ? roles.getExternalRoleID(roleId, roleVersion)
} else if (!remove && indexOf === -1) { : roleId
constraints.inclusion.push(roleId) const indexOfRoleId = constraints.inclusion.indexOf(updatedRoleId)
if (remove && indexOfRoleId !== -1) {
constraints.inclusion.splice(indexOfRoleId, 1)
} else if (!remove && indexOfRoleId === -1) {
constraints.inclusion.push(updatedRoleId)
} }
break break
} }
@ -48,14 +53,23 @@ export async function find(ctx: UserCtx) {
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
let { _id, name, inherits, permissionId } = ctx.request.body let { _id, name, inherits, permissionId, version } = ctx.request.body
let isCreate = false let isCreate = false
if (!_id) {
_id = generateRoleID() if (_id && roles.isBuiltin(_id)) {
isCreate = true
} else if (roles.isBuiltin(_id)) {
ctx.throw(400, "Cannot update builtin roles.") ctx.throw(400, "Cannot update builtin roles.")
} }
// if not id found, then its creation
if (!_id) {
_id = generateRoleID(name)
isCreate = true
}
// version 2 roles need updated to add back role_
else if (version === roles.RoleIDVersion.NAME) {
_id = generateRoleID(name)
}
const role = new roles.Role(_id, name, permissionId).addInheritance(inherits) const role = new roles.Role(_id, name, permissionId).addInheritance(inherits)
if (ctx.request.body._rev) { if (ctx.request.body._rev) {
role._rev = ctx.request.body._rev role._rev = ctx.request.body._rev
@ -66,7 +80,12 @@ export async function save(ctx: UserCtx) {
} else { } else {
await events.role.updated(role) await events.role.updated(role)
} }
await updateRolesOnUserTable(db, _id, UpdateRolesOptions.CREATED) await updateRolesOnUserTable(
db,
_id,
UpdateRolesOptions.CREATED,
role.version
)
role._rev = result.rev role._rev = result.rev
ctx.body = role ctx.body = role
ctx.message = `Role '${role.name}' created successfully.` ctx.message = `Role '${role.name}' created successfully.`
@ -74,11 +93,14 @@ export async function save(ctx: UserCtx) {
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const roleId = ctx.params.roleId let roleId = ctx.params.roleId
const role = await db.get(roleId)
if (roles.isBuiltin(roleId)) { if (roles.isBuiltin(roleId)) {
ctx.throw(400, "Cannot delete builtin role.") ctx.throw(400, "Cannot delete builtin role.")
} else {
// make sure has the prefix (if it has it then it won't be added)
roleId = generateRoleID(roleId)
} }
const role = await db.get(roleId)
// first check no users actively attached to role // first check no users actively attached to role
const users = ( const users = (
await db.allDocs( await db.allDocs(
@ -97,7 +119,8 @@ export async function destroy(ctx: UserCtx) {
await updateRolesOnUserTable( await updateRolesOnUserTable(
db, db,
ctx.params.roleId, ctx.params.roleId,
UpdateRolesOptions.REMOVED UpdateRolesOptions.REMOVED,
role.version
) )
ctx.message = `Role ${ctx.params.roleId} deleted successfully` ctx.message = `Role ${ctx.params.roleId} deleted successfully`
ctx.status = 200 ctx.status = 200

View File

@ -29,10 +29,11 @@ describe("/roles", () => {
describe("create", () => { describe("create", () => {
it("returns a success message when role is successfully created", async () => { it("returns a success message when role is successfully created", async () => {
const res = await createRole() const role = basicRole()
const res = await createRole(role)
expect(res.res.statusMessage).toEqual( expect(res.res.statusMessage).toEqual(
"Role 'NewRole' created successfully." `Role '${role.name}' created successfully.`
) )
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined() expect(res.body._rev).toBeDefined()
@ -44,12 +45,13 @@ describe("/roles", () => {
describe("update", () => { describe("update", () => {
it("updates a role", async () => { it("updates a role", async () => {
let res = await createRole() const role = basicRole()
let res = await createRole(role)
jest.clearAllMocks() jest.clearAllMocks()
res = await createRole(res.body) res = await createRole(res.body)
expect(res.res.statusMessage).toEqual( expect(res.res.statusMessage).toEqual(
"Role 'NewRole' created successfully." `Role '${role.name}' created successfully.`
) )
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined() expect(res.body._rev).toBeDefined()
@ -86,7 +88,7 @@ describe("/roles", () => {
expect(powerUserRole.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC) expect(powerUserRole.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
expect(powerUserRole.permissionId).toEqual(BuiltinPermissionID.POWER) expect(powerUserRole.permissionId).toEqual(BuiltinPermissionID.POWER)
const customRoleFetched = res.body.find(r => r._id === customRole._id) const customRoleFetched = res.body.find(r => r._id === customRole.name)
expect(customRoleFetched).toBeDefined() expect(customRoleFetched).toBeDefined()
expect(customRoleFetched.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC) expect(customRoleFetched.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
expect(customRoleFetched.permissionId).toEqual( expect(customRoleFetched.permissionId).toEqual(

View File

@ -134,7 +134,7 @@ export function roleValidator() {
return auth.joiValidator.body(Joi.object({ return auth.joiValidator.body(Joi.object({
_id: OPTIONAL_STRING, _id: OPTIONAL_STRING,
_rev: OPTIONAL_STRING, _rev: OPTIONAL_STRING,
name: Joi.string().required(), name: Joi.string().regex(/^[a-zA-Z0-9_]*$/).required(),
// this is the base permission ID (for now a built in) // this is the base permission ID (for now a built in)
permissionId: Joi.string().valid(...Object.values(permissions.BuiltinPermissionID)).required(), permissionId: Joi.string().valid(...Object.values(permissions.BuiltinPermissionID)).required(),
permissions: Joi.object() permissions: Joi.object()

View File

@ -22,12 +22,12 @@ import tar from "tar"
const MemoryStream = require("memorystream") const MemoryStream = require("memorystream")
interface DBDumpOpts { export interface DBDumpOpts {
filter?: any filter?: any
exportPath?: string exportPath?: string
} }
interface ExportOpts extends DBDumpOpts { export interface ExportOpts extends DBDumpOpts {
tar?: boolean tar?: boolean
excludeRows?: boolean excludeRows?: boolean
excludeLogs?: boolean excludeLogs?: boolean
@ -57,7 +57,10 @@ function tarFilesToTmp(tmpDir: string, files: string[]) {
* a filter function or the name of the export. * a filter function or the name of the export.
* @return {*} either a readable stream or a string * @return {*} either a readable stream or a string
*/ */
export async function exportDB(dbName: string, opts: DBDumpOpts = {}) { export async function exportDB(
dbName: string,
opts: DBDumpOpts = {}
): Promise<DBDumpOpts> {
const exportOpts = { const exportOpts = {
filter: opts?.filter, filter: opts?.filter,
batch_size: 1000, batch_size: 1000,
@ -178,6 +181,7 @@ export async function exportApp(appId: string, config?: ExportOpts) {
* Streams a backup of the database state for an app * Streams a backup of the database state for an app
* @param {string} appId The ID of the app which is to be backed up. * @param {string} appId The ID of the app which is to be backed up.
* @param {boolean} excludeRows Flag to state whether the export should include data. * @param {boolean} excludeRows Flag to state whether the export should include data.
* @param {string} encryptPassword password for encrypting the export.
* @returns {*} a readable stream of the backup which is written in real time * @returns {*} a readable stream of the backup which is written in real time
*/ */
export async function streamExportApp({ export async function streamExportApp({

View File

@ -282,9 +282,10 @@ export function basicLinkedRow(
export function basicRole() { export function basicRole() {
return { return {
name: "NewRole", name: `NewRole_${utils.newid()}`,
inherits: roles.BUILTIN_ROLE_IDS.BASIC, inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: permissions.BuiltinPermissionID.READ_ONLY, permissionId: permissions.BuiltinPermissionID.READ_ONLY,
version: "name",
} }
} }

View File

@ -4,4 +4,5 @@ export interface Role extends Document {
permissionId: string permissionId: string
inherits?: string inherits?: string
permissions: { [key: string]: string[] } permissions: { [key: string]: string[] }
version?: string
} }

View File

@ -19,6 +19,7 @@ jest.mock("@budibase/backend-core", () => {
let appId: string let appId: string
let appDb: Database let appDb: Database
const ROLE_NAME = "newRole"
async function addAppMetadata() { async function addAppMetadata() {
await appDb.put({ await appDb.put({
@ -34,7 +35,7 @@ describe("/api/global/roles", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
const role = new roles.Role( const role = new roles.Role(
db.generateRoleID("newRole"), db.generateRoleID(ROLE_NAME),
roles.BUILTIN_ROLE_IDS.BASIC, roles.BUILTIN_ROLE_IDS.BASIC,
permissions.BuiltinPermissionID.READ_ONLY permissions.BuiltinPermissionID.READ_ONLY
) )
@ -66,7 +67,7 @@ describe("/api/global/roles", () => {
const res = await config.api.roles.get() const res = await config.api.roles.get()
expect(res.body).toBeDefined() expect(res.body).toBeDefined()
expect(res.body[appId].roles.length).toEqual(5) expect(res.body[appId].roles.length).toEqual(5)
expect(res.body[appId].roles.map((r: any) => r._id)).toContain(role._id) expect(res.body[appId].roles.map((r: any) => r._id)).toContain(ROLE_NAME)
}) })
}) })