diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index 08c085317..6bb21ed41 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -28,8 +28,8 @@ export default class OrganizationQueries extends SchemaQueries< rolesScopes: new RelationQueries( this.pool, OrganizationRoleScopeRelations.table, - OrganizationRoles.table, - OrganizationScopes.table + OrganizationRoles, + OrganizationScopes ), }; diff --git a/packages/core/src/routes/organization-roles.ts b/packages/core/src/routes/organization-roles.ts index ee83270a1..3b5d42782 100644 --- a/packages/core/src/routes/organization-roles.ts +++ b/packages/core/src/routes/organization-roles.ts @@ -3,6 +3,7 @@ import { type OrganizationRole, type OrganizationRoleKeys, OrganizationRoles, + OrganizationScopes, } from '@logto/schemas'; import { UniqueIntegrityConstraintViolationError } from 'slonik'; import { z } from 'zod'; @@ -82,5 +83,63 @@ export default function organizationRoleRoutes( } ); + // MARK: Role - scope relations routes + router.get( + '/:id/scopes', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + response: OrganizationScopes.guard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + + // Ensure that role exists + await actions.getById(id); + + ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id }); + return next(); + } + ); + + router.post( + '/:id/scopes', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: z.object({ scopeIds: z.string().min(1).array().nonempty() }), + response: OrganizationScopes.guard.array(), + status: [200, 404, 422], + }), + async (ctx, next) => { + const { + params: { id }, + body: { scopeIds }, + } = ctx.guard; + + await rolesScopes.insert(...scopeIds.map<[string, string]>((scopeId) => [id, scopeId])); + + ctx.body = await rolesScopes.getEntries(OrganizationScopes, { organizationRoleId: id }); + return next(); + } + ); + + router.delete( + '/:id/scopes/:scopeId', + koaGuard({ + params: z.object({ id: z.string().min(1), scopeId: z.string().min(1) }), + status: [204, 422], + }), + async (ctx, next) => { + const { + params: { id, scopeId }, + } = ctx.guard; + + await rolesScopes.delete({ organizationRoleId: id, organizationScopeId: scopeId }); + + ctx.status = 204; + return next(); + } + ); + originalRouter.use(router.routes()); } diff --git a/packages/core/src/utils/RelationQueries.ts b/packages/core/src/utils/RelationQueries.ts index 7d2c48488..a2a5e20de 100644 --- a/packages/core/src/utils/RelationQueries.ts +++ b/packages/core/src/utils/RelationQueries.ts @@ -1,9 +1,23 @@ -import pluralize from 'pluralize'; +import { type KeysToCamelCase } from '@silverhand/essentials'; import { sql, type CommonQueryMethods } from 'slonik'; +import snakecaseKeys from 'snakecase-keys'; +import { type z } from 'zod'; type AtLeast2 = `${T['length']}` extends '0' | '1' ? never : T; -type RemoveLiteral = T extends L ? Exclude : T; +type TableInfo = { + table: Table; + tableSingular: TableSingular; + guard: z.ZodType; +}; + +type InferSchema = T extends TableInfo ? Schema : never; + +type CamelCaseIdObject = KeysToCamelCase<{ + [Key in `${T}_id`]: string; +}>; + +class RelationQueryError extends Error {} /** * Query class for relation tables that connect several tables by their entry ids. @@ -37,14 +51,14 @@ type RemoveLiteral = T extends L ? Exclude>, - Length = AtLeast2['length'], + Schemas extends Array>, + Length = AtLeast2['length'], > { protected get table() { return sql.identifier([this.relationTable]); } - public readonly relations: SnakeCaseRelations; + public readonly schemas: Schemas; /** * @param pool The database pool. @@ -54,9 +68,9 @@ export default class RelationQueries< constructor( public readonly pool: CommonQueryMethods, public readonly relationTable: string, - ...relations: Readonly + ...schemas: Readonly ) { - this.relations = relations; + this.schemas = schemas; } /** @@ -83,7 +97,7 @@ export default class RelationQueries< async insert(...data: ReadonlyArray) { return this.pool.query(sql` insert into ${this.table} (${sql.join( - this.relations.map((relation) => sql.identifier([pluralize(relation, 1) + '_id'])), + this.schemas.map(({ tableSingular }) => sql.identifier([tableSingular + '_id'])), sql`, ` )}) values ${sql.join( @@ -99,10 +113,41 @@ export default class RelationQueries< `); } - async getEntries( - forRelation: L, - where: Record, unknown> - ) { - throw new Error('Not implemented'); + async delete(data: CamelCaseIdObject) { + const snakeCaseData = snakecaseKeys(data); + return this.pool.query(sql` + delete from ${this.table} + where ${sql.join( + Object.entries(snakeCaseData).map( + ([column, value]) => sql`${sql.identifier([column])} = ${value}` + ), + sql` and ` + )}; + `); + } + + async getEntries( + forSchema: S, + where: CamelCaseIdObject> + ): Promise>> { + const snakeCaseWhere = snakecaseKeys(where); + const forTable = sql.identifier([forSchema.table]); + + const { rows } = await this.pool.query>(sql` + select ${forTable}.* + from ${this.table} + join ${forTable} on ${sql.identifier([ + this.relationTable, + forSchema.tableSingular + '_id', + ])} = ${forTable}.id + where ${sql.join( + Object.entries(snakeCaseWhere).map( + ([column, value]) => sql`${sql.identifier([column])} = ${value}` + ), + sql` and ` + )}; + `); + + return rows; } } diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index efbc7ad38..92d59ee8a 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -41,6 +41,7 @@ export class SchemaActions< * * @param id The ID of the entity to be fetched. * @returns The entity to be returned. + * @throws An `RequestError` with 404 status code if the entity is not found. */ public async getById(id: string): Promise> { return this.queries.findById(id); @@ -63,6 +64,7 @@ export class SchemaActions< * @param id The ID of the entity to be updated. * @param data The data of the entity to be updated. * @returns The updated entity. + * @throws An `RequestError` with 404 status code if the entity is not found. */ public async patchById(id: string, data: Partial): Promise> { return this.queries.updateById(id, data); @@ -72,6 +74,7 @@ export class SchemaActions< * The function for `DELETE /:id` route to delete an entity by ID. * * @param id The ID of the entity to be deleted. + * @throws An `RequestError` with 404 status code if the entity is not found. */ public async deleteById(id: string): Promise { return this.queries.deleteById(id); diff --git a/packages/integration-tests/src/api/organization-role.ts b/packages/integration-tests/src/api/organization-role.ts index 98dfc6f1a..e446a8d0f 100644 --- a/packages/integration-tests/src/api/organization-role.ts +++ b/packages/integration-tests/src/api/organization-role.ts @@ -1,5 +1,6 @@ -import { type OrganizationRole } from '@logto/schemas'; +import { type OrganizationScope, type OrganizationRole } from '@logto/schemas'; +import { authedAdminApi } from './api.js'; import { ApiFactory } from './factory.js'; class OrganizationRoleApi extends ApiFactory< @@ -9,6 +10,18 @@ class OrganizationRoleApi extends ApiFactory< constructor() { super('organization-roles'); } + + async addScopes(id: string, scopeIds: string[]): Promise { + await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { scopeIds } }); + } + + async getScopes(id: string): Promise { + return authedAdminApi.get(`${this.path}/${id}/scopes`).json(); + } + + async deleteScope(id: string, scopeId: string): Promise { + await authedAdminApi.delete(`${this.path}/${id}/scopes/${scopeId}`); + } } export const roleApi = new OrganizationRoleApi(); diff --git a/packages/integration-tests/src/tests/api/organization-role.test.ts b/packages/integration-tests/src/tests/api/organization-role.test.ts index ad0759b37..d463a817f 100644 --- a/packages/integration-tests/src/tests/api/organization-role.test.ts +++ b/packages/integration-tests/src/tests/api/organization-role.test.ts @@ -9,107 +9,218 @@ import { scopeApi } from '#src/api/organization-scope.js'; const randomId = () => generateStandardId(4); -describe('organization roles', () => { - it('should fail if the name of the new organization role already exists', async () => { - const name = 'test' + randomId(); - await roleApi.create({ name }); - const response = await roleApi.create({ name }).catch((error: unknown) => error); +// Add additional layer of describe to run tests in band +describe('organization role APIs', () => { + describe('organization roles', () => { + it('should fail if the name of the new organization role already exists', async () => { + const name = 'test' + randomId(); + const createdRole = await roleApi.create({ name }); + const response = await roleApi.create({ name }).catch((error: unknown) => error); - assert(response instanceof HTTPError); + assert(response instanceof HTTPError); - const { statusCode, body: raw } = response.response; - const body: unknown = JSON.parse(String(raw)); - expect(statusCode).toBe(400); - expect(isKeyInObject(body, 'code') && body.code).toBe('entity.duplicate_value_of_unique_field'); - }); + const { statusCode, body: raw } = response.response; + const body: unknown = JSON.parse(String(raw)); + expect(statusCode).toBe(400); + expect(isKeyInObject(body, 'code') && body.code).toBe( + 'entity.duplicate_value_of_unique_field' + ); - it('should be able to create a role with some scopes', async () => { - const name = 'test' + randomId(); - const [scope1, scope2] = await Promise.all([ - scopeApi.create({ name: 'test' + randomId() }), - scopeApi.create({ name: 'test' + randomId() }), - ]); - const scopeIds = [scope1.id, scope2.id]; - const role = await roleApi.create({ name, scopeIds }); - - expect(role).toStrictEqual( - expect.objectContaining({ - name, - }) - ); - - // TODO: Check scopes under a role after API is implemented - await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]); - }); - - it('should get organization roles successfully', async () => { - const [name1, name2] = ['test' + randomId(), 'test' + randomId()]; - await roleApi.create({ name: name1, description: 'A test organization role.' }); - await roleApi.create({ name: name2 }); - const roles = await roleApi.getList(); - - expect(roles).toContainEqual( - expect.objectContaining({ name: name1, description: 'A test organization role.' }) - ); - expect(roles).toContainEqual(expect.objectContaining({ name: name2, description: null })); - }); - - it('should get organization roles with pagination', async () => { - // Add 20 roles to exceed the default page size - await Promise.all( - Array.from({ length: 30 }).map(async () => roleApi.create({ name: 'test' + randomId() })) - ); - - const roles = await roleApi.getList(); - expect(roles).toHaveLength(20); - - const roles2 = await roleApi.getList( - new URLSearchParams({ - page: '2', - page_size: '10', - }) - ); - expect(roles2.length).toBeGreaterThanOrEqual(10); - expect(roles2[0]?.id).not.toBeFalsy(); - expect(roles2[0]?.id).toBe(roles[10]?.id); - }); - - it('should be able to create and get organization roles by id', async () => { - const createdRole = await roleApi.create({ name: 'test' + randomId() }); - const role = await roleApi.get(createdRole.id); - - expect(role).toStrictEqual(createdRole); - }); - - it('should fail when try to get an organization role that does not exist', async () => { - const response = await roleApi.get('0').catch((error: unknown) => error); - - expect(response instanceof HTTPError && response.response.statusCode).toBe(404); - }); - - it('should be able to update organization role', async () => { - const createdRole = await roleApi.create({ name: 'test' + randomId() }); - const newName = 'test' + randomId(); - const role = await roleApi.update(createdRole.id, { - name: newName, - description: 'test description.', + await roleApi.delete(createdRole.id); }); - expect(role).toStrictEqual({ - ...createdRole, - name: newName, - description: 'test description.', + + it('should be able to create a role with some scopes', async () => { + const name = 'test' + randomId(); + const [scope1, scope2] = await Promise.all([ + scopeApi.create({ name: 'test' + randomId() }), + scopeApi.create({ name: 'test' + randomId() }), + ]); + const scopeIds = [scope1.id, scope2.id]; + const role = await roleApi.create({ name, scopeIds }); + + expect(role).toStrictEqual( + expect.objectContaining({ + name, + }) + ); + + // Check scopes under a role after API is implemented + const scopes = await roleApi.getScopes(role.id); + expect(scopes).toContainEqual( + expect.objectContaining({ + name: scope1.name, + }) + ); + expect(scopes).toContainEqual( + expect.objectContaining({ + name: scope2.name, + }) + ); + + await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]); + }); + + it('should get organization roles successfully', async () => { + const [name1, name2] = ['test' + randomId(), 'test' + randomId()]; + const createdRoles = await Promise.all([ + roleApi.create({ name: name1, description: 'A test organization role.' }), + roleApi.create({ name: name2 }), + ]); + const roles = await roleApi.getList(); + + expect(roles).toContainEqual( + expect.objectContaining({ name: name1, description: 'A test organization role.' }) + ); + expect(roles).toContainEqual(expect.objectContaining({ name: name2, description: null })); + + await Promise.all(createdRoles.map(async (role) => roleApi.delete(role.id))); + }); + + it('should get organization roles with pagination', async () => { + // Add 20 roles to exceed the default page size + const allRoles = await Promise.all( + Array.from({ length: 30 }).map(async () => roleApi.create({ name: 'test' + randomId() })) + ); + + const roles = await roleApi.getList(); + expect(roles).toHaveLength(20); + + const roles2 = await roleApi.getList( + new URLSearchParams({ + page: '2', + page_size: '10', + }) + ); + expect(roles2.length).toBeGreaterThanOrEqual(10); + expect(roles2[0]?.id).not.toBeFalsy(); + expect(roles2[0]?.id).toBe(roles[10]?.id); + + await Promise.all(allRoles.map(async (role) => roleApi.delete(role.id))); + }); + + it('should be able to create and get organization roles by id', async () => { + const createdRole = await roleApi.create({ name: 'test' + randomId() }); + const role = await roleApi.get(createdRole.id); + + expect(role).toStrictEqual(createdRole); + await roleApi.delete(createdRole.id); + }); + + it('should fail when try to get an organization role that does not exist', async () => { + const response = await roleApi.get('0').catch((error: unknown) => error); + + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should be able to update organization role', async () => { + const createdRole = await roleApi.create({ name: 'test' + randomId() }); + const newName = 'test' + randomId(); + const role = await roleApi.update(createdRole.id, { + name: newName, + description: 'test description.', + }); + expect(role).toStrictEqual({ + ...createdRole, + name: newName, + description: 'test description.', + }); + await roleApi.delete(createdRole.id); + }); + + it('should be able to delete organization role', async () => { + const createdRole = await roleApi.create({ name: 'test' + randomId() }); + await roleApi.delete(createdRole.id); + const response = await roleApi.get(createdRole.id).catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should fail when try to delete an organization role that does not exist', async () => { + const response = await roleApi.delete('0').catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); }); }); - it('should be able to delete organization role', async () => { - const createdRole = await roleApi.create({ name: 'test' + randomId() }); - await roleApi.delete(createdRole.id); - const response = await roleApi.get(createdRole.id).catch((error: unknown) => error); - expect(response instanceof HTTPError && response.response.statusCode).toBe(404); - }); + describe('organization role - scope relations', () => { + it('should be able to get scopes of a role', async () => { + const [role, scope1, scope2] = await Promise.all([ + roleApi.create({ name: 'test' + randomId() }), + scopeApi.create({ name: 'test' + randomId() }), + scopeApi.create({ name: 'test' + randomId() }), + ]); + await roleApi.addScopes(role.id, [scope1.id, scope2.id]); + const scopes = await roleApi.getScopes(role.id); - it('should fail when try to delete an organization role that does not exist', async () => { - const response = await roleApi.delete('0').catch((error: unknown) => error); - expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + expect(scopes).toContainEqual( + expect.objectContaining({ + name: scope1.name, + }) + ); + expect(scopes).toContainEqual( + expect.objectContaining({ + name: scope2.name, + }) + ); + + await Promise.all([ + roleApi.delete(role.id), + scopeApi.delete(scope1.id), + scopeApi.delete(scope2.id), + ]); + }); + + it('should be able to add scopes to a role', async () => { + const [role, scope1, scope2] = await Promise.all([ + roleApi.create({ name: 'test' + randomId() }), + scopeApi.create({ name: 'test' + randomId() }), + scopeApi.create({ name: 'test' + randomId() }), + ]); + await roleApi.addScopes(role.id, [scope1.id, scope2.id]); + const scopes = await roleApi.getScopes(role.id); + + expect(scopes).toContainEqual( + expect.objectContaining({ + name: scope1.name, + }) + ); + expect(scopes).toContainEqual( + expect.objectContaining({ + name: scope2.name, + }) + ); + + await Promise.all([ + roleApi.delete(role.id), + scopeApi.delete(scope1.id), + scopeApi.delete(scope2.id), + ]); + }); + + it('should be able to remove scopes from a role', async () => { + const [role, scope1, scope2] = await Promise.all([ + roleApi.create({ name: 'test' + randomId() }), + scopeApi.create({ name: 'test' + randomId() }), + scopeApi.create({ name: 'test' + randomId() }), + ]); + await roleApi.addScopes(role.id, [scope1.id, scope2.id]); + await roleApi.deleteScope(role.id, scope1.id); + const scopes = await roleApi.getScopes(role.id); + + expect(scopes).not.toContainEqual( + expect.objectContaining({ + name: scope1.name, + }) + ); + expect(scopes).toContainEqual( + expect.objectContaining({ + name: scope2.name, + }) + ); + + await Promise.all([ + roleApi.delete(role.id), + scopeApi.delete(scope1.id), + scopeApi.delete(scope2.id), + ]); + }); }); });