mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
test: add integration tests for tenant custom domain
This commit is contained in:
parent
26c215fbdc
commit
c66ffc644b
5 changed files with 182 additions and 22 deletions
|
@ -1,6 +1,7 @@
|
|||
import { type CloudflareData, type Domain, DomainStatus } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import SystemContext from '#src/tenants/SystemContext.js';
|
||||
|
@ -79,7 +80,10 @@ export const createDomainLibrary = (queries: Queries) => {
|
|||
domain: hostname,
|
||||
id: generateStandardId(),
|
||||
cloudflareData,
|
||||
status: DomainStatus.PendingVerification,
|
||||
// For integration tests, automatically set the domain to active
|
||||
status: EnvSet.values.isIntegrationTest
|
||||
? DomainStatus.Active
|
||||
: DomainStatus.PendingVerification,
|
||||
dnsRecords: [
|
||||
{
|
||||
type: 'CNAME',
|
||||
|
|
|
@ -110,6 +110,12 @@ export const getTenantId = async (
|
|||
return [developmentTenantId, false];
|
||||
}
|
||||
|
||||
// Find custom domains before check multi-tenancy, custom domain should have higher priority
|
||||
const customDomainTenantId = await getTenantIdFromCustomDomain(url, pool);
|
||||
if (customDomainTenantId) {
|
||||
return [customDomainTenantId, true];
|
||||
}
|
||||
|
||||
if (!isMultiTenancy) {
|
||||
return [defaultTenantId, false];
|
||||
}
|
||||
|
@ -118,11 +124,5 @@ export const getTenantId = async (
|
|||
return [matchPathBasedTenantId(urlSet, url), false];
|
||||
}
|
||||
|
||||
const customDomainTenantId = await getTenantIdFromCustomDomain(url, pool);
|
||||
|
||||
if (customDomainTenantId) {
|
||||
return [customDomainTenantId, true];
|
||||
}
|
||||
|
||||
return [matchDomainBasedTenantId(urlSet.endpoint, url), false];
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { LogtoConfig, SignInOptions } from '@logto/node';
|
||||
import type { LogtoConfig, SignInOptions, JwtVerifier, StandardLogtoClient } from '@logto/node';
|
||||
import LogtoClient from '@logto/node';
|
||||
import { demoAppApplicationId } from '@logto/schemas';
|
||||
import type { Nullable, Optional } from '@silverhand/essentials';
|
||||
|
@ -15,6 +15,15 @@ export const defaultConfig = {
|
|||
appId: demoAppApplicationId,
|
||||
persistAccessToken: false,
|
||||
};
|
||||
|
||||
class EmptyJwtVerifier implements JwtVerifier {
|
||||
constructor(protected client: StandardLogtoClient) {}
|
||||
|
||||
async verifyIdToken(): Promise<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export default class MockClient {
|
||||
public rawCookies: string[] = [];
|
||||
protected readonly config: LogtoConfig;
|
||||
|
@ -23,18 +32,29 @@ export default class MockClient {
|
|||
|
||||
private navigateUrl?: string;
|
||||
private readonly api: KyInstance;
|
||||
private readonly interactionApi: KyInstance;
|
||||
|
||||
constructor(config?: Partial<LogtoConfig>) {
|
||||
constructor(
|
||||
config?: Partial<LogtoConfig>,
|
||||
interactionApi?: KyInstance,
|
||||
/* Skip ID token JWT verification */
|
||||
skipIdTokenVerification = false
|
||||
) {
|
||||
this.storage = new MemoryStorage();
|
||||
this.config = { ...defaultConfig, ...config };
|
||||
this.api = ky.extend({ prefixUrl: this.config.endpoint + '/api' });
|
||||
this.interactionApi = interactionApi ?? ky;
|
||||
this.api = this.interactionApi.extend({ prefixUrl: this.config.endpoint + '/api' });
|
||||
|
||||
this.logto = new LogtoClient(this.config, {
|
||||
this.logto = new LogtoClient(
|
||||
this.config,
|
||||
{
|
||||
navigate: (url: string) => {
|
||||
this.navigateUrl = url;
|
||||
},
|
||||
storage: this.storage,
|
||||
});
|
||||
},
|
||||
skipIdTokenVerification ? (client) => new EmptyJwtVerifier(client) : undefined
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Rename to sessionCookies or something accurate
|
||||
|
@ -71,7 +91,7 @@ export default class MockClient {
|
|||
);
|
||||
|
||||
// Mock SDK sign-in navigation
|
||||
const response = await ky(this.navigateUrl, {
|
||||
const response = await this.interactionApi(this.navigateUrl, {
|
||||
redirect: 'manual',
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
@ -102,7 +122,7 @@ export default class MockClient {
|
|||
new Error('SignIn or Register failed')
|
||||
);
|
||||
|
||||
const authResponse = await ky.get(redirectTo, {
|
||||
const authResponse = await this.interactionApi.get(redirectTo, {
|
||||
headers: {
|
||||
cookie: this.interactionCookie,
|
||||
},
|
||||
|
@ -141,7 +161,7 @@ export default class MockClient {
|
|||
}
|
||||
|
||||
await this.logto.signOut(postSignOutRedirectUri);
|
||||
await ky(this.navigateUrl);
|
||||
await this.interactionApi(this.navigateUrl);
|
||||
}
|
||||
|
||||
public async isAuthenticated() {
|
||||
|
@ -179,7 +199,7 @@ export default class MockClient {
|
|||
assert(this.interactionCookie, new Error('Session not found'));
|
||||
assert(this.interactionCookie.includes('_session.sig'), new Error('Session not found'));
|
||||
|
||||
const consentResponse = await ky.get(`${this.config.endpoint}/consent`, {
|
||||
const consentResponse = await this.interactionApi.get(`${this.config.endpoint}/consent`, {
|
||||
headers: {
|
||||
cookie: this.interactionCookie,
|
||||
},
|
||||
|
@ -194,7 +214,7 @@ export default class MockClient {
|
|||
throw new Error('Consent failed');
|
||||
}
|
||||
|
||||
const authCodeResponse = await ky.get(redirectTo, {
|
||||
const authCodeResponse = await this.interactionApi.get(redirectTo, {
|
||||
headers: {
|
||||
cookie: this.interactionCookie,
|
||||
},
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import type { LogtoConfig, SignInOptions } from '@logto/node';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { type KyInstance } from 'ky';
|
||||
|
||||
import MockClient from '#src/client/index.js';
|
||||
|
||||
export const initClient = async (
|
||||
config?: Partial<LogtoConfig>,
|
||||
redirectUri?: string,
|
||||
options: Omit<SignInOptions, 'redirectUri'> = {}
|
||||
options: Omit<SignInOptions, 'redirectUri'> & {
|
||||
interactionApi?: KyInstance;
|
||||
skipIdTokenVerification?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const client = new MockClient(config);
|
||||
const client = new MockClient(config, options.interactionApi, options.skipIdTokenVerification);
|
||||
await client.initSession(redirectUri, options);
|
||||
assert(client.interactionCookie, new Error('Session not found'));
|
||||
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
import { ConnectorType, InteractionEvent } from '@logto/schemas';
|
||||
import ky from 'ky';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { createDomain, deleteDomain, getDomains } from '#src/api/domain.js';
|
||||
import { putInteraction } from '#src/api/interaction.js';
|
||||
import { initClient, logoutClient, processSession } from '#src/helpers/client.js';
|
||||
import {
|
||||
clearConnectorsByTypes,
|
||||
setEmailConnector,
|
||||
setSmsConnector,
|
||||
} from '#src/helpers/connector.js';
|
||||
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
import { generateDomain } from '#src/utils.js';
|
||||
|
||||
const localHostname = '127.0.0.1';
|
||||
const localPort = '3001';
|
||||
|
||||
const overrideOrigin = (url: string | URL): URL => {
|
||||
const newUrl = new URL(url.toString());
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
newUrl.hostname = localHostname;
|
||||
newUrl.port = localPort;
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
return newUrl;
|
||||
};
|
||||
|
||||
describe('Using custom domain', () => {
|
||||
const domainName = 'auth.example.com';
|
||||
|
||||
beforeAll(async () => {
|
||||
await createDomain(domainName);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const domains = await getDomains();
|
||||
await Promise.all(domains.map(async (domain) => deleteDomain(domain.id)));
|
||||
});
|
||||
|
||||
describe('OIDC discovery', () => {
|
||||
it('should return OIDC configuration with custom domain', async () => {
|
||||
const response = ky(
|
||||
`http://${localHostname}:${localPort}/oidc/.well-known/openid-configuration`,
|
||||
{
|
||||
headers: {
|
||||
Host: domainName,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(response.json<{ issuer: string }>()).resolves.toMatchObject({
|
||||
issuer: `http://${domainName}/oidc`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to localhost for unknown domain', async () => {
|
||||
const response = ky('http://127.0.0.1:3001/oidc/.well-known/openid-configuration', {
|
||||
headers: {
|
||||
Host: generateDomain(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(response.json<{ issuer: string }>()).resolves.toMatchObject({
|
||||
issuer: 'http://localhost:3001/oidc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sign in', () => {
|
||||
const originalFetch = global.fetch;
|
||||
beforeAll(async () => {
|
||||
await enableAllPasswordSignInMethods();
|
||||
await clearConnectorsByTypes([ConnectorType.Sms, ConnectorType.Email]);
|
||||
await setSmsConnector();
|
||||
await setEmailConnector();
|
||||
// Intercept fetch request (used in Logto SDK) to custom domain and redirect to localhost
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
global.fetch = async (input, init) => {
|
||||
if (typeof input === 'string' && input.startsWith(`http://${domainName}`)) {
|
||||
return originalFetch(overrideOrigin(input).toString(), {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Host: domainName,
|
||||
},
|
||||
});
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('sign-in with custom domain endpoint', async () => {
|
||||
const { userProfile, user } = await generateNewUser({ username: true, password: true });
|
||||
const client = await initClient({ endpoint: `http://${domainName}` }, undefined, {
|
||||
skipIdTokenVerification: true,
|
||||
interactionApi: ky.create({
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.hostname === domainName) {
|
||||
request.headers.set('Host', domainName);
|
||||
return new Request(overrideOrigin(url).toString(), request);
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
username: userProfile.username,
|
||||
password: userProfile.password,
|
||||
},
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
|
||||
await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
await deleteUser(user.id);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue