diff --git a/.changeset/sixty-ladybugs-exercise.md b/.changeset/sixty-ladybugs-exercise.md new file mode 100644 index 000000000..c4648bce8 --- /dev/null +++ b/.changeset/sixty-ladybugs-exercise.md @@ -0,0 +1,7 @@ +--- +"@logto/integration-tests": patch +"@logto/console": patch +"@logto/core": patch +--- + +fix 500 error when using search component in console to filter both roles and applications. diff --git a/packages/console/src/components/RolesTransfer/components/SourceRolesBox/index.tsx b/packages/console/src/components/RolesTransfer/components/SourceRolesBox/index.tsx index 3a7dc4a16..c1fd88b6b 100644 --- a/packages/console/src/components/RolesTransfer/components/SourceRolesBox/index.tsx +++ b/packages/console/src/components/RolesTransfer/components/SourceRolesBox/index.tsx @@ -41,8 +41,7 @@ function SourceRolesBox({ entityId, type, selectedRoles, onChange }: Props) { const url = buildUrl('api/roles', { page: String(page), page_size: String(pageSize), - 'search.type': type, - 'mode.type': 'exact', + type, [type === RoleType.User ? 'excludeUserId' : 'excludeApplicationId']: entityId, ...conditional(keyword && { search: `%${keyword}%` }), }); diff --git a/packages/console/src/pages/Roles/components/AssignRoleModal/index.tsx b/packages/console/src/pages/Roles/components/AssignRoleModal/index.tsx index 5c6ab02de..2f3bb46fd 100644 --- a/packages/console/src/pages/Roles/components/AssignRoleModal/index.tsx +++ b/packages/console/src/pages/Roles/components/AssignRoleModal/index.tsx @@ -98,9 +98,7 @@ function AssignRoleModal({ pathname: `api/${phraseKey}`, parameters: { excludeRoleId: roleId, - ...(roleType === RoleType.User - ? {} - : { 'search.type': ApplicationType.MachineToMachine, 'mode.type': 'exact' }), + ...(roleType === RoleType.User ? {} : { types: ApplicationType.MachineToMachine }), }, }} selectedEntities={entities} diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 6f39f4337..69bf8f44b 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -4,7 +4,6 @@ import type { OmitAutoSetFields } from '@logto/shared'; import { convertToIdentifiers, conditionalSql, conditionalArraySql } from '@logto/shared'; import type { CommonQueryMethods, SqlSqlToken } from 'slonik'; import { sql } from 'slonik'; -import { snakeCase } from 'snake-case'; import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; @@ -23,7 +22,6 @@ const buildApplicationConditions = (search: Search) => { Applications.fields.id, Applications.fields.name, Applications.fields.description, - Applications.fields.type, ]; return conditionalSql( @@ -33,9 +31,7 @@ const buildApplicationConditions = (search: Search) => { * Avoid specifying the DB column type when calling the API (which is meaningless). * Should specify the DB column type of enum type. */ - sql`${buildConditionsFromSearch(search, searchFields, { - [Applications.fields.type]: snakeCase('ApplicationType'), - })}` + sql`${buildConditionsFromSearch(search, searchFields)}` ); }; @@ -48,7 +44,19 @@ const buildConditionArray = (conditions: SqlSqlToken[]) => { }; export const createApplicationQueries = (pool: CommonQueryMethods) => { - const countApplications = async (search: Search, excludeApplicationIds: string[]) => { + /** + * Get the number of applications that match the search conditions, conditions are joined in `and` mode. + * + * @param search The search config object, can apply to `id`, `name` and `description` field for application. + * @param excludeApplicationIds Exclude applications with these ids. + * @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included. + * @returns A Promise that resolves the number of applications that match the search conditions. + */ + const countApplications = async ( + search: Search, + excludeApplicationIds: string[], + types?: ApplicationType[] + ) => { const { count } = await pool.one<{ count: string }>(sql` select count(*) from ${table} @@ -56,6 +64,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { excludeApplicationIds.length > 0 ? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})` : sql``, + types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``, buildApplicationConditions(search), ])} `); @@ -63,9 +72,20 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { return { count: Number(count) }; }; + /** + * Get the list of applications that match the search conditions, conditions are joined in `and` mode. + * + * @param search The search config object, can apply to `id`, `name` and `description` field for application + * @param excludeApplicationIds Exclude applications with these ids. + * @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included. + * @param limit Limit of the number of applications in each page. + * @param offset Offset of the applications in the result. + * @returns A Promise that resolves the list of applications that match the search conditions. + */ const findApplications = async ( search: Search, excludeApplicationIds: string[], + types?: ApplicationType[], limit?: number, offset?: number ) => @@ -76,6 +96,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { excludeApplicationIds.length > 0 ? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})` : sql``, + types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``, buildApplicationConditions(search), ])} order by ${fields.createdAt} desc diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts index 566ba6e36..9b767d8fc 100644 --- a/packages/core/src/queries/roles.ts +++ b/packages/core/src/queries/roles.ts @@ -1,10 +1,9 @@ -import type { CreateRole, Role } from '@logto/schemas'; +import type { CreateRole, Role, RoleType } from '@logto/schemas'; import { internalRolePrefix, SearchJointMode, Roles } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; import { conditionalArraySql, conditionalSql, convertToIdentifiers } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; -import { snakeCase } from 'snake-case'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; @@ -17,19 +16,11 @@ const { table, fields } = convertToIdentifiers(Roles); const buildRoleConditions = (search: Search) => { const hasSearch = search.matches.length > 0; - const searchFields = [ - Roles.fields.id, - Roles.fields.name, - Roles.fields.description, - Roles.fields.type, - ]; + const searchFields = [Roles.fields.id, Roles.fields.name, Roles.fields.description]; return conditionalSql( hasSearch, - () => - sql`and ${buildConditionsFromSearch(search, searchFields, { - [Roles.fields.type]: snakeCase('RoleType'), - })}` + () => sql`and ${buildConditionsFromSearch(search, searchFields)}` ); }; @@ -38,7 +29,11 @@ export const defaultSearch = { matches: [], isCaseSensitive: false, joint: Searc export const createRolesQueries = (pool: CommonQueryMethods) => { const countRoles = async ( search: Search = defaultSearch, - { excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {} + { + excludeRoleIds = [], + roleIds, + type, + }: { excludeRoleIds?: string[]; roleIds?: string[]; type?: RoleType } = {} ) => { const { count } = await pool.one<{ count: string }>(sql` select count(*) @@ -53,6 +48,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => { (value) => sql`and ${fields.id} in (${value.length > 0 ? sql.join(value, sql`, `) : sql`null`})` )} + ${conditionalSql(type, (type) => sql`and ${fields.type}=${type}`)} ${buildRoleConditions(search)} `); @@ -63,7 +59,11 @@ export const createRolesQueries = (pool: CommonQueryMethods) => { search: Search, limit?: number, offset?: number, - { excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {} + { + excludeRoleIds = [], + roleIds, + type, + }: { excludeRoleIds?: string[]; roleIds?: string[]; type?: RoleType } = {} ) => pool.any( sql` @@ -79,6 +79,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => { (value) => sql`and ${fields.id} in (${value.length > 0 ? sql.join(value, sql`, `) : sql`null`})` )} + ${conditionalSql(type, (type) => sql`and ${fields.type}=${type}`)} ${buildRoleConditions(search)} ${conditionalSql(limit, (value) => sql`limit ${value}`)} ${conditionalSql(offset, (value) => sql`offset ${value}`)} diff --git a/packages/core/src/routes/admin-user/role.ts b/packages/core/src/routes/admin-user/role.ts index a7cdbc0cb..e54a3cce8 100644 --- a/packages/core/src/routes/admin-user/role.ts +++ b/packages/core/src/routes/admin-user/role.ts @@ -44,8 +44,8 @@ export default function adminUserRoleRoutes( const usersRoles = await findUsersRolesByUserId(userId); const roleIds = usersRoles.map(({ roleId }) => roleId); const [{ count }, roles] = await Promise.all([ - countRoles(search, { roleIds }), - findRoles(search, limit, offset, { roleIds }), + countRoles(search, { roleIds, type: RoleType.User }), + findRoles(search, limit, offset, { roleIds, type: RoleType.User }), ]); // Return totalCount to pagination middleware diff --git a/packages/core/src/routes/application-role.ts b/packages/core/src/routes/application-role.ts index 73a57e925..2bcefe935 100644 --- a/packages/core/src/routes/application-role.ts +++ b/packages/core/src/routes/application-role.ts @@ -55,8 +55,8 @@ export default function applicationRoleRoutes( const applicationRoles = await findApplicationsRolesByApplicationId(applicationId); const roleIds = applicationRoles.map(({ roleId }) => roleId); const [{ count }, roles] = await Promise.all([ - countRoles(search, { roleIds }), - findRoles(search, limit, offset, { roleIds }), + countRoles(search, { roleIds, type: RoleType.MachineToMachine }), + findRoles(search, limit, offset, { roleIds, type: RoleType.MachineToMachine }), ]); // Return totalCount to pagination middleware diff --git a/packages/core/src/routes/application.ts b/packages/core/src/routes/application.ts index 574a015b8..bd3334e78 100644 --- a/packages/core/src/routes/application.ts +++ b/packages/core/src/routes/application.ts @@ -21,6 +21,8 @@ import type { AuthedRouter, RouterInitArgs } from './types.js'; const includesInternalAdminRole = (roles: Readonly>) => roles.some(({ role: { name } }) => name === InternalRole.Admin); +const applicationTypeGuard = z.nativeEnum(ApplicationType); + export default function applicationRoutes( ...[ router, @@ -51,12 +53,23 @@ export default function applicationRoutes( '/applications', koaPagination({ isOptional: true }), koaGuard({ + query: object({ + /** + * We treat the `types` query param as an array, but it will be parsed as string-typed + * value if only one type is specified, manually convert to ApplicationType array. + */ + types: applicationTypeGuard + .array() + .or(applicationTypeGuard.transform((type) => [type])) + .optional(), + }), response: z.array(Applications.guard), status: 200, }), async (ctx, next) => { const { limit, offset, disabled: paginationDisabled } = ctx.pagination; const { searchParams } = ctx.URL; + const { types } = ctx.guard.query; const search = parseSearchParamsForSearch(searchParams); @@ -69,14 +82,14 @@ export default function applicationRoutes( ); if (paginationDisabled) { - ctx.body = await findApplications(search, excludeApplicationIds); + ctx.body = await findApplications(search, excludeApplicationIds, types); return next(); } const [{ count }, applications] = await Promise.all([ - countApplications(search, excludeApplicationIds), - findApplications(search, excludeApplicationIds, limit, offset), + countApplications(search, excludeApplicationIds, types), + findApplications(search, excludeApplicationIds, types, limit, offset), ]); // Return totalCount to pagination middleware diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index 44f6e8cac..3dfdb294e 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -49,6 +49,10 @@ export default function roleRoutes(...[router, tenant]: '/roles', koaPagination(), koaGuard({ + query: object({ + excludeUserId: string().optional(), + excludeApplicationId: string().optional(), + }).merge(Roles.guard.pick({ type: true }).partial()), response: Roles.guard .merge( object({ @@ -64,15 +68,14 @@ export default function roleRoutes(...[router, tenant]: async (ctx, next) => { const { limit, offset } = ctx.pagination; const { searchParams } = ctx.request.URL; + const { type, excludeUserId, excludeApplicationId } = ctx.guard.query; return tryThat( async () => { const search = parseSearchParamsForSearch(searchParams); - const excludeUserId = searchParams.get('excludeUserId'); const usersRoles = excludeUserId ? await findUsersRolesByUserId(excludeUserId) : []; - const excludeApplicationId = searchParams.get('excludeApplicationId'); const applicationsRoles = excludeApplicationId ? await findApplicationsRolesByApplicationId(excludeApplicationId) : []; @@ -83,8 +86,8 @@ export default function roleRoutes(...[router, tenant]: ]; const [{ count }, roles] = await Promise.all([ - countRoles(search, { excludeRoleIds }), - findRoles(search, limit, offset, { excludeRoleIds }), + countRoles(search, { excludeRoleIds, type }), + findRoles(search, limit, offset, { excludeRoleIds, type }), ]); const rolesResponse: RoleResponse[] = await Promise.all( diff --git a/packages/core/src/utils/search.ts b/packages/core/src/utils/search.ts index 9fe693f16..f3c56ca93 100644 --- a/packages/core/src/utils/search.ts +++ b/packages/core/src/utils/search.ts @@ -1,6 +1,6 @@ import { SearchJointMode, SearchMatchMode } from '@logto/schemas'; import type { Nullable, Optional } from '@silverhand/essentials'; -import { yes, conditionalString, conditional, cond } from '@silverhand/essentials'; +import { yes, conditionalString, cond } from '@silverhand/essentials'; import { sql } from 'slonik'; import { snakeCase } from 'snake-case'; @@ -195,13 +195,9 @@ const getMatchModeOperator = (match: SearchMatchMode, isCaseSensitive: boolean) const validateAndBuildValueExpression = ( rawValues: string[], field: string, - shouldLowercase: boolean, - fieldsTypeMapping?: Record + shouldLowercase: boolean ) => { - const values = - shouldLowercase && isLowercaseValid(field, fieldsTypeMapping) - ? rawValues.map((rawValue) => rawValue.toLowerCase()) - : rawValues; + const values = shouldLowercase ? rawValues.map((rawValue) => rawValue.toLowerCase()) : rawValues; // Type check for the first value assertThat( @@ -210,25 +206,11 @@ const validateAndBuildValueExpression = ( ); const valueExpression = - values.length === 1 - ? sql`${values[0]}` - : sql`any(${sql.array(values, conditional(fieldsTypeMapping?.[field]) ?? 'varchar')})`; + values.length === 1 ? sql`${values[0]}` : sql`any(${sql.array(values, 'varchar')})`; return valueExpression; }; -const isLowercaseValid = (field: string, fieldsTypeMapping?: Record) => { - return !conditional(fieldsTypeMapping?.[field]); -}; - -const showLowercase = ( - shouldLowercase: boolean, - field: string, - fieldsTypeMapping?: Record -) => { - return shouldLowercase && isLowercaseValid(field, fieldsTypeMapping); -}; - /** * Build search SQL token by parsing the search object and available search fields. * Note all `field`s will be normalized to snake case, so camel case fields are valid. @@ -238,11 +220,7 @@ const showLowercase = ( * @returns The SQL token that includes the all condition checks. * @throws TypeError error if fields in `search` do not match the `searchFields`, or invalid condition found (e.g. the value is empty). */ -export const buildConditionsFromSearch = ( - search: Search, - searchFields: readonly string[], - fieldsTypeMapping?: Readonly> -) => { +export const buildConditionsFromSearch = (search: Search, searchFields: readonly string[]) => { assertThat(searchFields.length > 0, new TypeError('No search field found.')); const { matches, joint, isCaseSensitive } = search; @@ -259,15 +237,13 @@ export const buildConditionsFromSearch = ( const fields = field ? [field] : searchFields; const getValueExpressionFor = (fieldName: string, shouldLowercase: boolean) => - validateAndBuildValueExpression(values, fieldName, shouldLowercase, fieldsTypeMapping); + validateAndBuildValueExpression(values, fieldName, shouldLowercase); return sql`(${sql.join( fields.map( (field) => sql`${ - showLowercase(shouldLowercase, field, fieldsTypeMapping) - ? sql`lower(${sql.identifier([field])})` - : sql.identifier([field]) + shouldLowercase ? sql`lower(${sql.identifier([field])})` : sql.identifier([field]) } ${getMatchModeOperator(mode, isCaseSensitive)} ${getValueExpressionFor( field, shouldLowercase diff --git a/packages/integration-tests/src/api/admin-user.ts b/packages/integration-tests/src/api/admin-user.ts index 985f67561..2180ee705 100644 --- a/packages/integration-tests/src/api/admin-user.ts +++ b/packages/integration-tests/src/api/admin-user.ts @@ -48,8 +48,17 @@ export const deleteUserIdentity = async (userId: string, connectorTarget: string export const assignRolesToUser = async (userId: string, roleIds: string[]) => authedAdminApi.post(`users/${userId}/roles`, { json: { roleIds } }); -export const getUserRoles = async (userId: string) => - authedAdminApi.get(`users/${userId}/roles`).json(); +/** + * Get roles assigned to the user. + * + * @param userId Concerned user id + * @param keyword Search among all roles (on `id`, `name` and `description` fields) assigned to the user with `keyword` + * @returns All roles which contains the keyword assigned to the user + */ +export const getUserRoles = async (userId: string, keyword?: string) => { + const searchParams = new URLSearchParams(keyword && [['search', `%${keyword}%`]]); + return authedAdminApi.get(`users/${userId}/roles`, { searchParams }).json(); +}; export const deleteRoleFromUser = async (userId: string, roleId: string) => authedAdminApi.delete(`users/${userId}/roles/${roleId}`); diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index 2519506f7..5ab9d760b 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -24,13 +24,18 @@ export const createApplication = async ( }) .json(); -export const getApplications = async (types?: ApplicationType[]) => { - const searchParams = new URLSearchParams( - conditional( - types && - types.length > 0 && [...types.map((type) => ['search.type', type]), ['mode.type', 'exact']] - ) - ); +export const getApplications = async ( + types?: ApplicationType[], + searchParameters?: Record +) => { + const searchParams = new URLSearchParams([ + ...(conditional(types && types.length > 0 && types.map((type) => ['types', type])) ?? []), + ...(conditional( + searchParameters && + Object.keys(searchParameters).length > 0 && + Object.entries(searchParameters).map(([key, value]) => [key, value]) + ) ?? []), + ]); return authedAdminApi.get('applications', { searchParams }).json(); }; @@ -57,8 +62,17 @@ export const updateApplication = async ( export const deleteApplication = async (applicationId: string) => authedAdminApi.delete(`applications/${applicationId}`); -export const getApplicationRoles = async (applicationId: string) => - authedAdminApi.get(`applications/${applicationId}/roles`).json(); +/** + * Get roles assigned to the m2m app. + * + * @param applicationId Concerned m2m app id + * @param keyword Search among all roles (on `id`, `name` and `description` fields) assigned to the m2m app with `keyword` + * @returns All roles which contains the keyword assigned to the m2m app + */ +export const getApplicationRoles = async (applicationId: string, keyword?: string) => { + const searchParams = new URLSearchParams(conditional(keyword && [['search', `%${keyword}%`]])); + return authedAdminApi.get(`applications/${applicationId}/roles`, { searchParams }).json(); +}; export const assignRolesToApplication = async (applicationId: string, roleIds: string[]) => authedAdminApi.post(`applications/${applicationId}/roles`, { diff --git a/packages/integration-tests/src/api/role.ts b/packages/integration-tests/src/api/role.ts index 1cd502f83..6b6c467f0 100644 --- a/packages/integration-tests/src/api/role.ts +++ b/packages/integration-tests/src/api/role.ts @@ -5,7 +5,12 @@ import { generateRoleName } from '#src/utils.js'; import { authedAdminApi } from './api.js'; -export type GetRoleOptions = { excludeUserId?: string; excludeApplicationId?: string }; +export type GetRoleOptions = { + excludeUserId?: string; + excludeApplicationId?: string; + type?: RoleType; + search?: string; +}; export const createRole = async ({ name, @@ -58,8 +63,17 @@ export const assignScopesToRole = async (scopeIds: string[], roleId: string) => export const deleteScopeFromRole = async (scopeId: string, roleId: string) => authedAdminApi.delete(`roles/${roleId}/scopes/${scopeId}`); -export const getRoleUsers = async (roleId: string) => - authedAdminApi.get(`roles/${roleId}/users`).json(); +/** + * Get users assigned to the role. + * + * @param roleId Concerned role id. + * @param keyword Search among all users (on `id`, `name` and `description` fields) assigned to the role with `keyword`. + * @returns A Promise that resolves all users which contains the keyword assigned to the role. + */ +export const getRoleUsers = async (roleId: string, keyword?: string) => { + const searchParams = new URLSearchParams(keyword && [['search', `%${keyword}%`]]); + return authedAdminApi.get(`roles/${roleId}/users`, { searchParams }).json(); +}; export const assignUsersToRole = async (userIds: string[], roleId: string) => authedAdminApi.post(`roles/${roleId}/users`, { @@ -69,8 +83,17 @@ export const assignUsersToRole = async (userIds: string[], roleId: string) => export const deleteUserFromRole = async (userId: string, roleId: string) => authedAdminApi.delete(`roles/${roleId}/users/${userId}`); -export const getRoleApplications = async (roleId: string) => - authedAdminApi.get(`roles/${roleId}/applications`).json(); +/** + * Get apps assigned to the role. + * + * @param roleId Concerned role id. + * @param keyword Search among all m2m apps (on `id`, `name` and `description` fields) assigned to the role with `keyword`. + * @returns A Promise that resolves all m2m apps which contains the keyword assigned to the role. + */ +export const getRoleApplications = async (roleId: string, keyword?: string) => { + const searchParams = new URLSearchParams(keyword && [['search', `%${keyword}%`]]); + return authedAdminApi.get(`roles/${roleId}/applications`, { searchParams }).json(); +}; export const assignApplicationsToRole = async (applicationIds: string[], roleId: string) => authedAdminApi.post(`roles/${roleId}/applications`, { diff --git a/packages/integration-tests/src/tests/api/admin-user.roles.test.ts b/packages/integration-tests/src/tests/api/admin-user.roles.test.ts index c1932cf59..dd44bea27 100644 --- a/packages/integration-tests/src/tests/api/admin-user.roles.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.roles.test.ts @@ -15,7 +15,8 @@ describe('admin console user management (roles)', () => { it('should successfully assign user role to user and get list, but failed to assign m2m role to user', async () => { const user = await createUserByAdmin(); - const role = await createRole({}); + const role1 = await createRole({}); + const role2 = await createRole({}); const m2mRole = await createRole({ type: RoleType.MachineToMachine }); await expectRejects(assignRolesToUser(user.id, [m2mRole.id]), { @@ -23,10 +24,18 @@ describe('admin console user management (roles)', () => { statusCode: 422, }); - await assignRolesToUser(user.id, [role.id]); + await assignRolesToUser(user.id, [role1.id, role2.id]); const roles = await getUserRoles(user.id); - expect(roles.length).toBe(1); - expect(roles[0]).toHaveProperty('id', role.id); + expect(roles.length).toBe(2); + expect(roles.find(({ id }) => id === role1.id)).toBeDefined(); + + // Empty keyword should be ignored, all assigned roles should be returned + await expect(getUserRoles(user.id, '')).resolves.toHaveLength(2); + + // Get right assigned roles with search keyword + const assignedRolesWithKeyword = await getUserRoles(user.id, role1.name); + expect(assignedRolesWithKeyword).toHaveLength(1); + expect(assignedRolesWithKeyword.find(({ id }) => id === role2.id)).toBeUndefined(); }); it('should fail when assign duplicated role to user', async () => { diff --git a/packages/integration-tests/src/tests/api/application.roles.test.ts b/packages/integration-tests/src/tests/api/application.roles.test.ts index 80b62fb72..ea5a9331c 100644 --- a/packages/integration-tests/src/tests/api/application.roles.test.ts +++ b/packages/integration-tests/src/tests/api/application.roles.test.ts @@ -8,8 +8,9 @@ import { assignRolesToApplication, deleteRoleFromApplication, putRolesToApplication, + getApplications, } from '#src/api/index.js'; -import { createRole } from '#src/api/role.js'; +import { createRole, assignApplicationsToRole } from '#src/api/role.js'; import { expectRejects } from '#src/helpers/index.js'; describe('admin console application management (roles)', () => { @@ -40,6 +41,14 @@ describe('admin console application management (roles)', () => { expect(roles.length).toBe(2); expect(roles.find(({ id }) => id === role1.id)).toBeDefined(); expect(roles.find(({ id }) => id === role2.id)).toBeDefined(); + + // Empty keyword should be ignored, all assigned roles should be returned + await expect(getApplicationRoles(application.id, '')).resolves.toHaveLength(2); + + // Get right assigned roles with search keyword + const rolesWithSearchParams = await getApplicationRoles(application.id, role1.name); + expect(rolesWithSearchParams).toHaveLength(1); + expect(rolesWithSearchParams.find(({ id }) => id === role2.id)).toBeUndefined(); }); it('should fail when assign duplicated role to app', async () => { @@ -107,4 +116,34 @@ describe('admin console application management (roles)', () => { ); expect(response instanceof HTTPError && response.response.statusCode === 404).toBe(true); }); + + // This case tests GET operation on applications and filter by `types` parameter and `search` parameter. + it('search applications with specified keyword, types and other parameters', async () => { + await createApplication('test-m2m-app-001', ApplicationType.MachineToMachine); + const m2mApp002 = await createApplication('test-m2m-app-002', ApplicationType.MachineToMachine); + await createApplication('test-spa-app-001', ApplicationType.SPA); + await createApplication('test-spa-app-002', ApplicationType.SPA); + await createApplication('test-native-app-001', ApplicationType.Native); + await createApplication('test-native-app-002', ApplicationType.Native); + + // Search applications with `types` and `search` parameters + const spaAndM2mAppsWithKeyword = await getApplications( + [ApplicationType.SPA, ApplicationType.MachineToMachine], + { search: '%002%' } + ); + expect(spaAndM2mAppsWithKeyword.length).toBe(2); + expect(spaAndM2mAppsWithKeyword.find(({ name }) => name === 'test-m2m-app-002')).toBeTruthy(); + expect(spaAndM2mAppsWithKeyword.find(({ name }) => name === 'test-spa-app-002')).toBeTruthy(); + + // Search applications with `types`, `search` and `excludeRoleId` parameters + const m2mRole = await createRole({ type: RoleType.MachineToMachine }); + await assignApplicationsToRole([m2mApp002.id], m2mRole.id); + const applications = await getApplications( + [ApplicationType.SPA, ApplicationType.MachineToMachine], + { search: '%002%', excludeRoleId: m2mRole.id } + ); + expect(applications.length).toBe(1); + expect(applications.find(({ name }) => name === 'test-m2m-app-002')).toBeFalsy(); + expect(applications.find(({ name }) => name === 'test-spa-app-002')).toBeTruthy(); + }); }); diff --git a/packages/integration-tests/src/tests/api/role.application.test.ts b/packages/integration-tests/src/tests/api/role.application.test.ts index d05a06fa6..4939ae1e1 100644 --- a/packages/integration-tests/src/tests/api/role.application.test.ts +++ b/packages/integration-tests/src/tests/api/role.application.test.ts @@ -2,7 +2,7 @@ import { ApplicationType, RoleType } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { HTTPError } from 'got'; -import { createApplication } from '#src/api/index.js'; +import { assignRolesToApplication, createApplication } from '#src/api/index.js'; import { assignApplicationsToRole, createRole, @@ -32,12 +32,21 @@ describe('roles applications', () => { it('should assign applications to role successfully', async () => { const role = await createRole({ type: RoleType.MachineToMachine }); - const m2mApp1 = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); - const m2mApp2 = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + const m2mApp1 = await createApplication('m2m-app-001', ApplicationType.MachineToMachine); + const m2mApp2 = await createApplication('m2m-app-002', ApplicationType.MachineToMachine); await assignApplicationsToRole([m2mApp1.id, m2mApp2.id], role.id); - const applications = await getRoleApplications(role.id); - expect(applications.length).toBe(2); + // No assigned m2m apps satisfy the search keyword + await expect(getRoleApplications(role.id, 'not-found')).resolves.toHaveLength(0); + + // Get right assigned m2m apps with search keyword + await expect(getRoleApplications(role.id, 'm2m-app')).resolves.toHaveLength(2); + + // Empty search keyword should be ignored, all assigned m2m apps should be returned + await expect(getRoleApplications(role.id, '')).resolves.toHaveLength(2); + + // Get all assigned m2m apps + await expect(getRoleApplications(role.id)).resolves.toHaveLength(2); }); it('should fail when try to assign empty applications', async () => { @@ -96,4 +105,32 @@ describe('roles applications', () => { ); expect(response instanceof HTTPError && response.response.statusCode).toBe(404); }); + + // This case tests GET operation on roles and filter by `type` parameter and `search` parameter. + it('search roles with specified keyword, type and other parameters', async () => { + const m2mRole1 = await createRole({ + type: RoleType.MachineToMachine, + name: 'test-m2m-role-001', + }); + await createRole({ type: RoleType.MachineToMachine, name: 'test-m2m-role-002' }); + await createRole({ type: RoleType.User, name: 'test-user-role-001' }); + await createRole({ type: RoleType.User, name: 'test-user-role-002' }); + + const m2mRoles = await getRoles({ type: RoleType.MachineToMachine, search: '%m2m-role-00%' }); + expect(m2mRoles.length).toBe(2); + expect(m2mRoles.find(({ name }) => name === 'test-m2m-role-001')).toBeDefined(); + expect(m2mRoles.find(({ name }) => name === 'test-m2m-role-002')).toBeDefined(); + + const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + await assignRolesToApplication(m2mApp.id, [m2mRole1.id]); + const roles = await getRoles({ + excludeApplicationId: m2mApp.id, + type: RoleType.MachineToMachine, + search: '%m2m-role-00%', + }); + + expect(roles.length).toBe(1); + expect(roles.find(({ name }) => name === 'test-m2m-role-001')).toBeUndefined(); + expect(roles.find(({ name }) => name === 'test-m2m-role-002')).toBeDefined(); + }); }); diff --git a/packages/integration-tests/src/tests/api/role.user.test.ts b/packages/integration-tests/src/tests/api/role.user.test.ts index d59b50f86..dfa2f8cec 100644 --- a/packages/integration-tests/src/tests/api/role.user.test.ts +++ b/packages/integration-tests/src/tests/api/role.user.test.ts @@ -33,12 +33,32 @@ describe('roles users', () => { it('should assign users to role successfully', async () => { const role = await createRole({}); - const user1 = await createUser(generateNewUserProfile({})); - const user2 = await createUser(generateNewUserProfile({})); - await assignUsersToRole([user1.id, user2.id], role.id); - const users = await getRoleUsers(role.id); + const user1 = await createUser({ + username: 'username001', + name: 'user001', + primaryEmail: 'user001@logto.io', + }); + const user2 = await createUser({ name: 'user002', primaryPhone: '123456789' }); + const user3 = await createUser({ username: 'username3', primaryEmail: 'user3@logto.io' }); + await assignUsersToRole([user1.id, user2.id, user3.id], role.id); - expect(users.length).toBe(2); + // No assigned users satisfy the search keyword + await expect(getRoleUsers(role.id, 'not-found')).resolves.toHaveLength(0); + + // Get right assigned users with search keyword + const assignedUsersWithEmailDomainSuffix = await getRoleUsers(role.id, '@logto.io'); + expect(assignedUsersWithEmailDomainSuffix).toHaveLength(2); + expect(assignedUsersWithEmailDomainSuffix.find(({ id }) => id === user2.id)).toBeUndefined(); + + const assignedUsersWithAnotherKeyword = await getRoleUsers(role.id, 'user00'); + expect(assignedUsersWithAnotherKeyword).toHaveLength(2); + expect(assignedUsersWithAnotherKeyword.find(({ id }) => id === user3.id)).toBeUndefined(); + + // Empty search keyword should be ignored, all assigned users should be returned + await expect(getRoleUsers(role.id, '')).resolves.toHaveLength(3); + + // Get all assigned users + await expect(getRoleUsers(role.id)).resolves.toHaveLength(3); }); it('should throw when assigning users to m2m role', async () => { diff --git a/packages/integration-tests/src/tests/console/machine-to-machine-rbac/machime-to-machime-rbac.test.ts b/packages/integration-tests/src/tests/console/machine-to-machine-rbac/machime-to-machime-rbac.test.ts index 5a216855a..1a880b2e2 100644 --- a/packages/integration-tests/src/tests/console/machine-to-machine-rbac/machime-to-machime-rbac.test.ts +++ b/packages/integration-tests/src/tests/console/machine-to-machine-rbac/machime-to-machime-rbac.test.ts @@ -34,6 +34,8 @@ describe('M2M RBAC', () => { const permissionDescription = 'Dummy permission description'; const roleName = generateRoleName(); const roleDescription = 'Dummy role description'; + const anotherRoleName = generateRoleName(); + const anotherRoleDescription = 'Another dummy role description'; const rbacTestAppname = 'm2m-app-001'; const m2mFramework = 'Machine-to-machine'; @@ -152,11 +154,37 @@ describe('M2M RBAC', () => { ); }); - it('create a m2m role and assign permissions to the role', async () => { - await createM2mRoleAndAssignPermissions(page, { roleName, roleDescription }, [ - { apiResourceName, permissionName }, - { apiResourceName: managementApiResourceName, permissionName: managementApiPermission }, - ]); + it('create a m2m role and assign permissions to the role, then go back to role listing page', async () => { + await createM2mRoleAndAssignPermissions( + page, + { roleName, roleDescription }, + [ + { apiResourceName, permissionName }, + { apiResourceName: managementApiResourceName, permissionName: managementApiPermission }, + ], + true + ); + }); + + it('create another m2m role and assign permissions to the role, then go back to role listing page', async () => { + await createM2mRoleAndAssignPermissions( + page, + { roleName: anotherRoleName, roleDescription: anotherRoleDescription }, + [ + { apiResourceName, permissionName }, + { apiResourceName: managementApiResourceName, permissionName: managementApiPermission }, + ], + true + ); + }); + + it('search for a role and enter its details page', async () => { + await expect(page).toFill('div[class$=filter] input', roleName); + await expect(page).toClick('button', { text: 'Search' }); + + await expect(page).toClick('table tbody tr td div[class$=meta]:has(a[class$=title])', { + text: roleName, + }); }); it('delete a permission from a role on the role details page', async () => { @@ -229,6 +257,7 @@ describe('M2M RBAC', () => { await expectModalWithTitle(page, 'Assign apps'); + await expect(page).toFill('.ReactModalPortal input[type=text]', rbacTestAppname); await expect(page).toClick( '.ReactModalPortal div[class$=rolesTransfer] div[class$=item] div[class$=title]', { @@ -248,30 +277,12 @@ describe('M2M RBAC', () => { } ); }); - }); - describe('assign/remove a role to/from a m2m app (on m2m app details page)', () => { - it('remove a role form a m2m app on the app details page', async () => { - // Navigate to app details page - await expect(page).toClick('table tbody tr td a[class$=title]', { + it('remove m2m app from the role on the role details page', async () => { + const m2mAppRole = await expect(page).toMatchElement('table tbody tr:has(td div)', { text: rbacTestAppname, }); - - await expect(page).toMatchElement('div[class$=header] > div[class$=metadata] div', { - text: rbacTestAppname, - }); - - // Go to roles tab - await expect(page).toClick('nav div[class$=item] div[class$=link] a', { - text: 'Roles', - }); - - const roleRow = await expect(page).toMatchElement('table tbody tr:has(td a[class$=title])', { - text: roleName, - }); - - // Click remove button - await expect(roleRow).toClick('td:last-of-type button'); + await expect(m2mAppRole).toClick('td > div[class$=anchor] > button'); await expectConfirmModalAndAct(page, { title: 'Reminder', @@ -279,11 +290,28 @@ describe('M2M RBAC', () => { }); await waitForToast(page, { - text: `${roleName} was successfully removed from this user.`, + text: `${rbacTestAppname} was successfully removed from this role`, + }); + }); + }); + + describe('assign/remove a role to/from a m2m app (on m2m app details page)', () => { + it('navigate to application page and enter m2m app details page', async () => { + await expectNavigation( + page.goto(appendPathname('/console/applications', logtoConsoleUrl).href) + ); + + await expect(page).toClick('table tbody tr td a[class$=title]', { + text: rbacTestAppname, }); }); it('add a role to m2m app on the application details page', async () => { + // Go to roles tab + await expect(page).toClick('nav div[class$=item] div[class$=link] a', { + text: 'Roles', + }); + await expect(page).toClick('div[class$=filter] button span', { text: 'Assign roles', }); @@ -303,5 +331,30 @@ describe('M2M RBAC', () => { text: 'Successfully assigned role(s)', }); }); + + it('remove a role form a m2m app on the app details page', async () => { + await expect(page).toMatchElement( + 'div[class$=header] > div[class$=metadata] > div[class$=name]', + { + text: rbacTestAppname, + } + ); + + const roleRow = await expect(page).toMatchElement('table tbody tr:has(td a[class$=title])', { + text: roleName, + }); + + // Click remove button + await expect(roleRow).toClick('td:last-of-type button'); + + await expectConfirmModalAndAct(page, { + title: 'Reminder', + actionText: 'Remove', + }); + + await waitForToast(page, { + text: `${roleName} was successfully removed from this user.`, + }); + }); }); }); diff --git a/packages/integration-tests/src/tests/console/machine-to-machine-rbac/utils.ts b/packages/integration-tests/src/tests/console/machine-to-machine-rbac/utils.ts index 30b5bf093..a79e35893 100644 --- a/packages/integration-tests/src/tests/console/machine-to-machine-rbac/utils.ts +++ b/packages/integration-tests/src/tests/console/machine-to-machine-rbac/utils.ts @@ -1,16 +1,29 @@ import { type Page } from 'puppeteer'; +import { logtoConsoleUrl } from '#src/constants.js'; import { expectModalWithTitle, expectToClickModalAction, waitForToast, } from '#src/ui-helpers/index.js'; +import { expectNavigation, appendPathname } from '#src/utils.js'; +/** + * Create a machine-to-machine role and assign permissions to it by operating on the Web + * + * @param page The page to run the test on + * @param createRolePayload The payload to create the role + * @param apiResources The list of API resources which are going to be assigned to the role + * @param backToListingPage Whether to go back to the roles listing page after creating the role + */ export const createM2mRoleAndAssignPermissions = async ( page: Page, - { roleName, roleDescription }: { roleName: string; roleDescription: string }, - apiResources: Array<{ apiResourceName: string; permissionName: string }> + createRolePayload: { roleName: string; roleDescription: string }, + apiResources: Array<{ apiResourceName: string; permissionName: string }>, + backToListingPage = false ) => { + const { roleName, roleDescription } = createRolePayload; + await expect(page).toClick('div[class$=headline] button span', { text: 'Create role', }); @@ -63,4 +76,17 @@ export const createM2mRoleAndAssignPermissions = async ( await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div[class$=name]', { text: roleName, }); + + if (backToListingPage) { + await expectNavigation( + page.goto(appendPathname('/console/roles', new URL(logtoConsoleUrl)).href) + ); + + await expect(page).toMatchElement( + 'div[class$=main] div[class$=headline] div[class$=titleEllipsis]', + { + text: 'Roles', + } + ); + } };