0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(core,phrases): update applications api to support third-party app (#5096)

update applications api to support third-party app
This commit is contained in:
simeng-li 2023-12-13 11:00:03 +08:00 committed by GitHub
parent a93a39aa1b
commit dcc226b5d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 157 additions and 53 deletions

View file

@ -21,7 +21,6 @@ const pool = createMockPool({
const { createApplicationQueries } = await import('./application.js');
const {
findTotalNumberOfApplications,
findAllApplications,
findApplicationById,
insertApplication,
updateApplicationById,
@ -47,29 +46,6 @@ describe('application query', () => {
await expect(findTotalNumberOfApplications()).resolves.toEqual({ count: 10 });
});
it('findAllApplications', async () => {
const limit = 10;
const offset = 1;
const rowData = { id: 'foo' };
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
order by "created_at" desc
limit $1
offset $2
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([limit, offset]);
return createMockQueryResult([rowData]);
});
await expect(findAllApplications(limit, offset)).resolves.toEqual([rowData]);
});
it('findApplicationById', async () => {
const id = 'foo';
const rowData = { id };

View file

@ -5,7 +5,6 @@ import { convertToIdentifiers, conditionalSql, conditionalArraySql } from '@logt
import type { CommonQueryMethods, SqlSqlToken } from 'slonik';
import { sql } from 'slonik';
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { getTotalRowCountWithPool } from '#src/database/row-count.js';
@ -49,12 +48,14 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
*
* @param search The search config object, can apply to `id`, `name` and `description` field for application.
* @param excludeApplicationIds Exclude applications with these ids.
* @param isThirdParty Optional boolean, filter applications by whether it is a third party application.
* @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included.
* @returns A Promise that resolves the number of applications that match the search conditions.
*/
const countApplications = async (
search: Search,
excludeApplicationIds: string[],
isThirdParty?: boolean,
types?: ApplicationType[]
) => {
const { count } = await pool.one<{ count: string }>(sql`
@ -65,6 +66,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`
: sql``,
types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``,
isThirdParty ? sql`${fields.isThirdParty} = true` : sql`${fields.isThirdParty} = false`,
buildApplicationConditions(search),
])}
`);
@ -75,20 +77,32 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
/**
* Get the list of applications that match the search conditions, conditions are joined in `and` mode.
*
* @param search The search config object, can apply to `id`, `name` and `description` field for application
* @param excludeApplicationIds Exclude applications with these ids.
* @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included.
* @param limit Limit of the number of applications in each page.
* @param offset Offset of the applications in the result.
* @param conditions The conditions to filter applications.
* @param conditions.search The search config object, can apply to `id`, `name` and `description` field for application
* @param conditions.excludeApplicationIds Exclude applications with these ids.
* @param conditions.types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included.
* @param conditions.isThirdParty Optional boolean, filter applications by whether it is a third party application.
* @param conditions.pagination Optional pagination config object.
* @param conditions.pagination.limit The number of applications to return.
* @param conditions.pagination.offset The offset of applications to return.
* @returns A Promise that resolves the list of applications that match the search conditions.
*/
const findApplications = async (
search: Search,
excludeApplicationIds: string[],
types?: ApplicationType[],
limit?: number,
offset?: number
) =>
const findApplications = async ({
search,
excludeApplicationIds,
types,
isThirdParty,
pagination,
}: {
search: Search;
excludeApplicationIds: string[];
types?: ApplicationType[];
isThirdParty?: boolean;
pagination?: {
limit: number;
offset: number;
};
}) =>
pool.any<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
@ -97,19 +111,16 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`
: sql``,
types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``,
isThirdParty ? sql`${fields.isThirdParty} = true` : sql`${fields.isThirdParty} = false`,
buildApplicationConditions(search),
])}
order by ${fields.createdAt} desc
${conditionalSql(limit, (value) => sql`limit ${value}`)}
${conditionalSql(offset, (value) => sql`offset ${value}`)}
${conditionalSql(pagination?.limit, (value) => sql`limit ${value}`)}
${conditionalSql(pagination?.offset, (value) => sql`offset ${value}`)}
`);
const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table);
const findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [
{ field: 'createdAt', order: 'desc' },
]);
const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications);
const insertApplication = buildInsertIntoWithPool(pool)(Applications, {
@ -212,7 +223,6 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
countApplications,
findApplications,
findTotalNumberOfApplications,
findAllApplications,
findApplicationById,
insertApplication,
updateApplication,

View file

@ -7,8 +7,10 @@ import {
applicationPatchGuard,
} from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { boolean, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
@ -64,6 +66,11 @@ export default function applicationRoutes<T extends AuthedRouter>(
.array()
.or(applicationTypeGuard.transform((type) => [type]))
.optional(),
excludeRoleId: string().optional(),
// FIXME: @simeng-li Remove this guard once Logto as IdP is ready
...conditional(
EnvSet.values.isDevFeaturesEnabled && { isThirdParty: z.literal('true').optional() }
),
}),
response: z.array(applicationResponseGuard),
status: 200,
@ -71,27 +78,37 @@ export default function applicationRoutes<T extends AuthedRouter>(
async (ctx, next) => {
const { limit, offset, disabled: paginationDisabled } = ctx.pagination;
const { searchParams } = ctx.URL;
const { types } = ctx.guard.query;
const { types, excludeRoleId } = ctx.guard.query;
// FIXME: @simeng-li Remove this guard once Logto as IdP is ready
const isThirdParty = Boolean(ctx.guard.query.isThirdParty);
// This will only parse the `search` query param, other params will be ignored. Please use query guard to validate them.
const search = parseSearchParamsForSearch(searchParams);
const excludeRoleId = searchParams.get('excludeRoleId');
const excludeApplicationsRoles = excludeRoleId
? await findApplicationsRolesByRoleId(excludeRoleId)
: [];
const excludeApplicationIds = excludeApplicationsRoles.map(
({ applicationId }) => applicationId
);
if (paginationDisabled) {
ctx.body = await findApplications(search, excludeApplicationIds, types);
ctx.body = await findApplications({ search, excludeApplicationIds, types, isThirdParty });
return next();
}
const [{ count }, applications] = await Promise.all([
countApplications(search, excludeApplicationIds, types),
findApplications(search, excludeApplicationIds, types, limit, offset),
countApplications(search, excludeApplicationIds, isThirdParty, types),
findApplications({
search,
excludeApplicationIds,
types,
isThirdParty,
pagination: { limit, offset },
}),
]);
// Return totalCount to pagination middleware
@ -118,6 +135,14 @@ export default function applicationRoutes<T extends AuthedRouter>(
: 'applicationsLimit'
);
// Third party applications must be traditional type
if (rest.isThirdParty) {
assertThat(
rest.type === ApplicationType.Traditional,
'application.invalid_third_party_application_type'
);
}
ctx.body = await insertApplication({
id: generateStandardId(),
secret: generateStandardSecret(),

View file

@ -6,10 +6,14 @@ import {
import { EnvSet } from '#src/env-set/index.js';
// FIXME: @simeng-li Remove this guard once Logto as IdP is ready
export const applicationResponseGuard = EnvSet.values.isDevFeaturesEnabled
// @ts-expect-error -- hide the dev feature field from the guard type, but always return the full type to make the api logic simpler
export const applicationResponseGuard: typeof Applications.guard = EnvSet.values
.isDevFeaturesEnabled
? Applications.guard
: Applications.guard.omit({ isThirdParty: true });
export const applicationCreateGuard = EnvSet.values.isDevFeaturesEnabled
// @ts-expect-error -- hide the dev feature field from the guard type, but always return the full type to make the api logic simpler
export const applicationCreateGuard: typeof originalApplicationCreateGuard = EnvSet.values
.isDevFeaturesEnabled
? originalApplicationCreateGuard
: originalApplicationCreateGuard.omit({ isThirdParty: true });

View file

@ -26,7 +26,8 @@ export const createApplication = async (
export const getApplications = async (
types?: ApplicationType[],
searchParameters?: Record<string, string>
searchParameters?: Record<string, string>,
isThirdParty?: boolean
) => {
const searchParams = new URLSearchParams([
...(conditional(types && types.length > 0 && types.map((type) => ['types', type])) ?? []),
@ -35,6 +36,7 @@ export const getApplications = async (
Object.keys(searchParameters).length > 0 &&
Object.entries(searchParameters).map(([key, value]) => [key, value])
) ?? []),
...(conditional(isThirdParty && [['isThirdParty', 'true']]) ?? []),
]);
return authedAdminApi.get('applications', { searchParams }).json<Application[]>();

View file

@ -8,6 +8,7 @@ import {
deleteApplication,
getApplications,
} from '#src/api/index.js';
import { expectRejects } from '#src/helpers/index.js';
describe('admin console application', () => {
it('should create application successfully', async () => {
@ -17,6 +18,7 @@ describe('admin console application', () => {
expect(application.name).toBe(applicationName);
expect(application.type).toBe(applicationType);
expect(application.isThirdParty).toBe(false);
const fetchedApplication = await getApplication(application.id);
@ -24,6 +26,28 @@ describe('admin console application', () => {
expect(fetchedApplication.id).toBe(application.id);
});
it('should throw error when creating a third party application with invalid type', async () => {
await expectRejects(
createApplication('test-create-app', ApplicationType.Native, {
isThirdParty: true,
}),
{ code: 'application.invalid_third_party_application_type', statusCode: 400 }
);
});
it('should create third party application successfully', async () => {
const applicationName = 'test-third-party-app';
const application = await createApplication(applicationName, ApplicationType.Traditional, {
isThirdParty: true,
});
expect(application.name).toBe(applicationName);
expect(application.type).toBe(ApplicationType.Traditional);
expect(application.isThirdParty).toBe(true);
await deleteApplication(application.id);
});
it('should update application details successfully', async () => {
const application = await createApplication('test-update-app', ApplicationType.Traditional);
@ -74,11 +98,30 @@ describe('admin console application', () => {
expect(application.id).toBe('demo-app');
});
it('should fetch all applications created above', async () => {
it('should fetch all non-third party applications created above', async () => {
const applications = await getApplications();
const applicationNames = applications.map(({ name }) => name);
expect(applicationNames).toContain('test-create-app');
expect(applicationNames).toContain('test-update-app');
expect(applications.some(({ isThirdParty }) => isThirdParty)).toBe(false);
});
it('should fetch all third party applications created', async () => {
const application = await createApplication(
'test-third-party-app',
ApplicationType.Traditional,
{
isThirdParty: true,
}
);
const applications = await getApplications(undefined, undefined, true);
expect(applications.find(({ id }) => id === application.id)).toEqual(application);
expect(applications.some(({ isThirdParty }) => !isThirdParty)).toBe(false);
await deleteApplication(application.id);
});
it('should create m2m application successfully and can get only m2m applications by specifying types', async () => {

View file

@ -3,6 +3,9 @@ const application = {
role_exists: 'Die Rolle mit der ID {{roleId}} wurde bereits dieser Anwendung hinzugefügt.',
invalid_role_type:
'Es ist nicht möglich, einer Maschinen-zu-Maschinen-Anwendung eine Benutzertyp-Rolle zuzuweisen.',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -2,6 +2,8 @@ const application = {
invalid_type: 'Only machine to machine applications can have associated roles.',
role_exists: 'The role id {{roleId}} is already been added to this application.',
invalid_role_type: 'Can not assign user type role to machine to machine application.',
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -3,6 +3,9 @@ const application = {
role_exists: 'La identificación del rol {{roleId}} ya se ha agregado a esta aplicación.',
invalid_role_type:
'No se puede asignar un rol de tipo usuario a una aplicación de máquina a máquina.',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -3,6 +3,9 @@ const application = {
role_exists: "Le rôle d'identifiant {{roleId}} a déjà été ajouté à cette application.",
invalid_role_type:
"Impossible d'assigner un rôle de type utilisateur à une application machine à machine.",
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -3,6 +3,9 @@ const application = {
role_exists: "L'ID ruolo {{roleId}} è già stato aggiunto a questa applicazione.",
invalid_role_type:
"Impossibile assegnare un ruolo di tipo utente all'applicazione da macchina a macchina.",
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -3,6 +3,9 @@ const application = {
role_exists: 'ロールID {{roleId}} は、すでにこのアプリケーションに追加されています。',
invalid_role_type:
'ユーザータイプのロールをマシン間アプリケーションに割り当てることはできません。',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -2,6 +2,9 @@ const application = {
invalid_type: '관련 역할을 가질 수 있는 것은 기계 대 기계 응용 프로그램만 가능합니다.',
role_exists: '역할 ID {{roleId}} 가 이미이 응용 프로그램에 추가되었습니다.',
invalid_role_type: '사용자 유형 역할을 기계 대 기계 응용 프로그램에 할당할 수 없습니다.',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -2,6 +2,9 @@ const application = {
invalid_type: 'Tylko aplikacje maszyna-do-maszyny mogą mieć przypisane role.',
role_exists: 'Rola o identyfikatorze {{roleId}} została już dodana do tej aplikacji.',
invalid_role_type: 'Nie można przypisać roli typu użytkownika do aplikacji maszyna-do-maszyny.',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -3,6 +3,9 @@ const application = {
role_exists: 'O id da função {{roleId}} já foi adicionado a este aplicativo.',
invalid_role_type:
'Não é possível atribuir uma função de tipo de usuário a um aplicativo de máquina para máquina.',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -3,6 +3,9 @@ const application = {
role_exists: 'O id da função {{roleId}} já foi adicionado a esta aplicação.',
invalid_role_type:
'Não é possível atribuir uma função de tipo de utilizador a uma aplicação máquina a máquina.',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -3,6 +3,9 @@ const application = {
role_exists: 'Роль с идентификатором {{roleId}} уже добавлена в это приложение.',
invalid_role_type:
'Невозможно назначить роль типа "пользователь" для приложения типа "от машины к машине".',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -2,6 +2,9 @@ const application = {
invalid_type: 'Sadece makine ile makine uygulamaları rollerle ilişkilendirilebilir.',
role_exists: 'Bu uygulamaya zaten {{roleId}} kimlikli bir rol eklenmiş.',
invalid_role_type: 'Kullanıcı tipi rolü makine ile makine uygulamasına atayamaz.',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -2,6 +2,9 @@ const application = {
invalid_type: '只有机器对机器应用程序可以有关联角色。',
role_exists: '角色 ID {{roleId}} 已添加到此应用程序。',
invalid_role_type: '无法将用户类型角色分配给机器对机器应用程序。',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -2,6 +2,9 @@ const application = {
invalid_type: '只有機器對機器應用程式才能有相關職能。',
role_exists: '角色 ID {{roleId}} 已經被添加到此應用程式中。',
invalid_role_type: '無法將使用者類型的角色分配給機器對機器應用程式。',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);

View file

@ -2,6 +2,9 @@ const application = {
invalid_type: '僅允許機器對機器應用程式附加角色。',
role_exists: '該角色 ID {{roleId}} 已被添加至此應用程式。',
invalid_role_type: '無法將使用者類型的角色指派給機器對機器應用程式。',
/** UNTRANSLATED */
invalid_third_party_application_type:
'Only traditional web applications can be marked as a third-party app.',
};
export default Object.freeze(application);