0
Fork 0
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:
wangsijie 2023-01-05 15:24:24 +08:00 committed by GitHub
parent 9b5dbeea34
commit cf900d4aef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 210 additions and 6 deletions

View file

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

View 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);
}
};

View file

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

View file

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

View file

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

View file

@ -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()),