Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: add organization jit role api tests #6026

Merged
merged 1 commit into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
const [totalCount, connectors] = paginationDisabled
? await getSsoConnectors()
: await getSsoConnectors(limit, offset);
ctx.pagination.totalCount = totalCount;

if (!paginationDisabled) {
ctx.pagination.totalCount = totalCount;
}

Check warning on line 149 in packages/core/src/routes/sso-connector/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/sso-connector/index.ts#L146-L149

Added lines #L146 - L149 were not covered by tests

// 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 @@
/** 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 @@
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 @@
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 @@
const [totalCount, entities] = await relationQueries.getEntities(
relationSchema,
{ [columns.schemaId]: id },
ctx.pagination
ctx.pagination.disabled ? undefined : ctx.pagination

Check warning on line 228 in packages/core/src/utils/SchemaRouter.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/utils/SchemaRouter.ts#L228

Added line #L228 was not covered by tests
);

ctx.pagination.totalCount = totalCount;
if (!ctx.pagination.disabled) {
ctx.pagination.totalCount = totalCount;
}

Check warning on line 233 in packages/core/src/utils/SchemaRouter.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/utils/SchemaRouter.ts#L231-L233

Added lines #L231 - L233 were not covered by tests
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
Loading