0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(core,phrases): update protected app (#5189)

This commit is contained in:
wangsijie 2024-01-11 11:03:01 +08:00 committed by GitHub
parent 1ee1efc532
commit 5bc649e10d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 195 additions and 16 deletions

View file

@ -43,6 +43,7 @@ import MachineToMachineApplicationRoles from './components/MachineToMachineAppli
import RefreshTokenSettings from './components/RefreshTokenSettings';
import Settings from './components/Settings';
import * as styles from './index.module.scss';
import { type ApplicationForm, applicationFormDataParser } from './utils';
const mapToUriFormatArrays = (value?: string[]) =>
value?.filter(Boolean).map((uri) => decodeURIComponent(uri));
@ -73,7 +74,7 @@ function ApplicationDetails() {
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const api = useApi();
const formMethods = useForm<Application & { isAdmin: boolean }>({
const formMethods = useForm<ApplicationForm>({
defaultValues: { customClientMetadata: customClientMetadataDefault, isAdmin: false },
});
@ -92,7 +93,7 @@ function ApplicationDetails() {
return;
}
reset(data);
reset(applicationFormDataParser.fromResponse(data));
}, [data, isDirty, reset]);
const onSubmit = handleSubmit(

View file

@ -0,0 +1,20 @@
import { type ApplicationResponse } from '@logto/schemas';
export type ApplicationForm = Pick<
ApplicationResponse,
'name' | 'description' | 'oidcClientMetadata' | 'customClientMetadata' | 'isAdmin'
>;
export const applicationFormDataParser = {
fromResponse: (data: ApplicationResponse): ApplicationForm => {
const { name, description, oidcClientMetadata, customClientMetadata, isAdmin } = data;
return {
name,
description,
oidcClientMetadata,
customClientMetadata,
isAdmin,
};
},
};

View file

@ -12,6 +12,12 @@ const { jest } = import.meta;
const findApplicationById = jest.fn(async () => mockApplication);
const deleteApplicationById = jest.fn();
const syncAppConfigsToRemote = jest.fn();
const updateApplicationById = jest.fn(
async (_, data: Partial<CreateApplication>): Promise<Application> => ({
...mockApplication,
...data,
})
);
await mockIdGenerators();
@ -33,12 +39,7 @@ const tenantContext = new MockTenant(
},
})
),
updateApplicationById: jest.fn(
async (_, data: Partial<CreateApplication>): Promise<Application> => ({
...mockApplication,
...data,
})
),
updateApplicationById,
},
},
undefined,
@ -60,6 +61,11 @@ const customClientMetadata = {
};
describe('application route', () => {
afterEach(() => {
updateApplicationById.mockClear();
syncAppConfigsToRemote.mockClear();
});
const applicationRequest = createRequester({ authedRoutes: applicationRoutes, tenantContext });
it('GET /applications', async () => {
@ -197,6 +203,23 @@ describe('application route', () => {
expect(response.body).toEqual({ ...mockApplication, name, description, customClientMetadata });
});
it('PATCH /applications/:applicationId for protected app', async () => {
findApplicationById.mockResolvedValueOnce(mockProtectedApplication);
const name = 'FooApplication';
const description = 'FooDescription';
const origin = 'https://example.com';
const response = await applicationRequest
.patch('/applications/foo')
.send({ name, description, protectedAppMetadata: { origin } });
expect(response.status).toEqual(200);
expect(response.body).toEqual({ ...mockApplication, name, description });
expect(syncAppConfigsToRemote).toHaveBeenCalledWith('foo');
expect(updateApplicationById).toHaveBeenNthCalledWith(1, 'foo', {
protectedAppMetadata: { ...mockProtectedApplication.protectedAppMetadata, origin },
});
});
it('PATCH /applications/:applicationId expect to throw with invalid properties', async () => {
await expect(
applicationRequest.patch('/applications/doo').send({

View file

@ -217,7 +217,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
'/applications/:id',
koaGuard({
params: object({ id: string().min(1) }),
body: applicationPatchGuard.deepPartial().merge(
body: applicationPatchGuard.merge(
object({
isAdmin: boolean().optional(),
})
@ -231,7 +231,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
body,
} = ctx.guard;
const { isAdmin, ...rest } = body;
const { isAdmin, protectedAppMetadata, ...rest } = body;
// User can enable the admin access of Machine-to-Machine apps by switching on a toggle on Admin Console.
// Since those apps sit in the user tenant, we provide an internal role to apply the necessary scopes.
@ -261,6 +261,34 @@ export default function applicationRoutes<T extends AuthedRouter>(
}
}
if (protectedAppMetadata) {
const { type, protectedAppMetadata: originProtectedAppMetadata } =
await findApplicationById(id);
assertThat(type === ApplicationType.Protected, 'application.protected_application_only');
assertThat(
originProtectedAppMetadata,
new RequestError({
code: 'application.protected_application_misconfigured',
status: 422,
})
);
await updateApplicationById(id, {
protectedAppMetadata: {
...originProtectedAppMetadata,
...protectedAppMetadata,
},
});
try {
await protectedApps.syncAppConfigsToRemote(id);
} catch (error: unknown) {
// Revert changes on sync failure
await updateApplicationById(id, {
protectedAppMetadata: originProtectedAppMetadata,
});
throw error;
}
}
ctx.body = await (Object.keys(rest).length > 0
? updateApplicationById(id, rest)
: findApplicationById(id));

View file

@ -37,6 +37,28 @@ const applicationCreateGuardWithProtectedAppMetadata = originalApplicationCreate
.optional(),
});
const applicationPatchGuardWithProtectedAppMetadata = originalApplicationPatchGuard
.deepPartial()
.omit({
protectedAppMetadata: true,
})
.extend({
protectedAppMetadata: z
.object({
origin: z.string().optional(),
sessionDuration: z.number().optional(),
pageRules: z
.array(
z.object({
/* The path pattern (regex) to match */
path: z.string(),
})
)
.optional(),
})
.optional(),
});
// FIXME: @wangsijie Remove this guard once protected app is ready
// @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 applicationCreateGuardWithProtectedAppMetadata = EnvSet
@ -48,7 +70,7 @@ export const applicationCreateGuard: typeof applicationCreateGuardWithProtectedA
// FIXME: @wangsijie Remove this guard once protected app is ready
// @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 applicationPatchGuard: typeof originalApplicationPatchGuard = EnvSet.values
.isDevFeaturesEnabled
? originalApplicationPatchGuard
: originalApplicationPatchGuard.omit({ protectedAppMetadata: true });
export const applicationPatchGuard: typeof applicationPatchGuardWithProtectedAppMetadata = EnvSet
.values.isDevFeaturesEnabled
? applicationPatchGuardWithProtectedAppMetadata
: applicationPatchGuardWithProtectedAppMetadata.omit({ protectedAppMetadata: true });

View file

@ -4,6 +4,7 @@ import {
type ApplicationType,
type OidcClientMetadata,
type Role,
type ProtectedAppMetadata,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
@ -48,9 +49,9 @@ export const getApplication = async (applicationId: string) =>
export const updateApplication = async (
applicationId: string,
payload: Partial<
Omit<CreateApplication, 'id' | 'created_at' | 'oidcClientMetadata'> & {
Omit<CreateApplication, 'id' | 'created_at' | 'oidcClientMetadata' | 'protectedAppMetadata'> & {
oidcClientMetadata: Partial<OidcClientMetadata>;
}
} & { protectedAppMetadata: Partial<ProtectedAppMetadata> }
> & { isAdmin?: boolean }
) =>
authedAdminApi

View file

@ -102,6 +102,32 @@ describe('admin console application', () => {
});
});
it('should update application details for protected app successfully', async () => {
const metadata = {
origin: 'https://example.com',
host: 'example.protected.app',
};
const application = await createApplication('test-update-app', ApplicationType.Protected, {
// @ts-expect-error the create guard has been modified
protectedAppMetadata: metadata,
});
const newApplicationDescription = `new_${application.description ?? ''}`;
const newOrigin = 'https://example2.com';
await updateApplication(application.id, {
description: newApplicationDescription,
protectedAppMetadata: { origin: newOrigin },
});
const updatedApplication = await getApplication(application.id);
expect(updatedApplication.description).toBe(newApplicationDescription);
expect(updatedApplication.protectedAppMetadata?.origin).toEqual(newOrigin);
});
it('should update application "admin" successfully', async () => {
const application = await createApplication(
'test-update-is-admin',

View file

@ -12,6 +12,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -9,6 +9,8 @@ const application = {
protected_app_metadata_is_required: 'Protected app metadata is required.',
protected_app_not_configured: 'Protected app provider is not configured.',
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
protected_application_only: 'The feature is only available for protected applications.',
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -12,6 +12,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -13,6 +13,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -13,6 +13,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -12,6 +12,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -11,6 +11,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -11,6 +11,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -12,6 +12,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -12,6 +12,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -13,6 +13,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -11,6 +11,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -10,6 +10,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -10,6 +10,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);

View file

@ -10,6 +10,10 @@ const application = {
protected_app_not_configured: 'Protected app provider is not configured.',
/** UNTRANSLATED */
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
/** UNTRANSLATED */
protected_application_only: 'The feature is only available for protected applications.',
/** UNTRANSLATED */
protected_application_misconfigured: 'Protected application is misconfigured.',
};
export default Object.freeze(application);