0
Fork 0
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:
wangsijie 2024-03-28 15:31:55 +08:00
parent 26c215fbdc
commit c66ffc644b
No known key found for this signature in database
GPG key ID: C72642FE24F7D42B
5 changed files with 182 additions and 22 deletions

View file

@ -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',

View file

@ -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];
};

View file

@ -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,
},

View file

@ -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'));

View file

@ -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);
});
});
});