From 0e8817f279e150b202b3ea7a66ffc1023917f476 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Fri, 2 Jun 2023 16:50:36 +0800 Subject: [PATCH] fix(core): delete cloudflare custom domain (#3953) * fix(core): delete cloudflare custom domain * fix(core,phrases): handle cloudflare api errors --- packages/core/src/libraries/domain.test.ts | 32 ++++++++-- packages/core/src/libraries/domain.ts | 22 ++++++- packages/core/src/routes/domain.test.ts | 11 ++-- packages/core/src/routes/domain.ts | 6 +- packages/core/src/utils/cloudflare/index.ts | 64 ++++++++++++++++++- .../phrases/src/locales/de/errors/domain.ts | 1 + .../phrases/src/locales/en/errors/domain.ts | 1 + .../phrases/src/locales/es/errors/domain.ts | 1 + .../phrases/src/locales/fr/errors/domain.ts | 1 + .../phrases/src/locales/it/errors/domain.ts | 1 + .../phrases/src/locales/ja/errors/domain.ts | 1 + .../phrases/src/locales/ko/errors/domain.ts | 1 + .../src/locales/pl-pl/errors/domain.ts | 1 + .../src/locales/pt-br/errors/domain.ts | 1 + .../src/locales/pt-pt/errors/domain.ts | 1 + .../phrases/src/locales/ru/errors/domain.ts | 1 + .../src/locales/tr-tr/errors/domain.ts | 1 + .../src/locales/zh-cn/errors/domain.ts | 1 + .../src/locales/zh-hk/errors/domain.ts | 1 + .../src/locales/zh-tw/errors/domain.ts | 1 + 20 files changed, 133 insertions(+), 17 deletions(-) diff --git a/packages/core/src/libraries/domain.test.ts b/packages/core/src/libraries/domain.test.ts index db3e9bd2e..129f9b5e3 100644 --- a/packages/core/src/libraries/domain.test.ts +++ b/packages/core/src/libraries/domain.test.ts @@ -18,11 +18,12 @@ import SystemContext from '#src/tenants/SystemContext.js'; const { jest } = import.meta; const { mockEsm } = createMockUtils(jest); -const { getCustomHostname, createCustomHostname } = mockEsm( +const { getCustomHostname, createCustomHostname, deleteCustomHostname } = mockEsm( '#src/utils/cloudflare/index.js', () => ({ createCustomHostname: jest.fn(async () => mockCloudflareData), getCustomHostname: jest.fn(async () => mockCloudflareData), + deleteCustomHostname: jest.fn(), }) ); @@ -31,8 +32,10 @@ const { createDomainLibrary } = await import('./domain.js'); const updateDomainById = jest.fn(async (_, data) => data); const insertDomain = jest.fn(async (data) => data); -const { syncDomainStatus, addDomain } = createDomainLibrary( - new MockQueries({ domains: { updateDomainById, insertDomain } }) +const findDomainById = jest.fn(async () => mockDomain); +const deleteDomainById = jest.fn(); +const { syncDomainStatus, addDomain, deleteDomain } = createDomainLibrary( + new MockQueries({ domains: { updateDomainById, insertDomain, findDomainById, deleteDomainById } }) ); const fallbackOrigin = 'fake_origin'; @@ -50,7 +53,7 @@ afterAll(() => { SystemContext.shared.hostnameProviderConfig = undefined; }); -describe('addDomainToCloudflare()', () => { +describe('addDomain()', () => { it('should call createCustomHostname and return cloudflare data', async () => { const response = await addDomain(mockDomain.domain); expect(createCustomHostname).toBeCalledTimes(1); @@ -133,3 +136,24 @@ describe('syncDomainStatus()', () => { expect(response.errorMessage).toContain('fake_error'); }); }); + +describe('deleteDomain()', () => { + afterEach(() => { + deleteDomainById.mockClear(); + deleteCustomHostname.mockClear(); + }); + + it('should delete from remote and then delete local record', async () => { + findDomainById.mockResolvedValueOnce(mockDomainWithCloudflareData); + await deleteDomain(mockDomain.id); + expect(deleteCustomHostname).toBeCalledTimes(1); + expect(deleteDomainById).toBeCalledTimes(1); + }); + + it('should delete local record for non-synced domain', async () => { + findDomainById.mockResolvedValueOnce(mockDomain); + await deleteDomain(mockDomain.id); + expect(deleteCustomHostname).not.toBeCalled(); + expect(deleteDomainById).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/libraries/domain.ts b/packages/core/src/libraries/domain.ts index 40c49e2b6..93966bfc4 100644 --- a/packages/core/src/libraries/domain.ts +++ b/packages/core/src/libraries/domain.ts @@ -9,7 +9,11 @@ import { generateStandardId } from '@logto/shared'; import type Queries from '#src/tenants/Queries.js'; import SystemContext from '#src/tenants/SystemContext.js'; import assertThat from '#src/utils/assert-that.js'; -import { getCustomHostname, createCustomHostname } from '#src/utils/cloudflare/index.js'; +import { + getCustomHostname, + createCustomHostname, + deleteCustomHostname, +} from '#src/utils/cloudflare/index.js'; import { findSslTxtRecord, findVerificationTxtRecord } from '#src/utils/cloudflare/utils.js'; export type DomainLibrary = ReturnType; @@ -27,7 +31,7 @@ const getDomainStatusFromCloudflareData = (data: CloudflareData): DomainStatus = export const createDomainLibrary = (queries: Queries) => { const { - domains: { updateDomainById, insertDomain }, + domains: { updateDomainById, insertDomain, findDomainById, deleteDomainById }, } = queries; const syncDomainStatusFromCloudflareData = async ( @@ -103,8 +107,22 @@ export const createDomainLibrary = (queries: Queries) => { }); }; + const deleteDomain = async (id: string) => { + const { hostnameProviderConfig } = SystemContext.shared; + assertThat(hostnameProviderConfig, 'domain.not_configured'); + + const domain = await findDomainById(id); + + if (domain.cloudflareData?.id) { + await deleteCustomHostname(hostnameProviderConfig, domain.cloudflareData.id); + } + + await deleteDomainById(id); + }; + return { syncDomainStatus, addDomain, + deleteDomain, }; }; diff --git a/packages/core/src/routes/domain.test.ts b/packages/core/src/routes/domain.test.ts index 43a92c659..b314fa179 100644 --- a/packages/core/src/routes/domain.test.ts +++ b/packages/core/src/routes/domain.test.ts @@ -1,4 +1,4 @@ -import { type Domain, type CreateDomain } from '@logto/schemas'; +import { type Domain } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; import { mockDomain, mockDomainResponse } from '#src/__mocks__/domain.js'; @@ -9,10 +9,6 @@ const { jest } = import.meta; const domains = { findAllDomains: jest.fn(async (): Promise => [mockDomain]), - insertDomain: async (data: CreateDomain): Promise => ({ - ...mockDomain, - ...data, - }), findDomainById: async (id: string): Promise => { const domain = [mockDomain].find((domain) => domain.id === id); if (!domain) { @@ -20,7 +16,6 @@ const domains = { } return domain; }, - deleteDomainById: jest.fn(), }; const syncDomainStatus = jest.fn(async (domain: Domain): Promise => domain); @@ -30,11 +25,13 @@ const addDomain = jest.fn( domain, }) ); +const deleteDomain = jest.fn(); const mockLibraries = { domains: { syncDomainStatus, addDomain, + deleteDomain, }, }; @@ -64,6 +61,7 @@ describe('domain routes', () => { it('POST /domains', async () => { domains.findAllDomains.mockResolvedValueOnce([]); const response = await domainRequest.post('/domains').send({ domain: 'test.com' }); + expect(addDomain).toBeCalledWith('test.com'); expect(response.status).toEqual(201); expect(response.body.id).toBeTruthy(); expect(response.body.domain).toEqual('test.com'); @@ -80,5 +78,6 @@ describe('domain routes', () => { 'status', 204 ); + expect(deleteDomain).toBeCalledWith(mockDomain.id); }); }); diff --git a/packages/core/src/routes/domain.ts b/packages/core/src/routes/domain.ts index 8d8c122c7..edcd288dd 100644 --- a/packages/core/src/routes/domain.ts +++ b/packages/core/src/routes/domain.ts @@ -12,10 +12,10 @@ export default function domainRoutes( ...[router, { queries, libraries }]: RouterInitArgs ) { const { - domains: { findAllDomains, findDomainById, deleteDomainById }, + domains: { findAllDomains, findDomainById }, } = queries; const { - domains: { syncDomainStatus, addDomain }, + domains: { syncDomainStatus, addDomain, deleteDomain }, } = libraries; router.get( @@ -83,7 +83,7 @@ export default function domainRoutes( koaGuard({ params: z.object({ id: z.string() }), status: [204, 404] }), async (ctx, next) => { const { id } = ctx.guard.params; - await deleteDomainById(id); + await deleteDomain(id); ctx.status = 204; return next(); diff --git a/packages/core/src/utils/cloudflare/index.ts b/packages/core/src/utils/cloudflare/index.ts index 3ca85739c..590ecf6fd 100644 --- a/packages/core/src/utils/cloudflare/index.ts +++ b/packages/core/src/utils/cloudflare/index.ts @@ -3,6 +3,8 @@ import path from 'node:path'; import { type HostnameProviderData, cloudflareDataGuard } from '@logto/schemas'; import { got } from 'got'; +import RequestError from '#src/errors/RequestError/index.js'; + import assertThat from '../assert-that.js'; import { baseUrl } from './consts.js'; @@ -29,10 +31,23 @@ export const createCustomHostname = async (auth: HostnameProviderData, hostname: hostname, ssl: { method: 'txt', type: 'dv', settings: { min_tls_version: '1.0' } }, }, + throwHttpErrors: false, } ); - assertThat(response.ok, 'domain.cloudflare_unknown_error'); + if (!response.ok) { + if (response.statusCode === 409) { + throw new RequestError('domain.hostname_already_exists'); + } + + throw new RequestError( + { + code: 'domain.cloudflare_unknown_error', + status: 500, + }, + response.body + ); + } const result = cloudflareDataGuard.safeParse(parseCloudflareResponse(response.body)); @@ -60,10 +75,20 @@ export const getCustomHostname = async (auth: HostnameProviderData, identifier: headers: { Authorization: `Bearer ${auth.apiToken}`, }, + throwHttpErrors: false, } ); - assertThat(response.ok, 'domain.cloudflare_unknown_error'); + assertThat( + response.ok, + new RequestError( + { + code: 'domain.cloudflare_unknown_error', + status: 500, + }, + response.body + ) + ); const result = cloudflareDataGuard.safeParse(parseCloudflareResponse(response.body)); @@ -71,3 +96,38 @@ export const getCustomHostname = async (auth: HostnameProviderData, identifier: return result.data; }; + +export const deleteCustomHostname = async (auth: HostnameProviderData, identifier: string) => { + const { + EnvSet: { + values: { isIntegrationTest }, + }, + } = await import('#src/env-set/index.js'); + if (isIntegrationTest) { + return; + } + + const response = await got.delete( + new URL( + path.join(baseUrl.pathname, `/zones/${auth.zoneId}/custom_hostnames/${identifier}`), + baseUrl + ), + { + headers: { + Authorization: `Bearer ${auth.apiToken}`, + }, + throwHttpErrors: false, + } + ); + + assertThat( + response.ok, + new RequestError( + { + code: 'domain.cloudflare_unknown_error', + status: 500, + }, + response.body + ) + ); +}; diff --git a/packages/phrases/src/locales/de/errors/domain.ts b/packages/phrases/src/locales/de/errors/domain.ts index 2a855c241..49b11ccb1 100644 --- a/packages/phrases/src/locales/de/errors/domain.ts +++ b/packages/phrases/src/locales/de/errors/domain.ts @@ -5,6 +5,7 @@ const domain = { 'Beim Anfordern der Cloudflare-API ist ein unbekannter Fehler aufgetreten', cloudflare_response_error: 'Vom Cloudflare wurde eine unerwartete Antwort erhalten.', limit_to_one_domain: 'Sie können nur eine benutzerdefinierte Domain haben.', + hostname_already_exists: 'Diese Domain existiert bereits auf unserem Server.', }; export default domain; diff --git a/packages/phrases/src/locales/en/errors/domain.ts b/packages/phrases/src/locales/en/errors/domain.ts index 68eae5ca7..74c7652c5 100644 --- a/packages/phrases/src/locales/en/errors/domain.ts +++ b/packages/phrases/src/locales/en/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API', cloudflare_response_error: 'Got unexpected response from Cloudflare.', limit_to_one_domain: 'You can only have one custom domain.', + hostname_already_exists: 'This domain already exists in our server.', }; export default domain; diff --git a/packages/phrases/src/locales/es/errors/domain.ts b/packages/phrases/src/locales/es/errors/domain.ts index 0c3aea249..7b0624510 100644 --- a/packages/phrases/src/locales/es/errors/domain.ts +++ b/packages/phrases/src/locales/es/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Se produjo un error desconocido al solicitar la API de Cloudflare', cloudflare_response_error: 'Recibió una respuesta inesperada de Cloudflare.', limit_to_one_domain: 'Solo puedes tener un dominio personalizado.', + hostname_already_exists: 'Este dominio ya existe en nuestro servidor.', }; export default domain; diff --git a/packages/phrases/src/locales/fr/errors/domain.ts b/packages/phrases/src/locales/fr/errors/domain.ts index c0b7fd68c..bb8a52bf9 100644 --- a/packages/phrases/src/locales/fr/errors/domain.ts +++ b/packages/phrases/src/locales/fr/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: "Erreur inconnue lors de la requête de l'API Cloudflare", cloudflare_response_error: 'Réponse inattendue de Cloudflare', limit_to_one_domain: "Vous ne pouvez avoir qu'un seul domaine personnalisé", + hostname_already_exists: 'Ce domaine existe déjà sur notre serveur.', }; export default domain; diff --git a/packages/phrases/src/locales/it/errors/domain.ts b/packages/phrases/src/locales/it/errors/domain.ts index 3ffbee78d..81bd0fc60 100644 --- a/packages/phrases/src/locales/it/errors/domain.ts +++ b/packages/phrases/src/locales/it/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Errore sconosciuto durante la richiesta di API Cloudflare', cloudflare_response_error: 'Ricevuta una risposta non prevista da Cloudflare.', limit_to_one_domain: 'Puoi avere solo un dominio personalizzato.', + hostname_already_exists: 'Questo dominio esiste già nel nostro server.', }; export default domain; diff --git a/packages/phrases/src/locales/ja/errors/domain.ts b/packages/phrases/src/locales/ja/errors/domain.ts index d32bb72be..2ca20854d 100644 --- a/packages/phrases/src/locales/ja/errors/domain.ts +++ b/packages/phrases/src/locales/ja/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Cloudflare API のリクエスト中に未知のエラーが発生しました。', cloudflare_response_error: 'Cloudflare から予期しない応答がありました。', limit_to_one_domain: 'カスタムドメインは1つしか持てません。', + hostname_already_exists: 'サーバーには既にこのドメインが存在しています。', }; export default domain; diff --git a/packages/phrases/src/locales/ko/errors/domain.ts b/packages/phrases/src/locales/ko/errors/domain.ts index 79b0cd0f6..2f7381d15 100644 --- a/packages/phrases/src/locales/ko/errors/domain.ts +++ b/packages/phrases/src/locales/ko/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Cloudflare API 요청시 알 수 없는 오류 발생', cloudflare_response_error: 'Cloudflare 로부터 예상치 못한 응답을 받았습니다.', limit_to_one_domain: '하나의 맞춤 도메인만 사용할 수 있습니다.', + hostname_already_exists: '이 도메인은 이미 서버에 존재합니다.', }; export default domain; diff --git a/packages/phrases/src/locales/pl-pl/errors/domain.ts b/packages/phrases/src/locales/pl-pl/errors/domain.ts index 3441f8bd6..67f4c3748 100644 --- a/packages/phrases/src/locales/pl-pl/errors/domain.ts +++ b/packages/phrases/src/locales/pl-pl/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Otrzymano nieznany błąd podczas żądania API Cloudflare', cloudflare_response_error: 'Otrzymano nieoczekiwaną odpowiedź od Cloudflare.', limit_to_one_domain: 'Możesz mieć tylko jedną niestandardową domenę.', + hostname_already_exists: 'Ta domena już istnieje na naszym serwerze.', }; export default domain; diff --git a/packages/phrases/src/locales/pt-br/errors/domain.ts b/packages/phrases/src/locales/pt-br/errors/domain.ts index 0a13dd38b..6403513e0 100644 --- a/packages/phrases/src/locales/pt-br/errors/domain.ts +++ b/packages/phrases/src/locales/pt-br/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Recebido erro desconhecido ao solicitar API do Cloudflare', cloudflare_response_error: 'Recebido resposta inesperada do Cloudflare.', limit_to_one_domain: 'Você só pode ter um domínio personalizado.', + hostname_already_exists: 'Este domínio já existe em nosso servidor.', }; export default domain; diff --git a/packages/phrases/src/locales/pt-pt/errors/domain.ts b/packages/phrases/src/locales/pt-pt/errors/domain.ts index 1447d0e61..57b475ab1 100644 --- a/packages/phrases/src/locales/pt-pt/errors/domain.ts +++ b/packages/phrases/src/locales/pt-pt/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Obteve um erro desconhecido ao solicitar a API Cloudflare', cloudflare_response_error: 'Obteve uma resposta inesperada da Cloudflare.', limit_to_one_domain: 'Você só pode ter um domínio personalizado.', + hostname_already_exists: 'Este domínio já existe em nosso servidor.', }; export default domain; diff --git a/packages/phrases/src/locales/ru/errors/domain.ts b/packages/phrases/src/locales/ru/errors/domain.ts index dbc16086a..e1104cca1 100644 --- a/packages/phrases/src/locales/ru/errors/domain.ts +++ b/packages/phrases/src/locales/ru/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Получена неизвестная ошибка при запросе к API Cloudflare', cloudflare_response_error: 'Получен неожиданный ответ от Cloudflare.', limit_to_one_domain: 'Вы можете использовать только один пользовательский домен.', + hostname_already_exists: 'Этот домен уже существует на нашем сервере.', }; export default domain; diff --git a/packages/phrases/src/locales/tr-tr/errors/domain.ts b/packages/phrases/src/locales/tr-tr/errors/domain.ts index 1d348a3f1..d71f3c06a 100644 --- a/packages/phrases/src/locales/tr-tr/errors/domain.ts +++ b/packages/phrases/src/locales/tr-tr/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: 'Cloudflare API isteği yapılırken bilinmeyen bir hata oluştu.', cloudflare_response_error: 'Cloudflare’dan beklenmeyen bir yanıt alındı.', limit_to_one_domain: 'Sadece bir özel alan adınız olabilir.', + hostname_already_exists: 'Bu alan adı sunucumuzda zaten mevcut.', }; export default domain; diff --git a/packages/phrases/src/locales/zh-cn/errors/domain.ts b/packages/phrases/src/locales/zh-cn/errors/domain.ts index c90714a3e..6ccc3e851 100644 --- a/packages/phrases/src/locales/zh-cn/errors/domain.ts +++ b/packages/phrases/src/locales/zh-cn/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: '请求 Cloudflare API 时出现未知错误。', cloudflare_response_error: '从 Cloudflare 得到意外的响应。', limit_to_one_domain: '仅限一个自定义域名。', + hostname_already_exists: '该域名在我们的服务器中已存在。', }; export default domain; diff --git a/packages/phrases/src/locales/zh-hk/errors/domain.ts b/packages/phrases/src/locales/zh-hk/errors/domain.ts index 33a54a0e2..65bf71d97 100644 --- a/packages/phrases/src/locales/zh-hk/errors/domain.ts +++ b/packages/phrases/src/locales/zh-hk/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: '獲取 Cloudflare API 時發生未知錯誤', cloudflare_response_error: '從 Cloudflare 獲取到意外的響應', limit_to_one_domain: '您只能擁有一個自定義域名。', + hostname_already_exists: '此域名已存在於我們的伺服器中。', }; export default domain; diff --git a/packages/phrases/src/locales/zh-tw/errors/domain.ts b/packages/phrases/src/locales/zh-tw/errors/domain.ts index d2864ca80..658523319 100644 --- a/packages/phrases/src/locales/zh-tw/errors/domain.ts +++ b/packages/phrases/src/locales/zh-tw/errors/domain.ts @@ -4,6 +4,7 @@ const domain = { cloudflare_unknown_error: '在請求 Cloudflare API 時出現未知錯誤', cloudflare_response_error: '從 Cloudflare 收到意外回應', limit_to_one_domain: '您只能擁有一個自訂網域。', + hostname_already_exists: '此網域名稱已經存在我們的伺服器中。', }; export default domain;