0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

fix(core): delete cloudflare custom domain (#3953)

* fix(core): delete cloudflare custom domain

* fix(core,phrases): handle cloudflare api errors
This commit is contained in:
wangsijie 2023-06-02 16:50:36 +08:00 committed by GitHub
parent 6f6941b9ba
commit 0e8817f279
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 133 additions and 17 deletions

View file

@ -18,11 +18,12 @@ import SystemContext from '#src/tenants/SystemContext.js';
const { jest } = import.meta; const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest); const { mockEsm } = createMockUtils(jest);
const { getCustomHostname, createCustomHostname } = mockEsm( const { getCustomHostname, createCustomHostname, deleteCustomHostname } = mockEsm(
'#src/utils/cloudflare/index.js', '#src/utils/cloudflare/index.js',
() => ({ () => ({
createCustomHostname: jest.fn(async () => mockCloudflareData), createCustomHostname: jest.fn(async () => mockCloudflareData),
getCustomHostname: 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 updateDomainById = jest.fn(async (_, data) => data);
const insertDomain = jest.fn(async (data) => data); const insertDomain = jest.fn(async (data) => data);
const { syncDomainStatus, addDomain } = createDomainLibrary( const findDomainById = jest.fn(async () => mockDomain);
new MockQueries({ domains: { updateDomainById, insertDomain } }) const deleteDomainById = jest.fn();
const { syncDomainStatus, addDomain, deleteDomain } = createDomainLibrary(
new MockQueries({ domains: { updateDomainById, insertDomain, findDomainById, deleteDomainById } })
); );
const fallbackOrigin = 'fake_origin'; const fallbackOrigin = 'fake_origin';
@ -50,7 +53,7 @@ afterAll(() => {
SystemContext.shared.hostnameProviderConfig = undefined; SystemContext.shared.hostnameProviderConfig = undefined;
}); });
describe('addDomainToCloudflare()', () => { describe('addDomain()', () => {
it('should call createCustomHostname and return cloudflare data', async () => { it('should call createCustomHostname and return cloudflare data', async () => {
const response = await addDomain(mockDomain.domain); const response = await addDomain(mockDomain.domain);
expect(createCustomHostname).toBeCalledTimes(1); expect(createCustomHostname).toBeCalledTimes(1);
@ -133,3 +136,24 @@ describe('syncDomainStatus()', () => {
expect(response.errorMessage).toContain('fake_error'); 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);
});
});

View file

@ -9,7 +9,11 @@ import { generateStandardId } from '@logto/shared';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import SystemContext from '#src/tenants/SystemContext.js'; import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.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'; import { findSslTxtRecord, findVerificationTxtRecord } from '#src/utils/cloudflare/utils.js';
export type DomainLibrary = ReturnType<typeof createDomainLibrary>; export type DomainLibrary = ReturnType<typeof createDomainLibrary>;
@ -27,7 +31,7 @@ const getDomainStatusFromCloudflareData = (data: CloudflareData): DomainStatus =
export const createDomainLibrary = (queries: Queries) => { export const createDomainLibrary = (queries: Queries) => {
const { const {
domains: { updateDomainById, insertDomain }, domains: { updateDomainById, insertDomain, findDomainById, deleteDomainById },
} = queries; } = queries;
const syncDomainStatusFromCloudflareData = async ( 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 { return {
syncDomainStatus, syncDomainStatus,
addDomain, addDomain,
deleteDomain,
}; };
}; };

View file

@ -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 { pickDefault } from '@logto/shared/esm';
import { mockDomain, mockDomainResponse } from '#src/__mocks__/domain.js'; import { mockDomain, mockDomainResponse } from '#src/__mocks__/domain.js';
@ -9,10 +9,6 @@ const { jest } = import.meta;
const domains = { const domains = {
findAllDomains: jest.fn(async (): Promise<Domain[]> => [mockDomain]), findAllDomains: jest.fn(async (): Promise<Domain[]> => [mockDomain]),
insertDomain: async (data: CreateDomain): Promise<Domain> => ({
...mockDomain,
...data,
}),
findDomainById: async (id: string): Promise<Domain> => { findDomainById: async (id: string): Promise<Domain> => {
const domain = [mockDomain].find((domain) => domain.id === id); const domain = [mockDomain].find((domain) => domain.id === id);
if (!domain) { if (!domain) {
@ -20,7 +16,6 @@ const domains = {
} }
return domain; return domain;
}, },
deleteDomainById: jest.fn(),
}; };
const syncDomainStatus = jest.fn(async (domain: Domain): Promise<Domain> => domain); const syncDomainStatus = jest.fn(async (domain: Domain): Promise<Domain> => domain);
@ -30,11 +25,13 @@ const addDomain = jest.fn(
domain, domain,
}) })
); );
const deleteDomain = jest.fn();
const mockLibraries = { const mockLibraries = {
domains: { domains: {
syncDomainStatus, syncDomainStatus,
addDomain, addDomain,
deleteDomain,
}, },
}; };
@ -64,6 +61,7 @@ describe('domain routes', () => {
it('POST /domains', async () => { it('POST /domains', async () => {
domains.findAllDomains.mockResolvedValueOnce([]); domains.findAllDomains.mockResolvedValueOnce([]);
const response = await domainRequest.post('/domains').send({ domain: 'test.com' }); const response = await domainRequest.post('/domains').send({ domain: 'test.com' });
expect(addDomain).toBeCalledWith('test.com');
expect(response.status).toEqual(201); expect(response.status).toEqual(201);
expect(response.body.id).toBeTruthy(); expect(response.body.id).toBeTruthy();
expect(response.body.domain).toEqual('test.com'); expect(response.body.domain).toEqual('test.com');
@ -80,5 +78,6 @@ describe('domain routes', () => {
'status', 'status',
204 204
); );
expect(deleteDomain).toBeCalledWith(mockDomain.id);
}); });
}); });

View file

@ -12,10 +12,10 @@ export default function domainRoutes<T extends AuthedRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T> ...[router, { queries, libraries }]: RouterInitArgs<T>
) { ) {
const { const {
domains: { findAllDomains, findDomainById, deleteDomainById }, domains: { findAllDomains, findDomainById },
} = queries; } = queries;
const { const {
domains: { syncDomainStatus, addDomain }, domains: { syncDomainStatus, addDomain, deleteDomain },
} = libraries; } = libraries;
router.get( router.get(
@ -83,7 +83,7 @@ export default function domainRoutes<T extends AuthedRouter>(
koaGuard({ params: z.object({ id: z.string() }), status: [204, 404] }), koaGuard({ params: z.object({ id: z.string() }), status: [204, 404] }),
async (ctx, next) => { async (ctx, next) => {
const { id } = ctx.guard.params; const { id } = ctx.guard.params;
await deleteDomainById(id); await deleteDomain(id);
ctx.status = 204; ctx.status = 204;
return next(); return next();

View file

@ -3,6 +3,8 @@ import path from 'node:path';
import { type HostnameProviderData, cloudflareDataGuard } from '@logto/schemas'; import { type HostnameProviderData, cloudflareDataGuard } from '@logto/schemas';
import { got } from 'got'; import { got } from 'got';
import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '../assert-that.js'; import assertThat from '../assert-that.js';
import { baseUrl } from './consts.js'; import { baseUrl } from './consts.js';
@ -29,10 +31,23 @@ export const createCustomHostname = async (auth: HostnameProviderData, hostname:
hostname, hostname,
ssl: { method: 'txt', type: 'dv', settings: { min_tls_version: '1.0' } }, 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)); const result = cloudflareDataGuard.safeParse(parseCloudflareResponse(response.body));
@ -60,10 +75,20 @@ export const getCustomHostname = async (auth: HostnameProviderData, identifier:
headers: { headers: {
Authorization: `Bearer ${auth.apiToken}`, 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)); const result = cloudflareDataGuard.safeParse(parseCloudflareResponse(response.body));
@ -71,3 +96,38 @@ export const getCustomHostname = async (auth: HostnameProviderData, identifier:
return result.data; 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
)
);
};

View file

@ -5,6 +5,7 @@ const domain = {
'Beim Anfordern der Cloudflare-API ist ein unbekannter Fehler aufgetreten', 'Beim Anfordern der Cloudflare-API ist ein unbekannter Fehler aufgetreten',
cloudflare_response_error: 'Vom Cloudflare wurde eine unerwartete Antwort erhalten.', cloudflare_response_error: 'Vom Cloudflare wurde eine unerwartete Antwort erhalten.',
limit_to_one_domain: 'Sie können nur eine benutzerdefinierte Domain haben.', 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; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API', cloudflare_unknown_error: 'Got unknown error when requesting Cloudflare API',
cloudflare_response_error: 'Got unexpected response from Cloudflare.', cloudflare_response_error: 'Got unexpected response from Cloudflare.',
limit_to_one_domain: 'You can only have one custom domain.', limit_to_one_domain: 'You can only have one custom domain.',
hostname_already_exists: 'This domain already exists in our server.',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Se produjo un error desconocido al solicitar la API de Cloudflare', cloudflare_unknown_error: 'Se produjo un error desconocido al solicitar la API de Cloudflare',
cloudflare_response_error: 'Recibió una respuesta inesperada de Cloudflare.', cloudflare_response_error: 'Recibió una respuesta inesperada de Cloudflare.',
limit_to_one_domain: 'Solo puedes tener un dominio personalizado.', limit_to_one_domain: 'Solo puedes tener un dominio personalizado.',
hostname_already_exists: 'Este dominio ya existe en nuestro servidor.',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: "Erreur inconnue lors de la requête de l'API Cloudflare", cloudflare_unknown_error: "Erreur inconnue lors de la requête de l'API Cloudflare",
cloudflare_response_error: 'Réponse inattendue de Cloudflare', cloudflare_response_error: 'Réponse inattendue de Cloudflare',
limit_to_one_domain: "Vous ne pouvez avoir qu'un seul domaine personnalisé", 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; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Errore sconosciuto durante la richiesta di API Cloudflare', cloudflare_unknown_error: 'Errore sconosciuto durante la richiesta di API Cloudflare',
cloudflare_response_error: 'Ricevuta una risposta non prevista da Cloudflare.', cloudflare_response_error: 'Ricevuta una risposta non prevista da Cloudflare.',
limit_to_one_domain: 'Puoi avere solo un dominio personalizzato.', limit_to_one_domain: 'Puoi avere solo un dominio personalizzato.',
hostname_already_exists: 'Questo dominio esiste già nel nostro server.',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Cloudflare API のリクエスト中に未知のエラーが発生しました。', cloudflare_unknown_error: 'Cloudflare API のリクエスト中に未知のエラーが発生しました。',
cloudflare_response_error: 'Cloudflare から予期しない応答がありました。', cloudflare_response_error: 'Cloudflare から予期しない応答がありました。',
limit_to_one_domain: 'カスタムドメインは1つしか持てません。', limit_to_one_domain: 'カスタムドメインは1つしか持てません。',
hostname_already_exists: 'サーバーには既にこのドメインが存在しています。',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Cloudflare API 요청시 알 수 없는 오류 발생', cloudflare_unknown_error: 'Cloudflare API 요청시 알 수 없는 오류 발생',
cloudflare_response_error: 'Cloudflare 로부터 예상치 못한 응답을 받았습니다.', cloudflare_response_error: 'Cloudflare 로부터 예상치 못한 응답을 받았습니다.',
limit_to_one_domain: '하나의 맞춤 도메인만 사용할 수 있습니다.', limit_to_one_domain: '하나의 맞춤 도메인만 사용할 수 있습니다.',
hostname_already_exists: '이 도메인은 이미 서버에 존재합니다.',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Otrzymano nieznany błąd podczas żądania API Cloudflare', cloudflare_unknown_error: 'Otrzymano nieznany błąd podczas żądania API Cloudflare',
cloudflare_response_error: 'Otrzymano nieoczekiwaną odpowiedź od Cloudflare.', cloudflare_response_error: 'Otrzymano nieoczekiwaną odpowiedź od Cloudflare.',
limit_to_one_domain: 'Możesz mieć tylko jedną niestandardową domenę.', limit_to_one_domain: 'Możesz mieć tylko jedną niestandardową domenę.',
hostname_already_exists: 'Ta domena już istnieje na naszym serwerze.',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Recebido erro desconhecido ao solicitar API do Cloudflare', cloudflare_unknown_error: 'Recebido erro desconhecido ao solicitar API do Cloudflare',
cloudflare_response_error: 'Recebido resposta inesperada do Cloudflare.', cloudflare_response_error: 'Recebido resposta inesperada do Cloudflare.',
limit_to_one_domain: 'Você só pode ter um domínio personalizado.', 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; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Obteve um erro desconhecido ao solicitar a API Cloudflare', cloudflare_unknown_error: 'Obteve um erro desconhecido ao solicitar a API Cloudflare',
cloudflare_response_error: 'Obteve uma resposta inesperada da Cloudflare.', cloudflare_response_error: 'Obteve uma resposta inesperada da Cloudflare.',
limit_to_one_domain: 'Você só pode ter um domínio personalizado.', 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; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Получена неизвестная ошибка при запросе к API Cloudflare', cloudflare_unknown_error: 'Получена неизвестная ошибка при запросе к API Cloudflare',
cloudflare_response_error: 'Получен неожиданный ответ от Cloudflare.', cloudflare_response_error: 'Получен неожиданный ответ от Cloudflare.',
limit_to_one_domain: 'Вы можете использовать только один пользовательский домен.', limit_to_one_domain: 'Вы можете использовать только один пользовательский домен.',
hostname_already_exists: 'Этот домен уже существует на нашем сервере.',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: 'Cloudflare API isteği yapılırken bilinmeyen bir hata oluştu.', cloudflare_unknown_error: 'Cloudflare API isteği yapılırken bilinmeyen bir hata oluştu.',
cloudflare_response_error: 'Cloudflaredan beklenmeyen bir yanıt alındı.', cloudflare_response_error: 'Cloudflaredan beklenmeyen bir yanıt alındı.',
limit_to_one_domain: 'Sadece bir özel alan adınız olabilir.', limit_to_one_domain: 'Sadece bir özel alan adınız olabilir.',
hostname_already_exists: 'Bu alan adı sunucumuzda zaten mevcut.',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: '请求 Cloudflare API 时出现未知错误。', cloudflare_unknown_error: '请求 Cloudflare API 时出现未知错误。',
cloudflare_response_error: '从 Cloudflare 得到意外的响应。', cloudflare_response_error: '从 Cloudflare 得到意外的响应。',
limit_to_one_domain: '仅限一个自定义域名。', limit_to_one_domain: '仅限一个自定义域名。',
hostname_already_exists: '该域名在我们的服务器中已存在。',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: '獲取 Cloudflare API 時發生未知錯誤', cloudflare_unknown_error: '獲取 Cloudflare API 時發生未知錯誤',
cloudflare_response_error: '從 Cloudflare 獲取到意外的響應', cloudflare_response_error: '從 Cloudflare 獲取到意外的響應',
limit_to_one_domain: '您只能擁有一個自定義域名。', limit_to_one_domain: '您只能擁有一個自定義域名。',
hostname_already_exists: '此域名已存在於我們的伺服器中。',
}; };
export default domain; export default domain;

View file

@ -4,6 +4,7 @@ const domain = {
cloudflare_unknown_error: '在請求 Cloudflare API 時出現未知錯誤', cloudflare_unknown_error: '在請求 Cloudflare API 時出現未知錯誤',
cloudflare_response_error: '從 Cloudflare 收到意外回應', cloudflare_response_error: '從 Cloudflare 收到意外回應',
limit_to_one_domain: '您只能擁有一個自訂網域。', limit_to_one_domain: '您只能擁有一個自訂網域。',
hostname_already_exists: '此網域名稱已經存在我們的伺服器中。',
}; };
export default domain; export default domain;