0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor: add organization jit role api tests

This commit is contained in:
Gao Sun 2024-06-14 22:44:03 +08:00
parent 3ea37c5275
commit b25bca3aa2
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
13 changed files with 300 additions and 141 deletions

View file

@ -16,7 +16,7 @@ const appendHeader = jest.fn((key: string, value: string) => {
}
});
const createContext = (query: Record<string, string>): WithPaginationContext<Context> => {
const createContext = (query: Record<string, string>): WithPaginationContext<Context, false> => {
const baseContext = createMockContext();
const context = {
...baseContext,

View file

@ -9,37 +9,58 @@ type Pagination = {
offset: number;
limit: number;
totalCount?: number;
disabled?: boolean;
disabled?: false;
};
export type WithPaginationContext<ContextT> = ContextT & {
pagination: Pagination;
type DisabledPagination = {
offset: undefined;
limit: undefined;
totalCount: undefined;
disabled: true;
};
type PaginationConfig = {
export type WithPaginationContext<ContextT, IsOptional extends boolean> = ContextT & {
pagination: IsOptional extends true ? Pagination | DisabledPagination : Pagination;
};
type PaginationConfig<IsOptional extends boolean> = {
defaultPageSize?: number;
maxPageSize?: number;
isOptional?: boolean;
isOptional?: IsOptional;
};
export const isPaginationMiddleware = <Type extends IMiddleware>(
function_: Type
): function_ is WithPaginationContext<Type> => function_.name === 'paginationMiddleware';
): function_ is WithPaginationContext<Type, true> => function_.name === 'paginationMiddleware';
export const fallbackDefaultPageSize = 20;
export const pageNumberKey = 'page';
export const pageSizeKey = 'page_size';
export default function koaPagination<StateT, ContextT, ResponseBodyT>({
function koaPagination<StateT, ContextT, ResponseBodyT>(
config?: PaginationConfig<false>
): MiddlewareType<StateT, WithPaginationContext<ContextT, false>, ResponseBodyT>;
function koaPagination<StateT, ContextT, ResponseBodyT>(
config: PaginationConfig<true>
): MiddlewareType<StateT, WithPaginationContext<ContextT, true>, ResponseBodyT>;
function koaPagination<StateT, ContextT, ResponseBodyT>(
config?: PaginationConfig<boolean>
): MiddlewareType<StateT, WithPaginationContext<ContextT, boolean>, ResponseBodyT>;
function koaPagination<StateT, ContextT, ResponseBodyT, IsOptional extends boolean>({
defaultPageSize = fallbackDefaultPageSize,
maxPageSize = 100,
isOptional = false,
}: PaginationConfig = {}): MiddlewareType<StateT, WithPaginationContext<ContextT>, ResponseBodyT> {
isOptional,
}: PaginationConfig<IsOptional> = {}): MiddlewareType<
StateT,
WithPaginationContext<ContextT, true>,
ResponseBodyT
> {
// Name this anonymous function for the utility function `isPaginationMiddleware` to identify it
const paginationMiddleware: MiddlewareType<
StateT,
WithPaginationContext<ContextT>,
WithPaginationContext<ContextT, true>,
ResponseBodyT
// eslint-disable-next-line complexity -- maybe refactor me
> = async (ctx, next) => {
try {
const {
@ -56,7 +77,9 @@ export default function koaPagination<StateT, ContextT, ResponseBodyT>({
? number().positive().max(maxPageSize).parse(Number(rawPageSize))
: defaultPageSize;
ctx.pagination = { offset: (pageNumber - 1) * pageSize, limit: pageSize, disabled };
ctx.pagination = disabled
? { disabled, totalCount: undefined, offset: undefined, limit: undefined }
: { disabled, totalCount: undefined, offset: (pageNumber - 1) * pageSize, limit: pageSize };
} catch {
throw new RequestError({ code: 'guard.invalid_pagination', status: 400 });
}
@ -94,3 +117,5 @@ export default function koaPagination<StateT, ContextT, ResponseBodyT>({
return paginationMiddleware;
}
export default koaPagination;

View file

@ -16,15 +16,15 @@
}
},
"post": {
"summary": "Add organization JIT role",
"description": "Add a new organization role that will be assigned to users during just-in-time provisioning.",
"summary": "Add organization JIT roles",
"description": "Add new organization roles that will be assigned to users during just-in-time provisioning.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"organizationRoleId": {
"description": "The organization role ID to add."
"organizationRoleIds": {
"description": "The organization role IDs to add."
}
}
}
@ -33,10 +33,10 @@
},
"responses": {
"201": {
"description": "The organization role was added successfully."
"description": "The organization roles were added successfully."
},
"422": {
"description": "The organization role is already in use."
"description": "The organization roles could not be added. Some of the organization roles may not exist."
}
}
},

View file

@ -141,7 +141,7 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
// MARK: Just-in-time provisioning
emailDomainRoutes(router, organizations);
router.addRelationRoutes(organizations.jit.roles, 'jit/roles');
router.addRelationRoutes(organizations.jit.roles, 'jit/roles', { isPaginationOptional: true });
// MARK: Mount sub-routes
organizationRoleRoutes(...args);

View file

@ -143,7 +143,10 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
const [totalCount, connectors] = paginationDisabled
? await getSsoConnectors()
: await getSsoConnectors(limit, offset);
ctx.pagination.totalCount = totalCount;
if (!paginationDisabled) {
ctx.pagination.totalCount = totalCount;
}
// Fetch provider details for each connector
const connectorsWithProviderDetails = await Promise.all(

View file

@ -96,6 +96,11 @@ type RelationRoutesConfig = {
/** Disable `GET /:id/[pathname]` route. */
get: boolean;
};
/**
* If the GET route's pagination is optional.
* @default false
*/
isPaginationOptional?: boolean;
};
/**
@ -184,7 +189,7 @@ export default class SchemaRouter<
GeneratedSchema<string, RelationCreateSchema, RelationSchema>
>,
pathname = tableToPathname(relationQueries.schemas[1].table),
{ disabled, hookEvent }: Partial<RelationRoutesConfig> = {}
{ disabled, hookEvent, isPaginationOptional }: Partial<RelationRoutesConfig> = {}
) {
const relationSchema = relationQueries.schemas[1];
const relationSchemaId = camelCaseSchemaId(relationSchema);
@ -205,7 +210,7 @@ export default class SchemaRouter<
if (!disabled?.get) {
this.get(
`/:id/${pathname}`,
koaPagination(),
koaPagination({ isOptional: isPaginationOptional }),
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: relationSchema.guard.array(),
@ -220,10 +225,12 @@ export default class SchemaRouter<
const [totalCount, entities] = await relationQueries.getEntities(
relationSchema,
{ [columns.schemaId]: id },
ctx.pagination
ctx.pagination.disabled ? undefined : ctx.pagination
);
ctx.pagination.totalCount = totalCount;
if (!ctx.pagination.disabled) {
ctx.pagination.totalCount = totalCount;
}
ctx.body = entities;
return next();
}

View file

@ -0,0 +1,55 @@
import { type OrganizationRole, type OrganizationJitEmailDomain } from '@logto/schemas';
import { authedAdminApi } from './api.js';
export class OrganizationJitApi {
constructor(public path: string) {}
async getEmailDomains(
id: string,
page?: number,
pageSize?: number
): Promise<OrganizationJitEmailDomain[]> {
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}/jit/email-domains`, { searchParams })
.json<OrganizationJitEmailDomain[]>();
}
async addEmailDomain(id: string, emailDomain: string): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/jit/email-domains`, { json: { emailDomain } });
}
async deleteEmailDomain(id: string, emailDomain: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/jit/email-domains/${emailDomain}`);
}
async replaceEmailDomains(id: string, emailDomains: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/jit/email-domains`, { json: { emailDomains } });
}
async getRoles(id: string): Promise<OrganizationRole[]> {
return authedAdminApi.get(`${this.path}/${id}/jit/roles`).json<OrganizationRole[]>();
}
async addRole(id: string, organizationRoleIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } });
}
async deleteRole(id: string, organizationRoleId: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/jit/roles/${organizationRoleId}`);
}
async replaceRoles(id: string, organizationRoleIds: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } });
}
}

View file

@ -5,12 +5,12 @@ import {
type UserWithOrganizationRoles,
type OrganizationWithFeatured,
type OrganizationScope,
type OrganizationJitEmailDomain,
type CreateOrganization,
} from '@logto/schemas';
import { authedAdminApi } from './api.js';
import { ApiFactory } from './factory.js';
import { OrganizationJitApi } from './organization-jit.js';
type Query = {
q?: string;
@ -19,6 +19,8 @@ type Query = {
};
export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganization, 'id'>> {
jit = new OrganizationJitApi(this.path);
constructor() {
super('organizations');
}
@ -77,36 +79,4 @@ export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganiz
.get(`${this.path}/${id}/users/${userId}/scopes`)
.json<OrganizationScope[]>();
}
async getEmailDomains(
id: string,
page?: number,
pageSize?: number
): Promise<OrganizationJitEmailDomain[]> {
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}/jit/email-domains`, { searchParams })
.json<OrganizationJitEmailDomain[]>();
}
async addEmailDomain(id: string, emailDomain: string): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/jit/email-domains`, { json: { emailDomain } });
}
async deleteEmailDomain(id: string, emailDomain: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/jit/email-domains/${emailDomain}`);
}
async replaceEmailDomains(id: string, emailDomains: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/jit/email-domains`, { json: { emailDomains } });
}
}

View file

@ -19,7 +19,7 @@ describe('organization just-in-time provisioning', () => {
const emailDomain = 'foo.com';
await Promise.all(
organizations.map(async (organization) =>
organizationApi.addEmailDomain(organization.id, emailDomain)
organizationApi.jit.addEmailDomain(organization.id, emailDomain)
)
);

View file

@ -123,7 +123,7 @@ describe('manual data hook tests', () => {
it('should trigger `Organization.Membership.Updated` event when user is provisioned by Management API', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const domain = 'example.com';
await organizationApi.addEmailDomain(organization.id, domain);
await organizationApi.jit.addEmailDomain(organization.id, domain);
await userApi.create({ primaryEmail: `${randomString()}@${domain}` });
await assertOrganizationMembershipUpdated(organization.id);
@ -142,7 +142,7 @@ describe('manual data hook tests', () => {
const organization = await organizationApi.create({ name: 'foo' });
const domain = 'example.com';
await organizationApi.addEmailDomain(organization.id, domain);
await organizationApi.jit.addEmailDomain(organization.id, domain);
await registerWithEmail(`${randomString()}@${domain}`);
await assertOrganizationMembershipUpdated(organization.id);

View file

@ -48,7 +48,7 @@ describe('organization just-in-time provisioning', () => {
const emailDomain = 'foo.com';
await Promise.all(
organizations.map(async (organization) =>
organizationApi.addEmailDomain(organization.id, emailDomain)
organizationApi.jit.addEmailDomain(organization.id, emailDomain)
)
);

View file

@ -1,80 +0,0 @@
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.concat(emailDomainsPage2)).toEqual(
expect.arrayContaining(
emailDomains.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.toEqual(
expect.arrayContaining(
emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain }))
)
);
});
});

View file

@ -0,0 +1,179 @@
import { generateStandardId } from '@logto/shared';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import { randomString } from '#src/utils.js';
const randomId = () => generateStandardId(6);
describe('organization just-in-time provisioning', () => {
const organizationApi = new OrganizationApiTest();
afterEach(async () => {
await organizationApi.cleanUp();
});
describe('email domains', () => {
it('should add and delete email domains', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const emailDomain = `${randomId()}.com`;
await organizationApi.jit.addEmailDomain(organization.id, emailDomain);
await expect(organizationApi.jit.getEmailDomains(organization.id)).resolves.toMatchObject([
{ emailDomain },
]);
await organizationApi.jit.deleteEmailDomain(organization.id, emailDomain);
await expect(organizationApi.jit.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.jit.replaceEmailDomains(organization.id, emailDomains);
const emailDomainsPage1 = await organizationApi.jit.getEmailDomains(organization.id);
const emailDomainsPage2 = await organizationApi.jit.getEmailDomains(organization.id, 2);
expect(emailDomainsPage1).toHaveLength(20);
expect(emailDomainsPage2).toHaveLength(10);
expect(emailDomainsPage1.concat(emailDomainsPage2)).toEqual(
expect.arrayContaining(
emailDomains.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.jit.deleteEmailDomain(organization.id, emailDomain)
).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]');
});
it('should return 422 when adding an email domain that already exists', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const emailDomain = `${randomId()}.com`;
await organizationApi.jit.addEmailDomain(organization.id, emailDomain);
await expect(
organizationApi.jit.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.jit.addEmailDomain(organization.id, `${randomId()}.com`);
const emailDomains = [`${randomId()}.com`, `${randomId()}.com`];
await organizationApi.jit.replaceEmailDomains(organization.id, emailDomains);
await expect(organizationApi.jit.getEmailDomains(organization.id)).resolves.toEqual(
expect.arrayContaining(
emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain }))
)
);
});
});
describe('organization roles', () => {
it('should add and delete organization roles', async () => {
const organization = await organizationApi.create({ name: `jit-role:${randomString()}` });
const { id: organizationRoleId } = await organizationApi.roleApi.create({
name: `jit-role:${randomString()}`,
});
await organizationApi.jit.addRole(organization.id, [organizationRoleId]);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toMatchObject([
{ id: organizationRoleId },
]);
await organizationApi.jit.deleteRole(organization.id, organizationRoleId);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual([]);
});
it('should have no pagination', async () => {
const organization = await organizationApi.create({ name: `jit-role:${randomString()}` });
const organizationRoles = await Promise.all(
Array.from({ length: 30 }, async () =>
organizationApi.roleApi.create({
name: `jit-role:${randomString()}`,
})
)
);
await organizationApi.jit.replaceRoles(
organization.id,
organizationRoles.map(({ id }) => id)
);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual(
expect.arrayContaining(organizationRoles.map(({ id }) => expect.objectContaining({ id })))
);
});
it('should return 404 when deleting a non-existent organization role', async () => {
const organization = await organizationApi.create({ name: `jit-role:${randomString()}` });
const organizationRoleId = randomId();
await expect(
organizationApi.jit.deleteRole(organization.id, organizationRoleId)
).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]');
});
it('should return 422 when adding a non-existent organization role', async () => {
const organization = await organizationApi.create({ name: `jit-role:${randomString()}` });
const organizationRoleId = randomId();
await expect(
organizationApi.jit.addRole(organization.id, [organizationRoleId])
).rejects.toMatchInlineSnapshot(
'[HTTPError: Request failed with status code 422 Unprocessable Entity]'
);
});
it('should do nothing when adding an organization role that already exists', async () => {
const organization = await organizationApi.create({ name: `jit-role:${randomString()}` });
const organizationRoles = await Promise.all([
organizationApi.roleApi.create({
name: `jit-role:${randomString()}`,
}),
organizationApi.roleApi.create({
name: `jit-role:${randomString()}`,
}),
]);
await organizationApi.jit.addRole(organization.id, [organizationRoles[0].id]);
await expect(
organizationApi.jit.addRole(organization.id, [
organizationRoles[0].id,
organizationRoles[1].id,
])
).resolves.toBeUndefined();
});
it('should be able to replace organization roles', async () => {
const organization = await organizationApi.create({ name: `jit-role:${randomString()}` });
const organizationRoles = await Promise.all(
Array.from({ length: 2 }, async () =>
organizationApi.roleApi.create({
name: `jit-role:${randomString()}`,
})
)
);
await organizationApi.jit.replaceRoles(
organization.id,
organizationRoles.map(({ id }) => id)
);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual(
expect.arrayContaining(organizationRoles.map(({ id }) => expect.objectContaining({ id })))
);
});
});
});