0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(core,phrases): delete custom domain (#5261)

This commit is contained in:
wangsijie 2024-01-23 15:20:03 +08:00 committed by GitHub
parent fa89d33252
commit 12fc67a039
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 189 additions and 5 deletions

View file

@ -18,12 +18,13 @@ import { mockFallbackOrigin } from '#src/utils/cloudflare/mock.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const { updateProtectedAppSiteConfigs } = await mockEsmWithActual(
const { updateProtectedAppSiteConfigs, deleteCustomHostname } = await mockEsmWithActual(
'#src/utils/cloudflare/index.js',
() => ({
updateProtectedAppSiteConfigs: jest.fn(),
getCustomHostname: jest.fn(async () => mockCloudflareData),
getFallbackOrigin: jest.fn(async () => mockFallbackOrigin),
deleteCustomHostname: jest.fn(),
})
);
@ -36,12 +37,12 @@ const updateApplicationById = jest.fn(async (id: string, data: Partial<Applicati
...mockProtectedApplication,
...data,
}));
const {
syncAppConfigsToRemote,
checkAndBuildProtectedAppData,
syncAppCustomDomainStatus,
getDefaultDomain,
deleteDomainFromRemote,
} = createProtectedAppLibrary(
new MockQueries({
applications: {
@ -222,3 +223,10 @@ describe('syncAppCustomDomainStatus()', () => {
expect(updateApplicationById).not.toHaveBeenCalled();
});
});
describe('deleteDomainFromRemote()', () => {
it('should call deleteCustomHostname', async () => {
await deleteDomainFromRemote('id');
expect(deleteCustomHostname).toHaveBeenCalled();
});
});

View file

@ -17,6 +17,7 @@ import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js';
import {
createCustomHostname,
deleteCustomHostname,
deleteProtectedAppSiteConfigs,
getCustomHostname,
getDomainStatusFromCloudflareData,
@ -95,6 +96,14 @@ const addDomainToRemote = async (
};
};
/**
* Call Cloudflare API to delete the domain (custom hostname)
*/
const deleteDomainFromRemote = async (id: string) => {
const hostnameProviderConfig = await getHostnameProviderConfig();
await deleteCustomHostname(hostnameProviderConfig, id);
};
export const createProtectedAppLibrary = (queries: Queries) => {
const {
applications: { findApplicationById, findApplicationByProtectedAppHost, updateApplicationById },
@ -274,5 +283,6 @@ export const createProtectedAppLibrary = (queries: Queries) => {
getDefaultDomain,
addDomainToRemote,
syncAppCustomDomainStatus,
deleteDomainFromRemote,
};
};

View file

@ -41,6 +41,20 @@
}
}
}
},
"/api/applications/{id}/protected-app-metadata/custom-domains/{domain}": {
"delete": {
"summary": "Delete a custom domain.",
"description": "Add a custom domain.",
"responses": {
"204": {
"description": "The domain has been deleted."
},
"404": {
"description": "Can not find the domain."
}
}
}
}
}
}

View file

@ -2,7 +2,7 @@ import { type Application, DomainStatus } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { type Nullable } from '@silverhand/essentials';
import { mockProtectedApplication } from '#src/__mocks__/index.js';
import { mockCloudflareData, mockProtectedApplication } from '#src/__mocks__/index.js';
import { mockIdGenerators } from '#src/test-utils/nanoid.js';
import { MockTenant } from '#src/test-utils/tenant.js';
@ -38,6 +38,8 @@ const syncAppCustomDomainStatus = jest.fn(async () => ({
},
}));
const syncAppConfigsToRemote = jest.fn();
const deleteDomainFromRemote = jest.fn();
const deleteRemoteAppConfigs = jest.fn();
await mockIdGenerators();
@ -52,7 +54,13 @@ const tenantContext = new MockTenant(
},
undefined,
{
protectedApps: { addDomainToRemote, syncAppCustomDomainStatus, syncAppConfigsToRemote },
protectedApps: {
addDomainToRemote,
syncAppCustomDomainStatus,
syncAppConfigsToRemote,
deleteDomainFromRemote,
deleteRemoteAppConfigs,
},
applications: { validateProtectedApplicationById: jest.fn() },
}
);
@ -63,6 +71,10 @@ const applicationProtectedAppMetadataRoutes = await pickDefault(
);
describe('application protected app metadata routes', () => {
afterEach(() => {
updateApplicationById.mockClear();
});
const requester = createRequester({
authedRoutes: applicationProtectedAppMetadataRoutes,
tenantContext,
@ -131,4 +143,51 @@ describe('application protected app metadata routes', () => {
expect(response.status).toEqual(422);
});
});
describe('DELETE /applications/:applicationId/protected-app-metadata/custom-domains/:domain', () => {
it('should update application, delete remote domain, and delete site configs', async () => {
findApplicationById.mockResolvedValueOnce({
...mockProtectedApplication,
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [
{
...mockDomainResponse,
cloudflareData: mockCloudflareData,
},
],
},
});
const response = await requester.delete(
`/applications/${mockProtectedApplication.id}/protected-app-metadata/custom-domains/${mockDomainResponse.domain}`
);
expect(response.status).toEqual(204);
expect(updateApplicationById).toHaveBeenCalledWith(mockProtectedApplication.id, {
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [],
},
oidcClientMetadata: {
postLogoutRedirectUris: [`https://${mockProtectedApplication.protectedAppMetadata.host}`],
redirectUris: [`https://${mockProtectedApplication.protectedAppMetadata.host}/callback`],
},
});
expect(deleteDomainFromRemote).toHaveBeenCalledWith(mockCloudflareData.id);
expect(deleteRemoteAppConfigs).toHaveBeenCalledWith(mockDomainResponse.domain);
});
it('throw when domain exists', async () => {
findApplicationById.mockResolvedValueOnce({
...mockProtectedApplication,
protectedAppMetadata: {
...mockProtectedApplication.protectedAppMetadata,
customDomains: [mockDomainResponse],
},
});
const response = await requester.delete(
`/applications/${mockProtectedApplication.id}/protected-app-metadata/custom-domains/unexists.com`
);
expect(response.status).toEqual(404);
});
});
});

View file

@ -20,7 +20,13 @@ export default function applicationProtectedAppMetadataRoutes<T extends AuthedRo
},
libraries: {
applications: { validateProtectedApplicationById },
protectedApps: { addDomainToRemote, syncAppCustomDomainStatus, syncAppConfigsToRemote },
protectedApps: {
addDomainToRemote,
syncAppCustomDomainStatus,
syncAppConfigsToRemote,
deleteDomainFromRemote,
deleteRemoteAppConfigs,
},
},
},
]: RouterInitArgs<T>
@ -108,4 +114,62 @@ export default function applicationProtectedAppMetadataRoutes<T extends AuthedRo
return next();
}
);
router.delete(
`${customDomainsPathname}/:domain`,
koaGuard({
params: z.object({
...params,
domain: z.string(),
}),
status: [204, 404],
}),
async (ctx, next) => {
const { id, domain } = ctx.guard.params;
const { protectedAppMetadata, oidcClientMetadata } = await findApplicationById(id);
const domainObject = protectedAppMetadata?.customDomains?.find(
({ domain: domainName }) => domainName === domain
);
assertThat(
protectedAppMetadata && domainObject,
new RequestError({
code: 'application.custom_domain_not_found',
status: 404,
})
);
// Remove domain from Cloudflare
if (domainObject.cloudflareData?.id) {
await deleteDomainFromRemote(domainObject.cloudflareData.id);
}
// Remove site configs
await deleteRemoteAppConfigs(domain);
await updateApplicationById(id, {
oidcClientMetadata: {
...oidcClientMetadata,
redirectUris: oidcClientMetadata.redirectUris.filter(
(uri) => uri !== `https://${domain}/callback`
),
postLogoutRedirectUris: oidcClientMetadata.postLogoutRedirectUris.filter(
(uri) => uri !== `https://${domain}`
),
},
protectedAppMetadata: {
...protectedAppMetadata,
customDomains: protectedAppMetadata.customDomains?.filter(
({ domain: domainName }) => domainName !== domain
),
},
});
ctx.status = 204;
return next();
}
);
}

View file

@ -23,6 +23,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -15,6 +15,7 @@ const application = {
protected_application_subdomain_exists:
'The subdomain of Protected application is already in use.',
invalid_subdomain: 'Invalid subdomain.',
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -23,6 +23,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -24,6 +24,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -24,6 +24,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -23,6 +23,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -22,6 +22,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -22,6 +22,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -23,6 +23,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -23,6 +23,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -24,6 +24,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -22,6 +22,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -21,6 +21,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -21,6 +21,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);

View file

@ -21,6 +21,8 @@ const application = {
'The subdomain of Protected application is already in use.',
/** UNTRANSLATED */
invalid_subdomain: 'Invalid subdomain.',
/** UNTRANSLATED */
custom_domain_not_found: 'Custom domain not found.',
};
export default Object.freeze(application);