Skip to content

Commit

Permalink
Merge pull request #6026 from logto-io/gao-org-jit-roles-tests
Browse files Browse the repository at this point in the history
refactor: add organization jit role api tests
  • Loading branch information
gao-sun committed Jun 17, 2024
2 parents 4266ac8 + b25bca3 commit 59fe21a
Show file tree
Hide file tree
Showing 13 changed files with 300 additions and 141 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/middleware/koa-pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 36 additions & 11 deletions packages/core/src/middleware/koa-pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 });
}
Expand Down Expand Up @@ -94,3 +117,5 @@ export default function koaPagination<StateT, ContextT, ResponseBodyT>({

return paginationMiddleware;
}

export default koaPagination;
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand All @@ -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."
}
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/routes/sso-connector/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/utils/SchemaRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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(),
Expand All @@ -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();
}
Expand Down
55 changes: 55 additions & 0 deletions packages/integration-tests/src/api/organization-jit.ts
Original file line number Diff line number Diff line change
@@ -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 } });
}
}
36 changes: 3 additions & 33 deletions packages/integration-tests/src/api/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +19,8 @@ type Query = {
};

export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganization, 'id'>> {
jit = new OrganizationJitApi(this.path);

constructor() {
super('organizations');
}
Expand Down Expand Up @@ -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 } });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
);

Expand Down
Loading

0 comments on commit 59fe21a

Please sign in to comment.