mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): resource scopes crud (#2757)
This commit is contained in:
parent
9b5dbeea34
commit
cf900d4aef
6 changed files with 210 additions and 6 deletions
|
@ -1,5 +1,5 @@
|
|||
import { VerificationCodeType } from '@logto/connector-kit';
|
||||
import type { Application, Passcode, Resource, Role, Setting } from '@logto/schemas';
|
||||
import type { Application, Passcode, Resource, Role, Scope, Setting } from '@logto/schemas';
|
||||
import { ApplicationType } from '@logto/schemas';
|
||||
|
||||
export * from './connector.js';
|
||||
|
@ -32,6 +32,14 @@ export const mockResource: Resource = {
|
|||
accessTokenTtl: 3600,
|
||||
};
|
||||
|
||||
export const mockScope: Scope = {
|
||||
id: 'scope_id',
|
||||
name: 'read:users',
|
||||
description: 'read users',
|
||||
resourceId: mockResource.id,
|
||||
createdAt: 1_645_334_775_356,
|
||||
};
|
||||
|
||||
export const mockRole: Role = {
|
||||
id: 'role_id',
|
||||
name: 'admin',
|
||||
|
|
42
packages/core/src/queries/scope.ts
Normal file
42
packages/core/src/queries/scope.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import type { CreateScope, Scope } from '@logto/schemas';
|
||||
import { Scopes } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { buildFindEntityById } from '#src/database/find-entity-by-id.js';
|
||||
import { buildInsertInto } from '#src/database/insert-into.js';
|
||||
import { buildUpdateWhere } from '#src/database/update-where.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(Scopes);
|
||||
|
||||
export const findScopesByResourceId = async (resourceId: string) =>
|
||||
envSet.pool.any<Scope>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.resourceId}=${resourceId}
|
||||
`);
|
||||
|
||||
export const insertScope = buildInsertInto<CreateScope, Scope>(Scopes, {
|
||||
returning: true,
|
||||
});
|
||||
|
||||
export const findScopeById = buildFindEntityById<CreateScope, Scope>(Scopes);
|
||||
|
||||
const updateScope = buildUpdateWhere<CreateScope, Scope>(Scopes, true);
|
||||
|
||||
export const updateScopeById = async (id: string, set: Partial<OmitAutoSetFields<CreateScope>>) =>
|
||||
updateScope({ set, where: { id }, jsonbMode: 'merge' });
|
||||
|
||||
export const deleteScopeById = async (id: string) => {
|
||||
const { rowCount } = await envSet.pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.id}=${id}
|
||||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError(Scopes.table, id);
|
||||
}
|
||||
};
|
|
@ -1,17 +1,17 @@
|
|||
import type { Resource, CreateResource } from '@logto/schemas';
|
||||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
import { mockResource } from '#src/__mocks__/index.js';
|
||||
import { mockResource, mockScope } from '#src/__mocks__/index.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
mockEsm('#src/queries/resource.js', () => ({
|
||||
const { findResourceById } = mockEsm('#src/queries/resource.js', () => ({
|
||||
findTotalNumberOfResources: async () => ({ count: 10 }),
|
||||
findAllResources: async (): Promise<Resource[]> => [mockResource],
|
||||
findResourceById: async (): Promise<Resource> => mockResource,
|
||||
findResourceById: jest.fn(async (): Promise<Resource> => mockResource),
|
||||
insertResource: async (body: CreateResource): Promise<Resource> => ({
|
||||
...mockResource,
|
||||
...body,
|
||||
|
@ -21,6 +21,15 @@ mockEsm('#src/queries/resource.js', () => ({
|
|||
...data,
|
||||
}),
|
||||
deleteResourceById: jest.fn(),
|
||||
findScopesByResourceId: async () => [mockScope],
|
||||
}));
|
||||
|
||||
const { insertScope, updateScopeById } = mockEsm('#src/queries/scope.js', () => ({
|
||||
findScopesByResourceId: async () => [mockScope],
|
||||
insertScope: jest.fn(async () => mockScope),
|
||||
findScopeById: jest.fn(),
|
||||
updateScopeById: jest.fn(async () => mockScope),
|
||||
deleteScopeById: jest.fn(),
|
||||
}));
|
||||
|
||||
mockEsm('@logto/core-kit', () => ({
|
||||
|
@ -109,4 +118,52 @@ describe('resource routes', () => {
|
|||
it('DELETE /resources/:id', async () => {
|
||||
await expect(resourceRequest.delete('/resources/foo')).resolves.toHaveProperty('status', 204);
|
||||
});
|
||||
|
||||
it('GET /resources/:id/scopes', async () => {
|
||||
const response = await resourceRequest.get('/resources/foo/scopes');
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([mockScope]);
|
||||
expect(findResourceById).toHaveBeenCalledWith('foo');
|
||||
});
|
||||
|
||||
it('POST /resources/:id/scopes', async () => {
|
||||
const name = 'write:users';
|
||||
const description = 'description';
|
||||
|
||||
const response = await resourceRequest
|
||||
.post('/resources/foo/scopes')
|
||||
.send({ name, description });
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(findResourceById).toHaveBeenCalledWith('foo');
|
||||
expect(insertScope).toHaveBeenCalledWith({
|
||||
id: 'randomId',
|
||||
name,
|
||||
description,
|
||||
resourceId: 'foo',
|
||||
});
|
||||
});
|
||||
|
||||
it('PATCH /resources/:id/scopes/:scopeId', async () => {
|
||||
const name = 'write:users';
|
||||
const description = 'description';
|
||||
|
||||
const response = await resourceRequest
|
||||
.patch('/resources/foo/scopes/foz')
|
||||
.send({ name, description });
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(findResourceById).toHaveBeenCalledWith('foo');
|
||||
expect(updateScopeById).toHaveBeenCalledWith('foz', {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /resources/:id/scopes/:scopeId', async () => {
|
||||
await expect(resourceRequest.delete('/resources/foo/scopes/foz')).resolves.toHaveProperty(
|
||||
'status',
|
||||
204
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { buildIdGenerator } from '@logto/core-kit';
|
||||
import { Resources } from '@logto/schemas';
|
||||
import { Resources, Scopes } from '@logto/schemas';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
|
@ -12,10 +12,17 @@ import {
|
|||
updateResourceById,
|
||||
deleteResourceById,
|
||||
} from '#src/queries/resource.js';
|
||||
import {
|
||||
deleteScopeById,
|
||||
findScopesByResourceId,
|
||||
insertScope,
|
||||
updateScopeById,
|
||||
} from '#src/queries/scope.js';
|
||||
|
||||
import type { AuthedRouter } from './types.js';
|
||||
|
||||
const resourceId = buildIdGenerator(21);
|
||||
const scoupeId = resourceId;
|
||||
|
||||
export default function resourceRoutes<T extends AuthedRouter>(router: T) {
|
||||
router.get('/resources', koaPagination(), async (ctx, next) => {
|
||||
|
@ -95,4 +102,76 @@ export default function resourceRoutes<T extends AuthedRouter>(router: T) {
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/resources/:resourceId/scopes',
|
||||
koaGuard({ params: object({ resourceId: string().min(1) }) }),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { resourceId },
|
||||
} = ctx.guard;
|
||||
|
||||
ctx.body = await findScopesByResourceId(resourceId);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/resources/:resourceId/scopes',
|
||||
koaGuard({
|
||||
params: object({ resourceId: string().min(1) }),
|
||||
body: Scopes.createGuard.pick({ name: true, description: true }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { resourceId },
|
||||
body,
|
||||
} = ctx.guard;
|
||||
|
||||
ctx.body = await insertScope({
|
||||
...body,
|
||||
id: scoupeId(),
|
||||
resourceId,
|
||||
});
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/resources/:resourceId/scopes/:scopeId',
|
||||
koaGuard({
|
||||
params: object({ resourceId: string().min(1), scopeId: string().min(1) }),
|
||||
body: Scopes.createGuard.pick({ name: true, description: true }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { scopeId },
|
||||
body,
|
||||
} = ctx.guard;
|
||||
|
||||
ctx.body = await updateScopeById(scopeId, body);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/resources/:resourceId/scopes/:scopeId',
|
||||
koaGuard({
|
||||
params: object({ resourceId: string().min(1), scopeId: string().min(1) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { resourceId, scopeId },
|
||||
} = ctx.guard;
|
||||
|
||||
await deleteScopeById(scopeId);
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
ALTER TABLE scopes ALTER COLUMN resource_id SET NOT NULL;
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
ALTER TABLE scopes ALTER COLUMN resource_id DROP NOT NULL;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -1,6 +1,6 @@
|
|||
create table scopes (
|
||||
id varchar(21) not null,
|
||||
resource_id varchar(21) references resources (id) on update cascade on delete cascade,
|
||||
resource_id varchar(21) not null references resources (id) on update cascade on delete cascade,
|
||||
name varchar(256) not null,
|
||||
description text,
|
||||
created_at timestamptz not null default(now()),
|
||||
|
|
Loading…
Reference in a new issue