0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add search and pagination to role scopes (#2925)

This commit is contained in:
wangsijie 2023-01-12 16:56:29 +08:00 committed by GitHub
parent 9ec3a8fcb7
commit 6d874d5250
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 276 additions and 143 deletions

View file

@ -25,7 +25,12 @@ const buildResourceConditions = (search: Search) => {
};
export const createScopeQueries = (pool: CommonQueryMethods) => {
const findScopes = async (resourceId: string, search: Search, limit?: number, offset?: number) =>
const searchScopesByResourceId = async (
resourceId: string,
search: Search,
limit?: number,
offset?: number
) =>
pool.any<Scope>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
@ -35,7 +40,22 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
${conditionalSql(offset, (value) => sql`offset ${value}`)}
`);
const countScopes = async (resourceId: string, search: Search) =>
const searchScopesByScopeIds = async (
scopeIds: string[],
search: Search,
limit?: number,
offset?: number
) =>
pool.any<Scope>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.id} in (${scopeIds.length > 0 ? sql.join(scopeIds, sql`, `) : sql`null`})
${buildResourceConditions(search)}
${conditionalSql(limit, (value) => sql`limit ${value}`)}
${conditionalSql(offset, (value) => sql`offset ${value}`)}
`);
const countScopesByResourceId = async (resourceId: string, search: Search) =>
pool.one<{ count: number }>(sql`
select count(*)
from ${table}
@ -43,6 +63,14 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
${buildResourceConditions(search)}
`);
const countScopesByScopeIds = async (scopeIds: string[], search: Search) =>
pool.one<{ count: number }>(sql`
select count(*)
from ${table}
where ${fields.id} in (${scopeIds.length > 0 ? sql.join(scopeIds, sql`, `) : sql`null`})
${buildResourceConditions(search)}
`);
const findScopeByNameAndResourceId = async (
name: string,
resourceId: string,
@ -116,8 +144,10 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
};
return {
findScopes,
countScopes,
searchScopesByResourceId,
searchScopesByScopeIds,
countScopesByResourceId,
countScopesByScopeIds,
findScopeByNameAndResourceId,
findScopesByResourceId,
findScopesByResourceIds,

View file

@ -18,6 +18,7 @@ import logRoutes from './log.js';
import phraseRoutes from './phrase.js';
import resourceRoutes from './resource.js';
import roleRoutes from './role.js';
import roleScopeRoutes from './role.scope.js';
import settingRoutes from './setting.js';
import signInExperiencesRoutes from './sign-in-experience/index.js';
import statusRoutes from './status.js';
@ -41,6 +42,7 @@ const createRouters = (tenant: TenantContext) => {
adminUserRoleRoutes(managementRouter, tenant);
logRoutes(managementRouter, tenant);
roleRoutes(managementRouter, tenant);
roleScopeRoutes(managementRouter, tenant);
dashboardRoutes(managementRouter, tenant);
customPhraseRoutes(managementRouter, tenant);
hookRoutes(managementRouter, tenant);

View file

@ -28,7 +28,8 @@ const { findResourceById } = resources;
const scopes = {
findScopesByResourceId: async () => [mockScope],
findScopes: async () => [mockScope],
searchScopesByResourceId: async () => [mockScope],
countScopesByResourceId: async () => ({ count: 1 }),
insertScope: jest.fn(async () => mockScope),
updateScopeById: jest.fn(async () => mockScope),
deleteScopeById: jest.fn(),

View file

@ -28,9 +28,9 @@ export default function resourceRoutes<T extends AuthedRouter>(
deleteResourceById,
},
scopes: {
countScopes,
countScopesByResourceId,
deleteScopeById,
findScopes,
searchScopesByResourceId,
findScopeByNameAndResourceId,
insertScope,
updateScopeById,
@ -137,28 +137,22 @@ export default function resourceRoutes<T extends AuthedRouter>(
router.get(
'/resources/:resourceId/scopes',
koaPagination({ isOptional: true }),
koaPagination(),
koaGuard({ params: object({ resourceId: string().min(1) }) }),
async (ctx, next) => {
const {
params: { resourceId },
} = ctx.guard;
const { limit, offset, disabled } = ctx.pagination;
const { limit, offset } = ctx.pagination;
const { searchParams } = ctx.request.URL;
return tryThat(
async () => {
const search = parseSearchParamsForSearch(searchParams);
if (disabled) {
ctx.body = await findScopes(resourceId, search);
return next();
}
const [{ count }, roles] = await Promise.all([
countScopes(resourceId, search),
findScopes(resourceId, search, limit, offset),
countScopesByResourceId(resourceId, search),
searchScopesByResourceId(resourceId, search, limit, offset),
]);
// Return totalCount to pagination middleware

View file

@ -0,0 +1,95 @@
import type { Role } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockRole, mockScope, mockResource } from '#src/__mocks__/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const roles = {
findRoles: jest.fn(async (): Promise<Role[]> => [mockRole]),
countRoles: jest.fn(async () => ({ count: 10 })),
insertRole: jest.fn(async (data) => ({
...data,
id: mockRole.id,
})),
findRoleById: jest.fn(),
updateRoleById: jest.fn(async (id, data) => ({
...mockRole,
...data,
})),
findRolesByRoleIds: jest.fn(),
};
const { findRoleById } = roles;
const scopes = {
findScopeById: jest.fn(),
findScopesByIds: jest.fn(),
countScopesByScopeIds: jest.fn(async () => ({ count: 1 })),
searchScopesByScopeIds: jest.fn(async () => [mockScope]),
};
const { findScopesByIds } = scopes;
const resources = {
findResourcesByIds: jest.fn(async () => [mockResource]),
};
const rolesScopes = {
insertRolesScopes: jest.fn(),
findRolesScopesByRoleId: jest.fn(),
deleteRolesScope: jest.fn(),
};
const { insertRolesScopes, findRolesScopesByRoleId } = rolesScopes;
const users = {
findUserById: jest.fn(),
};
const roleRoutes = await pickDefault(import('./role.scope.js'));
const tenantContext = new MockTenant(undefined, {
users,
rolesScopes,
resources,
scopes,
roles,
});
describe('role scope routes', () => {
const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext });
it('GET /roles/:id/scopes', async () => {
findRoleById.mockResolvedValueOnce(mockRole);
findRolesScopesByRoleId.mockResolvedValueOnce([]);
findScopesByIds.mockResolvedValueOnce([mockScope]);
const response = await roleRequester.get(`/roles/${mockRole.id}/scopes`);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
...mockScope,
resource: mockResource,
},
]);
});
it('POST /roles/:id/scopes', async () => {
findRoleById.mockResolvedValueOnce(mockRole);
findRolesScopesByRoleId.mockResolvedValue([]);
findScopesByIds.mockResolvedValueOnce([]);
const response = await roleRequester.post(`/roles/${mockRole.id}/scopes`).send({
scopeIds: [mockScope.id],
});
expect(response.status).toEqual(200);
expect(insertRolesScopes).toHaveBeenCalledWith([
{ roleId: mockRole.id, scopeId: mockScope.id },
]);
});
it('DELETE /roles/:id/scopes/:scopeId', async () => {
findRoleById.mockResolvedValueOnce(mockRole);
findRolesScopesByRoleId.mockResolvedValueOnce([]);
const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`);
expect(response.status).toEqual(204);
});
});

View file

@ -0,0 +1,132 @@
import type { ScopeResponse } from '@logto/schemas';
import { tryThat } from '@logto/shared';
import { object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function roleScopeRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const {
resources: { findResourcesByIds },
rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes },
roles: { findRoleById },
scopes: { findScopeById, findScopesByIds, countScopesByScopeIds, searchScopesByScopeIds },
} = queries;
router.get(
'/roles/:id/scopes',
koaPagination(),
koaGuard({
params: object({ id: string().min(1) }),
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const { limit, offset } = ctx.pagination;
const { searchParams } = ctx.request.URL;
await findRoleById(id);
return tryThat(
async () => {
const search = parseSearchParamsForSearch(searchParams);
const rolesScopes = await findRolesScopesByRoleId(id);
const scopeIds = rolesScopes.map(({ scopeId }) => scopeId);
const [{ count }, scopes] = await Promise.all([
countScopesByScopeIds(scopeIds, search),
searchScopesByScopeIds(scopeIds, search, limit, offset),
]);
const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId));
const result: ScopeResponse[] = scopes.map((scope) => {
const resource = resources.find(({ id }) => scope.resourceId);
assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`));
return {
...scope,
resource,
};
});
// Return totalCount to pagination middleware
ctx.pagination.totalCount = count;
ctx.body = result;
return next();
},
(error) => {
if (error instanceof TypeError) {
throw new RequestError(
{ code: 'request.invalid_input', details: error.message },
error
);
}
throw error;
}
);
}
);
router.post(
'/roles/:id/scopes',
koaGuard({
params: object({ id: string().min(1) }),
body: object({ scopeIds: string().min(1).array() }),
}),
async (ctx, next) => {
const {
params: { id },
body: { scopeIds },
} = ctx.guard;
await findRoleById(id);
const rolesScopes = await findRolesScopesByRoleId(id);
for (const scopeId of scopeIds) {
assertThat(
!rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId),
new RequestError({
code: 'role.scope_exists',
status: 422,
scopeId,
})
);
}
await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId)));
await insertRolesScopes(scopeIds.map((scopeId) => ({ roleId: id, scopeId })));
const newRolesScopes = await findRolesScopesByRoleId(id);
const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId));
ctx.body = scopes;
return next();
}
);
router.delete(
'/roles/:id/scopes/:scopeId',
koaGuard({
params: object({ id: string().min(1), scopeId: string().min(1) }),
}),
async (ctx, next) => {
const {
params: { id, scopeId },
} = ctx.guard;
await deleteRolesScope(id, scopeId);
ctx.status = 204;
return next();
}
);
}

View file

@ -28,9 +28,8 @@ const { findRoleByRoleName, findRoleById, deleteRoleById } = roles;
const scopes = {
findScopeById: jest.fn(),
findScopesByIds: jest.fn(),
};
const { findScopeById, findScopesByIds } = scopes;
const { findScopeById } = scopes;
const resources = {
findResourcesByIds: jest.fn(async () => [mockResource]),
@ -38,10 +37,9 @@ const resources = {
const rolesScopes = {
insertRolesScopes: jest.fn(),
findRolesScopesByRoleId: jest.fn(),
deleteRolesScope: jest.fn(),
};
const { insertRolesScopes, findRolesScopesByRoleId } = rolesScopes;
const { insertRolesScopes } = rolesScopes;
const users = {
findUsersByIds: jest.fn(),
@ -156,40 +154,6 @@ describe('role routes', () => {
expect(deleteRoleById).toHaveBeenCalledWith(mockRole.id);
});
it('GET /roles/:id/scopes', async () => {
findRoleById.mockResolvedValueOnce(mockRole);
findRolesScopesByRoleId.mockResolvedValueOnce([]);
findScopesByIds.mockResolvedValueOnce([mockScope]);
const response = await roleRequester.get(`/roles/${mockRole.id}/scopes`);
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{
...mockScope,
resource: mockResource,
},
]);
});
it('POST /roles/:id/scopes', async () => {
findRoleById.mockResolvedValueOnce(mockRole);
findRolesScopesByRoleId.mockResolvedValue([]);
findScopesByIds.mockResolvedValueOnce([]);
const response = await roleRequester.post(`/roles/${mockRole.id}/scopes`).send({
scopeIds: [mockScope.id],
});
expect(response.status).toEqual(200);
expect(insertRolesScopes).toHaveBeenCalledWith([
{ roleId: mockRole.id, scopeId: mockScope.id },
]);
});
it('DELETE /roles/:id/scopes/:scopeId', async () => {
findRoleById.mockResolvedValueOnce(mockRole);
findRolesScopesByRoleId.mockResolvedValueOnce([]);
const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`);
expect(response.status).toEqual(204);
});
it('GET /roles/:id/users', async () => {
findRoleById.mockResolvedValueOnce(mockRole);
findUsersRolesByRoleId.mockResolvedValueOnce([]);

View file

@ -1,5 +1,5 @@
import { buildIdGenerator } from '@logto/core-kit';
import type { RoleResponse, ScopeResponse } from '@logto/schemas';
import type { RoleResponse } from '@logto/schemas';
import { userInfoSelectFields, Roles } from '@logto/schemas';
import { tryThat } from '@logto/shared';
import { pick } from '@silverhand/essentials';
@ -19,8 +19,7 @@ export default function roleRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const {
resources: { findResourcesByIds },
rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes },
rolesScopes: { insertRolesScopes },
roles: {
countRoles,
deleteRoleById,
@ -30,7 +29,7 @@ export default function roleRoutes<T extends AuthedRouter>(
insertRole,
updateRoleById,
},
scopes: { findScopeById, findScopesByIds },
scopes: { findScopeById },
users: { findUserById, findUsersByIds },
usersRoles: {
countUsersRolesByRoleId,
@ -189,90 +188,6 @@ export default function roleRoutes<T extends AuthedRouter>(
}
);
router.get(
'/roles/:id/scopes',
koaGuard({
params: object({ id: string().min(1) }),
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
await findRoleById(id);
const rolesScopes = await findRolesScopesByRoleId(id);
const scopes = await findScopesByIds(rolesScopes.map(({ scopeId }) => scopeId));
const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId));
const result: ScopeResponse[] = scopes.map((scope) => {
const resource = resources.find(({ id }) => scope.resourceId);
assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`));
return {
...scope,
resource,
};
});
ctx.body = result;
return next();
}
);
router.post(
'/roles/:id/scopes',
koaGuard({
params: object({ id: string().min(1) }),
body: object({ scopeIds: string().min(1).array() }),
}),
async (ctx, next) => {
const {
params: { id },
body: { scopeIds },
} = ctx.guard;
await findRoleById(id);
const rolesScopes = await findRolesScopesByRoleId(id);
for (const scopeId of scopeIds) {
assertThat(
!rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId),
new RequestError({
code: 'role.scope_exists',
status: 422,
scopeId,
})
);
}
await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId)));
await insertRolesScopes(scopeIds.map((scopeId) => ({ roleId: id, scopeId })));
const newRolesScopes = await findRolesScopesByRoleId(id);
const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId));
ctx.body = scopes;
return next();
}
);
router.delete(
'/roles/:id/scopes/:scopeId',
koaGuard({
params: object({ id: string().min(1), scopeId: string().min(1) }),
}),
async (ctx, next) => {
const {
params: { id, scopeId },
} = ctx.guard;
await deleteRolesScope(id, scopeId);
ctx.status = 204;
return next();
}
);
router.get(
'/roles/:id/users',
koaGuard({