From b85ecb6c935d0b99d428058ee709b6b3eb53d090 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 6 Jun 2024 10:33:24 +0800 Subject: [PATCH 1/3] feat(core): organization email domains apis --- .../src/queries/organization/email-domains.ts | 104 ++++++++++++++++++ .../core/src/queries/organization/index.ts | 4 + .../organization/index.email-domains.ts | 85 ++++++++++++++ .../core/src/routes/organization/index.ts | 7 +- .../integration-tests/src/api/organization.ts | 33 ++++++ .../organization-email-domains.test.ts | 79 +++++++++++++ 6 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/queries/organization/email-domains.ts create mode 100644 packages/core/src/routes/organization/index.email-domains.ts create mode 100644 packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts diff --git a/packages/core/src/queries/organization/email-domains.ts b/packages/core/src/queries/organization/email-domains.ts new file mode 100644 index 00000000000..b583cdb8d02 --- /dev/null +++ b/packages/core/src/queries/organization/email-domains.ts @@ -0,0 +1,104 @@ +import { + type OrganizationEmailDomain, + OrganizationEmailDomains, + type CreateOrganizationEmailDomain, +} from '@logto/schemas'; +import { type CommonQueryMethods, sql } from '@silverhand/slonik'; + +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; +import { type GetEntitiesOptions } from '#src/utils/RelationQueries.js'; +import { type OmitAutoSetFields, conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; + +const { table, fields } = convertToIdentifiers(OrganizationEmailDomains); + +export class EmailDomainQueries { + readonly #insert: ( + data: OmitAutoSetFields + ) => Promise>; + + constructor(protected pool: CommonQueryMethods) { + this.#insert = buildInsertIntoWithPool(this.pool)(OrganizationEmailDomains, { + returning: true, + }); + } + + async getEntities( + organizationId: string, + options: GetEntitiesOptions + ): Promise<[number, readonly OrganizationEmailDomain[]]> { + const { limit, offset } = options; + const mainSql = sql` + from ${table} + where ${fields.organizationId} = ${organizationId} + `; + + const [{ count }, rows] = await Promise.all([ + this.pool.one<{ count: string }>(sql` + select count(*) + ${mainSql} + `), + this.pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + ${mainSql} + ${conditionalSql(limit, (limit) => sql`limit ${limit}`)} + ${conditionalSql(offset, (offset) => sql`offset ${offset}`)} + `), + ]); + + return [Number(count), rows]; + } + + async insert(organizationId: string, emailDomain: string): Promise { + return this.#insert({ + organizationId, + emailDomain, + }); + } + + async delete(organizationId: string, emailDomain: string): Promise { + const { rowCount } = await this.pool.query(sql` + delete from ${table} + where ${fields.organizationId} = ${organizationId} + and ${fields.emailDomain} = ${emailDomain} + `); + + if (rowCount < 1) { + throw new DeletionError(OrganizationEmailDomains.table); + } + } + + async replace(organizationId: string, emailDomains: readonly string[]): Promise { + return this.pool.transaction(async (transaction) => { + // Lock organization + await transaction.query(sql` + select ${fields.organizationId} + from ${table} + where ${fields.organizationId} = ${organizationId} + for update + `); + + // Delete old email domains + await transaction.query(sql` + delete from ${table} + where ${fields.organizationId} = ${organizationId} + `); + + // Insert new email domains + if (emailDomains.length === 0) { + return; + } + + await transaction.query(sql` + insert into ${table} ( + ${fields.organizationId}, + ${fields.emailDomain} + ) + values ${sql.join( + emailDomains.map((emailDomain) => sql`(${organizationId}, ${emailDomain})`), + sql`, ` + )} + `); + }); + } +} diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 5d7ddb31fc6..236ae7fee21 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -28,6 +28,7 @@ import { TwoRelationsQueries } from '#src/utils/RelationQueries.js'; import SchemaQueries from '#src/utils/SchemaQueries.js'; import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; +import { EmailDomainQueries } from './email-domains.js'; import { RoleUserRelationQueries } from './role-user-relations.js'; import { UserRelationQueries } from './user-relations.js'; @@ -288,6 +289,9 @@ export default class OrganizationQueries extends SchemaQueries< ), }; + /** Queries for email domains that will be automatically provisioned. */ + emailDomains = new EmailDomainQueries(this.pool); + constructor(pool: CommonQueryMethods) { super(pool, Organizations); } diff --git a/packages/core/src/routes/organization/index.email-domains.ts b/packages/core/src/routes/organization/index.email-domains.ts new file mode 100644 index 00000000000..db1c54fcc1f --- /dev/null +++ b/packages/core/src/routes/organization/index.email-domains.ts @@ -0,0 +1,85 @@ +import { OrganizationEmailDomains } from '@logto/schemas'; +import { type IRouterParamContext } from 'koa-router'; +import type Router from 'koa-router'; +import { z } from 'zod'; + +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import type OrganizationQueries from '#src/queries/organization/index.js'; + +export default function emailDomainRoutes( + router: Router, + organizations: OrganizationQueries +) { + const params = Object.freeze({ id: z.string().min(1) }); + const pathname = '/:id/email-domains'; + + router.get( + pathname, + koaPagination(), + koaGuard({ + params: z.object(params), + response: OrganizationEmailDomains.guard.array(), + status: [200], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + const { limit, offset } = ctx.pagination; + + const [count, rows] = await organizations.emailDomains.getEntities(id, { limit, offset }); + ctx.pagination.totalCount = count; + ctx.body = rows; + return next(); + } + ); + + router.post( + pathname, + koaGuard({ + params: z.object(params), + body: z.object({ emailDomain: z.string().min(1) }), + response: OrganizationEmailDomains.guard, + status: [201], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + const { emailDomain } = ctx.guard.body; + + ctx.body = await organizations.emailDomains.insert(id, emailDomain); + ctx.status = 201; + return next(); + } + ); + + router.put( + pathname, + koaGuard({ + params: z.object(params), + body: z.object({ emailDomains: z.string().min(1).array().nonempty() }), + status: [204], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + const { emailDomains } = ctx.guard.body; + + await organizations.emailDomains.replace(id, emailDomains); + ctx.status = 204; + return next(); + } + ); + + router.delete( + `${pathname}/:emailDomain`, + koaGuard({ + params: z.object({ ...params, emailDomain: z.string().min(1) }), + status: [204], + }), + async (ctx, next) => { + const { id, emailDomain } = ctx.guard.params; + + await organizations.emailDomains.delete(id, emailDomain); + ctx.status = 204; + return next(); + } + ); +} diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 69aa6c9f419..55ebfe98ca9 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -7,6 +7,7 @@ import { import { yes } from '@silverhand/essentials'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; @@ -16,6 +17,7 @@ import { parseSearchOptions } from '#src/utils/search.js'; import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; +import emailDomainRoutes from './index.email-domains.js'; import userRoleRelationRoutes from './index.user-role-relations.js'; import organizationInvitationRoutes from './invitations.js'; import organizationRoleRoutes from './roles.js'; @@ -133,9 +135,12 @@ export default function organizationRoutes( } ); - // MARK: Organization - user - organization role relation routes userRoleRelationRoutes(router, organizations); + if (EnvSet.values.isDevFeaturesEnabled) { + emailDomainRoutes(router, organizations); + } + // MARK: Mount sub-routes organizationRoleRoutes(...args); organizationScopeRoutes(...args); diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts index 154472b19eb..195e00ec819 100644 --- a/packages/integration-tests/src/api/organization.ts +++ b/packages/integration-tests/src/api/organization.ts @@ -5,6 +5,7 @@ import { type UserWithOrganizationRoles, type OrganizationWithFeatured, type OrganizationScope, + type OrganizationEmailDomain, } from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -74,4 +75,36 @@ export class OrganizationApi extends ApiFactory< .get(`${this.path}/${id}/users/${userId}/scopes`) .json(); } + + async getEmailDomains( + id: string, + page?: number, + pageSize?: number + ): Promise { + const searchParams = new URLSearchParams(); + + if (page) { + searchParams.append('page', String(page)); + } + + if (pageSize) { + searchParams.append('page_size', String(pageSize)); + } + + return authedAdminApi + .get(`${this.path}/${id}/email-domains`, { searchParams }) + .json(); + } + + async addEmailDomain(id: string, emailDomain: string): Promise { + await authedAdminApi.post(`${this.path}/${id}/email-domains`, { json: { emailDomain } }); + } + + async deleteEmailDomain(id: string, emailDomain: string): Promise { + await authedAdminApi.delete(`${this.path}/${id}/email-domains/${emailDomain}`); + } + + async replaceEmailDomains(id: string, emailDomains: string[]): Promise { + await authedAdminApi.put(`${this.path}/${id}/email-domains`, { json: { emailDomains } }); + } } diff --git a/packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts b/packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts new file mode 100644 index 00000000000..3fc2d7726c6 --- /dev/null +++ b/packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts @@ -0,0 +1,79 @@ +import { generateStandardId } from '@logto/shared'; + +import { OrganizationApiTest } from '#src/helpers/organization.js'; + +const randomId = () => generateStandardId(6); + +describe('organization email domains', () => { + const organizationApi = new OrganizationApiTest(); + + afterEach(async () => { + await organizationApi.cleanUp(); + }); + + it('should add and delete email domains', async () => { + const organization = await organizationApi.create({ name: 'foo' }); + const emailDomain = `${randomId()}.com`; + + await organizationApi.addEmailDomain(organization.id, emailDomain); + await expect(organizationApi.getEmailDomains(organization.id)).resolves.toMatchObject([ + { emailDomain }, + ]); + + await organizationApi.deleteEmailDomain(organization.id, emailDomain); + await expect(organizationApi.getEmailDomains(organization.id)).resolves.toEqual([]); + }); + + it('should have default pagination', async () => { + const organization = await organizationApi.create({ name: 'foo' }); + + const emailDomains = Array.from({ length: 30 }, () => `${randomId()}.com`); + + await organizationApi.replaceEmailDomains(organization.id, emailDomains); + + const emailDomainsPage1 = await organizationApi.getEmailDomains(organization.id); + const emailDomainsPage2 = await organizationApi.getEmailDomains(organization.id, 2); + + expect(emailDomainsPage1).toHaveLength(20); + expect(emailDomainsPage2).toHaveLength(10); + expect(emailDomainsPage1).toMatchObject( + emailDomains.slice(0, 20).map((emailDomain) => expect.objectContaining({ emailDomain })) + ); + expect(emailDomainsPage2).toEqual( + emailDomains.slice(20).map((emailDomain) => expect.objectContaining({ emailDomain })) + ); + }); + + it('should return 404 when deleting a non-existent email domain', async () => { + const organization = await organizationApi.create({ name: 'foo' }); + const emailDomain = `${randomId()}.com`; + + await expect( + organizationApi.deleteEmailDomain(organization.id, emailDomain) + ).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]'); + }); + + it('should return 400 when adding an email domain that already exists', async () => { + const organization = await organizationApi.create({ name: 'foo' }); + const emailDomain = `${randomId()}.com`; + + await organizationApi.addEmailDomain(organization.id, emailDomain); + await expect( + organizationApi.addEmailDomain(organization.id, emailDomain) + ).rejects.toMatchInlineSnapshot( + '[HTTPError: Request failed with status code 422 Unprocessable Entity]' + ); + }); + + it('should be able to replace email domains', async () => { + const organization = await organizationApi.create({ name: 'foo' }); + await organizationApi.addEmailDomain(organization.id, `${randomId()}.com`); + + const emailDomains = [`${randomId()}.com`, `${randomId()}.com`]; + + await organizationApi.replaceEmailDomains(organization.id, emailDomains); + await expect(organizationApi.getEmailDomains(organization.id)).resolves.toMatchObject( + emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain })) + ); + }); +}); From f6dbb0aad1aa8b0e9afcd81d9992dbbf5e67f22e Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 6 Jun 2024 13:37:43 +0800 Subject: [PATCH 2/3] chore: add api docs --- .../index.email-domain.openapi.json | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 packages/core/src/routes/organization/index.email-domain.openapi.json diff --git a/packages/core/src/routes/organization/index.email-domain.openapi.json b/packages/core/src/routes/organization/index.email-domain.openapi.json new file mode 100644 index 00000000000..96a4a98b9c6 --- /dev/null +++ b/packages/core/src/routes/organization/index.email-domain.openapi.json @@ -0,0 +1,90 @@ +{ + "tags": [{ + "name": "Organizations" + }], + "paths": { + "/api/organizations/{id}/email-domains": { + "get": { + "summary": "Get organization email domains", + "description": "Get email domains for just-in-time provisioning of users in the organization.", + "responses": { + "200": { + "description": "A list of email domains." + } + } + }, + "post": { + "summary": "Add organization email domain", + "description": "Add a new email domain for just-in-time provisioning of users in the organization.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "emailDomain": { + "description": "The email domain to add." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The email domain was added successfully." + }, + "422": { + "description": "The email domain is already in use." + } + } + }, + "put": { + "summary": "Replace organization email domains", + "description": "Replace all just-in-time provisioning email domains for the organization with the given data.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "emailDomains": { + "description": "An array of email domains to replace existing email domains." + } + } + } + } + } + }, + "responses": { + "204": { + "description": "The email domains were replaced successfully." + } + } + } + }, + "/api/organizations/{id}/email-domains/{emailDomain}": { + "delete": { + "summary": "Remove organization email domain", + "description": "Remove an email domain for just-in-time provisioning of users in the organization.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "emailDomain", + "in": "path", + "required": true, + "description": "The email domain to remove." + } + ], + "responses": { + "204": { + "description": "The email domain was removed successfully." + }, + "404": { + "description": "The email domain was not found." + } + } + } + } + } +} From be9d94d4fdc472f3a473452e542ea508fe928711 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 6 Jun 2024 14:30:05 +0800 Subject: [PATCH 3/3] refactor: fix tests --- .../organization/index.email-domain.openapi.json | 8 +++++--- .../organization-email-domains.test.ts | 15 ++++++++------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/core/src/routes/organization/index.email-domain.openapi.json b/packages/core/src/routes/organization/index.email-domain.openapi.json index 96a4a98b9c6..b48e1e6e2bc 100644 --- a/packages/core/src/routes/organization/index.email-domain.openapi.json +++ b/packages/core/src/routes/organization/index.email-domain.openapi.json @@ -1,7 +1,9 @@ { - "tags": [{ - "name": "Organizations" - }], + "tags": [ + { + "name": "Organizations" + } + ], "paths": { "/api/organizations/{id}/email-domains": { "get": { diff --git a/packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts b/packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts index 3fc2d7726c6..ba467181a77 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-email-domains.test.ts @@ -36,11 +36,10 @@ describe('organization email domains', () => { expect(emailDomainsPage1).toHaveLength(20); expect(emailDomainsPage2).toHaveLength(10); - expect(emailDomainsPage1).toMatchObject( - emailDomains.slice(0, 20).map((emailDomain) => expect.objectContaining({ emailDomain })) - ); - expect(emailDomainsPage2).toEqual( - emailDomains.slice(20).map((emailDomain) => expect.objectContaining({ emailDomain })) + expect(emailDomainsPage1.concat(emailDomainsPage2)).toEqual( + expect.arrayContaining( + emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain })) + ) ); }); @@ -72,8 +71,10 @@ describe('organization email domains', () => { const emailDomains = [`${randomId()}.com`, `${randomId()}.com`]; await organizationApi.replaceEmailDomains(organization.id, emailDomains); - await expect(organizationApi.getEmailDomains(organization.id)).resolves.toMatchObject( - emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain })) + await expect(organizationApi.getEmailDomains(organization.id)).resolves.toEqual( + expect.arrayContaining( + emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain })) + ) ); }); });