0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

test: use native ESM (#2621)

This commit is contained in:
Gao Sun 2022-12-12 13:43:23 +08:00 committed by GitHub
parent d65e25a10e
commit 25f0a2e158
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
105 changed files with 1691 additions and 1520 deletions

1
.github/CODEOWNERS vendored
View file

@ -1,2 +1,3 @@
/packages/schemas/tables @simeng-li @wangsijie
/packages/core/src/routes/session @simeng-li @wangsijie
/.changeset @gao-sun

View file

@ -20,20 +20,22 @@ jobs:
- name: Setup Node and pnpm
uses: silverhand-io/actions-node-pnpm-run-steps@v2
with:
node-version: 18
- name: Build
run: pnpm ci:build
main-lint:
# avoid out of memory issue since macOS has bigger memory
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
runs-on: ubuntu-latest-4-cores
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node and pnpm
uses: silverhand-io/actions-node-pnpm-run-steps@v2
with:
node-version: 18
- name: Prepack
run: pnpm prepack

View file

@ -0,0 +1,10 @@
const config = {
coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/'],
coverageReporters: ['text-summary', 'lcov'],
roots: ['./lib'],
moduleNameMapper: {
'^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js',
},
};
export default config;

View file

@ -1,17 +0,0 @@
import type { Config } from '@silverhand/jest-config';
import { merge } from '@silverhand/jest-config';
const config: Config.InitialOptions = {
...merge({
setupFilesAfterEnv: ['./jest.setup.ts'],
roots: ['./src'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^(chalk|inquirer|ora)$': '<rootDir>/../shared/src/utils/module-proxy.ts',
},
}),
// Will update common config soon
transformIgnorePatterns: ['node_modules/(?!(.*(nanoid|jose|ky|@logto))/)'],
};
export default config;

View file

@ -1,19 +0,0 @@
/**
* Mocking `import.meta.url` and `got` here since they inevitably needs native ESM, but jest is sticking with CJS.
* Will figure out a way to run tests in native ESM mode.
*/
jest.mock('./src/commands/database/alteration/meta-url.js', () => ({
metaUrl: 'file:///',
}));
jest.mock('./src/meta-url.js', () => ({
metaUrl: 'file:///',
}));
jest.mock('got', () => ({
got: {},
}));
// Make lint-staged happy
export {};

View file

@ -25,13 +25,14 @@
"precommit": "lint-staged",
"prepare:package-json": "node -p \"'export const packageJson = ' + JSON.stringify(require('./package.json'), undefined, 2) + ';'\" > src/package-json.ts",
"build": "rimraf lib && pnpm prepare:package-json && tsc -p tsconfig.build.json",
"build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap",
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental",
"start": "node .",
"start:dev": "ts-node --files src/index.ts",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"test": "jest",
"test:ci": "jest",
"test": "pnpm build:test && NODE_OPTIONS=--experimental-vm-modules jest",
"test:ci": "pnpm run test",
"prepack": "pnpm build"
},
"engines": {
@ -52,7 +53,7 @@
"hpagent": "^1.2.0",
"inquirer": "^8.2.2",
"nanoid": "^3.3.4",
"ora": "^5.0.0",
"ora": "^6.1.2",
"p-retry": "^4.6.1",
"roarr": "^7.11.0",
"semver": "^7.3.8",
@ -65,13 +66,13 @@
},
"devDependencies": {
"@silverhand/eslint-config": "1.3.0",
"@silverhand/jest-config": "1.2.2",
"@silverhand/ts-config": "1.2.1",
"@types/fs-extra": "^9.0.13",
"@types/inquirer": "^8.2.1",
"@types/jest": "^29.1.2",
"@types/node": "^16.0.0",
"@types/semver": "^7.3.12",
"@types/sinon": "^10.0.13",
"@types/tar": "^6.1.2",
"@types/yargs": "^17.0.13",
"eslint": "^8.21.0",
@ -79,7 +80,7 @@
"lint-staged": "^13.0.0",
"prettier": "^2.7.1",
"rimraf": "^3.0.2",
"ts-node": "^10.9.1",
"sinon": "^15.0.0",
"typescript": "^4.7.4"
},
"eslintConfig": {

View file

@ -1,42 +1,45 @@
import { mockEsmWithActual } from '@logto/shared/esm';
import Sinon from 'sinon';
import { createMockPool } from 'slonik';
import * as queries from '../../../queries/logto-config.js';
import type { QueryType } from '../../../test-utilities.js';
import * as functions from './index.js';
import { chooseAlterationsByVersion } from './version.js';
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
const { jest } = import.meta;
const pool = createMockPool({
query: async (sql, values) => {
return mockQuery(sql, values);
},
query: jest.fn(),
});
const files = Object.freeze([
{ filename: '1.0.0-1663923770-a.js', path: '/alterations-js/1.0.0-1663923770-a.js' },
{ filename: '1.0.0-1663923771-b.js', path: '/alterations-js/1.0.0-1663923771-b.js' },
{ filename: '1.0.0-1663923772-c.js', path: '/alterations-js/1.0.0-1663923772-c.js' },
]);
await mockEsmWithActual('./utils.js', () => ({
getAlterationFiles: async () => files,
}));
const { getCurrentDatabaseAlterationTimestamp } = await mockEsmWithActual(
'../../../queries/logto-config.js',
() => ({
getCurrentDatabaseAlterationTimestamp: jest.fn(),
})
);
const { getUndeployedAlterations } = await import('./index.js');
describe('getUndeployedAlterations()', () => {
const files = Object.freeze([
{ filename: '1.0.0-1663923770-a.js', path: '/alterations-js/1.0.0-1663923770-a.js' },
{ filename: '1.0.0-1663923771-b.js', path: '/alterations-js/1.0.0-1663923771-b.js' },
{ filename: '1.0.0-1663923772-c.js', path: '/alterations-js/1.0.0-1663923772-c.js' },
]);
beforeEach(() => {
// `getAlterationFiles()` will ensure the order
jest.spyOn(functions, 'getAlterationFiles').mockResolvedValueOnce([...files]);
});
it('returns all files if database timestamp is 0', async () => {
jest.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp').mockResolvedValueOnce(0);
getCurrentDatabaseAlterationTimestamp.mockResolvedValue(0);
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual(files);
await expect(getUndeployedAlterations(pool)).resolves.toEqual(files);
});
it('returns files whose timestamp is greater then database timestamp', async () => {
jest
.spyOn(queries, 'getCurrentDatabaseAlterationTimestamp')
.mockResolvedValueOnce(1_663_923_770);
getCurrentDatabaseAlterationTimestamp.mockResolvedValue(1_663_923_770);
await expect(functions.getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
await expect(getUndeployedAlterations(pool)).resolves.toEqual([files[1], files[2]]);
});
});
@ -58,12 +61,19 @@ describe('chooseAlterationsByVersion()', () => {
'next1-1663923781-c.js',
].map((filename) => ({ filename, path: '/alterations/' + filename }))
);
const stub = Sinon.stub(global, 'process').value({ stdin: { isTTY: false } });
afterAll(() => {
stub.restore();
});
it('chooses nothing when input version is invalid', async () => {
await expect(chooseAlterationsByVersion(files, 'next1')).rejects.toThrow(
'Invalid Version: next1'
new TypeError('Invalid Version: next1')
);
await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow(
new TypeError('Invalid Version: ok')
);
await expect(chooseAlterationsByVersion([], 'ok')).rejects.toThrow('Invalid Version: ok');
});
it('chooses correct alteration files', async () => {

View file

@ -1,11 +1,6 @@
import { fileURLToPath } from 'node:url';
import path from 'path';
import type { AlterationScript } from '@logto/schemas/lib/types/alteration.js';
import { findPackage } from '@logto/shared';
import { conditionalString } from '@silverhand/essentials';
import chalk from 'chalk';
import fsExtra from 'fs-extra';
import type { DatabasePool } from 'slonik';
import type { CommandModule } from 'yargs';
@ -14,25 +9,11 @@ import {
getCurrentDatabaseAlterationTimestamp,
updateDatabaseTimestamp,
} from '../../../queries/logto-config.js';
import { getPathInModule, log } from '../../../utilities.js';
import { metaUrl } from './meta-url.js';
import { log } from '../../../utilities.js';
import type { AlterationFile } from './type.js';
import { getAlterationFiles, getTimestampFromFilename } from './utils.js';
import { chooseAlterationsByVersion } from './version.js';
const currentDirname = path.dirname(fileURLToPath(metaUrl));
const { copy, existsSync, remove, readdir } = fsExtra;
const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
const getTimestampFromFilename = (filename: string) => {
const match = alterationFilenameRegex.exec(filename);
if (!match?.[1]) {
throw new Error(`Can not get timestamp: ${filename}`);
}
return Number(match[1]);
};
const importAlterationScript = async (filePath: string): Promise<AlterationScript> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = await import(filePath);
@ -41,38 +22,6 @@ const importAlterationScript = async (filePath: string): Promise<AlterationScrip
return module.default as AlterationScript;
};
export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
const alterationDirectory = getPathInModule('@logto/schemas', 'alterations-js');
/**
* We copy all alteration scripts to the CLI package root directory,
* since they need a proper context that includes required dependencies (such as slonik) in `node_modules/`.
* While the original `@logto/schemas` may remove them in production.
*/
const packageDirectory = await findPackage(currentDirname);
const localAlterationDirectory = path.resolve(
packageDirectory ?? currentDirname,
'alteration-scripts'
);
if (!existsSync(alterationDirectory)) {
return [];
}
// We need to copy alteration files to execute in the CLI context to make `slonik` available
await remove(localAlterationDirectory);
await copy(alterationDirectory, localAlterationDirectory);
const directory = await readdir(localAlterationDirectory);
const files = directory.filter((file) => alterationFilenameRegex.test(file));
return files
.slice()
.sort((file1, file2) => getTimestampFromFilename(file1) - getTimestampFromFilename(file2))
.map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename }));
};
export const getLatestAlterationTimestamp = async () => {
const files = await getAlterationFiles();
const lastFile = files[files.length - 1];

View file

@ -0,0 +1,55 @@
import { fileURLToPath } from 'node:url';
import path from 'path';
import { findPackage } from '@logto/shared';
import fsExtra from 'fs-extra';
import { getPathInModule } from '../../../utilities.js';
import { metaUrl } from './meta-url.js';
import type { AlterationFile } from './type.js';
const currentDirname = path.dirname(fileURLToPath(metaUrl));
const { copy, existsSync, remove, readdir } = fsExtra;
const alterationFilenameRegex = /-(\d+)-?.*\.js$/;
export const getTimestampFromFilename = (filename: string) => {
const match = alterationFilenameRegex.exec(filename);
if (!match?.[1]) {
throw new Error(`Can not get timestamp: ${filename}`);
}
return Number(match[1]);
};
export const getAlterationFiles = async (): Promise<AlterationFile[]> => {
const alterationDirectory = getPathInModule('@logto/schemas', 'alterations-js');
/**
* We copy all alteration scripts to the CLI package root directory,
* since they need a proper context that includes required dependencies (such as slonik) in `node_modules/`.
* While the original `@logto/schemas` may remove them in production.
*/
const packageDirectory = await findPackage(currentDirname);
const localAlterationDirectory = path.resolve(
packageDirectory ?? currentDirname,
'alteration-scripts'
);
if (!existsSync(alterationDirectory)) {
return [];
}
// We need to copy alteration files to execute in the CLI context to make `slonik` available
await remove(localAlterationDirectory);
await copy(alterationDirectory, localAlterationDirectory);
const directory = await readdir(localAlterationDirectory);
const files = directory.filter((file) => alterationFilenameRegex.test(file));
return files
.slice()
.sort((file1, file2) => getTimestampFromFilename(file1) - getTimestampFromFilename(file2))
.map((filename) => ({ path: path.join(localAlterationDirectory, filename), filename }));
};

View file

@ -42,6 +42,7 @@ export const chooseAlterationsByVersion = async (
.filter((version, index, self) => index === self.findIndex((another) => eq(version, another)))
.slice()
.sort((i, j) => compare(j, i));
const initialSemVersion = conditional(
initialVersion && initialVersion !== latestTag && new SemVer(initialVersion)
);

View file

@ -6,6 +6,7 @@ import type { QueryType } from '../test-utilities.js';
import { expectSqlAssert } from '../test-utilities.js';
import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
const pool = createMockPool({

View file

@ -11,6 +11,7 @@ import type { Progress } from 'got';
import { got } from 'got';
import { HttpsProxyAgent } from 'hpagent';
import inquirer from 'inquirer';
import type { Options } from 'ora';
import ora from 'ora';
import { z } from 'zod';
@ -92,7 +93,7 @@ export const getPathInModule = (moduleName: string, relativePath = '/') =>
export const oraPromise = async <T>(
promise: PromiseLike<T>,
options?: ora.Options,
options?: Options,
exitOnError = false
) => {
const spinner = ora(options).start();

View file

@ -3,5 +3,6 @@
"compilerOptions": {
"isolatedModules": false,
"allowJs": true
}
},
"include": ["src"]
}

View file

@ -62,7 +62,7 @@
"lint-staged": "^13.0.0",
"lodash.get": "^4.4.2",
"lodash.kebabcase": "^4.1.1",
"nanoid": "^3.1.23",
"nanoid": "^3.3.4",
"parcel": "2.8.0",
"postcss": "^8.4.6",
"postcss-modules": "^4.3.0",

View file

@ -0,0 +1,14 @@
/** @type {import('jest').Config} */
const config = {
coveragePathIgnorePatterns: ['/node_modules/', '/src/__mocks__/'],
coverageReporters: ['text-summary', 'lcov'],
testPathIgnorePatterns: ['/node_modules/', '/build/routes/session/'], // `routes/session` is freezed
setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.js'],
roots: ['./build'],
moduleNameMapper: {
'^#src/(.*)\\.js(x)?$': '<rootDir>/build/$1',
'^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js',
},
};
export default config;

View file

@ -1,18 +0,0 @@
import type { Config } from '@silverhand/jest-config';
import { merge } from '@silverhand/jest-config';
const config: Config.InitialOptions = {
...merge({
testPathIgnorePatterns: ['/core/connectors/'],
setupFilesAfterEnv: ['jest-matcher-specific-error', './jest.setup.ts'],
moduleNameMapper: {
'^#src/(.*)\\.js(x)?$': '<rootDir>/src/$1',
'^(\\.{1,2}/.*)\\.js$': '$1',
'^(chalk|inquirer|ora)$': '<rootDir>/../shared/src/utils/module-proxy.ts',
},
}),
// Will update common config soon
transformIgnorePatterns: ['node_modules/(?!(.*(nanoid|jose|ky|@logto|got))/)'],
};
export default config;

View file

@ -0,0 +1,39 @@
/**
* Setup environment variables for unit test
*/
import { mockEsm } from '@logto/shared/esm';
import { createMockQueryResult, createMockPool } from 'slonik';
const { jest } = import.meta;
mockEsm('#src/env-set/index.js', () => ({
MountedApps: {
Api: 'api',
Oidc: 'oidc',
Console: 'console',
DemoApp: 'demo-app',
Welcome: 'welcome',
},
default: {
get values() {
return {
endpoint: 'https://logto.test',
adminConsoleUrl: 'https://logto.test/console',
};
},
get oidc() {
return {
issuer: 'https://logto.test/oidc',
};
},
get pool() {
return createMockPool({ query: async () => createMockQueryResult([]) });
},
load: jest.fn(),
},
}));
// Logger is not considered in all test cases
// eslint-disable-next-line unicorn/consistent-function-scoping
mockEsm('koa-logger', () => ({ default: () => (_, next) => next() }));

View file

@ -1,34 +0,0 @@
/**
* Setup environment variables for unit test
*/
import envSet from '#src/env-set/index.js';
jest.mock('#src/lib/logto-config.js');
jest.mock('#src/env-set/check-alteration-state.js');
// eslint-disable-next-line unicorn/prefer-top-level-await
(async () => {
await envSet.load();
})();
/**
* Mocking `import.meta.url` and `got` here since they inevitably needs native ESM, but jest is sticking with CJS.
* Will figure out a way to run tests in native ESM mode.
*/
jest.mock('./src/connectors/meta-url.js', () => ({
metaUrl: 'file:///',
}));
jest.mock('../cli/lib/meta-url.js', () => ({
metaUrl: 'file:///',
}));
jest.mock('../cli/lib/commands/database/alteration/meta-url.js', () => ({
metaUrl: 'file:///',
}));
jest.mock('got', () => ({
got: {},
}));

View file

@ -14,12 +14,14 @@
"precommit": "lint-staged",
"copyfiles": "copyfiles -u 1 src/**/*.md build",
"build": "rm -rf build/ && tsc -p tsconfig.build.json && pnpm run copyfiles",
"build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"dev": "rm -rf build/ && pnpm run copyfiles && nodemon",
"start": "NODE_ENV=production node build/index.js",
"test": "jest",
"test:ci": "jest --coverage --silent",
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "pnpm build:test && pnpm test:only build/",
"test:ci": "pnpm run test --coverage --silent",
"test:report": "codecov -F core"
},
"dependencies": {
@ -56,7 +58,7 @@
"koa-router": "^12.0.0",
"koa-send": "^5.0.1",
"lodash.pick": "^4.4.0",
"nanoid": "^3.1.23",
"nanoid": "^3.3.4",
"oidc-provider": "^7.13.0",
"p-retry": "^4.6.1",
"query-string": "^7.0.1",
@ -69,9 +71,7 @@
"zod": "^3.19.1"
},
"devDependencies": {
"@shopify/jest-koa-mocks": "^5.0.1",
"@silverhand/eslint-config": "1.3.0",
"@silverhand/jest-config": "1.2.2",
"@silverhand/ts-config": "1.2.1",
"@types/debug": "^4.1.7",
"@types/etag": "^1.8.1",
@ -87,6 +87,7 @@
"@types/lodash.pick": "^4.4.6",
"@types/node": "^16.0.0",
"@types/oidc-provider": "^7.12.0",
"@types/sinon": "^10.0.13",
"@types/supertest": "^2.0.11",
"copyfiles": "^2.4.1",
"eslint": "^8.21.0",
@ -94,10 +95,11 @@
"jest": "^29.1.2",
"jest-matcher-specific-error": "^1.0.0",
"lint-staged": "^13.0.0",
"nock": "^13.2.2",
"node-mocks-http": "^1.12.1",
"nodemon": "^2.0.19",
"openapi-types": "^12.0.0",
"prettier": "^2.7.1",
"sinon": "^15.0.0",
"supertest": "^6.2.2",
"typescript": "^4.9.3"
},

View file

@ -33,6 +33,8 @@ export {
mockMetadata3,
} from './connector-base-data.js';
const { jest } = import.meta;
export const mockConnector: Connector = {
id: 'id',
config: {},

View file

@ -1,46 +1,39 @@
import { mockEsmDefault, pickDefault } from '@logto/shared/esm';
import Koa from 'koa';
import * as koaErrorHandler from '#src/middleware/koa-error-handler.js';
import * as koaI18next from '#src/middleware/koa-i18next.js';
import * as koaLog from '#src/middleware/koa-log.js';
import * as koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js';
import * as koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
import * as koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import * as initOidc from '#src/oidc/init.js';
import * as initRouter from '#src/routes/init.js';
import { emptyMiddleware } from '#src/utils/test-utils.js';
import initI18n from '../i18n/init.js';
import initApp from './init.js';
const { jest } = import.meta;
const middlewareList = [
'error-handler',
'i18next',
'log',
'oidc-error-handler',
'slonik-error-handler',
'spa-proxy',
].map((name) => {
const mock = jest.fn(() => emptyMiddleware);
mockEsmDefault(`#src/middleware/koa-${name}.js`, () => mock);
return mock;
});
const initI18n = await pickDefault(import('../i18n/init.js'));
const initApp = await pickDefault(import('./init.js'));
describe('App Init', () => {
const listenMock = jest.spyOn(Koa.prototype, 'listen').mockImplementation(jest.fn());
const middlewareList = [
koaErrorHandler,
koaI18next,
koaLog,
koaOIDCErrorHandler,
koaSlonikErrorHandler,
koaSpaProxy,
];
const initMethods = [initRouter, initOidc];
const middlewareSpys = middlewareList.map((module) => jest.spyOn(module, 'default'));
const initMethodSpys = initMethods.map((module) => jest.spyOn(module, 'default'));
it('app init properly with 404 not found route', async () => {
const app = new Koa();
await initI18n();
await initApp(app);
for (const middleware of middlewareSpys) {
for (const middleware of middlewareList) {
expect(middleware).toBeCalled();
}
for (const inits of initMethodSpys) {
expect(inits).toBeCalled();
}
expect(listenMock).toBeCalled();
});
});

View file

@ -1,8 +1,9 @@
import type { Connector } from '@logto/schemas';
import { mockEsmWithActual } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { getConnectorConfig } from './index.js';
const { jest } = import.meta;
const connectors: Connector[] = [
{
@ -15,13 +16,12 @@ const connectors: Connector[] = [
},
];
const findAllConnectors = jest.fn(async () => connectors);
jest.mock('#src/queries/connector.js', () => ({
...jest.requireActual('#src/queries/connector.js'),
findAllConnectors: async () => findAllConnectors(),
await mockEsmWithActual('#src/queries/connector.js', () => ({
findAllConnectors: jest.fn(async () => connectors),
}));
const { getConnectorConfig } = await import('./index.js');
it('getConnectorConfig() should return right config', async () => {
const config = await getConnectorConfig('id');
expect(config).toMatchObject({ foo: 'bar' });

View file

@ -9,6 +9,7 @@ import { createTestPool } from '#src/utils/test-utils.js';
import { buildInsertInto } from './insert-into.js';
const { jest } = import.meta;
const poolSpy = jest.spyOn(envSet, 'pool', 'get');
const buildExpectedInsertIntoSql = (keys: string[]) => [

View file

@ -8,6 +8,7 @@ import { createTestPool } from '#src/utils/test-utils.js';
import { buildUpdateWhere } from './update-where.js';
const { jest } = import.meta;
const poolSpy = jest.spyOn(envSet, 'pool', 'get');
describe('buildUpdateWhere()', () => {

View file

@ -1,6 +1,6 @@
import type { SchemaLike, GeneratedSchema } from '@logto/schemas';
import type { UpdateWhereData } from '@logto/shared';
import { convertToIdentifiers, convertToPrimitiveOrSql, conditionalSql } from '@logto/shared';
import type { UpdateWhereData } from '@logto/shared';
import type { Truthy } from '@silverhand/essentials';
import { notFalsy } from '@silverhand/essentials';
import { sql } from 'slonik';

View file

@ -1,6 +1,7 @@
import { createMockContext } from '@shopify/jest-koa-mocks';
import type { ParameterizedContext } from 'koa';
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
import detectLanguage from './detect-language.js';
describe('detectLanguage', () => {

View file

@ -1,56 +1,47 @@
import { ConnectorType } from '@logto/connector-kit';
import type { Passcode } from '@logto/schemas';
import { PasscodeType } from '@logto/schemas';
import { mockEsm } from '@logto/shared/esm';
import { any } from 'zod';
import { mockConnector, mockMetadata } from '#src/__mocks__/index.js';
import { defaultConnectorMethods } from '#src/connectors/consts.js';
import { getLogtoConnectors } from '#src/connectors/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import {
consumePasscode,
deletePasscodesByIds,
const { jest } = import.meta;
const {
findUnconsumedPasscodeByJtiAndType,
findUnconsumedPasscodesByJtiAndType,
deletePasscodesByIds,
increasePasscodeTryCount,
insertPasscode,
} from '#src/queries/passcode.js';
consumePasscode,
} = mockEsm('#src/queries/passcode.js', () => ({
findUnconsumedPasscodesByJtiAndType: jest.fn(),
findUnconsumedPasscodeByJtiAndType: jest.fn(),
deletePasscodesByIds: jest.fn(),
insertPasscode: jest.fn(),
consumePasscode: jest.fn(),
increasePasscodeTryCount: jest.fn(),
}));
import {
const { getLogtoConnectors } = mockEsm('#src/connectors/index.js', () => ({
getLogtoConnectors: jest.fn(),
}));
const {
createPasscode,
passcodeExpiration,
passcodeMaxTryCount,
passcodeLength,
sendPasscode,
verifyPasscode,
} from './passcode.js';
jest.mock('#src/queries/passcode.js');
jest.mock('#src/connectors.js');
const mockedFindUnconsumedPasscodesByJtiAndType =
findUnconsumedPasscodesByJtiAndType as jest.MockedFunction<
typeof findUnconsumedPasscodesByJtiAndType
>;
const mockedFindUnconsumedPasscodeByJtiAndType =
findUnconsumedPasscodeByJtiAndType as jest.MockedFunction<
typeof findUnconsumedPasscodeByJtiAndType
>;
const mockedDeletePasscodesByIds = deletePasscodesByIds as jest.MockedFunction<
typeof deletePasscodesByIds
>;
const mockedInsertPasscode = insertPasscode as jest.MockedFunction<typeof insertPasscode>;
const mockedGetLogtoConnectors = getLogtoConnectors as jest.MockedFunction<
typeof getLogtoConnectors
>;
const mockedConsumePasscode = consumePasscode as jest.MockedFunction<typeof consumePasscode>;
const mockedIncreasePasscodeTryCount = increasePasscodeTryCount as jest.MockedFunction<
typeof increasePasscodeTryCount
>;
} = await import('./passcode.js');
beforeAll(() => {
mockedFindUnconsumedPasscodesByJtiAndType.mockResolvedValue([]);
mockedInsertPasscode.mockImplementation(async (data): Promise<Passcode> => {
findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]);
insertPasscode.mockImplementation(async (data): Promise<Passcode> => {
return {
phone: null,
email: null,
@ -88,7 +79,7 @@ describe('createPasscode', () => {
it('should disable existing passcode', async () => {
const email = 'jony@example.com';
const jti = 'jti';
mockedFindUnconsumedPasscodesByJtiAndType.mockResolvedValue([
findUnconsumedPasscodesByJtiAndType.mockResolvedValue([
{
id: 'id',
interactionJti: jti,
@ -104,7 +95,7 @@ describe('createPasscode', () => {
await createPasscode(jti, PasscodeType.SignIn, {
email,
});
expect(mockedDeletePasscodesByIds).toHaveBeenCalledWith(['id']);
expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']);
});
});
@ -127,7 +118,7 @@ describe('sendPasscode', () => {
});
it('should throw error when email or sms connector can not be found', async () => {
mockedGetLogtoConnectors.mockResolvedValueOnce([
getLogtoConnectors.mockResolvedValueOnce([
{
...defaultConnectorMethods,
dbEntry: {
@ -164,7 +155,7 @@ describe('sendPasscode', () => {
it('should call sendPasscode with params matching', async () => {
const sendMessage = jest.fn();
mockedGetLogtoConnectors.mockResolvedValueOnce([
getLogtoConnectors.mockResolvedValueOnce([
{
...defaultConnectorMethods,
configGuard: any(),
@ -230,20 +221,20 @@ describe('verifyPasscode', () => {
};
it('should mark as consumed on successful verification', async () => {
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
await verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' });
expect(mockedConsumePasscode).toHaveBeenCalledWith(passcode.id);
expect(consumePasscode).toHaveBeenCalledWith(passcode.id);
});
it('should fail when passcode not found', async () => {
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(null);
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(null);
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' })
).rejects.toThrow(new RequestError('passcode.not_found'));
});
it('should fail when phone mismatch', async () => {
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, {
phone: 'invalid_phone',
@ -252,7 +243,7 @@ describe('verifyPasscode', () => {
});
it('should fail when email mismatch', async () => {
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({
findUnconsumedPasscodeByJtiAndType.mockResolvedValue({
...passcode,
phone: null,
email: 'email',
@ -265,7 +256,7 @@ describe('verifyPasscode', () => {
});
it('should fail when expired', async () => {
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({
findUnconsumedPasscodeByJtiAndType.mockResolvedValue({
...passcode,
createdAt: Date.now() - passcodeExpiration - 100,
});
@ -275,7 +266,7 @@ describe('verifyPasscode', () => {
});
it('should fail when exceed max count', async () => {
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue({
findUnconsumedPasscodeByJtiAndType.mockResolvedValue({
...passcode,
tryCount: passcodeMaxTryCount,
});
@ -285,10 +276,10 @@ describe('verifyPasscode', () => {
});
it('should fail when invalid code, and should increase try_count', async () => {
mockedFindUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, 'invalid', { phone: 'phone' })
).rejects.toThrow(new RequestError('passcode.code_mismatch'));
expect(mockedIncreasePasscodeTryCount).toHaveBeenCalledWith(passcode.id);
expect(increasePasscodeTryCount).toHaveBeenCalledWith(passcode.id);
});
});

View file

@ -1,5 +1,6 @@
import resource from '@logto/phrases-ui';
import type { CustomPhrase } from '@logto/schemas';
import { mockEsm } from '@logto/shared/esm';
import deepmerge from 'deepmerge';
import {
@ -12,8 +13,8 @@ import {
zhHkTag,
} from '#src/__mocks__/custom-phrase.js';
import RequestError from '#src/errors/RequestError/index.js';
import { getPhrase } from '#src/lib/phrase.js';
const { jest } = import.meta;
const englishBuiltInPhrase = resource[enTag];
const customOnlyLanguage = zhHkTag;
@ -39,10 +40,12 @@ const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
return mockCustomPhrase;
});
jest.mock('#src/queries/custom-phrase.js', () => ({
findCustomPhraseByLanguageTag: async (key: string) => findCustomPhraseByLanguageTag(key),
mockEsm('#src/queries/custom-phrase.js', () => ({
findCustomPhraseByLanguageTag,
}));
const { getPhrase } = await import('#src/lib/phrase.js');
afterEach(() => {
jest.clearAllMocks();
});

View file

@ -2,6 +2,7 @@ import type { LanguageTag } from '@logto/language-kit';
import { builtInLanguages } from '@logto/phrases-ui';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import { mockEsm } from '@logto/shared/esm';
import {
socialTarget01,
@ -10,42 +11,36 @@ import {
mockSignInExperience,
mockSocialConnectors,
} from '#src/__mocks__/index.js';
import type { LogtoConnector } from '#src/connectors/types.js';
import RequestError from '#src/errors/RequestError/index.js';
import {
const { jest } = import.meta;
const allCustomLanguageTags: LanguageTag[] = [];
const { findAllCustomLanguageTags } = mockEsm('#src/queries/custom-phrase.js', () => ({
findAllCustomLanguageTags: jest.fn(async () => allCustomLanguageTags),
}));
const { getLogtoConnectors } = mockEsm('#src/connectors.js', () => ({
getLogtoConnectors: jest.fn(),
}));
const { findDefaultSignInExperience, updateDefaultSignInExperience } = mockEsm(
'#src/queries/sign-in-experience.js',
() => ({
findDefaultSignInExperience: jest.fn(),
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
})
);
const {
validateBranding,
validateTermsOfUse,
validateLanguageInfo,
removeUnavailableSocialConnectorTargets,
} from '#src/lib/sign-in-experience/index.js';
import { updateDefaultSignInExperience } from '#src/queries/sign-in-experience.js';
const allCustomLanguageTags: LanguageTag[] = [];
const findAllCustomLanguageTags = jest.fn(async () => allCustomLanguageTags);
const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<LogtoConnector[]>
>;
const findDefaultSignInExperience = jest.fn() as jest.MockedFunction<
() => Promise<SignInExperience>
>;
jest.mock('#src/queries/custom-phrase.js', () => ({
findAllCustomLanguageTags: async () => findAllCustomLanguageTags(),
}));
jest.mock('#src/connectors.js', () => ({
getLogtoConnectors: async () => getLogtoConnectorsPlaceHolder(),
}));
jest.mock('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
}));
} = await import('./index.js');
beforeEach(() => {
jest.clearAllMocks();
@ -163,7 +158,7 @@ describe('remove unavailable social connector targets', () => {
...mockSignInExperience,
socialSignInConnectorTargets: mockSocialConnectorTargets,
});
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockSocialConnectors);
getLogtoConnectors.mockResolvedValueOnce(mockSocialConnectors);
expect(mockSocialConnectorTargets).toEqual([socialTarget01, socialTarget02]);
await removeUnavailableSocialConnectorTargets();
expect(updateDefaultSignInExperience).toBeCalledWith({

View file

@ -1,17 +1,18 @@
import { ConnectorType, SignInIdentifier } from '@logto/schemas';
import { mockEsmWithActual } from '@logto/shared/esm';
import { mockAliyunDmConnector, mockAliyunSmsConnector, mockSignUp } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { validateSignUp } from './sign-up.js';
const { jest } = import.meta;
const enabledConnectors = [mockAliyunDmConnector, mockAliyunSmsConnector];
jest.mock('#src/lib/session.js', () => ({
...jest.requireActual('#src/lib/session.js'),
await mockEsmWithActual('#src/lib/session.js', () => ({
getApplicationIdFromInteraction: jest.fn(),
}));
const { validateSignUp } = await import('./sign-up.js');
describe('validate sign-up', () => {
describe('There must be at least one connector for the specific identifier.', () => {
test('should throw when there is no email connector and identifier is email', async () => {

View file

@ -1,20 +1,22 @@
import { UsersPasswordEncryptionMethod } from '@logto/schemas';
import { mockEsmWithActual } from '@logto/shared/esm';
import { hasUserWithId, updateUserById } from '#src/queries/user.js';
const { jest } = import.meta;
import { encryptUserPassword, generateUserId } from './user.js';
const { updateUserById, hasUserWithId } = await mockEsmWithActual('#src/queries/user.js', () => ({
updateUserById: jest.fn(),
hasUserWithId: jest.fn(),
}));
jest.mock('#src/queries/user.js');
const { encryptUserPassword, generateUserId } = await import('./user.js');
describe('generateUserId()', () => {
afterEach(() => {
(hasUserWithId as jest.MockedFunction<typeof hasUserWithId>).mockClear();
hasUserWithId.mockClear();
});
it('generates user ID with correct length when no conflict found', async () => {
const mockedHasUserWithId = (
hasUserWithId as jest.MockedFunction<typeof hasUserWithId>
).mockImplementationOnce(async () => false);
const mockedHasUserWithId = hasUserWithId.mockImplementationOnce(async () => false);
await expect(generateUserId()).resolves.toHaveLength(12);
expect(mockedHasUserWithId).toBeCalledTimes(1);
@ -23,9 +25,7 @@ describe('generateUserId()', () => {
it('generates user ID with correct length when retry limit is not reached', async () => {
// eslint-disable-next-line @silverhand/fp/no-let
let tried = 0;
const mockedHasUserWithId = (
hasUserWithId as jest.MockedFunction<typeof hasUserWithId>
).mockImplementation(async () => {
const mockedHasUserWithId = hasUserWithId.mockImplementation(async () => {
if (tried) {
return false;
}
@ -41,9 +41,7 @@ describe('generateUserId()', () => {
});
it('rejects with correct error message when retry limit is reached', async () => {
const mockedHasUserWithId = (
hasUserWithId as jest.MockedFunction<typeof hasUserWithId>
).mockImplementation(async () => true);
const mockedHasUserWithId = hasUserWithId.mockImplementation(async () => true);
await expect(generateUserId(10)).rejects.toThrow(
'Cannot generate user ID in reasonable retries'

View file

@ -1,5 +1,5 @@
import { UserRole } from '@logto/schemas';
import { jwtVerify } from 'jose';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
@ -8,12 +8,15 @@ import RequestError from '#src/errors/RequestError/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithAuthContext } from './koa-auth.js';
import koaAuth from './koa-auth.js';
jest.mock('jose', () => ({
jwtVerify: jest.fn(() => ({ payload: { sub: 'fooUser', role_names: ['admin'] } })),
const { jest } = import.meta;
const { jwtVerify } = mockEsm('jose', () => ({
jwtVerify: jest.fn().mockReturnValue({ payload: { sub: 'fooUser', role_names: ['admin'] } }),
}));
const koaAuth = await pickDefault(import('./koa-auth.js'));
describe('koaAuth middleware', () => {
const baseCtx = createContextWithRouteParameters();
@ -136,8 +139,7 @@ describe('koaAuth middleware', () => {
});
it('expect to throw if jwt sub is missing', async () => {
const mockJwtVerify = jwtVerify as jest.Mock;
mockJwtVerify.mockImplementationOnce(() => ({ payload: {} }));
jwtVerify.mockImplementationOnce(() => ({ payload: {} }));
ctx.request = {
...ctx.request,
@ -150,8 +152,7 @@ describe('koaAuth middleware', () => {
});
it('expect to have `client` type per jwt verify result', async () => {
const mockJwtVerify = jwtVerify as jest.Mock;
mockJwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'bar', client_id: 'bar' } }));
jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'bar', client_id: 'bar' } }));
ctx.request = {
...ctx.request,
@ -165,8 +166,7 @@ describe('koaAuth middleware', () => {
});
it('expect to throw if jwt role_names is missing', async () => {
const mockJwtVerify = jwtVerify as jest.Mock;
mockJwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'fooUser' } }));
jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'fooUser' } }));
ctx.request = {
...ctx.request,
@ -179,8 +179,7 @@ describe('koaAuth middleware', () => {
});
it('expect to throw if jwt role_names does not include admin', async () => {
const mockJwtVerify = jwtVerify as jest.Mock;
mockJwtVerify.mockImplementationOnce(() => ({
jwtVerify.mockImplementationOnce(() => ({
payload: { sub: 'fooUser', role_names: ['foo'] },
}));
@ -195,8 +194,7 @@ describe('koaAuth middleware', () => {
});
it('expect to throw unauthorized error if unknown error occurs', async () => {
const mockJwtVerify = jwtVerify as jest.Mock;
mockJwtVerify.mockImplementationOnce(() => {
jwtVerify.mockImplementationOnce(() => {
throw new Error('unknown error');
});
ctx.request = {

View file

@ -5,6 +5,8 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaConnectorErrorHandler from './koa-connector-error-handler.js';
const { jest } = import.meta;
describe('koaConnectorErrorHandler middleware', () => {
const next = jest.fn();
const ctx = createContextWithRouteParameters();

View file

@ -1,10 +1,12 @@
import { createMockContext } from '@shopify/jest-koa-mocks';
import createHttpError from 'http-errors';
import RequestError from '#src/errors/RequestError/index.js';
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
import koaErrorHandler from './koa-error-handler.js';
const { jest } = import.meta;
describe('koaErrorHandler middleware', () => {
const mockBody = { data: 'foo' };

View file

@ -1,10 +1,12 @@
import { mockEsmDefault } from '@logto/shared/esm';
import { z } from 'zod';
import { emptyMiddleware, createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaGuard, { isGuardMiddleware } from './koa-guard.js';
const { jest } = import.meta;
jest.mock('koa-body', () => emptyMiddleware);
mockEsmDefault('koa-body', () => emptyMiddleware);
const { default: koaGuard, isGuardMiddleware } = await import('./koa-guard.js');
describe('koaGuardMiddleware', () => {
describe('isGuardMiddleware', () => {

View file

@ -1,13 +1,14 @@
import { mockEsmDefault, pickDefault } from '@logto/shared/esm';
import i18next from 'i18next';
import initI18n from '#src/i18n/init.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaI18next from './koa-i18next.js';
const { jest } = import.meta;
const mockLanguage = () => ['zh-cn'];
mockEsmDefault('#src/i18n/detect-language.js', () => mockLanguage);
// Can not access outter scope function in jest mock
// eslint-disable-next-line unicorn/consistent-function-scoping
jest.mock('#src/i18n/detect-language.js', () => () => ['zh-cn']);
const initI18n = await pickDefault(import('#src/i18n/init.js'));
const koaI18next = await pickDefault(import('./koa-i18next.js'));
const changLanguageSpy = jest.spyOn(i18next, 'changeLanguage');
describe('koaI18next', () => {

View file

@ -4,13 +4,10 @@ import koaLogSession from '#src/middleware/koa-log-session.js';
import type { WithLogContext } from '#src/middleware/koa-log.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
const { jest } = import.meta;
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
})),
}));
const provider = new Provider('https://logto.test');
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
describe('koaLogSession', () => {
const sessionId = 'sessionId';
@ -19,6 +16,7 @@ describe('koaLogSession', () => {
const log = jest.fn();
const next = jest.fn();
// @ts-expect-error for testing
interactionDetails.mockResolvedValue({
jti: sessionId,
params: {
@ -37,7 +35,7 @@ describe('koaLogSession', () => {
log,
};
await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow();
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
expect(interactionDetails).toHaveBeenCalled();
});
@ -48,7 +46,7 @@ describe('koaLogSession', () => {
log,
};
await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow();
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId });
});
@ -59,7 +57,7 @@ describe('koaLogSession', () => {
log,
};
await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow();
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
expect(next).toHaveBeenCalled();
});
@ -74,6 +72,6 @@ describe('koaLogSession', () => {
throw new Error('message');
});
await expect(koaLogSession(new Provider(''))(ctx, next)).resolves.not.toThrow();
await expect(koaLogSession(provider)(ctx, next)).resolves.not.toThrow();
});
});

View file

@ -1,29 +1,31 @@
import type { LogPayload } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import i18next from 'i18next';
import RequestError from '#src/errors/RequestError/index.js';
import { insertLog } from '#src/queries/log.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithLogContext } from './koa-log.js';
import koaLog from './koa-log.js';
const { jest } = import.meta;
const nanoIdMock = 'mockId';
const addLogContext = jest.fn();
const log = jest.fn();
jest.mock('#src/queries/log.js', () => ({
insertLog: jest.fn(async () => 0),
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
insertLog: jest.fn(),
}));
jest.mock('nanoid', () => ({
nanoid: jest.fn(() => nanoIdMock),
mockEsm('nanoid', () => ({
nanoid: () => nanoIdMock,
}));
const koaLog = await pickDefault(import('./koa-log.js'));
describe('koaLog middleware', () => {
const insertLogMock = insertLog as jest.Mock;
const type = 'SignInUsernamePassword';
const mockPayload: LogPayload = {
userId: 'foo',
@ -54,7 +56,7 @@ describe('koaLog middleware', () => {
};
await koaLog()(ctx, next);
expect(insertLogMock).toBeCalledWith({
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
type,
payload: {
@ -79,7 +81,7 @@ describe('koaLog middleware', () => {
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function
const next = async () => {};
await koaLog()(ctx, next);
expect(insertLogMock).not.toBeCalled();
expect(insertLog).not.toBeCalled();
});
describe('should insert an error log with the error message when next() throws an error', () => {
@ -101,7 +103,7 @@ describe('koaLog middleware', () => {
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
expect(insertLogMock).toBeCalledWith({
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
type,
payload: {
@ -135,7 +137,7 @@ describe('koaLog middleware', () => {
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
expect(insertLogMock).toBeCalledWith({
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
type,
payload: {

View file

@ -5,6 +5,8 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaOIDCErrorHandler from './koa-oidc-error-handler.js';
const { jest } = import.meta;
describe('koaOIDCErrorHandler middleware', () => {
const next = jest.fn();
const ctx = createContextWithRouteParameters();

View file

@ -1,9 +1,12 @@
import { createMockContext } from '@shopify/jest-koa-mocks';
import type { Context } from 'koa';
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
import type { WithPaginationContext } from './koa-pagination.js';
import koaPagination from './koa-pagination.js';
const { jest } = import.meta;
const next = jest.fn();
const setHeader = jest.fn();
const links = new Set<string>();

View file

@ -2,6 +2,8 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaRootProxy from './koa-root-proxy.js';
const { jest } = import.meta;
describe('koaRootProxy', () => {
const next = jest.fn();

View file

@ -7,6 +7,8 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaSlonikErrorHandler from './koa-slonik-error-handler.js';
const { jest } = import.meta;
describe('koaSlonikErrorHandler middleware', () => {
const next = jest.fn();
const ctx = createContextWithRouteParameters();

View file

@ -1,18 +1,21 @@
import { mockEsmDefault, pickDefault } from '@logto/shared/esm';
import envSet, { MountedApps } from '#src/env-set/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaSpaProxy from './koa-spa-proxy.js';
const { jest } = import.meta;
const mockProxyMiddleware = jest.fn();
const mockStaticMiddleware = jest.fn();
jest.mock('fs/promises', () => ({
...jest.requireActual('fs/promises'),
mockEsmDefault('fs/promises', () => ({
readdir: jest.fn().mockResolvedValue(['sign-in']),
}));
jest.mock('koa-proxies', () => jest.fn(() => mockProxyMiddleware));
jest.mock('#src/middleware/koa-serve-static.js', () => jest.fn(() => mockStaticMiddleware));
mockEsmDefault('koa-proxies', () => jest.fn(() => mockProxyMiddleware));
mockEsmDefault('#src/middleware/koa-serve-static.js', () => jest.fn(() => mockStaticMiddleware));
const koaSpaProxy = await pickDefault(import('./koa-spa-proxy.js'));
describe('koaSpaProxy middleware', () => {
const envBackup = process.env;

View file

@ -1,23 +1,25 @@
import { mockEsmWithActual } from '@logto/shared/esm';
import { Provider } from 'oidc-provider';
import { MountedApps } from '#src/env-set/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaSpaSessionGuard, { sessionNotFoundPath, guardedPath } from './koa-spa-session-guard.js';
const { jest } = import.meta;
jest.mock('fs/promises', () => ({
...jest.requireActual('fs/promises'),
await mockEsmWithActual('fs/promises', () => ({
readdir: jest.fn().mockResolvedValue(['index.js']),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(),
})),
}));
const {
default: koaSpaSessionGuard,
sessionNotFoundPath,
guardedPath,
} = await import('./koa-spa-session-guard.js');
describe('koaSpaSessionGuard', () => {
const envBackup = process.env;
const provider = new Provider('https://logto.test');
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
beforeEach(() => {
process.env = { ...envBackup };
@ -33,7 +35,6 @@ describe('koaSpaSessionGuard', () => {
for (const app of Object.values(MountedApps)) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
it(`${app} path should not redirect`, async () => {
const provider = new Provider('');
const ctx = createContextWithRouteParameters({
url: `/${app}/foo`,
});
@ -45,9 +46,7 @@ describe('koaSpaSessionGuard', () => {
}
it(`should not redirect for path ${sessionNotFoundPath}`, async () => {
const provider = new Provider('');
(provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found'));
interactionDetails.mockRejectedValue(new Error('session not found'));
const ctx = createContextWithRouteParameters({
url: `${sessionNotFoundPath}`,
});
@ -56,9 +55,7 @@ describe('koaSpaSessionGuard', () => {
});
it(`should not redirect for path /callback`, async () => {
const provider = new Provider('');
(provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found'));
interactionDetails.mockRejectedValue(new Error('session not found'));
const ctx = createContextWithRouteParameters({
url: '/callback/github',
});
@ -67,7 +64,8 @@ describe('koaSpaSessionGuard', () => {
});
it('should not redirect if session found', async () => {
const provider = new Provider('');
// @ts-expect-error for testing
interactionDetails.mockResolvedValue({});
const ctx = createContextWithRouteParameters({
url: `/sign-in`,
});
@ -78,9 +76,7 @@ describe('koaSpaSessionGuard', () => {
for (const path of guardedPath) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
it(`should redirect if session not found for ${path}`, async () => {
const provider = new Provider('');
(provider.interactionDetails as jest.Mock).mockRejectedValue(new Error('session not found'));
interactionDetails.mockRejectedValue(new Error('session not found'));
const ctx = createContextWithRouteParameters({
url: `${path}/foo`,
});

View file

@ -1,13 +1,16 @@
import { mockEsm, pickDefault } from '@logto/shared/esm';
import envSet, { MountedApps } from '#src/env-set/index.js';
import { hasActiveUsers } from '#src/queries/user.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaWelcomeProxy from './koa-welcome-proxy.js';
const { jest } = import.meta;
jest.mock('#src/queries/user.js', () => ({
const { hasActiveUsers } = mockEsm('#src/queries/user.js', () => ({
hasActiveUsers: jest.fn(),
}));
const koaWelcomeProxy = await pickDefault(import('./koa-welcome-proxy.js'));
describe('koaWelcomeProxy', () => {
const next = jest.fn();
@ -18,7 +21,7 @@ describe('koaWelcomeProxy', () => {
it('should redirect to admin console if has AdminUsers', async () => {
const { endpoint } = envSet.values;
(hasActiveUsers as jest.Mock).mockResolvedValue(true);
hasActiveUsers.mockResolvedValue(true);
const ctx = createContextWithRouteParameters({
url: `/${MountedApps.Welcome}`,
});
@ -31,7 +34,7 @@ describe('koaWelcomeProxy', () => {
it('should redirect to welcome page if has no Users', async () => {
const { endpoint } = envSet.values;
(hasActiveUsers as jest.Mock).mockResolvedValue(false);
hasActiveUsers.mockResolvedValue(false);
const ctx = createContextWithRouteParameters({
url: `/${MountedApps.Welcome}`,
});

View file

@ -1,24 +1,18 @@
import type { Application } from '@logto/schemas';
import { mockEsm } from '@logto/shared/esm';
import snakecaseKeys from 'snakecase-keys';
import { mockApplication } from '#src/__mocks__/index.js';
import {
consumeInstanceById,
destroyInstanceById,
findPayloadById,
findPayloadByPayloadField,
revokeInstanceByGrantId,
upsertInstance,
} from '#src/queries/oidc-model-instance.js';
import postgresAdapter from './adapter.js';
import { getConstantClientMetadata } from './utils.js';
jest.mock('#src/queries/application.js', () => ({
const { jest } = import.meta;
mockEsm('#src/queries/application.js', () => ({
findApplicationById: jest.fn(async (): Promise<Application> => mockApplication),
}));
jest.mock('#src/queries/oidc-model-instance.js', () => ({
mockEsm('#src/queries/oidc-model-instance.js', () => ({
upsertInstance: jest.fn(),
findPayloadById: jest.fn(),
findPayloadByPayloadField: jest.fn(),
@ -27,15 +21,25 @@ jest.mock('#src/queries/oidc-model-instance.js', () => ({
revokeInstanceByGrantId: jest.fn(),
}));
const now = Date.now();
jest.mock(
mockEsm(
'date-fns',
jest.fn(() => ({
addSeconds: jest.fn((_: Date, seconds: number) => new Date(now + seconds * 1000)),
}))
);
const { default: postgresAdapter } = await import('./adapter.js');
const {
consumeInstanceById,
destroyInstanceById,
findPayloadById,
findPayloadByPayloadField,
revokeInstanceByGrantId,
upsertInstance,
} = await import('#src/queries/oidc-model-instance.js');
const now = Date.now();
describe('postgres Adapter', () => {
it('Client Modal', async () => {
const rejectError = new Error('Not implemented');

View file

@ -18,6 +18,7 @@ import {
deleteApplicationById,
} from './application.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -18,6 +18,7 @@ import {
updateConnector,
} from './connector.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -1,21 +1,14 @@
import type { CreateOidcModelInstance } from '@logto/schemas';
import { OidcModelInstances } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { mockEsmWithActual } from '@logto/shared/esm';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
import envSet from '#src/env-set/index.js';
import type { QueryType } from '#src/utils/test-utils.js';
import { expectSqlAssert } from '#src/utils/test-utils.js';
import {
upsertInstance,
findPayloadById,
findPayloadByPayloadField,
consumeInstanceById,
destroyInstanceById,
revokeInstanceByGrantId,
} from './oidc-model-instance.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
@ -26,11 +19,19 @@ jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
})
);
jest.mock('@logto/shared', () => ({
...jest.requireActual('@logto/shared'),
await mockEsmWithActual('@logto/shared', () => ({
convertToTimestamp: () => 100,
}));
const {
upsertInstance,
findPayloadById,
findPayloadByPayloadField,
consumeInstanceById,
destroyInstanceById,
revokeInstanceByGrantId,
} = await import('./oidc-model-instance.js');
describe('oidc-model-instance query', () => {
const { table, fields } = convertToIdentifiers(OidcModelInstances);
const expiresAt = Date.now();

View file

@ -17,6 +17,7 @@ import {
deletePasscodesByIds,
} from './passcode.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -18,6 +18,7 @@ import {
deleteResourceById,
} from './resource.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -9,6 +9,7 @@ import { expectSqlAssert } from '#src/utils/test-utils.js';
import { findAllRoles, findRolesByRoleNames } from './roles.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -9,6 +9,7 @@ import { expectSqlAssert } from '#src/utils/test-utils.js';
import { defaultSettingId, getSetting, updateSetting } from './setting.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -10,6 +10,7 @@ import {
updateDefaultSignInExperience,
} from './sign-in-experience.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -26,6 +26,7 @@ import {
deleteUserIdentity,
} from './user.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(

View file

@ -1,5 +1,6 @@
import type { CreateUser, Role, User } from '@logto/schemas';
import { userInfoSelectFields } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import pick from 'lodash.pick';
import {
@ -8,19 +9,9 @@ import {
mockUserListResponse,
mockUserResponse,
} from '#src/__mocks__/index.js';
import { encryptUserPassword } from '#src/lib/user.js';
import { findRolesByRoleNames } from '#src/queries/roles.js';
import {
hasUser,
findUserById,
updateUserById,
deleteUserIdentity,
deleteUserById,
} from '#src/queries/user.js';
import { createRequester } from '#src/utils/test-utils.js';
import adminUserRoutes from './admin-user.js';
const { jest } = import.meta;
const filterUsersWithSearch = (users: User[], search: string) =>
users.filter((user) =>
[user.username, user.primaryEmail, user.primaryPhone, user.name].some((value) =>
@ -28,45 +19,44 @@ const filterUsersWithSearch = (users: User[], search: string) =>
)
);
const mockFindDefaultSignInExperience = jest.fn(async () => ({
signUp: {
identifiers: [],
password: false,
verify: false,
},
}));
jest.mock('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: jest.fn(async () => mockFindDefaultSignInExperience()),
mockEsm('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: jest.fn(async () => ({
signUp: {
identifiers: [],
password: false,
verify: false,
},
})),
}));
const mockHasUser = jest.fn(async () => false);
const mockHasUserWithEmail = jest.fn(async () => false);
const mockHasUserWithPhone = jest.fn(async () => false);
jest.mock('#src/queries/user.js', () => ({
countUsers: jest.fn(async (search) => ({
count: search ? filterUsersWithSearch(mockUserList, search).length : mockUserList.length,
})),
findUsers: jest.fn(
async (limit, offset, search): Promise<User[]> =>
search ? filterUsersWithSearch(mockUserList, search) : mockUserList
),
findUserById: jest.fn(async (): Promise<User> => mockUser),
hasUser: jest.fn(async () => mockHasUser()),
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(),
}));
jest.mock('#src/lib/user.js', () => ({
...jest.requireActual('#src/lib/user.js'),
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
await mockEsmWithActual('#src/queries/user.js', () => ({
countUsers: jest.fn(async (search) => ({
count: search ? filterUsersWithSearch(mockUserList, search).length : mockUserList.length,
})),
findUsers: jest.fn(
async (limit, offset, search): Promise<User[]> =>
search ? filterUsersWithSearch(mockUserList, search) : mockUserList
),
findUserById: jest.fn(async (): Promise<User> => mockUser),
hasUser: jest.fn(async () => mockHasUser()),
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
deleteUserById: jest.fn(),
deleteUserIdentity: jest.fn(),
}));
const { encryptUserPassword } = await mockEsmWithActual('#src/lib/user.js', () => ({
generateUserId: jest.fn(() => 'fooId'),
encryptUserPassword: jest.fn(() => ({
passwordEncrypted: 'password',
@ -80,18 +70,18 @@ jest.mock('#src/lib/user.js', () => ({
),
}));
jest.mock('#src/queries/roles.js', () => ({
const { findRolesByRoleNames } = mockEsm('#src/queries/roles.js', () => ({
findRolesByRoleNames: jest.fn(
async (): Promise<Role[]> => [{ id: 'role_id', name: 'admin', description: 'none' }]
),
}));
const revokeInstanceByUserId = jest.fn();
jest.mock('#src/queries/oidc-model-instance.js', () => ({
revokeInstanceByUserId: async (modelName: string, userId: string) =>
revokeInstanceByUserId(modelName, userId),
const { revokeInstanceByUserId } = mockEsm('#src/queries/oidc-model-instance.js', () => ({
revokeInstanceByUserId: jest.fn(),
}));
const adminUserRoutes = await pickDefault(import('./admin-user.js'));
describe('adminUserRoutes', () => {
const userRequest = createRequester({ authedRoutes: adminUserRoutes });

View file

@ -1,13 +1,12 @@
import type { Application, CreateApplication } from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { mockApplication } from '#src/__mocks__/index.js';
import { findApplicationById } from '#src/queries/application.js';
import { createRequester } from '#src/utils/test-utils.js';
import applicationRoutes from './application.js';
const { jest } = import.meta;
jest.mock('#src/queries/application.js', () => ({
const { findApplicationById } = mockEsm('#src/queries/application.js', () => ({
findTotalNumberOfApplications: jest.fn(async () => ({ count: 10 })),
findAllApplications: jest.fn(async () => [mockApplication]),
findApplicationById: jest.fn(async () => mockApplication),
@ -30,23 +29,21 @@ jest.mock('#src/queries/application.js', () => ({
),
}));
jest.mock('@logto/shared', () => ({
mockEsm('@logto/shared', () => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
buildIdGenerator: jest.fn(() => () => 'randomId'),
buildApplicationSecret: jest.fn(() => 'randomId'),
}));
const { createRequester } = await import('#src/utils/test-utils.js');
const applicationRoutes = await pickDefault(import('./application.js'));
const customClientMetadata = {
corsAllowedOrigins: ['http://localhost:5000', 'http://localhost:5001', 'https://silverhand.com'],
idTokenTtl: 999_999,
refreshTokenTtl: 100_000_000,
};
const customOidcClientMetadata = {
redirectUris: [],
postLogoutRedirectUris: [],
};
describe('application route', () => {
const applicationRequest = createRequester({ authedRoutes: applicationRoutes });

View file

@ -1,11 +1,22 @@
import { mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import * as functions from '#src/middleware/koa-auth.js';
import { createRequester } from '#src/utils/test-utils.js';
import authnRoutes from './authn.js';
const { jest } = import.meta;
const { verifyBearerTokenFromRequest } = await mockEsmWithActual(
'#src/middleware/koa-auth.js',
() => ({
verifyBearerTokenFromRequest: jest.fn(),
})
);
const request = createRequester({
anonymousRoutes: await pickDefault(import('#src/routes/authn.js')),
});
describe('authn route for Hasura', () => {
const request = createRequester({ anonymousRoutes: authnRoutes });
const mockUserId = 'foo';
const mockExpectedRole = 'some_role';
const mockUnauthorizedRole = 'V';
@ -17,7 +28,7 @@ describe('authn route for Hasura', () => {
describe('with successful verification', () => {
beforeEach(() => {
jest.spyOn(functions, 'verifyBearerTokenFromRequest').mockResolvedValue({
verifyBearerTokenFromRequest.mockResolvedValue({
clientId: 'ok',
sub: mockUserId,
roleNames: [mockExpectedRole],
@ -59,15 +70,13 @@ describe('authn route for Hasura', () => {
describe('with failed verification', () => {
beforeEach(() => {
jest
.spyOn(functions, 'verifyBearerTokenFromRequest')
.mockImplementation(async (_, resource) => {
if (resource) {
throw new RequestError({ code: 'auth.jwt_sub_missing', status: 401 });
}
verifyBearerTokenFromRequest.mockImplementation(async (_, resource) => {
if (resource) {
throw new RequestError({ code: 'auth.jwt_sub_missing', status: 401 });
}
return { clientId: 'not ok', sub: mockUserId };
});
return { clientId: 'not ok', sub: mockUserId };
});
});
it('throws 401 if no unauthorized role presents', async () => {
@ -91,9 +100,10 @@ describe('authn route for Hasura', () => {
});
it('falls back to unauthorized role if JWT is invalid', async () => {
jest
.spyOn(functions, 'verifyBearerTokenFromRequest')
.mockRejectedValue(new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }));
verifyBearerTokenFromRequest.mockRejectedValue(
new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })
);
const response = await request
.get('/authn/hasura')
.query({ resource: 'https://api.logto.io', unauthorizedRole: mockUnauthorizedRole });

View file

@ -2,6 +2,7 @@
import type { EmailConnector, SmsConnector } from '@logto/connector-kit';
import { ConnectorPlatform, MessageTypes } from '@logto/connector-kit';
import { ConnectorType } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { any } from 'zod';
import {
@ -16,36 +17,30 @@ import {
mockLogtoConnector,
} from '#src/__mocks__/index.js';
import { defaultConnectorMethods } from '#src/connectors/consts.js';
import type { ConnectorFactory, LogtoConnector } from '#src/connectors/types.js';
import type { LogtoConnector } from '#src/connectors/types.js';
import RequestError from '#src/errors/RequestError/index.js';
import { removeUnavailableSocialConnectorTargets } from '#src/lib/sign-in-experience/index.js';
import {
import assertThat from '#src/utils/assert-that.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
mockEsm('#src/lib/connector.js', () => ({
checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(),
}));
const { removeUnavailableSocialConnectorTargets } = mockEsm(
'#src/lib/sign-in-experience/index.js',
() => ({
removeUnavailableSocialConnectorTargets: jest.fn(),
})
);
const {
findConnectorById,
countConnectorByConnectorId,
deleteConnectorById,
deleteConnectorByIds,
} from '#src/queries/connector.js';
import assertThat from '#src/utils/assert-that.js';
import { createRequester } from '#src/utils/test-utils.js';
import connectorRoutes from './connector.js';
const loadConnectorFactoriesPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<ConnectorFactory[]>
>;
const getLogtoConnectorsPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<LogtoConnector[]>
>;
jest.mock('#src/lib/connector.js', () => ({
checkSocialConnectorTargetAndPlatformUniqueness: jest.fn(),
}));
jest.mock('#src/lib/sign-in-experience/index.js', () => ({
removeUnavailableSocialConnectorTargets: jest.fn(),
}));
jest.mock('#src/queries/connector.js', () => ({
} = await mockEsmWithActual('#src/queries/connector.js', () => ({
findConnectorById: jest.fn(),
countConnectorByConnectorId: jest.fn(),
deleteConnectorById: jest.fn(),
@ -53,11 +48,13 @@ jest.mock('#src/queries/connector.js', () => ({
insertConnector: jest.fn(async (body: unknown) => body),
}));
jest.mock('#src/connectors/index.js', () => ({
loadConnectorFactories: async () => loadConnectorFactoriesPlaceHolder(),
getLogtoConnectors: async () => getLogtoConnectorsPlaceHolder(),
// eslint-disable-next-line @typescript-eslint/ban-types
const getLogtoConnectors = jest.fn<Promise<LogtoConnector[]>, []>();
const { loadConnectorFactories } = mockEsm('#src/connectors/index.js', () => ({
loadConnectorFactories: jest.fn(),
getLogtoConnectors,
getLogtoConnectorById: async (connectorId: string) => {
const connectors = await getLogtoConnectorsPlaceHolder();
const connectors = await getLogtoConnectors();
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
assertThat(
connector,
@ -71,6 +68,7 @@ jest.mock('#src/connectors/index.js', () => ({
return connector;
},
}));
const connectorRoutes = await pickDefault(import('./connector.js'));
describe('connector route', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes });
@ -81,13 +79,13 @@ describe('connector route', () => {
});
it('throws if more than one email connector exists', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList);
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('throws if more than one SMS connector exists', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(
getLogtoConnectors.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type !== ConnectorType.Email)
);
const response = await connectorRequest.get('/connectors').send({});
@ -95,7 +93,7 @@ describe('connector route', () => {
});
it('shows all connectors', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(
getLogtoConnectors.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type === ConnectorType.Social)
);
const response = await connectorRequest.get('/connectors').send({});
@ -105,7 +103,7 @@ describe('connector route', () => {
describe('GET /connector-factories', () => {
it('show all connector factories', async () => {
(loadConnectorFactoriesPlaceHolder as jest.Mock).mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, metadata: mockMetadata0, type: ConnectorType.Sms },
{ ...mockConnectorFactory, metadata: mockMetadata1, type: ConnectorType.Social },
{ ...mockConnectorFactory, metadata: mockMetadata2, type: ConnectorType.Email },
@ -128,39 +126,38 @@ describe('connector route', () => {
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList.slice(2));
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList.slice(2));
const response = await connectorRequest.get('/connectors/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('shows found connector information', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList);
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('POST /connectors', () => {
const mockedCountConnectorByConnectorId = countConnectorByConnectorId as jest.Mock;
afterEach(() => {
jest.clearAllMocks();
});
it('should post a new connector record', async () => {
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{
...mockConnectorFactory,
metadata: { ...mockConnectorFactory.metadata, id: 'connectorId' },
},
]);
mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([
countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: { ...mockConnector, connectorId: 'id0' },
metadata: { ...mockMetadata, id: 'id0' },
@ -185,13 +182,13 @@ describe('connector route', () => {
});
it('throws when connector factory not found', async () => {
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{
...mockConnectorFactory,
metadata: { ...mockConnectorFactory.metadata, id: 'connectorId' },
},
]);
mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
const response = await connectorRequest.post('/connectors').send({
connectorId: 'id0',
config: { cliend_id: 'client_id', client_secret: 'client_secret' },
@ -200,7 +197,7 @@ describe('connector route', () => {
});
it('should post a new record when add more than 1 instance with connector factory', async () => {
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{
...mockConnectorFactory,
metadata: {
@ -211,8 +208,8 @@ describe('connector route', () => {
},
},
]);
mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 1 });
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([
countConnectorByConnectorId.mockResolvedValueOnce({ count: 1 });
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: { ...mockConnector, connectorId: 'id0' },
metadata: { ...mockMetadata, id: 'id0', platform: ConnectorPlatform.Universal },
@ -239,13 +236,13 @@ describe('connector route', () => {
});
it('throws when add more than 1 instance with non-connector factory', async () => {
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{
...mockConnectorFactory,
metadata: { ...mockConnectorFactory.metadata, id: 'id0' },
},
]);
mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 1 });
countConnectorByConnectorId.mockResolvedValueOnce({ count: 1 });
const response = await connectorRequest.post('/connectors').send({
connectorId: 'id0',
config: { cliend_id: 'client_id', client_secret: 'client_secret' },
@ -254,14 +251,14 @@ describe('connector route', () => {
});
it('should add a new record and delete old records with same connector type when add passwordless connectors', async () => {
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{
...mockConnectorFactory,
type: ConnectorType.Sms,
metadata: { ...mockConnectorFactory.metadata, id: 'id0', isStandard: true },
},
]);
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: { ...mockConnector, connectorId: 'id0' },
metadata: { ...mockMetadata, id: 'id0' },
@ -289,7 +286,7 @@ describe('connector route', () => {
});
it('throws when add more than 1 social connector instance with same target and platform (add from standard connector)', async () => {
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{
...mockConnectorFactory,
metadata: {
@ -300,8 +297,8 @@ describe('connector route', () => {
},
},
]);
mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([
countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: { ...mockConnector, connectorId: 'id0', metadata: { target: 'target' } },
metadata: {
@ -322,7 +319,7 @@ describe('connector route', () => {
});
it('throws when add more than 1 social connector instance with same target and platform (add social connector)', async () => {
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
loadConnectorFactories.mockResolvedValueOnce([
{
...mockConnectorFactory,
metadata: {
@ -334,8 +331,8 @@ describe('connector route', () => {
},
},
]);
mockedCountConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([
countConnectorByConnectorId.mockResolvedValueOnce({ count: 0 });
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: { ...mockConnector, connectorId: 'id0', metadata: { target: 'target' } },
metadata: {
@ -373,7 +370,7 @@ describe('connector route', () => {
...defaultConnectorMethods,
sendMessage,
};
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([mockedSmsConnector]);
getLogtoConnectors.mockResolvedValueOnce([mockedSmsConnector]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901', config: { test: 123 } });
@ -401,7 +398,7 @@ describe('connector route', () => {
...defaultConnectorMethods,
sendMessage,
};
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([mockedEmailConnector]);
getLogtoConnectors.mockResolvedValueOnce([mockedEmailConnector]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com', config: { test: 123 } });
@ -425,7 +422,7 @@ describe('connector route', () => {
});
it('should throw when sms connector is not found', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901' });
@ -433,7 +430,7 @@ describe('connector route', () => {
});
it('should throw when email connector is not found', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce([]);
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com' });
@ -445,21 +442,22 @@ describe('connector route', () => {
beforeEach(() => {
jest.resetAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
it('delete connector instance and remove unavailable social connector targets', async () => {
(findConnectorById as jest.Mock).mockResolvedValueOnce(mockConnector);
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([mockConnectorFactory]);
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([mockConnectorFactory]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(1);
});
it('delete connector instance (connector factory is not social type)', async () => {
(findConnectorById as jest.Mock).mockResolvedValueOnce(mockConnector);
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([
{ ...mockConnectorFactory, type: ConnectorType.Sms },
]);
await connectorRequest.delete('/connectors/id').send({});
@ -468,8 +466,8 @@ describe('connector route', () => {
});
it('delete connector instance (connector factory is not found)', async () => {
(findConnectorById as jest.Mock).mockResolvedValueOnce(mockConnector);
loadConnectorFactoriesPlaceHolder.mockResolvedValueOnce([]);
findConnectorById.mockResolvedValueOnce(mockConnector);
loadConnectorFactories.mockResolvedValueOnce([]);
await connectorRequest.delete('/connectors/id').send({});
expect(deleteConnectorById).toHaveBeenCalledTimes(1);
expect(removeUnavailableSocialConnectorTargets).toHaveBeenCalledTimes(0);
@ -477,7 +475,7 @@ describe('connector route', () => {
it('throws when connector not exists with `id`', async () => {
// eslint-disable-next-line unicorn/no-useless-undefined
(findConnectorById as jest.Mock).mockResolvedValueOnce(undefined);
findConnectorById.mockResolvedValueOnce(undefined);
const response = await connectorRequest.delete('/connectors/id').send({});
expect(response).toHaveProperty('statusCode', 500);
});

View file

@ -1,5 +1,6 @@
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { ConnectorType } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import {
mockMetadata,
@ -9,17 +10,14 @@ import {
} from '#src/__mocks__/index.js';
import type { LogtoConnector } from '#src/connectors/types.js';
import RequestError from '#src/errors/RequestError/index.js';
import { updateConnector } from '#src/queries/connector.js';
import assertThat from '#src/utils/assert-that.js';
import { createRequester } from '#src/utils/test-utils.js';
import connectorRoutes from './connector.js';
const { jest } = import.meta;
const getLogtoConnectorsPlaceholder = jest.fn() as jest.MockedFunction<
() => Promise<LogtoConnector[]>
>;
const getLogtoConnectorByIdPlaceholder = jest.fn(async (connectorId: string) => {
const connectors = await getLogtoConnectorsPlaceholder();
const getLogtoConnectors = jest.fn() as jest.MockedFunction<() => Promise<LogtoConnector[]>>;
const getLogtoConnectorById = jest.fn(async (connectorId: string) => {
const connectors = await getLogtoConnectors();
const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId);
assertThat(
@ -36,22 +34,25 @@ const getLogtoConnectorByIdPlaceholder = jest.fn(async (connectorId: string) =>
sendMessage: sendMessagePlaceHolder,
};
}) as jest.MockedFunction<(connectorId: string) => Promise<LogtoConnector>>;
const mockedUpdateConnector = updateConnector as jest.Mock;
const sendMessagePlaceHolder = jest.fn();
jest.mock('#src/queries/connector.js', () => ({
const { updateConnector } = await mockEsmWithActual('#src/queries/connector.js', () => ({
updateConnector: jest.fn(),
}));
jest.mock('#src/connectors.js', () => ({
getLogtoConnectors: async () => getLogtoConnectorsPlaceholder(),
getLogtoConnectorById: async (connectorId: string) =>
getLogtoConnectorByIdPlaceholder(connectorId),
await mockEsmWithActual('#src/connectors.js', () => ({
getLogtoConnectors,
getLogtoConnectorById,
}));
jest.mock('#src/lib/sign-in-experience.js', () => ({
mockEsm('#src/lib/sign-in-experience.js', () => ({
// eslint-disable-next-line @typescript-eslint/no-empty-function
removeUnavailableSocialConnectorTargets: async () => {},
}));
const connectorRoutes = await pickDefault(import('./connector.js'));
describe('connector PATCH routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes });
@ -61,19 +62,19 @@ describe('connector PATCH routes', () => {
});
it('throws when connector can not be found by given connectorId (locally)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList.slice(0, 1));
getLogtoConnectors.mockResolvedValueOnce(mockLogtoConnectorList.slice(0, 1));
const response = await connectorRequest.patch('/connectors/findConnector').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('throws when connector can not be found by given connectorId (remotely)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([]);
getLogtoConnectors.mockResolvedValueOnce([]);
const response = await connectorRequest.patch('/connectors/id0').send({});
expect(response).toHaveProperty('statusCode', 404);
});
it('config validation fails', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
@ -91,7 +92,7 @@ describe('connector PATCH routes', () => {
});
it('throws when trying to update target', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValue([
getLogtoConnectors.mockResolvedValue([
{
dbEntry: mockConnector,
metadata: { ...mockMetadata, isStandard: true },
@ -108,7 +109,7 @@ describe('connector PATCH routes', () => {
});
it('successfully updates connector configs', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValue([
getLogtoConnectors.mockResolvedValue([
{
dbEntry: mockConnector,
metadata: { ...mockMetadata, isStandard: true },
@ -116,7 +117,7 @@ describe('connector PATCH routes', () => {
...mockLogtoConnector,
},
]);
mockedUpdateConnector.mockResolvedValueOnce({
updateConnector.mockResolvedValueOnce({
...mockConnector,
metadata: {
target: 'target',
@ -149,7 +150,7 @@ describe('connector PATCH routes', () => {
});
it('successfully clear connector config metadata', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: { ...mockMetadata, isStandard: true },
@ -157,7 +158,7 @@ describe('connector PATCH routes', () => {
...mockLogtoConnector,
},
]);
mockedUpdateConnector.mockResolvedValueOnce({
updateConnector.mockResolvedValueOnce({
...mockConnector,
metadata: {
target: '',
@ -186,7 +187,7 @@ describe('connector PATCH routes', () => {
});
it('throws when set syncProfile to `true` and with non-social connector', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
getLogtoConnectors.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
@ -200,7 +201,7 @@ describe('connector PATCH routes', () => {
});
it('successfully set syncProfile to `true` and with social connector', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValue([
getLogtoConnectors.mockResolvedValue([
{
dbEntry: { ...mockConnector, syncProfile: false },
metadata: mockMetadata,
@ -220,7 +221,7 @@ describe('connector PATCH routes', () => {
});
it('successfully set syncProfile to `false`', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValue([
getLogtoConnectors.mockResolvedValue([
{
dbEntry: { ...mockConnector, syncProfile: false },
metadata: mockMetadata,

View file

@ -1,73 +1,66 @@
import en from '@logto/phrases-ui/lib/locales/en.js';
import type { CustomPhrase, SignInExperience, Translation } from '@logto/schemas';
import type { CustomPhrase, SignInExperience } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { mockZhCnCustomPhrase, trTrTag, zhCnTag } from '#src/__mocks__/custom-phrase.js';
import { mockSignInExperience } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import customPhraseRoutes from '#src/routes/custom-phrase.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const mockLanguageTag = zhCnTag;
const mockPhrase = mockZhCnCustomPhrase;
const mockCustomPhrases: Record<string, CustomPhrase> = {
[mockLanguageTag]: mockPhrase,
};
const deleteCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
if (!mockCustomPhrases[languageTag]) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
});
const {
deleteCustomPhraseByLanguageTag,
findAllCustomPhrases,
findCustomPhraseByLanguageTag,
upsertCustomPhrase,
} = mockEsm('#src/queries/custom-phrase.js', () => ({
deleteCustomPhraseByLanguageTag: jest.fn(async (languageTag: string) => {
if (!mockCustomPhrases[languageTag]) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
}),
findAllCustomPhrases: jest.fn(async (): Promise<CustomPhrase[]> => []),
findCustomPhraseByLanguageTag: jest.fn(async (languageTag: string) => {
const mockCustomPhrase = mockCustomPhrases[languageTag];
const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => {
const mockCustomPhrase = mockCustomPhrases[languageTag];
if (!mockCustomPhrase) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
if (!mockCustomPhrase) {
throw new RequestError({ code: 'entity.not_found', status: 404 });
}
return mockCustomPhrase;
});
const findAllCustomPhrases = jest.fn(async (): Promise<CustomPhrase[]> => []);
const upsertCustomPhrase = jest.fn(async (customPhrase: CustomPhrase) => mockPhrase);
jest.mock('#src/queries/custom-phrase.js', () => ({
deleteCustomPhraseByLanguageTag: async (tag: string) => deleteCustomPhraseByLanguageTag(tag),
findAllCustomPhrases: async () => findAllCustomPhrases(),
findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag),
upsertCustomPhrase: async (customPhrase: CustomPhrase) => upsertCustomPhrase(customPhrase),
return mockCustomPhrase;
}),
upsertCustomPhrase: jest.fn(async () => mockPhrase),
}));
const isStrictlyPartial = jest.fn(
(fullTranslation: Translation, partialTranslation: Partial<Translation>) => true
);
jest.mock('#src/utils/translation.js', () => ({
isStrictlyPartial: (fullTranslation: Translation, partialTranslation: Translation) =>
isStrictlyPartial(fullTranslation, partialTranslation),
const { isStrictlyPartial } = mockEsm('#src/utils/translation.js', () => ({
isStrictlyPartial: jest.fn(() => true),
}));
const mockFallbackLanguage = trTrTag;
const findDefaultSignInExperience = jest.fn(
async (): Promise<SignInExperience> => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: mockFallbackLanguage,
},
})
);
jest.mock('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
mockEsm('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: jest.fn(
async (): Promise<SignInExperience> => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: mockFallbackLanguage,
},
})
),
}));
describe('customPhraseRoutes', () => {
const customPhraseRequest = createRequester({ authedRoutes: customPhraseRoutes });
const customPhraseRoutes = await pickDefault(import('./custom-phrase.js'));
const customPhraseRequest = createRequester({ authedRoutes: customPhraseRoutes });
describe('customPhraseRoutes', () => {
afterEach(() => {
jest.clearAllMocks();
});

View file

@ -1,25 +1,20 @@
// The FP version works better for `format()`
/* eslint-disable import/no-duplicates */
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { endOfDay, subDays } from 'date-fns';
import { format } from 'date-fns/fp';
import { createRequester } from '#src/utils/test-utils.js';
/* eslint-enable import/no-duplicates */
import dashboardRoutes from '#src/routes/dashboard.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const totalUserCount = 1000;
const countUsers = jest.fn(async () => ({ count: totalUserCount }));
const getDailyNewUserCountsByTimeInterval = jest.fn(
async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyNewUserCounts
);
const formatToQueryDate = format('yyyy-MM-dd');
jest.mock('#src/queries/user.js', () => ({
countUsers: async () => countUsers(),
getDailyNewUserCountsByTimeInterval: async (
startTimeExclusive: number,
endTimeInclusive: number
) => getDailyNewUserCountsByTimeInterval(startTimeExclusive, endTimeInclusive),
const { countUsers, getDailyNewUserCountsByTimeInterval } = mockEsm('#src/queries/user.js', () => ({
countUsers: jest.fn(async () => ({ count: totalUserCount })),
getDailyNewUserCountsByTimeInterval: jest.fn(async () => mockDailyNewUserCounts),
}));
const mockDailyNewUserCounts = [
@ -44,21 +39,14 @@ const mockDailyActiveUserCounts = [
const mockActiveUserCount = 1000;
const getDailyActiveUserCountsByTimeInterval = jest.fn(
async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyActiveUserCounts
const { getDailyActiveUserCountsByTimeInterval, countActiveUsersByTimeInterval } = mockEsm(
'#src/queries/log.js',
() => ({
getDailyActiveUserCountsByTimeInterval: jest.fn().mockResolvedValue(mockDailyActiveUserCounts),
countActiveUsersByTimeInterval: jest.fn().mockResolvedValue({ count: mockActiveUserCount }),
})
);
const countActiveUsersByTimeInterval = jest.fn(
async (startTimeExclusive: number, endTimeInclusive: number) => ({ count: mockActiveUserCount })
);
jest.mock('#src/queries/log.js', () => ({
getDailyActiveUserCountsByTimeInterval: async (
startTimeExclusive: number,
endTimeInclusive: number
) => getDailyActiveUserCountsByTimeInterval(startTimeExclusive, endTimeInclusive),
countActiveUsersByTimeInterval: async (startTimeExclusive: number, endTimeInclusive: number) =>
countActiveUsersByTimeInterval(startTimeExclusive, endTimeInclusive),
}));
const dashboardRoutes = await pickDefault(import('./dashboard.js'));
describe('dashboardRoutes', () => {
const logRequest = createRequester({ authedRoutes: dashboardRoutes });

View file

@ -1,10 +1,7 @@
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { getLogtoConnectorById } from '#src/connectors/index.js';
import { assignInteractionResults } from '#src/lib/session.js';
import { encryptUserPassword, generateUserId, insertUser } from '#src/lib/user.js';
import { updateUserById } from '#src/queries/user.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
@ -14,19 +11,20 @@ import type {
VerifiedSignInInteractionResult,
VerifiedForgotPasswordInteractionResult,
} from '../types/index.js';
import submitInteraction from './submit-interaction.js';
jest.mock('#src/connectors/index.js', () => ({
const { jest } = import.meta;
const { getLogtoConnectorById } = mockEsm('#src/connectors/index.js', () => ({
getLogtoConnectorById: jest
.fn()
.mockResolvedValue({ metadata: { target: 'logto' }, dbEntry: { syncProfile: true } }),
}));
jest.mock('#src/lib/session.js', () => ({
const { assignInteractionResults } = mockEsm('#src/lib/session.js', () => ({
assignInteractionResults: jest.fn(),
}));
jest.mock('#src/lib/user.js', () => ({
const { encryptUserPassword, generateUserId, insertUser } = mockEsm('#src/lib/user.js', () => ({
encryptUserPassword: jest.fn().mockResolvedValue({
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
@ -35,25 +33,21 @@ jest.mock('#src/lib/user.js', () => ({
insertUser: jest.fn(),
}));
jest.mock('#src/queries/user.js', () => ({
mockEsm('#src/queries/user.js', () => ({
findUserById: jest
.fn()
.mockResolvedValue({ identities: { google: { userId: 'googleId', details: {} } } }),
updateUserById: jest.fn(),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
const { updateUserById } = await import('#src/queries/user.js');
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
const now = Date.now();
jest.useFakeTimers().setSystemTime(now);
describe('submit action', () => {
const provider = new Provider('');
const provider = createMockProvider();
const log = jest.fn();
const ctx: InteractionContext = {
...createContextWithRouteParameters(),
@ -116,7 +110,7 @@ describe('submit action', () => {
});
it('sign-in', async () => {
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});

View file

@ -1,21 +1,16 @@
import { ConnectorType } from '@logto/connector-kit';
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { demoAppApplicationId } from '@logto/schemas/lib/seeds/application.js';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createRequester } from '#src/utils/test-utils.js';
import submitInteraction from './actions/submit-interaction.js';
import interactionRoutes, { verificationPrefix, interactionPrefix } from './index.js';
import type { InteractionContext } from './types/index.js';
import { getInteractionStorage } from './utils/interaction.js';
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
import {
verifyIdentifier,
verifyProfile,
validateMandatoryUserProfile,
} from './verifications/index.js';
const { jest } = import.meta;
// FIXME @Darcy: no more `enabled` for `connectors` table
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
@ -36,7 +31,7 @@ const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
};
});
jest.mock('#src/connectors.js', () => ({
await mockEsmWithActual('#src/connectors/index.js', () => ({
getLogtoConnectorById: jest.fn(async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
@ -51,57 +46,67 @@ jest.mock('#src/connectors.js', () => ({
}),
}));
jest.mock('./utils/passcode-validation.js', () => ({
sendPasscodeToIdentifier: jest.fn(),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn().mockResolvedValue({
jti: 'jti',
result: {},
params: {
client_id: 'demo_app',
},
}),
})),
}));
jest.mock('#src/lib/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
jest.mock('./verifications/index.js', () => ({
verifyIdentifier: jest.fn(),
verifyProfile: jest.fn(),
validateMandatoryUserProfile: jest.fn(),
}));
jest.mock('./actions/submit-interaction.js', () =>
jest.fn((_interaction, ctx: InteractionContext) => {
ctx.body = { redirectUri: 'logto.io' };
const { sendPasscodeToIdentifier } = await mockEsmWithActual(
'./utils/passcode-validation.js',
() => ({
sendPasscodeToIdentifier: jest.fn(),
})
);
jest.mock('./utils/interaction.js', () => ({
mockEsm('#src/lib/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
const { verifyIdentifier, verifyProfile, validateMandatoryUserProfile } = mockEsm(
'./verifications/index.js',
() => ({
verifyIdentifier: jest.fn(),
verifyProfile: jest.fn(),
validateMandatoryUserProfile: jest.fn(),
})
);
const { default: submitInteraction } = mockEsm('./actions/submit-interaction.js', () => ({
default: jest.fn((_interaction, ctx: InteractionContext) => {
ctx.body = { redirectUri: 'logto.io' };
}),
}));
const { getInteractionStorage } = mockEsm('./utils/interaction.js', () => ({
getInteractionStorage: jest.fn(),
}));
const log = jest.fn();
const koaInteractionBodyGuardSpy = jest.spyOn(
jest.requireActual('./middleware/koa-interaction-body-guard.js'),
'default'
const koaInteractionBodyGuard = await pickDefault(
import('./middleware/koa-interaction-body-guard.js')
);
const koaSessionSignInExperienceGuardSpy = jest.spyOn(
jest.requireActual('./middleware/koa-session-sign-in-experience-guard.js'),
'default'
const koaSessionSignInExperienceGuard = await pickDefault(
import('./middleware/koa-session-sign-in-experience-guard.js')
);
const koaInteractionBodyGuardSpy = mockEsmDefault(
'./middleware/koa-interaction-body-guard.js',
() => jest.fn(koaInteractionBodyGuard)
);
const koaSessionSignInExperienceGuardSpy = mockEsmDefault(
'./middleware/koa-session-sign-in-experience-guard.js',
() => jest.fn(koaSessionSignInExperienceGuard)
);
const {
default: interactionRoutes,
verificationPrefix,
interactionPrefix,
} = await import('./index.js');
describe('session -> interactionRoutes', () => {
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
provider: new Provider(''),
provider: createMockProvider(
jest.fn().mockResolvedValue({ params: {}, jti: 'jti', client_id: demoAppApplicationId })
),
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
@ -162,14 +167,13 @@ describe('session -> interactionRoutes', () => {
describe('PATCH /interaction', () => {
const path = interactionPrefix;
const getInteractionStorageMock = getInteractionStorage as jest.Mock;
afterEach(() => {
jest.clearAllMocks();
});
it('sign-in event with register event interaction session in record should call methods properly', async () => {
getInteractionStorageMock.mockResolvedValueOnce({ event: Event.Register });
getInteractionStorage.mockResolvedValueOnce({ event: Event.Register });
const body = {
event: Event.SignIn,
@ -185,7 +189,7 @@ describe('session -> interactionRoutes', () => {
});
it('sign-in event with forgot password event interaction session in record should reject', async () => {
getInteractionStorageMock.mockResolvedValueOnce({ event: Event.ForgotPassword });
getInteractionStorage.mockResolvedValueOnce({ event: Event.ForgotPassword });
const body = {
event: Event.SignIn,
@ -200,7 +204,7 @@ describe('session -> interactionRoutes', () => {
});
it('Forgot event with forgot password event interaction session in record should call methods properly', async () => {
getInteractionStorageMock.mockResolvedValueOnce({ event: Event.ForgotPassword });
getInteractionStorage.mockResolvedValueOnce({ event: Event.ForgotPassword });
const body = {
event: Event.ForgotPassword,
@ -216,7 +220,7 @@ describe('session -> interactionRoutes', () => {
});
it('Forgot event with sign-in event interaction session in record should call methods properly', async () => {
getInteractionStorageMock.mockResolvedValueOnce({ event: Event.SignIn });
getInteractionStorage.mockResolvedValueOnce({ event: Event.SignIn });
const body = {
event: Event.ForgotPassword,

View file

@ -1,20 +1,17 @@
import { Event } from '@logto/schemas';
import { mockEsmDefault, pickDefault } from '@logto/shared/esm';
import type { Context } from 'koa';
import { interactionMocks } from '#src/__mocks__/interactions.js';
import { emptyMiddleware, createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js';
import koaInteractionBodyGuard from './koa-interaction-body-guard.js';
jest.mock('koa-body', () => emptyMiddleware);
const { jest } = import.meta;
// User this to bypass the context type assertion
const mockIdentifierPayload = Object.freeze({
type: 'username_password',
username: 'username',
password: 'password',
});
mockEsmDefault('koa-body', () => emptyMiddleware);
const koaInteractionBodyGuard = await pickDefault(import('./koa-interaction-body-guard.js'));
describe('koaInteractionBodyGuard', () => {
const baseCtx = createContextWithRouteParameters();

View file

@ -1,31 +1,27 @@
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import {
signInModeValidation,
identifierValidation,
profileValidation,
} from '../utils/sign-in-experience-validation.js';
import koaSessionSignInExperienceGuard from './koa-session-sign-in-experience-guard.js';
const { jest } = import.meta;
jest.mock('#src/lib/sign-in-experience/index.js', () => ({
mockEsm('#src/lib/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
jest.mock('../utils/sign-in-experience-validation.js', () => ({
const mockUtils = {
signInModeValidation: jest.fn(),
identifierValidation: jest.fn(),
profileValidation: jest.fn(),
}));
};
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {} })),
})),
}));
mockEsm('../utils/sign-in-experience-validation.js', () => mockUtils);
const koaSessionSignInExperienceGuard = await pickDefault(
import('./koa-session-sign-in-experience-guard.js')
);
describe('koaSessionSignInExperienceGuard', () => {
const baseCtx = createContextWithRouteParameters();
@ -41,14 +37,15 @@ describe('koaSessionSignInExperienceGuard', () => {
}),
signInExperience: mockSignInExperience,
};
const provider = createMockProvider();
await koaSessionSignInExperienceGuard(new Provider(''))(ctx, next);
await koaSessionSignInExperienceGuard(provider)(ctx, next);
expect(signInModeValidation).toBeCalledWith(Event.SignIn, mockSignInExperience);
expect(identifierValidation).toBeCalledWith(
expect(mockUtils.signInModeValidation).toBeCalledWith(Event.SignIn, mockSignInExperience);
expect(mockUtils.identifierValidation).toBeCalledWith(
{ username: 'username', password: 'password' },
mockSignInExperience
);
expect(profileValidation).toBeCalledWith({ email: 'email' }, mockSignInExperience);
expect(mockUtils.profileValidation).toBeCalledWith({ email: 'email' }, mockSignInExperience);
});
});

View file

@ -1,43 +1,41 @@
import { getLogtoConnectorById } from '#src/connectors/index.js';
import {
findUserByEmail,
findUserByUsername,
findUserByPhone,
findUserByIdentity,
} from '#src/queries/user.js';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import findUserByIdentifier from './find-user-by-identifier.js';
const { jest } = import.meta;
jest.mock('#src/queries/user.js', () => ({
const queries = {
findUserByEmail: jest.fn(),
findUserByUsername: jest.fn(),
findUserByPhone: jest.fn(),
findUserByIdentity: jest.fn(),
}));
};
jest.mock('#src/connectors/index.js', () => ({
mockEsm('#src/queries/user.js', () => queries);
const { getLogtoConnectorById } = mockEsm('#src/connectors/index.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({ metadata: { target: 'logto' } }),
}));
const findUserByIdentifier = await pickDefault(import('./find-user-by-identifier.js'));
describe('findUserByIdentifier', () => {
it('username', async () => {
await findUserByIdentifier({ username: 'foo' });
expect(findUserByUsername).toBeCalledWith('foo');
expect(queries.findUserByUsername).toBeCalledWith('foo');
});
it('email', async () => {
await findUserByIdentifier({ email: 'foo@logto.io' });
expect(findUserByEmail).toBeCalledWith('foo@logto.io');
expect(queries.findUserByEmail).toBeCalledWith('foo@logto.io');
});
it('phone', async () => {
await findUserByIdentifier({ phone: '123456' });
expect(findUserByPhone).toBeCalledWith('123456');
expect(queries.findUserByPhone).toBeCalledWith('123456');
});
it('social', async () => {
await findUserByIdentifier({ connectorId: 'connector', userInfo: { id: 'foo' } });
expect(getLogtoConnectorById).toBeCalledWith('connector');
expect(findUserByIdentity).toBeCalledWith('logto', 'foo');
expect(queries.findUserByIdentity).toBeCalledWith('logto', 'foo');
});
});

View file

@ -1,14 +1,17 @@
import { PasscodeType, Event } from '@logto/schemas';
import { createPasscode, sendPasscode } from '#src/lib/passcode.js';
import { mockEsmWithActual } from '@logto/shared/esm';
import type { SendPasscodePayload } from '../types/index.js';
import { sendPasscodeToIdentifier } from './passcode-validation.js';
jest.mock('#src/lib/passcode.js', () => ({
const { jest } = import.meta;
const passcode = {
createPasscode: jest.fn(() => ({})),
sendPasscode: jest.fn().mockResolvedValue({ dbEntry: { id: 'foo' } }),
}));
};
await mockEsmWithActual('#src/lib/passcode.js', () => passcode);
const { sendPasscodeToIdentifier } = await import('./passcode-validation.js');
const sendPasscodeTestCase = [
{
@ -38,8 +41,6 @@ const sendPasscodeTestCase = [
];
describe('passcode-validation utils', () => {
const createPasscodeMock = createPasscode as jest.Mock;
const sendPasscodeMock = sendPasscode as jest.Mock;
const log = jest.fn();
afterEach(() => {
@ -50,8 +51,8 @@ describe('passcode-validation utils', () => {
'send passcode successfully',
async ({ payload, createPasscodeParams }) => {
await sendPasscodeToIdentifier(payload as SendPasscodePayload, 'jti', log);
expect(createPasscodeMock).toBeCalledWith('jti', ...createPasscodeParams);
expect(sendPasscodeMock).toBeCalled();
expect(passcode.createPasscode).toBeCalledWith('jti', ...createPasscodeParams);
expect(passcode.sendPasscode).toBeCalled();
}
);
});

View file

@ -1,14 +1,13 @@
import { ConnectorType } from '@logto/connector-kit';
import { mockEsm } from '@logto/shared/esm';
import { getUserInfoByAuthCode } from '#src/lib/social.js';
const { jest } = import.meta;
import { verifySocialIdentity } from './social-verification.js';
jest.mock('#src/lib/social.js', () => ({
const { getUserInfoByAuthCode } = mockEsm('#src/lib/social.js', () => ({
getUserInfoByAuthCode: jest.fn().mockResolvedValue({ id: 'foo' }),
}));
jest.mock('#src/connectors.js', () => ({
mockEsm('#src/connectors.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
metadata: {
id: 'social',
@ -18,6 +17,7 @@ jest.mock('#src/connectors.js', () => ({
}),
}));
const { verifySocialIdentity } = await import('./social-verification.js');
const log = jest.fn();
describe('social-verification', () => {

View file

@ -1,55 +1,47 @@
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { verifyUserPassword } from '#src/lib/user.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { AnonymousInteractionResult, VerifiedPhoneIdentifier } from '../types/index.js';
import findUserByIdentifier from '../utils/find-user-by-identifier.js';
import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js';
import { verifySocialIdentity } from '../utils/social-verification.js';
import identifierPayloadVerification from './identifier-payload-verification.js';
jest.mock('#src/lib/user.js', () => ({
const { jest } = import.meta;
const { verifyUserPassword } = mockEsm('#src/lib/user.js', () => ({
verifyUserPassword: jest.fn(),
}));
jest.mock('../utils/find-user-by-identifier.js', () => jest.fn());
const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn());
jest.mock('../utils/interaction.js', () => ({
...jest.requireActual('../utils/interaction.js'),
await mockEsmWithActual('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
jest.mock('../utils/passcode-validation.js', () => ({
const { verifyIdentifierByPasscode } = mockEsm('../utils/passcode-validation.js', () => ({
verifyIdentifierByPasscode: jest.fn(),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
jest.mock('../utils/social-verification.js', () => ({
const { verifySocialIdentity } = mockEsm('../utils/social-verification.js', () => ({
verifySocialIdentity: jest.fn().mockResolvedValue({ id: 'foo' }),
}));
const identifierPayloadVerification = await pickDefault(
import('./identifier-payload-verification.js')
);
const log = jest.fn();
describe('identifier verification', () => {
const baseCtx = { ...createContextWithRouteParameters(), log };
const verifyUserPasswordMock = verifyUserPassword as jest.Mock;
const findUserByIdentifierMock = findUserByIdentifier as jest.Mock;
const verifyIdentifierByPasscodeMock = verifyIdentifierByPasscode as jest.Mock;
afterEach(() => {
jest.clearAllMocks();
});
it('username password user not found', async () => {
findUserByIdentifierMock.mockResolvedValueOnce(null);
findUserByIdentifier.mockResolvedValueOnce(null);
const identifier = {
username: 'username',
@ -64,14 +56,14 @@ describe('identifier verification', () => {
}),
};
await expect(identifierPayloadVerification(ctx, new Provider(''))).rejects.toThrow();
await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toThrow();
expect(findUserByIdentifier).toBeCalledWith({ username: 'username' });
expect(verifyUserPassword).toBeCalledWith(null, 'password');
});
it('username password user is suspended', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
verifyUserPasswordMock.mockResolvedValueOnce({ id: 'foo', isSuspended: true });
findUserByIdentifier.mockResolvedValueOnce({ id: 'foo' });
verifyUserPassword.mockResolvedValueOnce({ id: 'foo', isSuspended: true });
const identifier = {
username: 'username',
password: 'password',
@ -85,7 +77,7 @@ describe('identifier verification', () => {
}),
};
await expect(identifierPayloadVerification(ctx, new Provider(''))).rejects.toMatchError(
await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toMatchError(
new RequestError({ code: 'user.suspended', status: 401 })
);
@ -94,8 +86,8 @@ describe('identifier verification', () => {
});
it('email password', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
verifyUserPasswordMock.mockResolvedValueOnce({ id: 'foo', isSuspended: false });
findUserByIdentifier.mockResolvedValueOnce({ id: 'foo' });
verifyUserPassword.mockResolvedValueOnce({ id: 'foo', isSuspended: false });
const identifier = {
email: 'email',
@ -110,7 +102,7 @@ describe('identifier verification', () => {
}),
};
const result = await identifierPayloadVerification(ctx, new Provider(''));
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(findUserByIdentifier).toBeCalledWith({ email: 'email' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
expect(result).toEqual({
@ -120,8 +112,8 @@ describe('identifier verification', () => {
});
it('phone password', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
verifyUserPasswordMock.mockResolvedValueOnce({ id: 'foo', isSuspended: false });
findUserByIdentifier.mockResolvedValueOnce({ id: 'foo' });
verifyUserPassword.mockResolvedValueOnce({ id: 'foo', isSuspended: false });
const identifier = {
phone: 'phone',
@ -136,7 +128,7 @@ describe('identifier verification', () => {
}),
};
const result = await identifierPayloadVerification(ctx, new Provider(''));
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(findUserByIdentifier).toBeCalledWith({ phone: 'phone' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
expect(result).toEqual({
@ -156,8 +148,8 @@ describe('identifier verification', () => {
}),
};
const result = await identifierPayloadVerification(ctx, new Provider(''));
expect(verifyIdentifierByPasscodeMock).toBeCalledWith(
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
@ -180,8 +172,8 @@ describe('identifier verification', () => {
}),
};
const result = await identifierPayloadVerification(ctx, new Provider(''));
expect(verifyIdentifierByPasscodeMock).toBeCalledWith(
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log
@ -204,10 +196,10 @@ describe('identifier verification', () => {
}),
};
const result = await identifierPayloadVerification(ctx, new Provider(''));
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(verifySocialIdentity).toBeCalledWith(identifier, log);
expect(findUserByIdentifierMock).not.toBeCalled();
expect(findUserByIdentifier).not.toBeCalled();
expect(result).toEqual({
event: Event.SignIn,
@ -242,7 +234,11 @@ describe('identifier verification', () => {
}),
};
const result = await identifierPayloadVerification(ctx, new Provider(''), interactionRecord);
const result = await identifierPayloadVerification(
ctx,
createMockProvider(),
interactionRecord
);
expect(result).toEqual({
event: Event.SignIn,
identifiers: [
@ -273,7 +269,7 @@ describe('identifier verification', () => {
}),
};
await expect(identifierPayloadVerification(ctx, new Provider(''))).rejects.toMatchError(
await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toMatchError(
new RequestError('session.connector_session_not_found')
);
});
@ -303,7 +299,7 @@ describe('identifier verification', () => {
};
await expect(
identifierPayloadVerification(ctx, new Provider(''), interactionRecord)
identifierPayloadVerification(ctx, createMockProvider(), interactionRecord)
).rejects.toMatchError(new RequestError('session.connector_session_not_found'));
});
@ -319,12 +315,12 @@ describe('identifier verification', () => {
}),
};
const result = await identifierPayloadVerification(ctx, new Provider(''), {
const result = await identifierPayloadVerification(ctx, createMockProvider(), {
event: Event.Register,
identifiers: [oldIdentifier],
});
expect(verifyIdentifierByPasscodeMock).toBeCalledWith(
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
log

View file

@ -1,28 +1,26 @@
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
import { findUserById } from '#src/queries/user.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
import { isUserPasswordSet } from '../utils/index.js';
import validateMandatoryUserProfile from './mandatory-user-profile-validation.js';
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
const { jest } = import.meta;
jest.mock('#src/queries/user.js', () => ({
const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn(),
}));
jest.mock('../utils/index.js', () => ({
const { isUserPasswordSet } = mockEsm('../utils/index.js', () => ({
isUserPasswordSet: jest.fn(),
}));
const validateMandatoryUserProfile = await pickDefault(
import('./mandatory-user-profile-validation.js')
);
describe('validateMandatoryUserProfile', () => {
const baseCtx = createContextWithRouteParameters();
const interaction: IdentifierVerifiedInteractionResult = {
@ -55,10 +53,10 @@ describe('validateMandatoryUserProfile', () => {
});
it('user account has username and password', async () => {
(findUserById as jest.Mock).mockResolvedValueOnce({
findUserById.mockResolvedValueOnce({
username: 'foo',
});
(isUserPasswordSet as jest.Mock).mockResolvedValueOnce(true);
isUserPasswordSet.mockResolvedValueOnce(true);
const ctx = {
...baseCtx,
@ -86,7 +84,7 @@ describe('validateMandatoryUserProfile', () => {
});
it('user account has email', async () => {
(findUserById as jest.Mock).mockResolvedValueOnce({
findUserById.mockResolvedValueOnce({
primaryEmail: 'email',
});
@ -119,7 +117,7 @@ describe('validateMandatoryUserProfile', () => {
});
it('user account has phone', async () => {
(findUserById as jest.Mock).mockResolvedValueOnce({
findUserById.mockResolvedValueOnce({
primaryPhone: 'phone',
});

View file

@ -1,35 +1,30 @@
import { Event } from '@logto/schemas';
import { argon2Verify } from 'hash-wasm';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { findUserById } from '#src/queries/user.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { InteractionContext } from '../types/index.js';
import { storeInteractionResult } from '../utils/interaction.js';
import verifyProfile from './profile-verification.js';
jest.mock('../utils/interaction.js', () => ({
const { jest } = import.meta;
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
jest.mock('#src/queries/user.js', () => ({
const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn().mockResolvedValue({ id: 'foo', passwordEncrypted: 'passwordHash' }),
}));
jest.mock('hash-wasm', () => ({
const { argon2Verify } = mockEsm('hash-wasm', () => ({
argon2Verify: jest.fn(),
}));
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('forgot password interaction profile verification', () => {
const provider = new Provider('');
const provider = createMockProvider();
const baseCtx = createContextWithRouteParameters();
const interaction = {
@ -55,7 +50,7 @@ describe('forgot password interaction profile verification', () => {
});
it('same password', async () => {
(argon2Verify as jest.Mock).mockResolvedValueOnce(true);
argon2Verify.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {

View file

@ -1,8 +1,8 @@
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { findUserById } from '#src/queries/user.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
@ -10,32 +10,28 @@ import type {
IdentifierVerifiedInteractionResult,
InteractionContext,
} from '../types/index.js';
import { storeInteractionResult } from '../utils/interaction.js';
import verifyProfile from './profile-verification.js';
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
const { jest } = import.meta;
jest.mock('#src/queries/user.js', () => ({
const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
jest.mock('../utils/interaction.js', () => ({
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
jest.mock('../utils/index.js', () => ({
mockEsm('../utils/index.js', () => ({
isUserPasswordSet: jest.fn().mockResolvedValueOnce(true),
}));
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('Should throw when providing existing identifiers in profile', () => {
const provider = new Provider('');
const provider = createMockProvider();
const baseCtx = createContextWithRouteParameters();
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
@ -54,7 +50,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
});
it('username exists', async () => {
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', username: 'foo' });
findUserById.mockResolvedValueOnce({ id: 'foo', username: 'foo' });
const ctx: InteractionContext = {
...baseCtx,
@ -75,7 +71,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
});
it('email exists', async () => {
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', primaryEmail: 'email' });
findUserById.mockResolvedValueOnce({ id: 'foo', primaryEmail: 'email' });
const ctx: InteractionContext = {
...baseCtx,
@ -96,7 +92,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
});
it('phone exists', async () => {
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', primaryPhone: 'phone' });
findUserById.mockResolvedValueOnce({ id: 'foo', primaryPhone: 'phone' });
const ctx: InteractionContext = {
...baseCtx,
@ -117,7 +113,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
});
it('password exists', async () => {
(findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo' });
findUserById.mockResolvedValueOnce({ id: 'foo' });
const ctx: InteractionContext = {
...baseCtx,

View file

@ -1,13 +1,8 @@
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import {
hasUser,
hasUserWithEmail,
hasUserWithPhone,
hasUserWithIdentity,
} from '#src/queries/user.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
@ -15,33 +10,30 @@ import type {
InteractionContext,
IdentifierVerifiedInteractionResult,
} from '../types/index.js';
import { storeInteractionResult } from '../utils/interaction.js';
import verifyProfile from './profile-verification.js';
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
const { jest } = import.meta;
jest.mock('../utils/interaction.js', () => ({
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
jest.mock('#src/queries/user.js', () => ({
hasUser: jest.fn().mockResolvedValue(false),
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } =
await mockEsmWithActual('#src/queries/user.js', () => ({
hasUser: jest.fn().mockResolvedValue(false),
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
jest.mock('#src/connectors/index.js', () => ({
mockEsm('#src/connectors/index.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
metadata: { target: 'logto' },
}),
}));
const verifyProfile = await pickDefault(import('./profile-verification.js'));
const baseCtx = createContextWithRouteParameters();
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
@ -49,7 +41,7 @@ const identifiers: Identifier[] = [
{ key: 'phoneVerified', value: '123456' },
{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } },
];
const provider = new Provider('');
const provider = createMockProvider();
const interaction: IdentifierVerifiedInteractionResult = {
event: Event.Register,
@ -140,7 +132,7 @@ describe('register payload guard', () => {
describe('profile registered validation', () => {
it('username is registered', async () => {
(hasUser as jest.Mock).mockResolvedValueOnce(true);
hasUser.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
@ -163,7 +155,7 @@ describe('profile registered validation', () => {
});
it('email is registered', async () => {
(hasUserWithEmail as jest.Mock).mockResolvedValueOnce(true);
hasUserWithEmail.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
@ -185,7 +177,7 @@ describe('profile registered validation', () => {
});
it('phone is registered', async () => {
(hasUserWithPhone as jest.Mock).mockResolvedValueOnce(true);
hasUserWithPhone.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
@ -207,7 +199,7 @@ describe('profile registered validation', () => {
});
it('connector identity exist', async () => {
(hasUserWithIdentity as jest.Mock).mockResolvedValueOnce(true);
hasUserWithIdentity.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,

View file

@ -1,40 +1,37 @@
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { Identifier, InteractionContext } from '../types/index.js';
import { storeInteractionResult } from '../utils/interaction.js';
import verifyProfile from './profile-verification.js';
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
const { jest } = import.meta;
jest.mock('../utils/interaction.js', () => ({
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
jest.mock('#src/queries/user.js', () => ({
await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
jest.mock('#src/connectors/index.js', () => ({
mockEsm('#src/connectors/index.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
metadata: { target: 'logto' },
}),
}));
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('profile protected identifier verification', () => {
const baseCtx = createContextWithRouteParameters();
const interaction = { event: Event.SignIn, accountId: 'foo' };
const provider = new Provider('');
const provider = createMockProvider();
afterEach(() => {
jest.clearAllMocks();

View file

@ -1,32 +1,28 @@
import { Event } from '@logto/schemas';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { InteractionContext, PayloadVerifiedInteractionResult } from '../types/index.js';
import findUserByIdentifier from '../utils/find-user-by-identifier.js';
import { storeInteractionResult } from '../utils/interaction.js';
import userAccountVerification from './user-identity-verification.js';
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })),
})),
}));
const { jest } = import.meta;
jest.mock('../utils/find-user-by-identifier.js', () => jest.fn());
jest.mock('#src/lib/social.js', () => ({
const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn());
mockEsm('#src/lib/social.js', () => ({
findSocialRelatedUser: jest.fn().mockResolvedValue(null),
}));
jest.mock('../utils/interaction.js', () => ({
...jest.requireActual('../utils/interaction.js'),
const { storeInteractionResult } = await mockEsmWithActual('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
const userAccountVerification = await pickDefault(import('./user-identity-verification.js'));
describe('userAccountVerification', () => {
const findUserByIdentifierMock = findUserByIdentifier as jest.Mock;
const findUserByIdentifierMock = findUserByIdentifier;
const ctx: InteractionContext = {
...createContextWithRouteParameters(),
@ -34,7 +30,7 @@ describe('userAccountVerification', () => {
event: Event.SignIn,
},
};
const provider = new Provider('');
const provider = createMockProvider();
afterEach(() => {
jest.clearAllMocks();

View file

@ -1,25 +1,21 @@
import type { LogCondition } from '#src/queries/log.js';
import logRoutes from '#src/routes/log.js';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const mockBody = { type: 'a', payload: {}, createdAt: 123 };
const mockLog = { id: '1', ...mockBody };
const mockLogs = [mockLog, { id: '2', ...mockBody }];
const countLogs = jest.fn(async (condition: LogCondition) => ({
count: mockLogs.length,
}));
const findLogs = jest.fn(
async (limit: number, offset: number, condition: LogCondition) => mockLogs
);
const findLogById = jest.fn(async (id: string) => mockLog);
jest.mock('#src/queries/log.js', () => ({
countLogs: async (condition: LogCondition) => countLogs(condition),
findLogs: async (limit: number, offset: number, condition: LogCondition) =>
findLogs(limit, offset, condition),
findLogById: async (id: string) => findLogById(id),
const { countLogs, findLogs, findLogById } = mockEsm('#src/queries/log.js', () => ({
countLogs: jest.fn().mockResolvedValue({
count: mockLogs.length,
}),
findLogs: jest.fn().mockResolvedValue(mockLogs),
findLogById: jest.fn().mockResolvedValue(mockLog),
}));
const logRoutes = await pickDefault(import('./log.js'));
describe('logRoutes', () => {
const logRequest = createRequester({ authedRoutes: logRoutes });

View file

@ -1,51 +1,42 @@
import en from '@logto/phrases-ui/lib/locales/en.js';
import { Provider } from 'oidc-provider';
import { mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { trTrTag, zhCnTag, zhHkTag } from '#src/__mocks__/custom-phrase.js';
import { mockSignInExperience } from '#src/__mocks__/index.js';
import phraseRoutes from '#src/routes/phrase.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createRequester } from '#src/utils/test-utils.js';
const mockApplicationId = 'mockApplicationIdValue';
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
params: { client_id: mockApplicationId },
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
})),
}));
const { jest } = import.meta;
const fallbackLanguage = trTrTag;
const unsupportedLanguageX = 'xx-XX';
const unsupportedLanguageY = 'yy-YY';
const findDefaultSignInExperience = jest.fn(async () => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage,
},
}));
const { findDefaultSignInExperience } = await mockEsmWithActual(
'#src/queries/sign-in-experience.js',
() => ({
findDefaultSignInExperience: jest.fn(async () => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage,
},
})),
})
);
jest.mock('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
}));
jest.mock('#src/queries/custom-phrase.js', () => ({
await mockEsmWithActual('#src/queries/custom-phrase.js', () => ({
findAllCustomLanguageTags: async () => [trTrTag, zhCnTag],
}));
jest.mock('#src/lib/phrase.js', () => ({
...jest.requireActual('#src/lib/phrase.js'),
await mockEsmWithActual('#src/lib/phrase.js', () => ({
getPhrase: jest.fn().mockResolvedValue(en),
}));
const phraseRoutes = await pickDefault(import('./phrase.js'));
const phraseRequest = createRequester({
anonymousRoutes: phraseRoutes,
provider: new Provider(''),
provider: createMockProvider(),
});
afterEach(() => {

View file

@ -4,67 +4,48 @@ import {
adminConsoleApplicationId,
adminConsoleSignInExperience,
} from '@logto/schemas/lib/seeds/index.js';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { zhCnTag } from '#src/__mocks__/custom-phrase.js';
import { mockSignInExperience } from '#src/__mocks__/index.js';
import * as detectLanguage from '#src/i18n/detect-language.js';
import phraseRoutes from '#src/routes/phrase.js';
import { createRequester } from '#src/utils/test-utils.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
const mockApplicationId = 'mockApplicationIdValue';
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
params: { client_id: mockApplicationId },
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
})),
}));
const { jest } = import.meta;
const customizedLanguage = zhCnTag;
const findDefaultSignInExperience = jest.fn(
async (): Promise<SignInExperience> => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: customizedLanguage,
},
})
);
jest.mock('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: jest.fn(
async (): Promise<SignInExperience> => ({
...mockSignInExperience,
languageInfo: {
autoDetect: true,
fallbackLanguage: customizedLanguage,
},
})
),
}));
const detectLanguageSpy = jest.spyOn(detectLanguage, 'default');
const findAllCustomLanguageTags = jest.fn(async () => [customizedLanguage]);
const findCustomPhraseByLanguageTag = jest.fn(async (tag: string) => ({}));
jest.mock('#src/queries/custom-phrase.js', () => ({
findAllCustomLanguageTags: async () => findAllCustomLanguageTags(),
findCustomPhraseByLanguageTag: async (tag: string) => findCustomPhraseByLanguageTag(tag),
const { default: detectLanguageSpy } = mockEsm('#src/i18n/detect-language.js', () => ({
default: jest.fn().mockReturnValue([]),
}));
const getPhrase = jest.fn(async (language: string, customLanguages: string[]) => zhCN);
jest.mock('#src/lib/phrase.js', () => ({
...jest.requireActual('#src/lib/phrase.js'),
getPhrase: async (language: string, customLanguages: string[]) =>
getPhrase(language, customLanguages),
const { findAllCustomLanguageTags } = mockEsm('#src/queries/custom-phrase.js', () => ({
findAllCustomLanguageTags: jest.fn(async () => [customizedLanguage]),
findCustomPhraseByLanguageTag: jest.fn(async (tag: string) => ({})),
}));
const { getPhrase } = await mockEsmWithActual('#src/lib/phrase.js', () => ({
getPhrase: jest.fn(async () => zhCN),
}));
const interactionDetails = jest.fn();
const phraseRoutes = await pickDefault(import('./phrase.js'));
const { createRequester } = await import('#src/utils/test-utils.js');
const phraseRequest = createRequester({
anonymousRoutes: phraseRoutes,
provider: new Provider(''),
});
afterEach(() => {
jest.clearAllMocks();
provider: createMockProvider(interactionDetails),
});
describe('when the application is admin-console', () => {
@ -74,6 +55,10 @@ describe('when the application is admin-console', () => {
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call interactionDetails', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(interactionDetails).toBeCalledTimes(1);
@ -104,6 +89,18 @@ describe('when the application is admin-console', () => {
});
describe('when the application is not admin-console', () => {
beforeEach(() => {
interactionDetails.mockResolvedValue({
params: {},
jti: 'jti',
client_id: 'mockApplicationId',
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should call interactionDetails', async () => {
await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200);
expect(interactionDetails).toBeCalledTimes(1);

View file

@ -1,8 +1,7 @@
/* eslint-disable max-lines */
import type { CreateUser, User } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { mockEsm, mockEsmWithActual } from '@logto/shared/esm';
import { getUnixTime } from 'date-fns';
import { Provider } from 'oidc-provider';
import {
mockLogtoConnectorList,
@ -11,101 +10,79 @@ import {
mockUserResponse,
} from '#src/__mocks__/index.js';
import type { SocialUserInfo } from '#src/connectors/types.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createRequester } from '#src/utils/test-utils.js';
import profileRoutes, { profileRoute } from './profile.js';
const { jest } = import.meta;
const mockFindUserById = jest.fn(async (): Promise<User> => mockUser);
const mockHasUser = jest.fn(async () => false);
const mockHasUserWithEmail = jest.fn(async () => false);
const mockHasUserWithPhone = jest.fn(async () => false);
const mockUpdateUserById = jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
);
const mockDeleteUserIdentity = jest.fn();
const encryptUserPassword = jest.fn(async (password: string) => ({
passwordEncrypted: password + '_user1',
passwordEncryptionMethod: 'Argon2i',
}));
const mockArgon2Verify = jest.fn(async (password: string) => password === mockPasswordEncrypted);
const mockGetSession = jest.fn();
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
Session: {
get: async () => mockGetSession(),
},
})),
}));
jest.mock('#src/lib/user.js', () => ({
...jest.requireActual('#src/lib/user.js'),
encryptUserPassword: async (password: string) => encryptUserPassword(password),
}));
const mockGetLogtoConnectorById = jest.fn(async () => ({
const getLogtoConnectorById = jest.fn(async () => ({
dbEntry: { enabled: true },
metadata: { id: 'connectorId', target: 'mock_social' },
type: ConnectorType.Social,
getAuthorizationUri: jest.fn(async () => ''),
}));
jest.mock('#src/connectors/index.js', () => ({
getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList),
getLogtoConnectorById: jest.fn(async () => mockGetLogtoConnectorById()),
mockEsm('#src/connectors/index.js', () => ({
getLogtoConnectors: mockLogtoConnectorList,
getLogtoConnectorById,
}));
const mockFindSocialRelatedUser = jest.fn(async () => [
{ id: 'user1', identities: {}, isSuspended: false },
]);
const mockGetUserInfoByAuthCode = jest.fn();
jest.mock('#src/lib/social.js', () => ({
...jest.requireActual('#src/lib/social.js'),
findSocialRelatedUser: async () => mockFindSocialRelatedUser(),
getUserInfoByAuthCode: async () => mockGetUserInfoByAuthCode(),
const { getUserInfoByAuthCode } = await mockEsmWithActual('#src/lib/social.js', () => ({
findSocialRelatedUser: jest.fn(async () => [{ id: 'user1', identities: {}, isSuspended: false }]),
getUserInfoByAuthCode: jest.fn(),
}));
jest.mock('#src/queries/user.js', () => ({
...jest.requireActual('#src/queries/user.js'),
findUserById: async () => mockFindUserById(),
hasUser: async () => mockHasUser(),
hasUserWithEmail: async () => mockHasUserWithEmail(),
hasUserWithPhone: async () => mockHasUserWithPhone(),
updateUserById: async (id: string, data: Partial<CreateUser>) => mockUpdateUserById(id, data),
deleteUserIdentity: async (...args: unknown[]) => mockDeleteUserIdentity(...args),
const {
findUserById,
hasUser,
hasUserWithEmail,
hasUserWithPhone,
updateUserById,
deleteUserIdentity,
} = await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn(async (): Promise<User> => mockUser),
hasUser: jest.fn(async () => false),
hasUserWithEmail: jest.fn(async () => false),
hasUserWithPhone: jest.fn(async () => false),
updateUserById: jest.fn(
async (_, data: Partial<CreateUser>): Promise<User> => ({
...mockUser,
...data,
})
),
deleteUserIdentity: jest.fn(),
}));
const mockFindDefaultSignInExperience = jest.fn(async () => ({
signUp: {
identifier: [],
password: false,
verify: false,
},
const { encryptUserPassword } = await mockEsmWithActual('#src/lib/user.js', () => ({
encryptUserPassword: jest.fn(async (password: string) => ({
passwordEncrypted: password + '_user1',
passwordEncryptionMethod: 'Argon2i',
})),
}));
jest.mock('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: jest.fn(async () => mockFindDefaultSignInExperience()),
mockEsm('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: async () => ({
signUp: {
identifier: [],
password: false,
verify: false,
},
}),
}));
jest.mock('hash-wasm', () => ({
argon2Verify: async (password: string) => mockArgon2Verify(password),
const { argon2Verify } = mockEsm('hash-wasm', () => ({
argon2Verify: jest.fn(async (password: string) => password === mockPasswordEncrypted),
}));
const { default: profileRoutes, profileRoute } = await import('./profile.js');
describe('session -> profileRoutes', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetSession.mockImplementation(async () => ({
accountId: 'id',
loginTs: getUnixTime(new Date()) - 60,
}));
});
const provider = createMockProvider();
// @ts-expect-error for testing
const mockGetSession: jest.Mock = jest.spyOn(provider.Session, 'get');
const sessionRequest = createRequester({
anonymousRoutes: profileRoutes,
provider: new Provider(''),
provider,
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
@ -116,6 +93,14 @@ describe('session -> profileRoutes', () => {
],
});
beforeEach(() => {
jest.clearAllMocks();
mockGetSession.mockImplementation(async () => ({
accountId: 'id',
loginTs: getUnixTime(new Date()) - 60,
}));
});
describe('GET /session/profile', () => {
it('should return current user data', async () => {
const response = await sessionRequest.get(profileRoute);
@ -146,7 +131,7 @@ describe('session -> profileRoutes', () => {
const response = await sessionRequest.patch(profileRoute).send(updatedUserInfo);
expect(mockUpdateUserById).toBeCalledWith('id', expect.objectContaining(updatedUserInfo));
expect(updateUserById).toBeCalledWith('id', expect.objectContaining(updatedUserInfo));
expect(response.statusCode).toEqual(204);
});
@ -175,7 +160,7 @@ describe('session -> profileRoutes', () => {
.send({ username: 'test' });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should update username with the new value', async () => {
@ -190,7 +175,7 @@ describe('session -> profileRoutes', () => {
});
it('should throw when username is already in use', async () => {
mockHasUser.mockImplementationOnce(async () => true);
hasUser.mockImplementationOnce(async () => true);
const response = await sessionRequest
.patch(`${profileRoute}/username`)
@ -212,7 +197,7 @@ describe('session -> profileRoutes', () => {
.send({ password: mockPasswordEncrypted });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should update password with the new value', async () => {
@ -220,7 +205,7 @@ describe('session -> profileRoutes', () => {
.patch(`${profileRoute}/password`)
.send({ password: mockPasswordEncrypted });
expect(mockUpdateUserById).toBeCalledWith(
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
passwordEncrypted: 'a1b2c3_user1',
@ -235,14 +220,14 @@ describe('session -> profileRoutes', () => {
passwordEncrypted: password,
passwordEncryptionMethod: 'Argon2i',
}));
mockArgon2Verify.mockResolvedValueOnce(true);
argon2Verify.mockResolvedValueOnce(true);
const response = await sessionRequest
.patch(`${profileRoute}/password`)
.send({ password: 'password' });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
});
@ -258,7 +243,7 @@ describe('session -> profileRoutes', () => {
.send({ primaryEmail: 'test@logto.io' });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should link email address to the user profile', async () => {
@ -267,7 +252,7 @@ describe('session -> profileRoutes', () => {
.patch(`${profileRoute}/email`)
.send({ primaryEmail: mockEmailAddress });
expect(mockUpdateUserById).toBeCalledWith(
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
primaryEmail: mockEmailAddress,
@ -277,25 +262,25 @@ describe('session -> profileRoutes', () => {
});
it('should throw when email address already exists', async () => {
mockHasUserWithEmail.mockImplementationOnce(async () => true);
hasUserWithEmail.mockImplementationOnce(async () => true);
const response = await sessionRequest
.patch(`${profileRoute}/email`)
.send({ primaryEmail: mockUser.primaryEmail });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should throw when email address is invalid', async () => {
mockHasUserWithEmail.mockImplementationOnce(async () => true);
hasUserWithEmail.mockImplementationOnce(async () => true);
const response = await sessionRequest
.patch(`${profileRoute}/email`)
.send({ primaryEmail: 'test' });
expect(response.statusCode).toEqual(400);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should throw if last authentication time is over 10 mins ago on unlinking email', async () => {
@ -307,13 +292,13 @@ describe('session -> profileRoutes', () => {
const response = await sessionRequest.delete(`${profileRoute}/email`);
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should unlink email address from user', async () => {
const response = await sessionRequest.delete(`${profileRoute}/email`);
expect(response.statusCode).toEqual(204);
expect(mockUpdateUserById).toBeCalledWith(
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
primaryEmail: null,
@ -322,11 +307,11 @@ describe('session -> profileRoutes', () => {
});
it('should throw when no email address found in user on unlinking email', async () => {
mockFindUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryEmail: null }));
findUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryEmail: null }));
const response = await sessionRequest.delete(`${profileRoute}/email`);
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
});
@ -342,7 +327,7 @@ describe('session -> profileRoutes', () => {
.send({ primaryPhone: '6533333333' });
expect(updateResponse.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should link phone number to the user profile', async () => {
@ -351,7 +336,7 @@ describe('session -> profileRoutes', () => {
.patch(`${profileRoute}/phone`)
.send({ primaryPhone: mockPhoneNumber });
expect(mockUpdateUserById).toBeCalledWith(
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
primaryPhone: mockPhoneNumber,
@ -361,25 +346,25 @@ describe('session -> profileRoutes', () => {
});
it('should throw when phone number already exists on linking phone number', async () => {
mockHasUserWithPhone.mockImplementationOnce(async () => true);
hasUserWithPhone.mockImplementationOnce(async () => true);
const response = await sessionRequest
.patch(`${profileRoute}/phone`)
.send({ primaryPhone: mockUser.primaryPhone });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should throw when phone number is invalid', async () => {
mockHasUserWithPhone.mockImplementationOnce(async () => true);
hasUserWithPhone.mockImplementationOnce(async () => true);
const response = await sessionRequest
.patch(`${profileRoute}/phone`)
.send({ primaryPhone: 'invalid' });
expect(response.statusCode).toEqual(400);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should throw if last authentication time is over 10 mins ago on unlinking phone number', async () => {
@ -391,13 +376,13 @@ describe('session -> profileRoutes', () => {
const response = await sessionRequest.delete(`${profileRoute}/phone`);
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should unlink phone number from user', async () => {
const response = await sessionRequest.delete(`${profileRoute}/phone`);
expect(response.statusCode).toEqual(204);
expect(mockUpdateUserById).toBeCalledWith(
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
primaryPhone: null,
@ -406,11 +391,11 @@ describe('session -> profileRoutes', () => {
});
it('should throw when no phone number found in user on unlinking phone number', async () => {
mockFindUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryPhone: null }));
findUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryPhone: null }));
const response = await sessionRequest.delete(`${profileRoute}/phone`);
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
});
@ -423,7 +408,7 @@ describe('session -> profileRoutes', () => {
email: 'johndoe@social.com',
phone: '123456789',
};
mockGetUserInfoByAuthCode.mockReturnValueOnce(mockSocialUserInfo);
getUserInfoByAuthCode.mockReturnValueOnce(mockSocialUserInfo);
const response = await sessionRequest.patch(`${profileRoute}/identities`).send({
connectorId: 'connectorId',
@ -431,7 +416,7 @@ describe('session -> profileRoutes', () => {
});
expect(response.statusCode).toEqual(204);
expect(mockUpdateUserById).toBeCalledWith(
expect(updateUserById).toBeCalledWith(
'id',
expect.objectContaining({
identities: {
@ -456,7 +441,7 @@ describe('session -> profileRoutes', () => {
});
expect(response.statusCode).toEqual(401);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should throw if last authentication time is over 10 mins ago on linking email', async () => {
@ -470,11 +455,11 @@ describe('session -> profileRoutes', () => {
.send({ connectorId: 'connectorId', data: { code: '123456' } });
expect(response.statusCode).toEqual(422);
expect(mockUpdateUserById).not.toBeCalled();
expect(updateUserById).not.toBeCalled();
});
it('should unlink social identities from user', async () => {
mockFindUserById.mockImplementationOnce(async () => ({
findUserById.mockImplementationOnce(async () => ({
...mockUser,
identities: {
mock_social: {
@ -490,8 +475,7 @@ describe('session -> profileRoutes', () => {
const response = await sessionRequest.delete(`${profileRoute}/identities/mock_social`);
expect(response.statusCode).toEqual(204);
expect(mockDeleteUserIdentity).toBeCalledWith('id', 'mock_social');
expect(deleteUserIdentity).toBeCalledWith('id', 'mock_social');
});
});
});
/* eslint-enable max-lines */

View file

@ -65,15 +65,12 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
}),
async (ctx, next) => {
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
const { username } = ctx.guard.body;
await checkIdentifierCollision({ username }, userId);
const user = await updateUserById(userId, { username }, 'replace');
ctx.body = pick(user, ...userInfoSelectFields);
return next();

View file

@ -1,34 +1,33 @@
import type { Resource, CreateResource } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { mockResource } from '#src/__mocks__/index.js';
import { createRequester } from '#src/utils/test-utils.js';
import resourceRoutes from './resource.js';
const { jest } = import.meta;
jest.mock('#src/queries/resource.js', () => ({
findTotalNumberOfResources: jest.fn(async () => ({ count: 10 })),
findAllResources: jest.fn(async (): Promise<Resource[]> => [mockResource]),
findResourceById: jest.fn(async (): Promise<Resource> => mockResource),
insertResource: jest.fn(
async (body: CreateResource): Promise<Resource> => ({
...mockResource,
...body,
})
),
updateResourceById: jest.fn(
async (_, data: Partial<CreateResource>): Promise<Resource> => ({
...mockResource,
...data,
})
),
mockEsm('#src/queries/resource.js', () => ({
findTotalNumberOfResources: async () => ({ count: 10 }),
findAllResources: async (): Promise<Resource[]> => [mockResource],
findResourceById: async (): Promise<Resource> => mockResource,
insertResource: async (body: CreateResource): Promise<Resource> => ({
...mockResource,
...body,
}),
updateResourceById: async (_: unknown, data: Partial<CreateResource>): Promise<Resource> => ({
...mockResource,
...data,
}),
deleteResourceById: jest.fn(),
}));
jest.mock('@logto/shared', () => ({
mockEsm('@logto/shared', () => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
buildIdGenerator: jest.fn(() => () => 'randomId'),
buildIdGenerator: () => () => 'randomId',
}));
const resourceRoutes = await pickDefault(import('./resource.js'));
describe('resource routes', () => {
const resourceRequest = createRequester({ authedRoutes: resourceRoutes });

View file

@ -1,13 +1,15 @@
import type { Role } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { mockRole } from '#src/__mocks__/index.js';
import { createRequester } from '#src/utils/test-utils.js';
import roleRoutes from './role.js';
const { jest } = import.meta;
jest.mock('#src/queries/roles.js', () => ({
mockEsm('#src/queries/roles.js', () => ({
findAllRoles: jest.fn(async (): Promise<Role[]> => [mockRole]),
}));
const roleRoutes = await pickDefault(import('./role.js'));
describe('role routes', () => {
const roleRequester = createRequester({ authedRoutes: roleRoutes });

View file

@ -1,11 +1,11 @@
import type { User } from '@logto/schemas';
import { UserRole, SignInIdentifier } from '@logto/schemas';
import { createMockContext } from '@shopify/jest-koa-mocks';
import type { Nullable } from '@silverhand/essentials';
import { Provider } from 'oidc-provider';
import { mockSignInExperience, mockSignInMethod, mockUser } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
import { checkRequiredProfile, signInWithPassword } from './utils.js';

View file

@ -1,20 +1,19 @@
import type { Setting, CreateSetting } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { mockSetting } from '#src/__mocks__/index.js';
import { createRequester } from '#src/utils/test-utils.js';
import settingRoutes from './setting.js';
jest.mock('#src/queries/setting.js', () => ({
getSetting: jest.fn(async (): Promise<Setting> => mockSetting),
updateSetting: jest.fn(
async (data: Partial<CreateSetting>): Promise<Setting> => ({
...mockSetting,
...data,
})
),
mockEsm('#src/queries/setting.js', () => ({
getSetting: async (): Promise<Setting> => mockSetting,
updateSetting: async (data: Partial<CreateSetting>): Promise<Setting> => ({
...mockSetting,
...data,
}),
}));
const settingRoutes = await pickDefault(import('./setting.js'));
describe('settings routes', () => {
const roleRequester = createRequester({ authedRoutes: settingRoutes });

View file

@ -1,24 +1,24 @@
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { mockBranding, mockSignInExperience } from '#src/__mocks__/index.js';
import { createRequester } from '#src/utils/test-utils.js';
import signInExperiencesRoutes from './sign-in-experience.js';
jest.mock('#src/queries/sign-in-experience.js', () => ({
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
await mockEsmWithActual('#src/queries/sign-in-experience.js', () => ({
updateDefaultSignInExperience: async (
data: Partial<CreateSignInExperience>
): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
}),
}));
jest.mock('#src/connectors.js', () => ({
getLogtoConnectors: jest.fn(async () => []),
mockEsm('#src/connectors.js', () => ({
getLogtoConnectors: async () => [],
}));
const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js'));
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
const expectPatchResponseStatus = async (

View file

@ -1,23 +1,23 @@
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { mockColor, mockSignInExperience } from '#src/__mocks__/index.js';
import { createRequester } from '#src/utils/test-utils.js';
import signInExperiencesRoutes from './sign-in-experience.js';
jest.mock('#src/queries/sign-in-experience.js', () => ({
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
await mockEsmWithActual('#src/queries/sign-in-experience.js', () => ({
updateDefaultSignInExperience: async (
data: Partial<CreateSignInExperience>
): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
}),
}));
jest.mock('#src/connectors.js', () => ({
getLogtoConnectors: jest.fn(async () => []),
mockEsm('#src/connectors.js', () => ({
getLogtoConnectors: async () => [],
}));
const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js'));
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
const expectPatchResponseStatus = async (

View file

@ -1,4 +1,5 @@
import type { CreateSignInExperience, LanguageInfo, SignInExperience } from '@logto/schemas';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import {
mockAliyunDmConnector,
@ -10,11 +11,10 @@ import {
mockSignInExperience,
mockTermsOfUse,
} from '#src/__mocks__/index.js';
import { createRequester } from '#src/utils/test-utils.js';
import signInExperiencesRoutes from './sign-in-experience.js';
const { jest } = import.meta;
jest.mock('#src/connectors.js', () => ({
mockEsm('#src/connectors.js', () => ({
getLogtoConnectors: jest.fn(async () => [
mockAliyunDmConnector,
mockAliyunSmsConnector,
@ -24,23 +24,21 @@ jest.mock('#src/connectors.js', () => ({
]),
}));
// eslint-disable-next-line @typescript-eslint/no-empty-function
const validateLanguageInfo = jest.fn(async (languageInfo: LanguageInfo): Promise<void> => {});
jest.mock('#src/lib/sign-in-experience.js', () => ({
...jest.requireActual('#src/lib/sign-in-experience.js'),
validateLanguageInfo: async (languageInfo: LanguageInfo) => validateLanguageInfo(languageInfo),
const { validateLanguageInfo } = await mockEsmWithActual('#src/lib/sign-in-experience.js', () => ({
validateLanguageInfo: jest.fn(),
}));
jest.mock('#src/queries/sign-in-experience.js', () => ({
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
await mockEsmWithActual('#src/queries/sign-in-experience.js', () => ({
updateDefaultSignInExperience: async (
data: Partial<CreateSignInExperience>
): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
}),
}));
const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js'));
const { createRequester } = await import('#src/utils/test-utils.js');
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
const expectPatchResponseStatus = async (

View file

@ -1,4 +1,5 @@
import type { SignInExperience, CreateSignInExperience, TermsOfUse } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import {
mockFacebookConnector,
@ -13,12 +14,23 @@ import {
mockLanguageInfo,
mockAliyunSmsConnector,
} from '#src/__mocks__/index.js';
import * as signInExpLib from '#src/lib/sign-in-experience/index.js';
import * as signInLib from '#src/lib/sign-in-experience/sign-in.js';
import * as signUpLib from '#src/lib/sign-in-experience/sign-up.js';
import { createRequester } from '#src/utils/test-utils.js';
import signInExperiencesRoutes from './sign-in-experience.js';
const { jest } = import.meta;
const {
validateBranding,
validateLanguageInfo,
validateTermsOfUse,
validateSignIn,
validateSignUp,
} = await mockEsmWithActual('#src/lib/sign-in-experience/index.js', () => ({
validateBranding: jest.fn(),
validateLanguageInfo: jest.fn(),
validateTermsOfUse: jest.fn(),
validateSignIn: jest.fn(),
validateSignUp: jest.fn(),
}));
const logtoConnectors = [
mockFacebookConnector,
@ -28,33 +40,27 @@ const logtoConnectors = [
mockAliyunSmsConnector,
];
const getLogtoConnectors = jest.fn(async () => logtoConnectors);
jest.mock('#src/connectors.js', () => {
return {
...jest.requireActual('#src/connectors.js'),
getLogtoConnectors: jest.fn(async () => getLogtoConnectors()),
};
});
const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
jest.mock('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: jest.fn(async () => findDefaultSignInExperience()),
updateDefaultSignInExperience: jest.fn(
async (data: Partial<CreateSignInExperience>): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
})
),
await mockEsmWithActual('#src/connectors.js', () => ({
getLogtoConnectors: async () => logtoConnectors,
}));
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({
findDefaultSignInExperience: jest.fn(async () => mockSignInExperience),
updateDefaultSignInExperience: async (
data: Partial<CreateSignInExperience>
): Promise<SignInExperience> => ({
...mockSignInExperience,
...data,
}),
}));
jest.mock('#src/queries/custom-phrase.js', () => ({
mockEsm('#src/queries/custom-phrase.js', () => ({
findAllCustomLanguageTags: async () => [],
}));
const signInExperiencesRoutes = await pickDefault(import('./sign-in-experience.js'));
const signInExperienceRequester = createRequester({ authedRoutes: signInExperiencesRoutes });
describe('GET /sign-in-exp', () => {
afterAll(() => {
jest.clearAllMocks();
@ -103,12 +109,6 @@ describe('PATCH /sign-in-exp', () => {
const termsOfUse: TermsOfUse = { enabled: false };
const socialSignInConnectorTargets = ['github', 'facebook', 'wechat'];
const validateBranding = jest.spyOn(signInExpLib, 'validateBranding');
const validateLanguageInfo = jest.spyOn(signInExpLib, 'validateLanguageInfo');
const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse');
const validateSignIn = jest.spyOn(signInLib, 'validateSignIn');
const validateSignUp = jest.spyOn(signUpLib, 'validateSignUp');
const response = await signInExperienceRequester.patch('/sign-in-exp').send({
color: mockColor,
branding: mockBranding,

View file

@ -1,4 +1,4 @@
import { load } from 'js-yaml';
import { mockEsm } from '@logto/shared/esm';
import Koa from 'koa';
import Router from 'koa-router';
import request from 'supertest';
@ -8,12 +8,18 @@ import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import type { AnonymousRouter } from '#src/routes/types.js';
import swaggerRoutes, { defaultResponses, paginationParameters } from './swagger.js';
const { jest } = import.meta;
jest.mock('js-yaml', () => ({
const { load } = mockEsm('js-yaml', () => ({
load: jest.fn().mockReturnValue({ paths: {} }),
}));
const {
default: swaggerRoutes,
defaultResponses,
paginationParameters,
} = await import('./swagger.js');
export const createSwaggerRequest = (
allRouters: Router[],
swaggerRouter: AnonymousRouter = new Router()
@ -221,7 +227,7 @@ describe('GET /swagger.json', () => {
describe('should use correct responses', () => {
it('should use "defaultResponses" if there is no custom "responses" from the additional swagger', async () => {
(load as jest.Mock).mockReturnValueOnce({
load.mockReturnValueOnce({
paths: { '/api/mock': { delete: {} } },
});
@ -235,7 +241,7 @@ describe('GET /swagger.json', () => {
});
it('should use custom "responses" from the additional swagger if it exists', async () => {
(load as jest.Mock).mockReturnValueOnce({
load.mockReturnValueOnce({
paths: {
'/api/mock': {
get: {

View file

@ -3,7 +3,7 @@ import {
adminConsoleApplicationId,
adminConsoleSignInExperience,
} from '@logto/schemas/lib/seeds/index.js';
import { Provider } from 'oidc-provider';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import {
mockAliyunDmConnector,
@ -15,50 +15,48 @@ import {
mockWechatConnector,
mockWechatNativeConnector,
} from '#src/__mocks__/index.js';
import * as signInExperienceQueries from '#src/queries/sign-in-experience.js';
import wellKnownRoutes from '#src/routes/well-known.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createRequester } from '#src/utils/test-utils.js';
const getLogtoConnectors = jest.fn(async () => [
mockAliyunDmConnector,
mockAliyunSmsConnector,
mockFacebookConnector,
mockGithubConnector,
mockGoogleConnector,
mockWechatConnector,
mockWechatNativeConnector,
]);
jest.mock('#src/connectors.js', () => ({
getLogtoConnectors: async () => getLogtoConnectors(),
const { jest } = import.meta;
await mockEsmWithActual('i18next', () => ({
default: {
t: (key: string) => key,
},
}));
jest.mock('#src/queries/user.js', () => ({
const { findDefaultSignInExperience } = mockEsm('#src/queries/sign-in-experience.js', () => ({
updateDefaultSignInExperience: jest.fn(),
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
}));
await mockEsmWithActual('#src/queries/user.js', () => ({
hasActiveUsers: jest.fn().mockResolvedValue(true),
}));
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
params: {},
mockEsm('#src/connectors.js', () => ({
getLogtoConnectors: jest.fn(async () => [
mockAliyunDmConnector,
mockAliyunSmsConnector,
mockFacebookConnector,
mockGithubConnector,
mockGoogleConnector,
mockWechatConnector,
mockWechatNativeConnector,
]),
}));
jest.mock('oidc-provider', () => ({
Provider: jest.fn(() => ({
interactionDetails,
})),
}));
jest.mock('i18next', () => ({
t: (key: string) => key,
}));
const wellKnownRoutes = await pickDefault(import('#src/routes/well-known.js'));
describe('GET /.well-known/sign-in-exp', () => {
afterEach(() => {
jest.clearAllMocks();
});
const provider = createMockProvider();
const sessionRequest = createRequester({
anonymousRoutes: wellKnownRoutes,
provider: new Provider(''),
provider,
middlewares: [
async (ctx, next) => {
ctx.addLogContext = jest.fn();
@ -69,13 +67,9 @@ describe('GET /.well-known/sign-in-exp', () => {
],
});
const signInExperienceQuerySpyOn = jest
.spyOn(signInExperienceQueries, 'findDefaultSignInExperience')
.mockResolvedValue(mockSignInExperience);
it('should return github and facebook connector instances', async () => {
const response = await sessionRequest.get('/.well-known/sign-in-exp');
expect(signInExperienceQuerySpyOn).toHaveBeenCalledTimes(1);
expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1);
expect(response.status).toEqual(200);
expect(response.body).toMatchObject({
...mockSignInExperience,
@ -101,7 +95,10 @@ describe('GET /.well-known/sign-in-exp', () => {
});
it('should return admin console settings', async () => {
interactionDetails.mockResolvedValue({ params: { client_id: adminConsoleApplicationId } });
jest
.spyOn(provider, 'interactionDetails')
// @ts-expect-error for testing
.mockResolvedValue({ params: { client_id: adminConsoleApplicationId } });
const response = await sessionRequest.get('/.well-known/sign-in-exp');
expect(response.status).toEqual(200);

View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2018-present Shopify
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,3 @@
# jest-koa-mocks
Updated from https://github.com/Shopify/quilt/tree/main/packages/jest-koa-mocks since ESM tests need explicit `const { jest } = import.meta;` to fetch the global jest object.

View file

@ -0,0 +1,119 @@
/* eslint-disable unicorn/no-abusive-eslint-disable */
/* eslint-disable */
import stream from 'stream';
import { URL } from 'url';
import type { Context } from 'koa';
import Koa from 'koa';
import type { RequestMethod } from 'node-mocks-http';
import httpMocks from 'node-mocks-http';
import type { MockCookies } from './create-mock-cookies.js';
import createMockCookies from './create-mock-cookies.js';
const { jest } = import.meta;
export type Dictionary<T> = Record<string, T>;
export type MockContext = {
cookies: MockCookies;
request: Context['request'] & {
body?: any;
rawBody?: string;
session?: any;
};
} & Context;
export type Options<CustomProperties extends Record<string, unknown>, RequestBody = undefined> = {
url?: string;
method?: RequestMethod;
statusCode?: number;
session?: Dictionary<any>;
headers?: Dictionary<string>;
cookies?: Dictionary<string>;
state?: Dictionary<any>;
encrypted?: boolean;
host?: string;
requestBody?: RequestBody;
rawBody?: string;
throw?: Function;
redirect?: Function;
customProperties?: CustomProperties;
};
export default function createContext<
CustomProperties extends Record<string, unknown>,
RequestBody = undefined
>(options: Options<CustomProperties, RequestBody> = {}) {
const app = new Koa();
const {
cookies,
method,
statusCode,
session,
requestBody,
rawBody = '',
url = '',
host = 'test.com',
encrypted = false,
throw: throwFunction = jest.fn(),
redirect = jest.fn(),
headers = {},
state = {},
customProperties = {},
} = options;
const extensions = {
...customProperties,
throw: throwFunction,
session,
redirect,
state,
};
const protocolFallback = encrypted ? 'https' : 'http';
const urlObject = new URL(url, `${protocolFallback}://${host}`);
const request = httpMocks.createRequest({
url: urlObject.toString(),
method,
statusCode,
session,
headers: {
// Koa determines protocol based on the `Host` header.
Host: urlObject.host,
...headers,
},
});
// Some functions we call in the implementations will perform checks for `req.encrypted`, which delegates to the socket.
// MockRequest doesn't set a fake socket itself, so we create one here.
request.socket = new stream.Duplex() as any;
Object.defineProperty(request.socket, 'encrypted', {
writable: false,
value: urlObject.protocol === 'https:',
});
const res = httpMocks.createResponse();
// Koa sets a default status code of 404, not the node default of 200
// https://github.com/koajs/koa/blob/master/docs/api/response.md#responsestatus
res.statusCode = 404;
// This is to get around an odd behavior in the `cookies` library, where if `res.set` is defined, it will use an internal
// node function to set headers, which results in them being set in the wrong place.
res.set = undefined as any;
const context = app.createContext(request, res) as MockContext & CustomProperties;
Object.assign(context, extensions);
context.cookies = createMockCookies(cookies);
// Ctx.request.body is a common enough custom property for middleware to add that it's handy to just support it by default
context.request.body = requestBody;
context.request.rawBody = rawBody;
return context as Context;
}
/* eslint-enable */

View file

@ -0,0 +1,37 @@
/* eslint-disable unicorn/no-abusive-eslint-disable */
/* eslint-disable */
import type { Context } from 'koa';
const { jest } = import.meta;
export type Cookies = Context['cookies'];
export type Dictionary<T> = Record<string, T>;
export type MockCookies = {
requestStore: Map<string, string>;
responseStore: Map<string, string>;
} & Cookies;
export default function createMockCookies(
cookies: Record<string, unknown> = {},
secure = true
): MockCookies {
const cookieEntries = Object.keys(cookies).map((key) => [key, cookies[key]] as [string, string]);
const requestStore = new Map<string, string>(cookieEntries);
const responseStore = new Map<string, string>(cookieEntries);
return {
set: jest.fn((key, value) => {
return responseStore.set(key, value);
}),
get: jest.fn((key) => {
return requestStore.get(key);
}),
requestStore,
responseStore,
secure,
} as any;
}
/* eslint-enable */

View file

@ -0,0 +1,24 @@
import { Provider } from 'oidc-provider';
const { jest } = import.meta;
export const createMockProvider = (interactionDetails?: jest.Mock): Provider => {
const originalWarn = console.warn;
const warn = jest.spyOn(console, 'warn').mockImplementation((...args) => {
// Disable while creating. Too many warnings.
if (typeof args[0] === 'string' && args[0].includes('oidc-provider')) {
return;
}
originalWarn(...args);
});
const provider = new Provider('https://logto.test');
warn.mockRestore();
jest.spyOn(provider, 'interactionDetails').mockImplementation(
// @ts-expect-error for testing
interactionDetails ?? (async () => ({ params: {}, jti: 'jti', client_id: 'mockApplicationId' }))
);
return provider;
};

View file

@ -9,36 +9,26 @@ import {
} from '#src/utils/oidc-provider-event-listener.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const userId = 'userIdValue';
const sessionId = 'sessionIdValue';
const applicationId = 'applicationIdValue';
const addLogContext = jest.fn();
const log = jest.fn();
const addListener = jest.fn();
jest.mock('oidc-provider', () => ({ Provider: jest.fn(() => ({ addListener })) }));
describe('addOidcEventListeners', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should add grantSuccessListener', () => {
const provider = new Provider('');
it('should add proper listeners', () => {
const provider = new Provider('https://logto.test');
const addListener = jest.spyOn(provider, 'addListener');
addOidcEventListeners(provider);
expect(addListener).toHaveBeenCalledWith('grant.success', grantSuccessListener);
});
it('should add grantErrorListener', () => {
const provider = new Provider('');
addOidcEventListeners(provider);
expect(addListener).toHaveBeenCalledWith('grant.error', grantErrorListener);
});
it('should add grantRevokedListener', () => {
const provider = new Provider('');
addOidcEventListeners(provider);
expect(addListener).toHaveBeenCalledWith('grant.revoked', grantRevokedListener);
});
});

View file

@ -1,5 +1,3 @@
import type { Options } from '@shopify/jest-koa-mocks';
import { createMockContext } from '@shopify/jest-koa-mocks';
import type { MiddlewareType, Context, Middleware } from 'koa';
import Koa from 'koa';
import type { IRouterParamContext } from 'koa-router';
@ -11,6 +9,8 @@ import type { PrimitiveValueExpression } from 'slonik/dist/src/types.js';
import request from 'supertest';
import type { AuthedRouter, AnonymousRouter } from '#src/routes/types.js';
import type { Options } from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
/**
* Slonik Query Mock Utils

View file

@ -8,6 +8,14 @@
"files": [
"lib"
],
"exports": {
".": {
"import": "./lib/index.js"
},
"./esm": {
"import": "./lib/esm/index.js"
}
},
"publishConfig": {
"access": "public"
},

View file

@ -0,0 +1,2 @@
export { default as moduleProxy } from './module-proxy.js';
export * from './mock-esm.js';

Some files were not shown because too many files have changed in this diff Show more