From 6dd2565dca2830560b9a46418f8b2a07f05f3a7d Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 19 Jun 2024 22:29:44 +0800 Subject: [PATCH 1/2] feat(core): init organization app apis --- .../core/src/queries/organization/index.ts | 9 ++ .../index.applications.openapi.json | 82 +++++++++++++++++ .../organization/index.jit.roles.openapi.json | 3 + .../core/src/routes/organization/index.ts | 8 ++ packages/core/src/routes/swagger/index.ts | 9 +- .../core/src/routes/swagger/utils/general.ts | 4 +- packages/core/src/utils/SchemaRouter.ts | 1 + packages/integration-tests/src/api/factory.ts | 71 +++++++++++++++ .../src/api/organization-jit.ts | 23 ++--- .../integration-tests/src/api/organization.ts | 8 +- .../api/admin-user.organization-jit.test.ts | 4 +- .../tests/api/hook/hook.trigger.data.test.ts | 18 +++- .../src/tests/api/hook/test-cases.ts | 24 +++++ .../api/interaction/organization-jit.test.ts | 4 +- .../organization-application.test.ts | 87 +++++++++++++++++++ .../api/organization/organization-jit.test.ts | 24 ++--- ...5576-organization-application-relations.ts | 37 ++++++++ packages/schemas/tables/applications.sql | 5 ++ .../organization_application_relations.sql | 14 +++ 19 files changed, 396 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/routes/organization/index.applications.openapi.json create mode 100644 packages/integration-tests/src/tests/api/organization/organization-application.test.ts create mode 100644 packages/schemas/alterations/next-1718785576-organization-application-relations.ts create mode 100644 packages/schemas/tables/organization_application_relations.sql diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 32959af2b..baf8f3806 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -22,6 +22,8 @@ import { Resources, Users, OrganizationJitRoles, + OrganizationApplicationRelations, + Applications, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -283,6 +285,13 @@ export default class OrganizationQueries extends SchemaQueries< users: new UserRelationQueries(this.pool), /** Queries for organization - organization role - user relations. */ rolesUsers: new RoleUserRelationQueries(this.pool), + /** Queries for organization - application relations. */ + apps: new TwoRelationsQueries( + this.pool, + OrganizationApplicationRelations.table, + Organizations, + Applications + ), invitationsRoles: new TwoRelationsQueries( this.pool, OrganizationInvitationRoleRelations.table, diff --git a/packages/core/src/routes/organization/index.applications.openapi.json b/packages/core/src/routes/organization/index.applications.openapi.json new file mode 100644 index 000000000..c42cbc42c --- /dev/null +++ b/packages/core/src/routes/organization/index.applications.openapi.json @@ -0,0 +1,82 @@ +{ + "tags": [ + { + "name": "Organization applications", + "description": "Manage organization - application relationships. An application can be associated with one or more organizations in order to grant organization access to the application.\n\nCurrently, only machine-to-machine applications can be associated with organizations." + } + ], + "paths": { + "/api/organizations/{id}/applications": { + "get": { + "summary": "Get organization applications", + "description": "Get applications associated with the organization.", + "responses": { + "200": { + "description": "A list of applications." + } + } + }, + "post": { + "summary": "Add organization application", + "description": "Add an application to the organization.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "applicationIds": { + "description": "The application IDs to add." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The application was added successfully." + }, + "422": { + "description": "The application could not be added. Some of the applications may not exist." + } + } + }, + "put": { + "summary": "Replace organization applications", + "description": "Replace all applications associated with the organization with the given data.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "applicationIds": { + "description": "An array of application IDs to replace existing applications." + } + } + } + } + } + }, + "responses": { + "204": { + "description": "The applications were replaced successfully." + }, + "422": { + "description": "The applications could not be replaced. Some of the applications may not exist." + } + } + } + }, + "/api/organizations/{id}/applications/{applicationId}": { + "delete": { + "summary": "Remove organization application", + "description": "Remove an application from the organization.", + "responses": { + "204": { + "description": "The application was removed from the organization successfully." + } + } + } + } + } +} diff --git a/packages/core/src/routes/organization/index.jit.roles.openapi.json b/packages/core/src/routes/organization/index.jit.roles.openapi.json index a90179f5d..605ed4bbf 100644 --- a/packages/core/src/routes/organization/index.jit.roles.openapi.json +++ b/packages/core/src/routes/organization/index.jit.roles.openapi.json @@ -59,6 +59,9 @@ "responses": { "204": { "description": "The organization roles were replaced successfully." + }, + "422": { + "description": "The organization roles could not be replaced. Some of the organization roles may not exist." } } } diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 9797cf775..96339b49d 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'; @@ -139,6 +140,13 @@ export default function organizationRoutes( userRoleRelationRoutes(router, organizations); + // MARK: Organization - application relation routes + if (EnvSet.values.isDevFeaturesEnabled) { + router.addRelationRoutes(organizations.relations.apps, undefined, { + hookEvent: 'Organization.Membership.Updated', + }); + } + // MARK: Just-in-time provisioning emailDomainRoutes(router, organizations); router.addRelationRoutes(organizations.jit.roles, 'jit/roles', { isPaginationOptional: true }); diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts index 4e55105ee..80c3db953 100644 --- a/packages/core/src/routes/swagger/index.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -112,7 +112,7 @@ const isManagementApiRouter = ({ stack }: Router) => // Add more components here to cover more ID parameters in paths. For example, if there is a // path `/foo/:barBazId`, then add `bar-baz` to the array. -const identifiableEntityNames = [ +const identifiableEntityNames = Object.freeze([ 'key', 'connector-factory', 'factory', @@ -131,7 +131,10 @@ const identifiableEntityNames = [ 'organization-role', 'organization-scope', 'organization-invitation', -]; +]); + +/** Additional tags that cannot be inferred from the path. */ +const additionalTags = Object.freeze(['Organization applications']); /** * Attach the `/swagger.json` route which returns the generated OpenAPI document for the @@ -229,7 +232,7 @@ export default function swaggerRoutes ({ name: tag })), + tags: [...tags, ...additionalTags].map((tag) => ({ name: tag })), }; const data = supplementDocuments.reduce( diff --git a/packages/core/src/routes/swagger/utils/general.ts b/packages/core/src/routes/swagger/utils/general.ts index 6824cdd91..6df579559 100644 --- a/packages/core/src/routes/swagger/utils/general.ts +++ b/packages/core/src/routes/swagger/utils/general.ts @@ -142,7 +142,9 @@ export const validateSupplement = ( for (const { name } of supplementTags) { if (!originalTags.has(name)) { - throw new TypeError(`Supplement document contains extra tag \`${name}\`.`); + throw new TypeError( + `Supplement document contains extra tag \`${name}\`. If you want to add a new tag, please add it to the \`additionalTags\` array in the main swagger route file.` + ); } } } diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 19252da4f..8bee8fd83 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -159,6 +159,7 @@ export default class SchemaRouter< * * - `GET /:id/[pathname]`: Get the entities of the relation with pagination. * - `POST /:id/[pathname]`: Add entities to the relation. + * - `PUT /:id/[pathname]`: Replace the entities in the relation. * - `DELETE /:id/[pathname]/:relationSchemaId`: Remove an entity from the relation set. * The `:relationSchemaId` is the entity ID in the relation schema. * diff --git a/packages/integration-tests/src/api/factory.ts b/packages/integration-tests/src/api/factory.ts index f7ccbaac4..2a619ab2d 100644 --- a/packages/integration-tests/src/api/factory.ts +++ b/packages/integration-tests/src/api/factory.ts @@ -49,3 +49,74 @@ export class ApiFactory< await authedAdminApi.delete(this.path + '/' + id); } } + +type RelationApiFactoryConfig = { + /** The base path of the API. */ + basePath: string; + /** + * The path of the relation. It will to be appended to the base path and the ID of the parent. + * + * @example + * If the base path is `organizations` and the relation path is `applications`, the final paths + * will be `organizations/:id/applications` and `organizations/:id/applications/:applicationId`. + */ + relationPath: string; + /** + * The key name of the relation IDs. It will be used in the request body. + * + * @example + * If the key name is `applicationIds`, the request body will be + * `{ applicationIds: ['id1', 'id2'] }`. + */ + relationKey: string; +}; + +export class RelationApiFactory> { + constructor(public readonly config: RelationApiFactoryConfig) {} + + get basePath(): string { + return this.config.basePath; + } + + get relationPath(): string { + return this.config.relationPath; + } + + get relationKey(): string { + return this.config.relationKey; + } + + async getList(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 transform( + await authedAdminApi + .get(`${this.basePath}/${id}/${this.relationPath}`, { searchParams }) + .json() + ); + } + + async add(id: string, relationIds: string[]): Promise { + await authedAdminApi.post(`${this.basePath}/${id}/${this.relationPath}`, { + json: { [this.relationKey]: relationIds }, + }); + } + + async delete(id: string, relationId: string): Promise { + await authedAdminApi.delete(`${this.basePath}/${id}/${this.relationPath}/${relationId}`); + } + + async replace(id: string, relationIds: string[]): Promise { + await authedAdminApi.put(`${this.basePath}/${id}/${this.relationPath}`, { + json: { [this.relationKey]: relationIds }, + }); + } +} diff --git a/packages/integration-tests/src/api/organization-jit.ts b/packages/integration-tests/src/api/organization-jit.ts index 6d2e9fdfe..203e34db9 100644 --- a/packages/integration-tests/src/api/organization-jit.ts +++ b/packages/integration-tests/src/api/organization-jit.ts @@ -1,8 +1,15 @@ import { type OrganizationRole, type OrganizationJitEmailDomain } from '@logto/schemas'; import { authedAdminApi } from './api.js'; +import { RelationApiFactory } from './factory.js'; export class OrganizationJitApi { + roles = new RelationApiFactory({ + basePath: 'organizations', + relationPath: 'jit/roles', + relationKey: 'organizationRoleIds', + }); + constructor(public path: string) {} async getEmailDomains( @@ -36,20 +43,4 @@ export class OrganizationJitApi { async replaceEmailDomains(id: string, emailDomains: string[]): Promise { await authedAdminApi.put(`${this.path}/${id}/jit/email-domains`, { json: { emailDomains } }); } - - async getRoles(id: string): Promise { - return authedAdminApi.get(`${this.path}/${id}/jit/roles`).json(); - } - - async addRole(id: string, organizationRoleIds: string[]): Promise { - await authedAdminApi.post(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } }); - } - - async deleteRole(id: string, organizationRoleId: string): Promise { - await authedAdminApi.delete(`${this.path}/${id}/jit/roles/${organizationRoleId}`); - } - - async replaceRoles(id: string, organizationRoleIds: string[]): Promise { - await authedAdminApi.put(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } }); - } } diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts index 98bcac3b6..e5cb2e0f1 100644 --- a/packages/integration-tests/src/api/organization.ts +++ b/packages/integration-tests/src/api/organization.ts @@ -6,10 +6,11 @@ import { type OrganizationWithFeatured, type OrganizationScope, type CreateOrganization, + type Application, } from '@logto/schemas'; import { authedAdminApi } from './api.js'; -import { ApiFactory } from './factory.js'; +import { ApiFactory, RelationApiFactory } from './factory.js'; import { OrganizationJitApi } from './organization-jit.js'; type Query = { @@ -20,6 +21,11 @@ type Query = { export class OrganizationApi extends ApiFactory> { jit = new OrganizationJitApi(this.path); + applications = new RelationApiFactory({ + basePath: 'organizations', + relationPath: 'applications', + relationKey: 'applicationIds', + }); constructor() { super('organizations'); diff --git a/packages/integration-tests/src/tests/api/admin-user.organization-jit.test.ts b/packages/integration-tests/src/tests/api/admin-user.organization-jit.test.ts index 6cfe0ba59..2b1633a5c 100644 --- a/packages/integration-tests/src/tests/api/admin-user.organization-jit.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.organization-jit.test.ts @@ -28,8 +28,8 @@ describe('organization just-in-time provisioning', () => { ) ); await Promise.all([ - organizationApi.jit.addRole(organizations[0].id, [roles[0].id, roles[1].id]), - organizationApi.jit.addRole(organizations[1].id, [roles[0].id]), + organizationApi.jit.roles.add(organizations[0].id, [roles[0].id, roles[1].id]), + organizationApi.jit.roles.add(organizations[1].id, [roles[0].id]), ]); const email = randomString() + '@' + emailDomain; diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts index f6071e3ea..f3a5887ab 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts @@ -1,4 +1,5 @@ import { + ApplicationType, RoleType, hookEventGuard, hookEvents, @@ -10,6 +11,7 @@ import { assert } from '@silverhand/essentials'; import { z } from 'zod'; import { authedAdminApi } from '#src/api/api.js'; +import { createApplication } from '#src/api/application.js'; import { createResource } from '#src/api/resource.js'; import { createScope } from '#src/api/scope.js'; import { WebHookApiTest } from '#src/helpers/hook.js'; @@ -214,6 +216,7 @@ describe('organization data hook events', () => { /* eslint-disable @silverhand/fp/no-let */ let organizationId: string; let userId: string; + let applicationId: string; /* eslint-enable @silverhand/fp/no-let */ const organizationApi = new OrganizationApiTest(); @@ -226,10 +229,12 @@ describe('organization data hook events', () => { }); const user = await userApi.create({ name: generateName() }); + const application = await createApplication(generateName(), ApplicationType.MachineToMachine); /* eslint-disable @silverhand/fp/no-mutation */ organizationId = organization.id; userId = user.id; + applicationId = application.id; /* eslint-enable @silverhand/fp/no-mutation */ const organizationCreateHook = await getWebhookResult('POST /organizations'); @@ -244,8 +249,17 @@ describe('organization data hook events', () => { 'test case %#: %p', async ({ route, event, method, endpoint, payload, hookPayload }) => { await authedAdminApi[method]( - endpoint.replace('{organizationId}', organizationId).replace('{userId}', userId), - { json: JSON.parse(JSON.stringify(payload).replace('{userId}', userId)) } + endpoint + .replace('{organizationId}', organizationId) + .replace('{userId}', userId) + .replace('{applicationId}', applicationId), + { + json: JSON.parse( + JSON.stringify(payload) + .replace('{userId}', userId) + .replace('{applicationId}', applicationId) + ), + } ); const hook = await getWebhookResult(route); expect(hook?.payload.event).toBe(event); diff --git a/packages/integration-tests/src/tests/api/hook/test-cases.ts b/packages/integration-tests/src/tests/api/hook/test-cases.ts index 3151077e0..742a887a3 100644 --- a/packages/integration-tests/src/tests/api/hook/test-cases.ts +++ b/packages/integration-tests/src/tests/api/hook/test-cases.ts @@ -129,6 +129,30 @@ export const organizationDataHookTestCases: TestCase[] = [ payload: {}, hookPayload: { organizationId: expect.any(String) }, }, + { + route: 'POST /organizations/:id/applications', + event: 'Organization.Membership.Updated', + method: 'post', + endpoint: `organizations/{organizationId}/applications`, + payload: { applicationIds: ['{applicationId}'] }, + hookPayload: { organizationId: expect.any(String) }, + }, + { + route: 'PUT /organizations/:id/applications', + event: 'Organization.Membership.Updated', + method: 'put', + endpoint: `organizations/{organizationId}/applications`, + payload: { applicationIds: ['{applicationId}'] }, + hookPayload: { organizationId: expect.any(String) }, + }, + { + route: 'DELETE /organizations/:id/applications/:applicationId', + event: 'Organization.Membership.Updated', + method: 'delete', + endpoint: `organizations/{organizationId}/applications/{applicationId}`, + payload: {}, + hookPayload: { organizationId: expect.any(String) }, + }, { route: 'DELETE /organizations/:id', event: 'Organization.Deleted', diff --git a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts index d14af0d45..52d7fa9ca 100644 --- a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts @@ -62,8 +62,8 @@ describe('organization just-in-time provisioning', () => { ) ); await Promise.all([ - organizationApi.jit.addRole(organizations[0].id, [roles[0].id, roles[1].id]), - organizationApi.jit.addRole(organizations[1].id, [roles[0].id]), + organizationApi.jit.roles.add(organizations[0].id, [roles[0].id, roles[1].id]), + organizationApi.jit.roles.add(organizations[1].id, [roles[0].id]), ]); const email = randomString() + '@' + emailDomain; diff --git a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts new file mode 100644 index 000000000..96cb2953a --- /dev/null +++ b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert'; + +import { ApplicationType, type Application } from '@logto/schemas'; +import { HTTPError } from 'ky'; + +import { + createApplication as createApplicationApi, + deleteApplication, +} from '#src/api/application.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { generateTestName } from '#src/utils.js'; + +describe('organization application APIs', () => { + describe('organization - application relations', () => { + const organizationApi = new OrganizationApiTest(); + const applications: Application[] = []; + const createApplication = async (...args: Parameters) => { + const created = await createApplicationApi(...args); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + applications.push(created); + return created; + }; + + afterEach(async () => { + await Promise.all([ + organizationApi.cleanUp(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ...applications.map(async ({ id }) => deleteApplication(id).catch(() => {})), + ]); + }); + + it('should fail when try to add empty application list', async () => { + const organization = await organizationApi.create({ name: 'test' }); + const response = await organizationApi.applications + .add(organization.id, []) + .catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.status).toBe(400); + }); + + it('should fail when try to add application to an organization that does not exist', async () => { + const response = await organizationApi.applications + .add('0', ['0']) + .catch((error: unknown) => error); + assert(response instanceof HTTPError); + expect(response.response.status).toBe(422); + expect(await response.response.json()).toMatchObject( + expect.objectContaining({ code: 'entity.relation_foreign_key_not_found' }) + ); + }); + + it('should be able to add and delete organization application', async () => { + const organization = await organizationApi.create({ name: 'test' }); + const application = await createApplication( + generateTestName(), + ApplicationType.MachineToMachine + ); + + await organizationApi.applications.add(organization.id, [application.id]); + expect(await organizationApi.applications.getList(organization.id)).toContainEqual( + application + ); + + await organizationApi.applications.delete(organization.id, application.id); + expect(await organizationApi.applications.getList(organization.id)).not.toContainEqual( + application + ); + }); + + it('should fail when try to delete application from an organization that does not exist', async () => { + const response = await organizationApi.applications + .delete('0', '0') + .catch((error: unknown) => error); + assert(response instanceof HTTPError); + expect(response.response.status).toBe(404); + }); + + it('should fail when try to add application that is not machine-to-machine', async () => { + const organization = await organizationApi.create({ name: 'test' }); + const application = await createApplication(generateTestName(), ApplicationType.Native); + + const response = await organizationApi.applications + .add(organization.id, [application.id]) + .catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.status).toBe(422); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts b/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts index 04687c10d..3197f3ca7 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts @@ -88,13 +88,13 @@ describe('organization just-in-time provisioning', () => { name: `jit-role:${randomString()}`, }); - await organizationApi.jit.addRole(organization.id, [organizationRoleId]); - await expect(organizationApi.jit.getRoles(organization.id)).resolves.toMatchObject([ + await organizationApi.jit.roles.add(organization.id, [organizationRoleId]); + await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toMatchObject([ { id: organizationRoleId }, ]); - await organizationApi.jit.deleteRole(organization.id, organizationRoleId); - await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual([]); + await organizationApi.jit.roles.delete(organization.id, organizationRoleId); + await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toEqual([]); }); it('should have no pagination', async () => { @@ -107,12 +107,12 @@ describe('organization just-in-time provisioning', () => { ) ); - await organizationApi.jit.replaceRoles( + await organizationApi.jit.roles.replace( organization.id, organizationRoles.map(({ id }) => id) ); - await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual( + await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toEqual( expect.arrayContaining(organizationRoles.map(({ id }) => expect.objectContaining({ id }))) ); }); @@ -122,7 +122,7 @@ describe('organization just-in-time provisioning', () => { const organizationRoleId = randomId(); await expect( - organizationApi.jit.deleteRole(organization.id, organizationRoleId) + organizationApi.jit.roles.delete(organization.id, organizationRoleId) ).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]'); }); @@ -131,7 +131,7 @@ describe('organization just-in-time provisioning', () => { const organizationRoleId = randomId(); await expect( - organizationApi.jit.addRole(organization.id, [organizationRoleId]) + organizationApi.jit.roles.add(organization.id, [organizationRoleId]) ).rejects.toMatchInlineSnapshot( '[HTTPError: Request failed with status code 422 Unprocessable Entity]' ); @@ -148,9 +148,9 @@ describe('organization just-in-time provisioning', () => { }), ]); - await organizationApi.jit.addRole(organization.id, [organizationRoles[0].id]); + await organizationApi.jit.roles.add(organization.id, [organizationRoles[0].id]); await expect( - organizationApi.jit.addRole(organization.id, [ + organizationApi.jit.roles.add(organization.id, [ organizationRoles[0].id, organizationRoles[1].id, ]) @@ -167,11 +167,11 @@ describe('organization just-in-time provisioning', () => { ) ); - await organizationApi.jit.replaceRoles( + await organizationApi.jit.roles.replace( organization.id, organizationRoles.map(({ id }) => id) ); - await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual( + await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toEqual( expect.arrayContaining(organizationRoles.map(({ id }) => expect.objectContaining({ id }))) ); }); diff --git a/packages/schemas/alterations/next-1718785576-organization-application-relations.ts b/packages/schemas/alterations/next-1718785576-organization-application-relations.ts new file mode 100644 index 000000000..0798755cb --- /dev/null +++ b/packages/schemas/alterations/next-1718785576-organization-application-relations.ts @@ -0,0 +1,37 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + create function check_application_type(application_id varchar(21), target_type application_type) returns boolean as + $$ begin + return (select type from applications where id = application_id) = target_type; + end; $$ language plpgsql; + create table organization_application_relations ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + organization_id varchar(21) not null + references organizations (id) on update cascade on delete cascade, + application_id varchar(21) not null + references applications (id) on update cascade on delete cascade, + primary key (tenant_id, organization_id, application_id), + constraint application_type + check (check_application_type(application_id, 'MachineToMachine')) + ); + `); + await applyTableRls(pool, 'organization_application_relations'); + }, + down: async (pool) => { + await dropTableRls(pool, 'organization_application_relations'); + await pool.query(sql` + drop table organization_application_relations; + drop function check_application_type; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/tables/applications.sql b/packages/schemas/tables/applications.sql index 702f26971..3cf6cf336 100644 --- a/packages/schemas/tables/applications.sql +++ b/packages/schemas/tables/applications.sql @@ -33,3 +33,8 @@ create unique index applications__protected_app_metadata_custom_domain on applications ( (protected_app_metadata->'customDomains'->0->>'domain') ); + +create function check_application_type(application_id varchar(21), target_type application_type) returns boolean as +$$ begin + return (select type from applications where id = application_id) = target_type; +end; $$ language plpgsql; diff --git a/packages/schemas/tables/organization_application_relations.sql b/packages/schemas/tables/organization_application_relations.sql new file mode 100644 index 000000000..1bd6db036 --- /dev/null +++ b/packages/schemas/tables/organization_application_relations.sql @@ -0,0 +1,14 @@ +/* init_order = 2 */ + +/** The relations between organizations and applications. It indicates membership of applications in organizations. For now only machine-to-machine applications are supported. */ +create table organization_application_relations ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + organization_id varchar(21) not null + references organizations (id) on update cascade on delete cascade, + application_id varchar(21) not null + references applications (id) on update cascade on delete cascade, + primary key (tenant_id, organization_id, application_id), + constraint application_type + check (check_application_type(application_id, 'MachineToMachine')) +); From 34a64111cc34d5baa14e0c00562cec50b5a4e71d Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 19 Jun 2024 22:55:52 +0800 Subject: [PATCH 2/2] chore: skip tests if needed --- .../src/tests/api/hook/hook.trigger.data.test.ts | 11 +++++++++-- .../api/organization/organization-application.test.ts | 5 +++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts index f3a5887ab..17942fa77 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts @@ -11,9 +11,10 @@ import { assert } from '@silverhand/essentials'; import { z } from 'zod'; import { authedAdminApi } from '#src/api/api.js'; -import { createApplication } from '#src/api/application.js'; +import { createApplication, deleteApplication } from '#src/api/application.js'; import { createResource } from '#src/api/resource.js'; import { createScope } from '#src/api/scope.js'; +import { isDevFeaturesEnabled } from '#src/constants.js'; import { WebHookApiTest } from '#src/helpers/hook.js'; import { OrganizationApiTest, @@ -242,12 +243,18 @@ describe('organization data hook events', () => { }); afterAll(async () => { - await userApi.cleanUp(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + await Promise.all([userApi.cleanUp(), deleteApplication(applicationId).catch(() => {})]); }); it.each(organizationDataHookTestCases)( 'test case %#: %p', async ({ route, event, method, endpoint, payload, hookPayload }) => { + // TODO: Remove this check + if (route.includes('applications') && !isDevFeaturesEnabled) { + return; + } + await authedAdminApi[method]( endpoint .replace('{organizationId}', organizationId) diff --git a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts index 96cb2953a..0edecff25 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts @@ -8,9 +8,10 @@ import { deleteApplication, } from '#src/api/application.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; -import { generateTestName } from '#src/utils.js'; +import { devFeatureTest, generateTestName } from '#src/utils.js'; -describe('organization application APIs', () => { +// TODO: Remove this prefix +devFeatureTest.describe('organization application APIs', () => { describe('organization - application relations', () => { const organizationApi = new OrganizationApiTest(); const applications: Application[] = [];