mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
Merge pull request #3100 from logto-io/gao-add-tests
test(core): add tests
This commit is contained in:
commit
ff485db30d
25 changed files with 533 additions and 118 deletions
|
@ -5,7 +5,7 @@
|
|||
import { createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
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.ENDPOINT = 'https://logto.test';
|
||||
|
@ -29,16 +29,6 @@ mockEsmDefault('#src/env-set/oidc.js', () => () => ({
|
|||
}));
|
||||
/* 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
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
mockEsm('koa-logger', () => ({ default: () => (_, next) => next() }));
|
||||
|
|
|
@ -13,7 +13,7 @@ const logListening = (type: 'core' | 'admin' = 'core') => {
|
|||
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
|
||||
|
||||
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()}`)));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -5,10 +5,6 @@ import UrlSet from './UrlSet.js';
|
|||
import { isTrue } from './parameters.js';
|
||||
import { throwErrorWithDsnMessage } from './throw-errors.js';
|
||||
|
||||
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
|
||||
|
||||
type MultiTenancyMode = 'domain' | 'env';
|
||||
|
||||
export default class GlobalValues {
|
||||
public readonly isProduction = getEnv('NODE_ENV') === 'production';
|
||||
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 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
|
||||
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 developmentUserId = getEnv('DEVELOPMENT_USER_ID');
|
||||
public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER'));
|
||||
|
@ -35,7 +31,7 @@ export default class GlobalValues {
|
|||
return this.databaseUrl;
|
||||
}
|
||||
|
||||
public get endpoint(): string {
|
||||
public get endpoint(): URL {
|
||||
return this.urlSet.endpoint;
|
||||
}
|
||||
}
|
||||
|
|
81
packages/core/src/env-set/UrlSet.test.ts
Normal file
81
packages/core/src/env-set/UrlSet.test.ts
Normal 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.');
|
||||
});
|
||||
});
|
|
@ -2,8 +2,6 @@ import { deduplicate, getEnv, trySafe } from '@silverhand/essentials';
|
|||
|
||||
import { isTrue } from './parameters.js';
|
||||
|
||||
const localhostDisabledMessage = 'Localhost has been disabled in this URL Set.';
|
||||
|
||||
export default class UrlSet {
|
||||
readonly #port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort);
|
||||
readonly #endpoint = getEnv(this.envPrefix + 'ENDPOINT');
|
||||
|
@ -16,27 +14,35 @@ export default class UrlSet {
|
|||
protected readonly envPrefix: string = ''
|
||||
) {}
|
||||
|
||||
public deduplicated(): string[] {
|
||||
public deduplicated(): URL[] {
|
||||
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'
|
||||
)
|
||||
);
|
||||
).map((value) => new URL(value));
|
||||
}
|
||||
|
||||
public get port() {
|
||||
public get port(): number {
|
||||
if (this.isLocalhostDisabled) {
|
||||
throw new Error(localhostDisabledMessage);
|
||||
throw new Error('Localhost has been disabled in this URL Set.');
|
||||
}
|
||||
|
||||
return this.#port;
|
||||
}
|
||||
|
||||
public get localhostUrl() {
|
||||
return `${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${this.port}`;
|
||||
public get localhostUrl(): URL {
|
||||
return new URL(`${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${this.port}`);
|
||||
}
|
||||
|
||||
public get endpoint() {
|
||||
return this.#endpoint || this.localhostUrl;
|
||||
public get endpoint(): URL {
|
||||
if (!this.#endpoint) {
|
||||
if (this.isLocalhostDisabled) {
|
||||
throw new Error('No available endpoint in this URL Set.');
|
||||
}
|
||||
|
||||
return this.localhostUrl;
|
||||
}
|
||||
|
||||
return new URL(this.#endpoint);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { adminTenantId } from '@logto/schemas';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import type { QueryClient } from '@withtyped/server';
|
||||
import type { DatabasePool } from 'slonik';
|
||||
|
@ -14,6 +12,7 @@ import createPool from './create-pool.js';
|
|||
import createQueryClient from './create-query-client.js';
|
||||
import loadOidcValues from './oidc.js';
|
||||
import { throwNotLoadedError } from './throw-errors.js';
|
||||
import { getTenantEndpoint } from './utils.js';
|
||||
|
||||
/** Apps (also paths) for user tenants. */
|
||||
export enum UserApps {
|
||||
|
@ -29,21 +28,6 @@ export enum AdminApps {
|
|||
Welcome = 'welcome',
|
||||
}
|
||||
|
||||
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 {
|
||||
static values = new GlobalValues();
|
||||
|
||||
|
@ -109,7 +93,9 @@ export class EnvSet {
|
|||
const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export { getTenantEndpoint } from './utils.js';
|
||||
|
|
25
packages/core/src/env-set/utils.ts
Normal file
25
packages/core/src/env-set/utils.ts
Normal 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;
|
||||
};
|
|
@ -11,8 +11,9 @@ import { convertToIdentifiers } from '@logto/shared';
|
|||
import type { JWK } from 'jose';
|
||||
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 { appendPath } from '#src/utils/url.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(LogtoConfigs);
|
||||
|
||||
|
@ -46,9 +47,12 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
|
|||
return {
|
||||
keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))),
|
||||
issuer: [
|
||||
(isDomainBasedMultiTenancy
|
||||
? urlSet.endpoint.replace('*', adminTenantId)
|
||||
: adminUrlSet.endpoint) + '/oidc',
|
||||
appendPath(
|
||||
isDomainBasedMultiTenancy
|
||||
? getTenantEndpoint(adminTenantId, EnvSet.values)
|
||||
: adminUrlSet.endpoint,
|
||||
'/oidc'
|
||||
).toString(),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
|
@ -3,7 +3,8 @@ import { ConnectorType } from '@logto/connector-kit';
|
|||
import { adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
|
||||
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 type { AnonymousRouter, RouterInitArgs } from './types.js';
|
||||
|
@ -18,8 +19,12 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
|
|||
|
||||
if (id === adminTenantId) {
|
||||
router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => {
|
||||
if (!ctx.params.tenantId) {
|
||||
throw new RequestError('request.invalid_input');
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
user: EnvSet.values.urlSet.endpoint.replace('*', ctx.params.tenantId ?? '*'),
|
||||
user: getTenantEndpoint(ctx.params.tenantId, EnvSet.values),
|
||||
};
|
||||
|
||||
return next();
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
import { adminTenantId, defaultTenantId } from '@logto/schemas';
|
||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { emptyMiddleware } from '#src/utils/test-utils.js';
|
||||
|
||||
import { defaultTenant } from './consts.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm, mockEsmDefault } = createMockUtils(jest);
|
||||
|
||||
const middlewareList = [
|
||||
'error-handler',
|
||||
'i18next',
|
||||
'audit-log',
|
||||
'oidc-error-handler',
|
||||
'slonik-error-handler',
|
||||
'spa-proxy',
|
||||
].map((name) => {
|
||||
const buildMockMiddleware = (name: string) => {
|
||||
const mock = jest.fn(() => emptyMiddleware);
|
||||
mockEsm(`#src/middleware/koa-${name}.js`, () => ({
|
||||
default: mock,
|
||||
|
@ -23,7 +15,27 @@ const middlewareList = [
|
|||
}));
|
||||
|
||||
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', () => ({
|
||||
getTenantDatabaseDsn: async () => 'postgres://mock.db.url',
|
||||
|
@ -35,11 +47,38 @@ mockEsmDefault('#src/oidc/init.js', () => () => createMockProvider());
|
|||
const Tenant = await pickDefault(import('./Tenant.js'));
|
||||
|
||||
describe('Tenant', () => {
|
||||
it('should call middleware factories', async () => {
|
||||
await Tenant.create(defaultTenant);
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
for (const middleware of middlewareList) {
|
||||
expect(middleware).toBeCalled();
|
||||
it('should call middleware factories for user tenants', async () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -92,19 +92,19 @@ export default class Tenant implements TenantContext {
|
|||
koaSpaProxy(mountedApps, AdminApps.Console, 5002, AdminApps.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
|
||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)]));
|
||||
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export const defaultTenant = 'default';
|
|
@ -32,5 +32,4 @@ export class TenantPool {
|
|||
|
||||
export const tenantPool = new TenantPool();
|
||||
|
||||
export * from './consts.js';
|
||||
export * from './utils.js';
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { defaultTenantId } from '@logto/schemas';
|
||||
|
||||
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();
|
||||
|
|
95
packages/core/src/utils/tenant.test.ts
Normal file
95
packages/core/src/utils/tenant.test.ts
Normal 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');
|
||||
});
|
||||
});
|
|
@ -1,6 +1,18 @@
|
|||
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) => {
|
||||
const {
|
||||
|
@ -16,18 +28,25 @@ export const getTenantId = (url: URL) => {
|
|||
return developmentTenantId;
|
||||
}
|
||||
|
||||
const urlString = url.toString();
|
||||
|
||||
if (adminUrlSet.deduplicated().some((value) => urlString.startsWith(value))) {
|
||||
if (adminUrlSet.deduplicated().some((endpoint) => isEndpointOf(url, endpoint))) {
|
||||
return adminTenantId;
|
||||
}
|
||||
|
||||
if (
|
||||
!isDomainBasedMultiTenancy ||
|
||||
(!urlSet.isLocalhostDisabled && urlString.startsWith(urlSet.localhostUrl))
|
||||
(!urlSet.isLocalhostDisabled && isEndpointOf(url, urlSet.localhostUrl))
|
||||
) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,11 +1,21 @@
|
|||
import { got } from 'got';
|
||||
|
||||
import { logtoUrl } from '#src/constants.js';
|
||||
import { logtoConsoleUrl, logtoUrl } from '#src/constants.js';
|
||||
|
||||
export default got.extend({ prefixUrl: new URL('/api', logtoUrl) });
|
||||
const api = got.extend({ prefixUrl: new URL('/api', logtoUrl) });
|
||||
|
||||
export const authedAdminApi = got.extend({
|
||||
prefixUrl: new URL('/api', logtoUrl),
|
||||
export default api;
|
||||
|
||||
// TODO: @gao rename
|
||||
export const authedAdminApi = api.extend({
|
||||
headers: {
|
||||
'development-user-id': 'integration-test-admin-user',
|
||||
},
|
||||
});
|
||||
|
||||
export const adminTenantApi = got.extend({ prefixUrl: new URL('/api', logtoConsoleUrl) });
|
||||
|
||||
export const authedAdminTenantApi = adminTenantApi.extend({
|
||||
headers: {
|
||||
'development-user-id': 'integration-test-admin-user',
|
||||
},
|
||||
|
|
|
@ -4,6 +4,7 @@ import type {
|
|||
Profile,
|
||||
RequestVerificationCodePayload,
|
||||
} from '@logto/schemas';
|
||||
import type { Got } from 'got';
|
||||
|
||||
import api from './api.js';
|
||||
|
||||
|
@ -11,13 +12,13 @@ export type RedirectResponse = {
|
|||
redirectTo: string;
|
||||
};
|
||||
|
||||
export type interactionPayload = {
|
||||
export type InteractionPayload = {
|
||||
event: InteractionEvent;
|
||||
identifier?: IdentifierPayload;
|
||||
profile?: Profile;
|
||||
};
|
||||
|
||||
export const putInteraction = async (cookie: string, payload: interactionPayload) =>
|
||||
export const putInteraction = async (cookie: string, payload: InteractionPayload) =>
|
||||
api
|
||||
.put('interaction', {
|
||||
headers: { cookie },
|
||||
|
@ -66,7 +67,7 @@ export const deleteInteractionProfile = async (cookie: string) =>
|
|||
})
|
||||
.json();
|
||||
|
||||
export const submitInteraction = async (cookie: string) =>
|
||||
export const submitInteraction = async (api: Got, cookie: string) =>
|
||||
api
|
||||
.post('interaction/submit', { headers: { cookie }, followRedirect: false })
|
||||
.json<RedirectResponse>();
|
||||
|
@ -97,7 +98,7 @@ export const createSocialAuthorizationUri = async (
|
|||
followRedirect: false,
|
||||
});
|
||||
|
||||
export const consent = async (cookie: string) =>
|
||||
export const consent = async (api: Got, cookie: string) =>
|
||||
api
|
||||
.post('interaction/consent', {
|
||||
headers: {
|
||||
|
|
|
@ -3,6 +3,7 @@ import LogtoClient from '@logto/node';
|
|||
import { demoAppApplicationId } from '@logto/schemas';
|
||||
import type { Nullable, Optional } from '@silverhand/essentials';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import type { Got } from 'got';
|
||||
import { got } from 'got';
|
||||
|
||||
import { consent, submitInteraction } from '#src/api/index.js';
|
||||
|
@ -18,22 +19,24 @@ export const defaultConfig = {
|
|||
export default class MockClient {
|
||||
public rawCookies: string[] = [];
|
||||
|
||||
protected readonly config: LogtoConfig;
|
||||
|
||||
private navigateUrl?: string;
|
||||
private readonly storage: MemoryStorage;
|
||||
private readonly logto: LogtoClient;
|
||||
private readonly api: Got;
|
||||
|
||||
constructor(config?: Partial<LogtoConfig>) {
|
||||
this.storage = new MemoryStorage();
|
||||
this.config = { ...defaultConfig, ...config };
|
||||
this.api = got.extend({ prefixUrl: this.config.endpoint + '/api' });
|
||||
|
||||
this.logto = new LogtoClient(
|
||||
{ ...defaultConfig, ...config },
|
||||
{
|
||||
navigate: (url: string) => {
|
||||
this.navigateUrl = url;
|
||||
},
|
||||
storage: this.storage,
|
||||
}
|
||||
);
|
||||
this.logto = new LogtoClient(this.config, {
|
||||
navigate: (url: string) => {
|
||||
this.navigateUrl = url;
|
||||
},
|
||||
storage: this.storage,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Rename to sessionCookies or something accurate
|
||||
|
@ -62,7 +65,7 @@ export default class MockClient {
|
|||
|
||||
assert(this.navigateUrl, new Error('Unable to navigate to sign in uri'));
|
||||
assert(
|
||||
this.navigateUrl.startsWith(`${logtoUrl}/oidc/auth`),
|
||||
this.navigateUrl.startsWith(`${this.config.endpoint}/oidc/auth`),
|
||||
new Error('Unable to navigate to sign in uri')
|
||||
);
|
||||
|
||||
|
@ -84,7 +87,10 @@ export default class MockClient {
|
|||
|
||||
public async processSession(redirectTo: string) {
|
||||
// Note: should redirect to OIDC auth endpoint
|
||||
assert(redirectTo.startsWith(`${logtoUrl}/oidc/auth`), new Error('SignIn or Register failed'));
|
||||
assert(
|
||||
redirectTo.startsWith(`${this.config.endpoint}/oidc/auth`),
|
||||
new Error('SignIn or Register failed')
|
||||
);
|
||||
|
||||
const authResponse = await got.get(redirectTo, {
|
||||
headers: {
|
||||
|
@ -149,7 +155,7 @@ export default class MockClient {
|
|||
}
|
||||
|
||||
public async submitInteraction() {
|
||||
return submitInteraction(this.interactionCookie);
|
||||
return submitInteraction(this.api, this.interactionCookie);
|
||||
}
|
||||
|
||||
private readonly consent = async () => {
|
||||
|
@ -157,10 +163,10 @@ export default class MockClient {
|
|||
assert(this.interactionCookie, new Error('Session not found'));
|
||||
assert(this.interactionCookie.includes('_session.sig'), new Error('Session not found'));
|
||||
|
||||
const { redirectTo } = await consent(this.interactionCookie);
|
||||
const { redirectTo } = await consent(this.api, this.interactionCookie);
|
||||
|
||||
// Note: should redirect to oidc auth endpoint
|
||||
assert(redirectTo.startsWith(`${logtoUrl}/oidc/auth`), new Error('Consent failed'));
|
||||
assert(redirectTo.startsWith(`${this.config.endpoint}/oidc/auth`), new Error('Consent failed'));
|
||||
|
||||
const authCodeResponse = await got.get(redirectTo, {
|
||||
headers: {
|
||||
|
|
97
packages/integration-tests/src/helpers/admin-tenant.ts
Normal file
97
packages/integration-tests/src/helpers/admin-tenant.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
// To refactor: should combine into other similar utils
|
||||
// Since they are just different in URLs
|
||||
|
||||
import type { LogtoConfig } from '@logto/node';
|
||||
import type { Role, User } from '@logto/schemas';
|
||||
import {
|
||||
PredefinedScope,
|
||||
adminTenantId,
|
||||
defaultTenantId,
|
||||
getManagementApiResourceIndicator,
|
||||
adminConsoleApplicationId,
|
||||
InteractionEvent,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { authedAdminTenantApi as api, adminTenantApi } from '#src/api/api.js';
|
||||
import type { InteractionPayload } from '#src/api/interaction.js';
|
||||
import { adminConsoleRedirectUri, logtoConsoleUrl } from '#src/constants.js';
|
||||
import { initClient } from '#src/helpers/client.js';
|
||||
import { generatePassword, generateUsername } from '#src/utils.js';
|
||||
|
||||
export const resourceDefault = getManagementApiResourceIndicator(defaultTenantId);
|
||||
export const resourceMe = getManagementApiResourceIndicator(adminTenantId, 'me');
|
||||
|
||||
export const createResponseWithCode = (statusCode: number) => ({
|
||||
response: { statusCode },
|
||||
});
|
||||
|
||||
export const createUserWithAllRoles = async () => {
|
||||
const username = generateUsername();
|
||||
const password = generatePassword();
|
||||
const user = await api
|
||||
.post('users', {
|
||||
json: { username, password },
|
||||
})
|
||||
.json<User>();
|
||||
|
||||
// Should have roles for default tenant Management API and admin tenant Me API
|
||||
const roles = await api.get('roles').json<Role[]>();
|
||||
await Promise.all(
|
||||
roles.map(async ({ id }) =>
|
||||
api.post(`roles/${id}/users`, {
|
||||
json: { userIds: [user.id] },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return [user, { username, password }] as const;
|
||||
};
|
||||
|
||||
export const deleteUser = async (id: string) => {
|
||||
await api.delete(`users/${id}`);
|
||||
};
|
||||
|
||||
export const putInteraction = async (cookie: string, payload: InteractionPayload) =>
|
||||
adminTenantApi
|
||||
.put('interaction', {
|
||||
headers: { cookie },
|
||||
json: payload,
|
||||
followRedirect: false,
|
||||
})
|
||||
.json();
|
||||
|
||||
export const initClientAndSignIn = async (
|
||||
username: string,
|
||||
password: string,
|
||||
config?: Partial<LogtoConfig>
|
||||
) => {
|
||||
const client = await initClient(
|
||||
{
|
||||
endpoint: logtoConsoleUrl,
|
||||
appId: adminConsoleApplicationId,
|
||||
...config,
|
||||
},
|
||||
adminConsoleRedirectUri
|
||||
);
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await client.processSession(redirectTo);
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export const createUserAndSignInWithClient = async () => {
|
||||
const [{ id }, { username, password }] = await createUserWithAllRoles();
|
||||
const client = await initClientAndSignIn(username, password, {
|
||||
resources: [resourceDefault, resourceMe],
|
||||
scopes: [PredefinedScope.All],
|
||||
});
|
||||
|
||||
return { id, client };
|
||||
};
|
|
@ -1,10 +1,11 @@
|
|||
import type { LogtoConfig } from '@logto/node';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
|
||||
import MockClient from '#src/client/index.js';
|
||||
|
||||
export const initClient = async () => {
|
||||
const client = new MockClient();
|
||||
await client.initSession();
|
||||
export const initClient = async (config?: Partial<LogtoConfig>, redirectUri?: string) => {
|
||||
const client = new MockClient(config);
|
||||
await client.initSession(redirectUri);
|
||||
assert(client.interactionCookie, new Error('Session not found'));
|
||||
|
||||
return client;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { api } from '#src/api/index.js';
|
||||
|
||||
describe('Health check', () => {
|
||||
describe('health check', () => {
|
||||
it('should have a health state', async () => {
|
||||
expect(await api.get('status')).toHaveProperty('statusCode', 204);
|
||||
});
|
||||
|
|
57
packages/integration-tests/src/tests/api/me.test.ts
Normal file
57
packages/integration-tests/src/tests/api/me.test.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { got } from 'got';
|
||||
|
||||
import { logtoConsoleUrl, logtoUrl } from '#src/constants.js';
|
||||
import {
|
||||
createResponseWithCode,
|
||||
createUserAndSignInWithClient,
|
||||
deleteUser,
|
||||
resourceDefault,
|
||||
resourceMe,
|
||||
} from '#src/helpers/admin-tenant.js';
|
||||
|
||||
describe('me', () => {
|
||||
it('should only be available in admin tenant', async () => {
|
||||
await expect(got.get(new URL('/me/custom-data', logtoConsoleUrl))).rejects.toMatchObject(
|
||||
createResponseWithCode(401)
|
||||
);
|
||||
|
||||
// Redirect to UI
|
||||
const response = await got.get(new URL('/me/custom-data', logtoUrl));
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']?.startsWith('text/html;')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should only recognize the access token with correct resource and scope', async () => {
|
||||
const { id, client } = await createUserAndSignInWithClient();
|
||||
|
||||
await expect(
|
||||
got.get(logtoConsoleUrl + '/me/custom-data', {
|
||||
headers: { authorization: `Bearer ${await client.getAccessToken(resourceDefault)}` },
|
||||
})
|
||||
).rejects.toMatchObject(createResponseWithCode(401));
|
||||
|
||||
await expect(
|
||||
got.get(logtoConsoleUrl + '/me/custom-data', {
|
||||
headers: { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` },
|
||||
})
|
||||
).resolves.toHaveProperty('statusCode', 200);
|
||||
|
||||
await deleteUser(id);
|
||||
});
|
||||
|
||||
it('should be able to update custom data', async () => {
|
||||
const { id, client } = await createUserAndSignInWithClient();
|
||||
const headers = { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` };
|
||||
|
||||
const data = await got
|
||||
.get(logtoConsoleUrl + '/me/custom-data', { headers })
|
||||
.json<Record<string, unknown>>();
|
||||
const newData = await got
|
||||
.patch(logtoConsoleUrl + '/me/custom-data', { headers, json: { foo: 'bar' } })
|
||||
.json();
|
||||
|
||||
expect({ ...data, foo: 'bar' }).toStrictEqual(newData);
|
||||
|
||||
await deleteUser(id);
|
||||
});
|
||||
});
|
|
@ -58,7 +58,6 @@
|
|||
"@types/react": "^18.0.20",
|
||||
"eslint": "^8.21.0",
|
||||
"jest": "^29.0.3",
|
||||
"jest-matcher-specific-error": "^1.0.0",
|
||||
"lint-staged": "^13.0.0",
|
||||
"postcss": "^8.4.6",
|
||||
"prettier": "^2.8.1",
|
||||
|
|
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
|
@ -719,7 +719,6 @@ importers:
|
|||
color: ^4.2.3
|
||||
eslint: ^8.21.0
|
||||
jest: ^29.0.3
|
||||
jest-matcher-specific-error: ^1.0.0
|
||||
lint-staged: ^13.0.0
|
||||
nanoid: ^4.0.0
|
||||
postcss: ^8.4.6
|
||||
|
@ -746,7 +745,6 @@ importers:
|
|||
'@types/react': 18.0.26
|
||||
eslint: 8.21.0
|
||||
jest: 29.3.1_@types+node@18.11.18
|
||||
jest-matcher-specific-error: 1.0.0
|
||||
lint-staged: 13.0.0
|
||||
postcss: 8.4.18
|
||||
prettier: 2.8.1
|
||||
|
|
Loading…
Add table
Reference in a new issue