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

test(core): add unit tests

This commit is contained in:
Gao Sun 2023-02-12 23:37:47 +08:00
parent 7a7d7f9f41
commit 643d418bb1
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
16 changed files with 335 additions and 89 deletions

View file

@ -5,7 +5,7 @@
import { createMockUtils } from '@logto/shared/esm'; import { createMockUtils } from '@logto/shared/esm';
const { jest } = import.meta; const { jest } = import.meta;
const { mockEsm, mockEsmWithActual, mockEsmDefault } = createMockUtils(jest); const { mockEsm, mockEsmDefault } = createMockUtils(jest);
process.env.DB_URL = 'postgres://mock.db.url'; process.env.DB_URL = 'postgres://mock.db.url';
process.env.ENDPOINT = 'https://logto.test'; process.env.ENDPOINT = 'https://logto.test';
@ -29,16 +29,6 @@ mockEsmDefault('#src/env-set/oidc.js', () => () => ({
})); }));
/* End */ /* End */
await mockEsmWithActual('#src/env-set/index.js', () => ({
MountedApps: {
Api: 'api',
Oidc: 'oidc',
Console: 'console',
DemoApp: 'demo-app',
Welcome: 'welcome',
},
}));
// Logger is not considered in all test cases // Logger is not considered in all test cases
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
mockEsm('koa-logger', () => ({ default: () => (_, next) => next() })); mockEsm('koa-logger', () => ({ default: () => (_, next) => next() }));

View file

@ -13,7 +13,7 @@ const logListening = (type: 'core' | 'admin' = 'core') => {
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet; const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
for (const url of urlSet.deduplicated()) { for (const url of urlSet.deduplicated()) {
console.log(chalk.bold(chalk.green(`${toTitle(type)} app is running at ${url}`))); console.log(chalk.bold(chalk.green(`${toTitle(type)} app is running at ${url.toString()}`)));
} }
}; };

View file

@ -5,10 +5,6 @@ import UrlSet from './UrlSet.js';
import { isTrue } from './parameters.js'; import { isTrue } from './parameters.js';
import { throwErrorWithDsnMessage } from './throw-errors.js'; import { throwErrorWithDsnMessage } from './throw-errors.js';
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
type MultiTenancyMode = 'domain' | 'env';
export default class GlobalValues { export default class GlobalValues {
public readonly isProduction = getEnv('NODE_ENV') === 'production'; public readonly isProduction = getEnv('NODE_ENV') === 'production';
public readonly isTest = getEnv('NODE_ENV') === 'test'; public readonly isTest = getEnv('NODE_ENV') === 'test';
@ -21,11 +17,11 @@ export default class GlobalValues {
public readonly urlSet = new UrlSet(this.isHttpsEnabled, 3001); public readonly urlSet = new UrlSet(this.isHttpsEnabled, 3001);
public readonly adminUrlSet = new UrlSet(this.isHttpsEnabled, 3002, 'ADMIN_'); public readonly adminUrlSet = new UrlSet(this.isHttpsEnabled, 3002, 'ADMIN_');
public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.includes('*'); public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.hostname.includes('*');
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage); public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
public readonly developmentTenantId = getEnv(developmentTenantIdKey); public readonly developmentTenantId = getEnv('DEVELOPMENT_TENANT_ID');
public readonly userDefaultRoleNames = getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'); public readonly userDefaultRoleNames = getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES');
public readonly developmentUserId = getEnv('DEVELOPMENT_USER_ID'); public readonly developmentUserId = getEnv('DEVELOPMENT_USER_ID');
public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER')); public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER'));
@ -35,7 +31,7 @@ export default class GlobalValues {
return this.databaseUrl; return this.databaseUrl;
} }
public get endpoint(): string { public get endpoint(): URL {
return this.urlSet.endpoint; return this.urlSet.endpoint;
} }
} }

View file

@ -0,0 +1,81 @@
import UrlSet from './UrlSet.js';
describe('UrlSet', () => {
const backupEnv = process.env;
afterEach(() => {
process.env = backupEnv;
});
it('should resolve proper values when localhost is enabled and endpoint is provided', async () => {
process.env = {
...backupEnv,
ENDPOINT: 'https://logto.mock',
ADMIN_ENDPOINT: 'https://admin.logto.mock',
};
const set1 = new UrlSet(true, 3001);
expect(set1.deduplicated()).toStrictEqual([
new URL('https://localhost:3001'),
new URL('https://logto.mock'),
]);
expect(set1.port).toEqual(3001);
expect(set1.localhostUrl).toEqual(new URL('https://localhost:3001'));
expect(set1.endpoint).toEqual(new URL('https://logto.mock'));
const set2 = new UrlSet(false, 3002, 'ADMIN_');
expect(set2.deduplicated()).toStrictEqual([
new URL('http://localhost:3002/'),
new URL('https://admin.logto.mock/'),
]);
expect(set2.port).toEqual(3002);
expect(set2.localhostUrl).toEqual(new URL('http://localhost:3002'));
expect(set2.endpoint).toEqual(new URL('https://admin.logto.mock'));
});
it('should resolve proper values when localhost is enabled and endpoint is not provided', async () => {
process.env = {
...backupEnv,
ENDPOINT: undefined,
};
const set1 = new UrlSet(false, 3001);
expect(set1.deduplicated()).toStrictEqual([new URL('http://localhost:3001/')]);
expect(set1.port).toEqual(3001);
expect(set1.localhostUrl).toEqual(new URL('http://localhost:3001'));
expect(set1.endpoint).toEqual(new URL('http://localhost:3001'));
});
it('should resolve proper values when localhost is disabled and endpoint is provided', async () => {
process.env = {
...backupEnv,
ENDPOINT: 'https://logto.mock/logto',
DISABLE_LOCALHOST: '1',
};
const set1 = new UrlSet(true, 3001);
expect(set1.deduplicated()).toStrictEqual([new URL('https://logto.mock/logto')]);
expect(() => set1.port).toThrowError('Localhost has been disabled in this URL Set.');
expect(() => set1.localhostUrl).toThrowError('Localhost has been disabled in this URL Set.');
expect(set1.endpoint).toEqual(new URL('https://logto.mock/logto'));
});
it('should resolve proper values when localhost is disabled and endpoint is not provided', async () => {
process.env = {
...backupEnv,
ADMIN_ENDPOINT: undefined,
ADMIN_DISABLE_LOCALHOST: '1',
};
const set1 = new UrlSet(false, 3002, 'ADMIN_');
expect(set1.deduplicated()).toStrictEqual([]);
expect(() => set1.port).toThrowError('Localhost has been disabled in this URL Set.');
expect(() => set1.localhostUrl).toThrowError('Localhost has been disabled in this URL Set.');
expect(() => set1.endpoint).toThrowError('No available endpoint in this URL Set.');
});
});

View file

@ -2,8 +2,6 @@ import { deduplicate, getEnv, trySafe } from '@silverhand/essentials';
import { isTrue } from './parameters.js'; import { isTrue } from './parameters.js';
const localhostDisabledMessage = 'Localhost has been disabled in this URL Set.';
export default class UrlSet { export default class UrlSet {
readonly #port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort); readonly #port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort);
readonly #endpoint = getEnv(this.envPrefix + 'ENDPOINT'); readonly #endpoint = getEnv(this.envPrefix + 'ENDPOINT');
@ -16,27 +14,35 @@ export default class UrlSet {
protected readonly envPrefix: string = '' protected readonly envPrefix: string = ''
) {} ) {}
public deduplicated(): string[] { public deduplicated(): URL[] {
return deduplicate( return deduplicate(
[trySafe(() => this.localhostUrl), trySafe(() => this.endpoint)].filter( [trySafe(() => this.localhostUrl.toString()), trySafe(() => this.endpoint.toString())].filter(
(value): value is string => typeof value === 'string' (value): value is string => typeof value === 'string'
) )
); ).map((value) => new URL(value));
} }
public get port() { public get port(): number {
if (this.isLocalhostDisabled) { if (this.isLocalhostDisabled) {
throw new Error(localhostDisabledMessage); throw new Error('Localhost has been disabled in this URL Set.');
} }
return this.#port; return this.#port;
} }
public get localhostUrl() { public get localhostUrl(): URL {
return `${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${this.port}`; return new URL(`${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${this.port}`);
} }
public get endpoint() { public get endpoint(): URL {
return this.#endpoint || this.localhostUrl; if (!this.#endpoint) {
if (this.isLocalhostDisabled) {
throw new Error('No available endpoint in this URL Set.');
}
return this.localhostUrl;
}
return new URL(this.#endpoint);
} }
} }

View file

@ -1,6 +1,4 @@
import { adminTenantId } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials'; import type { Optional } from '@silverhand/essentials';
import { trySafe } from '@silverhand/essentials';
import type { PostgreSql } from '@withtyped/postgres'; import type { PostgreSql } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server'; import type { QueryClient } from '@withtyped/server';
import type { DatabasePool } from 'slonik'; import type { DatabasePool } from 'slonik';
@ -14,6 +12,7 @@ import createPool from './create-pool.js';
import createQueryClient from './create-query-client.js'; import createQueryClient from './create-query-client.js';
import loadOidcValues from './oidc.js'; import loadOidcValues from './oidc.js';
import { throwNotLoadedError } from './throw-errors.js'; import { throwNotLoadedError } from './throw-errors.js';
import { getTenantEndpoint } from './utils.js';
/** Apps (also paths) for user tenants. */ /** Apps (also paths) for user tenants. */
export enum UserApps { export enum UserApps {
@ -29,21 +28,6 @@ export enum AdminApps {
Me = 'me', Me = 'me',
} }
const getTenantEndpoint = (id: string) => {
const { urlSet, adminUrlSet, isDomainBasedMultiTenancy } = EnvSet.values;
const adminUrl = trySafe(() => adminUrlSet.endpoint);
if (adminUrl && id === adminTenantId) {
return adminUrl;
}
if (!isDomainBasedMultiTenancy) {
return urlSet.endpoint;
}
return urlSet.endpoint.replace('*', id);
};
export class EnvSet { export class EnvSet {
static values = new GlobalValues(); static values = new GlobalValues();
@ -109,7 +93,9 @@ export class EnvSet {
const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool)); const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool));
const oidcConfigs = await getOidcConfigs(); const oidcConfigs = await getOidcConfigs();
const endpoint = getTenantEndpoint(this.tenantId); const endpoint = getTenantEndpoint(this.tenantId, EnvSet.values);
this.#oidc = await loadOidcValues(appendPath(endpoint, '/oidc').toString(), oidcConfigs); this.#oidc = await loadOidcValues(appendPath(endpoint, '/oidc').toString(), oidcConfigs);
} }
} }
export { getTenantEndpoint } from './utils.js';

View file

@ -0,0 +1,25 @@
import { adminTenantId } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import type GlobalValues from './GlobalValues.js';
export const getTenantEndpoint = (
id: string,
{ urlSet, adminUrlSet, isDomainBasedMultiTenancy }: GlobalValues
): URL => {
const adminUrl = trySafe(() => adminUrlSet.endpoint);
if (adminUrl && id === adminTenantId) {
return adminUrl;
}
if (!isDomainBasedMultiTenancy) {
return urlSet.endpoint;
}
const tenantUrl = new URL(urlSet.endpoint);
// eslint-disable-next-line @silverhand/fp/no-mutation
tenantUrl.hostname = tenantUrl.hostname.replace('*', id);
return tenantUrl;
};

View file

@ -11,8 +11,9 @@ import { convertToIdentifiers } from '@logto/shared';
import type { JWK } from 'jose'; import type { JWK } from 'jose';
import { sql } from 'slonik'; import { sql } from 'slonik';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import { exportJWK } from '#src/utils/jwks.js'; import { exportJWK } from '#src/utils/jwks.js';
import { appendPath } from '#src/utils/url.js';
const { table, fields } = convertToIdentifiers(LogtoConfigs); const { table, fields } = convertToIdentifiers(LogtoConfigs);
@ -46,9 +47,12 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
return { return {
keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))), keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))),
issuer: [ issuer: [
(isDomainBasedMultiTenancy appendPath(
? urlSet.endpoint.replace('*', adminTenantId) isDomainBasedMultiTenancy
: adminUrlSet.endpoint) + '/oidc', ? getTenantEndpoint(adminTenantId, EnvSet.values)
: adminUrlSet.endpoint,
'/oidc'
).toString(),
], ],
}; };
}; };

View file

@ -3,7 +3,8 @@ import { ConnectorType } from '@logto/connector-kit';
import { adminConsoleApplicationId, adminTenantId } from '@logto/schemas'; import { adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
import etag from 'etag'; import etag from 'etag';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { getApplicationIdFromInteraction } from '#src/libraries/session.js'; import { getApplicationIdFromInteraction } from '#src/libraries/session.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js';
@ -18,8 +19,12 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
if (id === adminTenantId) { if (id === adminTenantId) {
router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => { router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => {
if (!ctx.params.tenantId) {
throw new RequestError('request.invalid_input');
}
ctx.body = { ctx.body = {
user: EnvSet.values.urlSet.endpoint.replace('*', ctx.params.tenantId ?? '*'), user: getTenantEndpoint(ctx.params.tenantId, EnvSet.values),
}; };
return next(); return next();

View file

@ -1,21 +1,13 @@
import { adminTenantId, defaultTenantId } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm'; import { createMockUtils, pickDefault } from '@logto/shared/esm';
import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { emptyMiddleware } from '#src/utils/test-utils.js'; import { emptyMiddleware } from '#src/utils/test-utils.js';
import { defaultTenant } from './consts.js';
const { jest } = import.meta; const { jest } = import.meta;
const { mockEsm, mockEsmDefault } = createMockUtils(jest); const { mockEsm, mockEsmDefault } = createMockUtils(jest);
const middlewareList = [ const buildMockMiddleware = (name: string) => {
'error-handler',
'i18next',
'audit-log',
'oidc-error-handler',
'slonik-error-handler',
'spa-proxy',
].map((name) => {
const mock = jest.fn(() => emptyMiddleware); const mock = jest.fn(() => emptyMiddleware);
mockEsm(`#src/middleware/koa-${name}.js`, () => ({ mockEsm(`#src/middleware/koa-${name}.js`, () => ({
default: mock, default: mock,
@ -23,7 +15,27 @@ const middlewareList = [
})); }));
return mock; return mock;
}); };
const middlewareList = Object.freeze(
[
'error-handler',
'i18next',
'audit-log',
'oidc-error-handler',
'slonik-error-handler',
'spa-proxy',
'check-demo-app',
'console-redirect-proxy',
].map((name) => [name, buildMockMiddleware(name)] as const)
);
const userMiddlewareList = middlewareList.map(
([name, middleware]) => [name, middleware, name !== 'console-redirect-proxy'] as const
);
const adminMiddlewareList = middlewareList.map(
([name, middleware]) => [name, middleware, name !== 'check-demo-app'] as const
);
mockEsm('./utils.js', () => ({ mockEsm('./utils.js', () => ({
getTenantDatabaseDsn: async () => 'postgres://mock.db.url', getTenantDatabaseDsn: async () => 'postgres://mock.db.url',
@ -35,11 +47,38 @@ mockEsmDefault('#src/oidc/init.js', () => () => createMockProvider());
const Tenant = await pickDefault(import('./Tenant.js')); const Tenant = await pickDefault(import('./Tenant.js'));
describe('Tenant', () => { describe('Tenant', () => {
it('should call middleware factories', async () => { afterEach(() => {
await Tenant.create(defaultTenant); jest.clearAllMocks();
});
for (const middleware of middlewareList) { it('should call middleware factories for user tenants', async () => {
expect(middleware).toBeCalled(); await Tenant.create(defaultTenantId);
for (const [, middleware, shouldCall] of userMiddlewareList) {
if (shouldCall) {
expect(middleware).toBeCalled();
} else {
expect(middleware).not.toBeCalled();
}
}
});
it('should call middleware factories for the admin tenant', async () => {
await Tenant.create(adminTenantId);
for (const [, middleware, shouldCall] of adminMiddlewareList) {
if (shouldCall) {
expect(middleware).toBeCalled();
} else {
expect(middleware).not.toBeCalled();
}
} }
}); });
}); });
describe('Tenant `.run()`', () => {
it('should return a function ', async () => {
const tenant = await Tenant.create(defaultTenantId);
expect(typeof tenant.run).toBe('function');
});
});

View file

@ -92,19 +92,19 @@ export default class Tenant implements TenantContext {
koaSpaProxy(mountedApps, UserApps.Console, 5002, UserApps.Console) koaSpaProxy(mountedApps, UserApps.Console, 5002, UserApps.Console)
) )
); );
} else {
// Mount demo app
app.use(
mount(
'/' + UserApps.DemoApp,
compose([
koaCheckDemoApp(this.queries),
koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp),
])
)
);
} }
// Mount demo app
app.use(
mount(
'/' + UserApps.DemoApp,
compose([
koaCheckDemoApp(this.queries),
koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp),
])
)
);
// Mount UI // Mount UI
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)])); app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)]));

View file

@ -1 +0,0 @@
export const defaultTenant = 'default';

View file

@ -32,5 +32,4 @@ export class TenantPool {
export const tenantPool = new TenantPool(); export const tenantPool = new TenantPool();
export * from './consts.js';
export * from './utils.js'; export * from './utils.js';

View file

@ -1,5 +1,7 @@
import { defaultTenantId } from '@logto/schemas';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
export const mockEnvSet = new EnvSet(EnvSet.values.endpoint, EnvSet.values.dbUrl); export const mockEnvSet = new EnvSet(defaultTenantId, EnvSet.values.dbUrl);
await mockEnvSet.load(); await mockEnvSet.load();

View file

@ -0,0 +1,95 @@
import { adminTenantId, defaultTenantId } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import GlobalValues from '#src/env-set/GlobalValues.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
await mockEsmWithActual('#src/env-set/index.js', () => ({
EnvSet: {
get values() {
return new GlobalValues();
},
},
}));
const { getTenantId } = await import('./tenant.js');
describe('getTenantId()', () => {
const backupEnv = process.env;
afterEach(() => {
process.env = backupEnv;
});
it('should resolve development tenant ID when needed', async () => {
process.env = {
...backupEnv,
NODE_ENV: 'test',
DEVELOPMENT_TENANT_ID: 'foo',
};
expect(getTenantId(new URL('https://some.random.url'))).toBe('foo');
process.env = {
...backupEnv,
NODE_ENV: 'production',
INTEGRATION_TEST: 'true',
DEVELOPMENT_TENANT_ID: 'bar',
};
expect(getTenantId(new URL('https://some.random.url'))).toBe('bar');
});
it('should resolve proper tenant ID for similar localhost endpoints', async () => {
expect(getTenantId(new URL('http://localhost:3002/some/path////'))).toBe(adminTenantId);
expect(getTenantId(new URL('http://localhost:30021/some/path'))).toBe(defaultTenantId);
expect(getTenantId(new URL('http://localhostt:30021/some/path'))).toBe(defaultTenantId);
expect(getTenantId(new URL('https://localhost:3002'))).toBe(defaultTenantId);
});
it('should resolve proper tenant ID for similar domain endpoints', async () => {
process.env = {
...backupEnv,
NODE_ENV: 'production',
ENDPOINT: 'https://foo.*.logto.mock/app',
};
expect(getTenantId(new URL('https://foo.foo.logto.mock/app///asdasd'))).toBe('foo');
expect(getTenantId(new URL('https://foo.*.logto.mock/app'))).toBe(undefined);
expect(getTenantId(new URL('https://foo.foo.logto.mockk/app///asdasd'))).toBe(undefined);
expect(getTenantId(new URL('https://foo.foo.logto.mock/appp'))).toBe(undefined);
expect(getTenantId(new URL('https://foo.foo.logto.mock:1/app/'))).toBe(undefined);
expect(getTenantId(new URL('http://foo.foo.logto.mock/app'))).toBe(undefined);
expect(getTenantId(new URL('https://user.foo.bar.logto.mock/app'))).toBe(undefined);
expect(getTenantId(new URL('https://foo.bar.bar.logto.mock/app'))).toBe(undefined);
});
it('should resolve proper tenant ID if admin localhost is disabled', async () => {
process.env = {
...backupEnv,
NODE_ENV: 'production',
PORT: '5000',
ENDPOINT: 'https://user.*.logto.mock/app',
ADMIN_ENDPOINT: 'https://admin.logto.mock/app',
ADMIN_DISABLE_LOCALHOST: '1',
};
expect(getTenantId(new URL('http://localhost:5000/app///asdasd'))).toBe(defaultTenantId);
expect(getTenantId(new URL('http://localhost:3002/app///asdasd'))).toBe(undefined);
expect(getTenantId(new URL('https://user.foo.logto.mock/app'))).toBe('foo');
expect(getTenantId(new URL('https://user.admin.logto.mock/app//'))).toBe(undefined); // Admin endpoint is explicitly set
expect(getTenantId(new URL('https://admin.logto.mock/app'))).toBe(adminTenantId);
process.env = {
...backupEnv,
NODE_ENV: 'production',
PORT: '5000',
ENDPOINT: 'https://user.*.logto.mock/app',
ADMIN_DISABLE_LOCALHOST: '1',
};
expect(getTenantId(new URL('https://user.admin.logto.mock/app//'))).toBe('admin');
});
});

View file

@ -1,6 +1,18 @@
import { adminTenantId, defaultTenantId } from '@logto/schemas'; import { adminTenantId, defaultTenantId } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
const normalizePathname = (pathname: string) =>
pathname + conditionalString(!pathname.endsWith('/') && '/');
const isEndpointOf = (current: URL, endpoint: URL) => {
// Make sure current pathname fragments start with endpoint's
return (
current.origin === endpoint.origin &&
normalizePathname(current.pathname).startsWith(normalizePathname(endpoint.pathname))
);
};
export const getTenantId = (url: URL) => { export const getTenantId = (url: URL) => {
const { const {
@ -16,18 +28,25 @@ export const getTenantId = (url: URL) => {
return developmentTenantId; return developmentTenantId;
} }
const urlString = url.toString(); if (adminUrlSet.deduplicated().some((endpoint) => isEndpointOf(url, endpoint))) {
if (adminUrlSet.deduplicated().some((value) => urlString.startsWith(value))) {
return adminTenantId; return adminTenantId;
} }
if ( if (
!isDomainBasedMultiTenancy || !isDomainBasedMultiTenancy ||
(!urlSet.isLocalhostDisabled && urlString.startsWith(urlSet.localhostUrl)) (!urlSet.isLocalhostDisabled && isEndpointOf(url, urlSet.localhostUrl))
) { ) {
return defaultTenantId; return defaultTenantId;
} }
return new RegExp(urlSet.endpoint.replace('*', '([^.]*)')).exec(urlString)?.[1]; const toMatch = urlSet.endpoint.hostname.replace('*', '([^.]*)');
const matchedId = new RegExp(toMatch).exec(url.hostname)?.[1];
if (!matchedId || matchedId === '*') {
return;
}
if (isEndpointOf(url, getTenantEndpoint(matchedId, EnvSet.values))) {
return matchedId;
}
}; };