0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

fix(console,core,test): support filter by type for GET roles and apps APIs (#4727)

* fix(console,core): do not reuse advanced search for role/app type

* chore(test): add integration test cases for using search component when assigning app/role

* chore: apply suggestions from code review

Co-authored-by: Gao Sun <gao@silverhand.io>

* chore(test): add API test cases for filtering roles/applications and add changeset

* chore: apply suggestions from code review

Co-authored-by: Gao Sun <gao@silverhand.io>

---------

Co-authored-by: Gao Sun <gao@silverhand.io>
This commit is contained in:
Darcy Ye 2023-10-30 22:25:15 +08:00 committed by GitHub
parent d41b71a54a
commit 1ab39d19b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 375 additions and 127 deletions

View file

@ -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.

View file

@ -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}%` }),
});

View file

@ -98,9 +98,7 @@ function AssignRoleModal<T extends Application | User>({
pathname: `api/${phraseKey}`,
parameters: {
excludeRoleId: roleId,
...(roleType === RoleType.User
? {}
: { 'search.type': ApplicationType.MachineToMachine, 'mode.type': 'exact' }),
...(roleType === RoleType.User ? {} : { types: ApplicationType.MachineToMachine }),
},
}}
selectedEntities={entities}

View file

@ -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

View file

@ -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<Role>(
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}`)}

View file

@ -44,8 +44,8 @@ export default function adminUserRoleRoutes<T extends AuthedRouter>(
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

View file

@ -55,8 +55,8 @@ export default function applicationRoleRoutes<T extends AuthedRouter>(
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

View file

@ -21,6 +21,8 @@ import type { AuthedRouter, RouterInitArgs } from './types.js';
const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
roles.some(({ role: { name } }) => name === InternalRole.Admin);
const applicationTypeGuard = z.nativeEnum(ApplicationType);
export default function applicationRoutes<T extends AuthedRouter>(
...[
router,
@ -51,12 +53,23 @@ export default function applicationRoutes<T extends AuthedRouter>(
'/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<T extends AuthedRouter>(
);
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

View file

@ -49,6 +49,10 @@ export default function roleRoutes<T extends AuthedRouter>(...[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<T extends AuthedRouter>(...[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<T extends AuthedRouter>(...[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(

View file

@ -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<string, string>
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<string, string>) => {
return !conditional(fieldsTypeMapping?.[field]);
};
const showLowercase = (
shouldLowercase: boolean,
field: string,
fieldsTypeMapping?: Record<string, string>
) => {
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<Record<string, string>>
) => {
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

View file

@ -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<Role[]>();
/**
* 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<Role[]>();
};
export const deleteRoleFromUser = async (userId: string, roleId: string) =>
authedAdminApi.delete(`users/${userId}/roles/${roleId}`);

View file

@ -24,13 +24,18 @@ export const createApplication = async (
})
.json<Application>();
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<string, string>
) => {
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<Application[]>();
};
@ -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<Role[]>();
/**
* 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<Role[]>();
};
export const assignRolesToApplication = async (applicationId: string, roleIds: string[]) =>
authedAdminApi.post(`applications/${applicationId}/roles`, {

View file

@ -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<User[]>();
/**
* 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<User[]>();
};
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<Application[]>();
/**
* 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<Application[]>();
};
export const assignApplicationsToRole = async (applicationIds: string[], roleId: string) =>
authedAdminApi.post(`roles/${roleId}/applications`, {

View file

@ -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 () => {

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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 () => {

View file

@ -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.`,
});
});
});
});

View file

@ -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',
}
);
}
};