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

feat(core)!: enhanced user search params (#2639)

This commit is contained in:
Gao Sun 2022-12-14 16:36:57 +08:00 committed by GitHub
parent c7add49165
commit 7bf52aa7ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 900 additions and 270 deletions

View file

@ -40,7 +40,7 @@ const Users = () => {
const keyword = query.get('search') ?? '';
const { data, error, mutate } = useSWR<[User[], number], RequestError>(
`/api/users?page=${pageIndex}&page_size=${pageSize}&hideAdminUser=true${conditionalString(
keyword && `&search=${keyword}`
keyword && `&search=%${keyword}%`
)}`
);
const isLoading = !data && !error;

View file

@ -1,7 +1,8 @@
{
"exec": "tsc -p tsconfig.build.json --incremental && node ./build/index.js || exit 1",
"ignore": [
"node_modules/**/node_modules"
"node_modules/**/node_modules",
"../integration-tests/"
],
"watch": [
"../*/lib/",

View file

@ -107,7 +107,11 @@
"node": "^16.13.0 || ^18.12.0"
},
"eslintConfig": {
"extends": "@silverhand"
"extends": "@silverhand",
"rules": {
"complexity": ["error", 11],
"default-case": "off"
}
},
"prettier": "@silverhand/eslint-config/.prettierrc"
}

View file

@ -1,4 +1,6 @@
export const isTrue = (value?: string) =>
import type { Nullable } from '@silverhand/essentials';
export const isTrue = (value?: Nullable<string>) =>
// We need to leverage the native type guard
// eslint-disable-next-line no-implicit-coercion
!!value && ['1', 'true', 'y', 'yes', 'yep', 'yeah'].includes(value.toLowerCase());

View file

@ -86,7 +86,6 @@ export const sendPasscode = async (passcode: Passcode) => {
export const passcodeExpiration = 10 * 60 * 1000; // 10 minutes.
export const passcodeMaxTryCount = 10;
// eslint-disable-next-line complexity
export const verifyPasscode = async (
sessionId: string,
type: PasscodeType,

View file

@ -5,7 +5,6 @@ import type { LogtoConnector } from '#src/connectors/types.js';
import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
/* eslint-disable complexity */
export const validateSignIn = (
signIn: SignIn,
signUp: SignUp,
@ -97,4 +96,3 @@ export const validateSignIn = (
);
}
};
/* eslint-enable complexity */

View file

@ -46,8 +46,6 @@ type TokenInfo = {
roleNames?: string[];
};
// TODO: @Gao refactor me
// eslint-disable-next-line complexity
export const verifyBearerTokenFromRequest = async (
request: Request,
resourceIndicator: Optional<string>

View file

@ -35,8 +35,6 @@ export default function koaPagination<StateT, ContextT, ResponseBodyT>({
StateT,
WithPaginationContext<ContextT>,
ResponseBodyT
// TODO: Refactor me
// eslint-disable-next-line complexity
> = async (ctx, next) => {
try {
const {

View file

@ -1,4 +1,4 @@
import { UserRole, Users } from '@logto/schemas';
import { Users } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
@ -19,8 +19,6 @@ import {
hasUserWithEmail,
hasUserWithIdentity,
hasUserWithPhone,
countUsers,
findUsers,
updateUserById,
deleteUserById,
deleteUserIdentity,
@ -236,165 +234,6 @@ describe('user query', () => {
await expect(hasUserWithIdentity(target, mockUser.id)).resolves.toEqual(true);
});
it('countUsers', async () => {
const search = 'foo';
const expectSql = sql`
select count(*)
from ${table}
where ${fields.primaryEmail} ilike $1 or ${fields.primaryPhone} ilike $2 or ${fields.username} ilike $3 or ${fields.name} ilike $4
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`]);
return createMockQueryResult([dbvalue]);
});
await expect(countUsers(search)).resolves.toEqual(dbvalue);
});
it('countUsers with hideAdminUser', async () => {
const search = 'foo';
const expectSql = sql`
select count(*)
from ${table}
where not (${fields.roleNames}::jsonb?$1)
and (${fields.primaryEmail} ilike $2 or ${fields.primaryPhone} ilike $3 or ${fields.username} ilike $4 or ${fields.name} ilike $5)
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([
UserRole.Admin,
`%${search}%`,
`%${search}%`,
`%${search}%`,
`%${search}%`,
]);
return createMockQueryResult([dbvalue]);
});
await expect(countUsers(search, true)).resolves.toEqual(dbvalue);
});
it('countUsers with isCaseSensitive', async () => {
const search = 'foo';
const expectSql = sql`
select count(*)
from ${table}
where ${fields.primaryEmail} like $1 or ${fields.primaryPhone} like $2 or ${fields.username} like $3 or ${fields.name} like $4
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`]);
return createMockQueryResult([dbvalue]);
});
await expect(countUsers(search, undefined, true)).resolves.toEqual(dbvalue);
});
it('findUsers', async () => {
const search = 'foo';
const limit = 100;
const offset = 1;
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.primaryEmail} ilike $1 or ${fields.primaryPhone} ilike $2 or ${
fields.username
} ilike $3 or ${fields.name} ilike $4
order by "created_at" desc
limit $5
offset $6
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([
`%${search}%`,
`%${search}%`,
`%${search}%`,
`%${search}%`,
limit,
offset,
]);
return createMockQueryResult([dbvalue]);
});
await expect(findUsers(limit, offset, search)).resolves.toEqual([dbvalue]);
});
it('findUsers with hideAdminUser', async () => {
const search = 'foo';
const limit = 100;
const offset = 1;
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where not (${fields.roleNames}::jsonb?$1)
and (${fields.primaryEmail} ilike $2 or ${fields.primaryPhone} ilike $3 or ${
fields.username
} ilike $4 or ${fields.name} ilike $5)
order by "created_at" desc
limit $6
offset $7
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([
UserRole.Admin,
`%${search}%`,
`%${search}%`,
`%${search}%`,
`%${search}%`,
limit,
offset,
]);
return createMockQueryResult([dbvalue]);
});
await expect(findUsers(limit, offset, search, true)).resolves.toEqual([dbvalue]);
});
it('findUsers with isCaseSensitive', async () => {
const search = 'foo';
const limit = 100;
const offset = 1;
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.primaryEmail} like $1 or ${fields.primaryPhone} like $2 or ${
fields.username
} like $3 or ${fields.name} like $4
order by "created_at" desc
limit $5
offset $6
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([
`%${search}%`,
`%${search}%`,
`%${search}%`,
`%${search}%`,
limit,
offset,
]);
return createMockQueryResult([dbvalue]);
});
await expect(findUsers(limit, offset, search, undefined, true)).resolves.toEqual([dbvalue]);
});
it('updateUserById', async () => {
const username = 'Joe';
const id = 'foo';

View file

@ -1,5 +1,5 @@
import type { User, CreateUser } from '@logto/schemas';
import { Users, UserRole } from '@logto/schemas';
import { SearchJointMode, Users, UserRole } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared';
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import { sql } from 'slonik';
@ -7,6 +7,8 @@ import { sql } from 'slonik';
import { buildUpdateWhere } from '#src/database/update-where.js';
import envSet from '#src/env-set/index.js';
import { DeletionError } from '#src/errors/SlonikError/index.js';
import type { Search } from '#src/utils/search.js';
import { buildConditionsFromSearch } from '#src/utils/search.js';
const { table, fields } = convertToIdentifiers(Users);
@ -87,65 +89,53 @@ export const hasUserWithIdentity = async (target: string, userId: string) =>
`
);
const buildUserSearchConditionSql = (search: string, isCaseSensitive = false) => {
const searchFields = [fields.primaryEmail, fields.primaryPhone, fields.username, fields.name];
const buildUserConditions = (search: Search, hideAdminUser: boolean) => {
const hasSearch = search.matches.length > 0;
const searchFields = [
Users.fields.id,
Users.fields.primaryEmail,
Users.fields.primaryPhone,
Users.fields.username,
Users.fields.name,
];
return sql`${sql.join(
searchFields.map(
(filedName) =>
sql`${filedName} ${isCaseSensitive ? sql`like` : sql`ilike`} ${'%' + search + '%'}`
),
sql` or `
)}`;
};
const buildUserConditions = (
search?: string,
hideAdminUser?: boolean,
isCaseSensitive?: boolean
) => {
if (hideAdminUser) {
// Cannot use \`= any()\` here since we didn't find the Slonik way to do so. Consider replacing Slonik.
return sql`
where not (${fields.roleNames}::jsonb?${UserRole.Admin})
where not ${fields.roleNames} @> ${sql.jsonb([UserRole.Admin])}
${conditionalSql(
search,
(search) => sql`and (${buildUserSearchConditionSql(search, isCaseSensitive)})`
hasSearch,
() => sql`and (${buildConditionsFromSearch(search, searchFields)})`
)}
`;
}
return sql`
${conditionalSql(
search,
(search) => sql`where ${buildUserSearchConditionSql(search, isCaseSensitive)}`
)}
`;
return conditionalSql(
hasSearch,
() => sql`where ${buildConditionsFromSearch(search, searchFields)}`
);
};
export const countUsers = async (
search?: string,
hideAdminUser?: boolean,
isCaseSensitive?: boolean
) =>
export const defaultUserSearch = { matches: [], isCaseSensitive: false, joint: SearchJointMode.Or };
export const countUsers = async (search: Search = defaultUserSearch, hideAdminUser = false) =>
envSet.pool.one<{ count: number }>(sql`
select count(*)
from ${table}
${buildUserConditions(search, hideAdminUser, isCaseSensitive)}
${buildUserConditions(search, hideAdminUser)}
`);
export const findUsers = async (
limit: number,
offset: number,
search?: string,
hideAdminUser?: boolean,
isCaseSensitive?: boolean
search: Search,
hideAdminUser: boolean
) =>
envSet.pool.any<User>(
sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
${buildUserConditions(search, hideAdminUser, isCaseSensitive)}
order by ${fields.createdAt} desc
${buildUserConditions(search, hideAdminUser)}
limit ${limit}
offset ${offset}
`

View file

@ -130,12 +130,12 @@ describe('adminUserRoutes', () => {
id: 'fooId',
username,
name,
roleNames: undefined, // API will call `insertUser()` with `roleNames` specified
});
});
it('POST /users should throw with invalid input params', async () => {
const username = 'MJAtLogto';
const password = 'PASSWORD';
const name = 'Michael';
// Invalid input format

View file

@ -1,6 +1,7 @@
import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
import { has } from '@silverhand/essentials';
import { arbitraryObjectGuard, userInfoSelectFields, UserRole } from '@logto/schemas';
import { tryThat } from '@logto/shared';
import { conditional, has } from '@silverhand/essentials';
import pick from 'lodash.pick';
import { boolean, literal, object, string } from 'zod';
@ -28,40 +29,38 @@ import {
hasUserWithPhone,
} from '#src/queries/user.js';
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter } from './types.js';
export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
router.get(
'/users',
koaPagination(),
koaGuard({
query: object({
search: string().optional(),
// Use `.transform()` once the type issue fixed
hideAdminUser: string().optional(),
isCaseSensitive: string().optional(),
}),
}),
async (ctx, next) => {
const { limit, offset } = ctx.pagination;
const {
query: { search, hideAdminUser: _hideAdminUser, isCaseSensitive: _isCaseSensitive },
} = ctx.guard;
router.get('/users', koaPagination(), async (ctx, next) => {
const { limit, offset } = ctx.pagination;
const { searchParams } = ctx.request.URL;
const hideAdminUser = isTrue(_hideAdminUser);
const isCaseSensitive = isTrue(_isCaseSensitive);
const [{ count }, users] = await Promise.all([
countUsers(search, hideAdminUser, isCaseSensitive),
findUsers(limit, offset, search, hideAdminUser, isCaseSensitive),
]);
return tryThat(
async () => {
const search = parseSearchParamsForSearch(searchParams);
const hideAdminUser = isTrue(searchParams.get('hideAdminUser'));
ctx.pagination.totalCount = count;
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
const [{ count }, users] = await Promise.all([
countUsers(search, hideAdminUser),
findUsers(limit, offset, search, hideAdminUser),
]);
return next();
}
);
ctx.pagination.totalCount = count;
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
return next();
},
(error) => {
if (error instanceof TypeError) {
throw new RequestError({ code: 'request.invalid_input', details: error.message }, error);
}
throw error;
}
);
});
router.get(
'/users/:userId',
@ -128,15 +127,16 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
'/users',
koaGuard({
body: object({
primaryPhone: string().regex(phoneRegEx).optional(),
primaryEmail: string().regex(emailRegEx).optional(),
username: string().regex(usernameRegEx).optional(),
primaryPhone: string().regex(phoneRegEx),
primaryEmail: string().regex(emailRegEx),
username: string().regex(usernameRegEx),
password: string().regex(passwordRegEx),
name: string().optional(),
}),
isAdmin: boolean(),
name: string(),
}).partial(),
}),
async (ctx, next) => {
const { primaryEmail, primaryPhone, username, password, name } = ctx.guard.body;
const { primaryEmail, primaryPhone, username, password, name, isAdmin } = ctx.guard.body;
assertThat(
!username || !(await hasUser(username)),
@ -159,16 +159,14 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
const id = await generateUserId();
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
const user = await insertUser({
id,
primaryEmail,
primaryPhone,
username,
passwordEncrypted,
passwordEncryptionMethod,
name,
roleNames: conditional(isAdmin && [UserRole.Admin]),
...conditional(password && (await encryptUserPassword(password))),
});
ctx.body = pick(user, ...userInfoSelectFields);

View file

@ -110,7 +110,6 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
syncProfile: true,
}),
}),
// eslint-disable-next-line complexity
async (ctx, next) => {
const {
body: { connectorId, metadata, config, syncProfile },

View file

@ -42,7 +42,6 @@ export const identifierValidation = (
// Email Identifier
if ('email' in identifier) {
assertThat(
// eslint-disable-next-line complexity
signIn.methods.some(({ identifier: method, password, verificationCode }) => {
if (method !== SignInIdentifier.Email) {
return false;
@ -74,7 +73,6 @@ export const identifierValidation = (
// Phone Identifier
if ('phone' in identifier) {
assertThat(
// eslint-disable-next-line complexity
signIn.methods.some(({ identifier: method, password, verificationCode }) => {
if (method !== SignInIdentifier.Sms) {
return false;

View file

@ -32,7 +32,6 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
koaGuard({
body: SignInExperiences.createGuard.omit({ id: true }).partial(),
}),
/* eslint-disable complexity */
async (ctx, next) => {
const { socialSignInConnectorTargets, ...rest } = ctx.guard.body;
const { branding, languageInfo, termsOfUse, signUp, signIn } = rest;
@ -81,5 +80,4 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
return next();
}
);
/* eslint-enable complexity */
}

View file

@ -0,0 +1,208 @@
import { SearchJointMode, SearchMatchMode } from '@logto/schemas';
import type { ListSqlToken, TaggedTemplateLiteralInvocation } from 'slonik';
import { sql } from 'slonik';
// Will add `params` to the exception list
// eslint-disable-next-line unicorn/prevent-abbreviations
import { buildConditionsFromSearch, parseSearchParamsForSearch } from './search.js';
import { expectSqlAssert, expectSqlTokenAssert } from './test-utils.js';
describe('parseSearchParamsForSearch()', () => {
it('should throw when input is not valid', () => {
expect(() => parseSearchParamsForSearch(new URLSearchParams([['joint', 'foo']]))).toThrowError(
/is not valid/
);
expect(() => parseSearchParamsForSearch(new URLSearchParams([['mode', 'foo']]))).toThrowError(
/is not valid/
);
expect(() =>
parseSearchParamsForSearch(new URLSearchParams([['mode.foo', 'foo']]))
).toThrowError(/is not valid/);
expect(() =>
parseSearchParamsForSearch(
new URLSearchParams([
['mode', 'like'],
['search', 'foo'],
['search', 'bar'],
])
)
).toThrowError(/Only one search value/);
expect(() =>
parseSearchParamsForSearch(
new URLSearchParams([
['mode', 'like'],
['search', ''],
])
)
).toThrowError(/cannot be empty/);
expect(() =>
parseSearchParamsForSearch(
new URLSearchParams([
['mode', 'exact'],
['search', ''],
['search', 'bar'],
])
)
).toThrowError(/cannot be empty/);
expect(() =>
parseSearchParamsForSearch(new URLSearchParams([['search.foo.bar', 'baz']]))
).toThrowError(/nested search field path/);
expect(() =>
parseSearchParamsForSearch(new URLSearchParams([['search.foo', 'baz']]), ['bar'])
).toThrowError(/is not allowed/);
});
it('should return proper result', () => {
expect(
parseSearchParamsForSearch(
new URLSearchParams([
['mode', 'exact'],
['search', 'foo'],
['search.foo', 'bar%'],
['search.bar', 'baz'],
['mode.foo', 'like'],
['isCaseSensitive', 'true'],
])
)
).toStrictEqual({
matches: [
{ mode: SearchMatchMode.Exact, field: undefined, values: ['foo'] },
{ mode: SearchMatchMode.Like, field: 'foo', values: ['bar%'] },
{ mode: SearchMatchMode.Exact, field: 'bar', values: ['baz'] },
],
joint: SearchJointMode.Or,
isCaseSensitive: true,
});
expect(
parseSearchParamsForSearch(
new URLSearchParams([
['joint', 'and'],
['search', 'foo'],
['search.foo', 'bar'],
]),
['foo', 'bar']
)
).toStrictEqual({
matches: [
{ mode: SearchMatchMode.Like, field: undefined, values: ['foo'] },
{ mode: SearchMatchMode.Like, field: 'foo', values: ['bar'] },
],
joint: SearchJointMode.And,
isCaseSensitive: false,
});
});
});
describe('buildConditionsFromSearch()', () => {
const defaultSearch = { matches: [], isCaseSensitive: false, joint: SearchJointMode.Or };
// eslint-disable-next-line unicorn/consistent-function-scoping
const getSql = (token: ListSqlToken | TaggedTemplateLiteralInvocation) => sql`${token}`;
it('should throw error when no search field found', () => {
expect(() => buildConditionsFromSearch(defaultSearch, [])).toThrowError(TypeError);
});
it('should throw when conditions has invalid field', () => {
expect(() =>
buildConditionsFromSearch(
{
...defaultSearch,
matches: [
{ mode: SearchMatchMode.Exact, field: 'primaryPhone', values: ['foo'] },
{ mode: SearchMatchMode.Exact, field: 'foo', values: ['foo'] },
],
},
['id', 'primary_phone']
)
).toThrowError(/`foo` is not valid/);
});
it('should throw when value is empty', () => {
expect(() =>
buildConditionsFromSearch(
{
...defaultSearch,
matches: [{ mode: SearchMatchMode.Exact, field: 'primaryPhone', values: ['foo', ''] }],
},
['id', 'primary_phone']
)
).toThrowError(/empty value found/i);
});
it('should throw when case insensitive but conditions include `similar to`', () => {
expect(() =>
buildConditionsFromSearch(
{
...defaultSearch,
matches: [
{ mode: SearchMatchMode.Exact, field: 'primaryPhone', values: ['foo'] },
{ mode: SearchMatchMode.SimilarTo, field: 'primaryPhone', values: ['t.*ma'] },
],
isCaseSensitive: false,
},
['id', 'primary_phone']
)
).toThrowError(/cannot use /i);
});
it('should return expected SQL', () => {
expectSqlAssert(getSql(buildConditionsFromSearch(defaultSearch, ['id', 'username'])).sql, '');
expectSqlTokenAssert(
getSql(
buildConditionsFromSearch(
{ ...defaultSearch, matches: [{ mode: SearchMatchMode.Like, values: ['foo'] }] },
['id', 'username']
)
),
'("id" ~~* $1 or "username" ~~* $2)',
['foo', 'foo']
);
expectSqlTokenAssert(
getSql(
buildConditionsFromSearch(
{
matches: [
{ mode: SearchMatchMode.Exact, field: 'userId', values: ['FOO', 'baR'] },
{ mode: SearchMatchMode.Like, values: ['t.*ma'] },
{ mode: SearchMatchMode.Posix, field: 'username', values: ['^(b|c)'] },
],
joint: SearchJointMode.And,
isCaseSensitive: false,
},
['user_id', 'username']
)
),
'(lower("user_id") = any($1::"varchar"[])) and ("user_id" ~~* $2 or "username" ~~* $3) and ("username" ~* $4)',
[['foo', 'bar'], 't.*ma', 't.*ma', '^(b|c)']
);
expectSqlTokenAssert(
getSql(
buildConditionsFromSearch(
{
matches: [
{ mode: SearchMatchMode.Exact, field: 'userId', values: ['FOO', 'baR'] },
{ mode: SearchMatchMode.SimilarTo, values: ['t.*ma'] },
{ mode: SearchMatchMode.Like, field: 'user_id', values: ['tma'] },
{ mode: SearchMatchMode.Posix, values: ['^(b|c)'] },
],
joint: SearchJointMode.And,
isCaseSensitive: true,
},
['user_id', 'username']
)
),
'("user_id" = any($1::"varchar"[]))' +
' and ' +
'("user_id" similar to $2 or "username" similar to $3)' +
' and ' +
'("user_id" ~~ $4)' +
' and ' +
'("user_id" ~ $5 or "username" ~ $6)',
[['FOO', 'baR'], 't.*ma', 't.*ma', 'tma', '^(b|c)', '^(b|c)']
);
});
});

View file

@ -0,0 +1,242 @@
import { SearchJointMode, SearchMatchMode } from '@logto/schemas';
import type { Nullable, Optional } from '@silverhand/essentials';
import { conditionalString } from '@silverhand/essentials';
import { sql } from 'slonik';
import { snakeCase } from 'snake-case';
import { isTrue } from '#src/env-set/parameters.js';
import assertThat from './assert-that.js';
const searchJointModes = Object.values(SearchJointMode);
const searchMatchModes = Object.values(SearchMatchMode);
export type SearchItem = {
mode: SearchMatchMode;
field?: string;
values: string[];
};
export type Search = {
matches: SearchItem[];
joint: SearchJointMode;
isCaseSensitive: boolean;
};
const isEnum = <T extends string>(list: T[], value: string): value is T =>
// @ts-expect-error the easiest way to perform type checking for a string enum
list.includes(value);
/**
* Parse a field string with "search." prefix to the actual first-level field.
* If `allowedFields` is not `undefined`, ensure the parsed field is included in the list.
*
* Examples:
*
* ```ts
* getSearchField('search.foo') // 'foo'
* getSearchField('search.foo.bar') // TypeError
* getSearchField('search.foo', ['bar']) // TypeError
* getSearchField('search', ['bar']) // undefined
* ```
*
* @param field The field string to check.
* @param allowedFields Available search fields. Note the general field is always allowed.
* @returns The actual search field string, `undefined` if it's a general field.
*/
const getSearchField = (field: string, allowedFields?: string[]): Optional<string> => {
const path = field.split('.');
assertThat(
path.length <= 2,
new TypeError(
`Unsupported nested search field path \`${path
.slice(1)
.join('.')}\` detected. Only the first level field is supported.`
)
);
if (allowedFields && path[1] && !allowedFields.includes(path[1])) {
throw new TypeError(
`Search field \`${path[1]}\` is not allowed. Expect one of ${allowedFields.join(', ')}.`
);
}
return path[1];
};
const getJointMode = (value?: Nullable<string>): SearchJointMode => {
if (!value) {
return SearchJointMode.Or;
}
assertThat(
isEnum(searchJointModes, value),
new TypeError(
`Search joint mode \`${value}\` is not valid, expect one of ${searchJointModes.join(', ')}.`
)
);
return value;
};
// Use a mutating approach to improve performance
/* eslint-disable @silverhand/fp/no-mutating-methods */
const getSearchMetadata = (searchParameters: URLSearchParams, allowedFields?: string[]) => {
const matchMode = new Map<Optional<string>, SearchMatchMode>();
const matchValues = new Map<Optional<string>, string[]>();
const joint = getJointMode(searchParameters.get('joint') ?? searchParameters.get('jointMode'));
const isCaseSensitive = isTrue(searchParameters.get('isCaseSensitive') ?? 'false');
// Parse the following values and return:
// 1. Search modes per field, if available
// 2. Search fields and values
for (const [key, value] of searchParameters.entries()) {
if (key.startsWith('mode')) {
const field = getSearchField(key, allowedFields);
assertThat(
isEnum(searchMatchModes, value),
new TypeError(
`Search match mode \`${value}\`${conditionalString(
field && ` for field \`${field}\``
)} is not valid, expect one of ${searchMatchModes.join(', ')}.`
)
);
matchMode.set(field, value);
continue;
}
if (key.startsWith('search')) {
const field = getSearchField(key, allowedFields);
const values = matchValues.get(field) ?? [];
values.push(value);
matchValues.set(field, values);
continue;
}
}
return { joint, matchMode, matchValues, isCaseSensitive };
};
/* eslint-disable unicorn/prevent-abbreviations */
export const parseSearchParamsForSearch = (
searchParams: URLSearchParams,
allowedFields?: string[]
): Search => {
/* eslint-enable unicorn/prevent-abbreviations */
const { matchMode, matchValues, ...rest } = getSearchMetadata(searchParams, allowedFields);
// Validate and generate result
const matches: SearchItem[] = [];
const result: Search = {
matches,
...rest,
};
const getModeFor = (field: Optional<string>): SearchMatchMode =>
// eslint-disable-next-line unicorn/no-useless-undefined
matchMode.get(field) ?? matchMode.get(undefined) ?? SearchMatchMode.Like;
for (const [field, values] of matchValues.entries()) {
const mode = getModeFor(field);
if (mode === SearchMatchMode.Exact) {
assertThat(values.every(Boolean), new TypeError('Search value cannot be empty.'));
} else {
assertThat(
values.length === 1,
new TypeError('Only one search value is allowed when search mode is not `exact`.')
);
assertThat(values[0], new TypeError('Search value cannot be empty.'));
}
matches.push({ mode, field, values });
}
return result;
};
/* eslint-enable @silverhand/fp/no-mutating-methods */
const getJointModeSql = (mode: SearchJointMode) => {
switch (mode) {
case SearchJointMode.And:
return sql` and `;
case SearchJointMode.Or:
return sql` or `;
}
};
const getMatchModeOperator = (match: SearchMatchMode, isCaseSensitive: boolean) => {
switch (match) {
case SearchMatchMode.Exact:
return sql`=`;
case SearchMatchMode.Like:
return isCaseSensitive ? sql`~~` : sql`~~*`;
case SearchMatchMode.SimilarTo:
assertThat(
isCaseSensitive,
new TypeError('Cannot use case-insensitive match for `similar to`.')
);
return sql`similar to`;
case SearchMatchMode.Posix:
return isCaseSensitive ? sql`~` : sql`~*`;
}
};
/**
* Build search SQL token by parsing the search object and available search fields.
* Note all `field`s will be normalized to snake case, so camel case fields are valid.
*
* @param search The search config object.
* @param searchFields Allowed and default search fields (columns).
* @param isCaseSensitive Should perform case sensitive search or not.
* @returns The SQL token that includes the all condition checks.
* @throws TypeError error if fields in `search` do not match the `searchFields`, or invalid condition found (e.g. the value is empty).
*/
export const buildConditionsFromSearch = (search: Search, searchFields: string[]) => {
assertThat(searchFields.length > 0, new TypeError('No search field found.'));
const { matches, joint, isCaseSensitive } = search;
const conditions = matches.map(({ mode, field: rawField, values: rawValues }) => {
const field = rawField && snakeCase(rawField);
if (field && !searchFields.includes(field)) {
throw new TypeError(
`Search field \`${field}\` is not valid, expect one of ${searchFields.join(', ')}.`
);
}
const shouldLowercase = !isCaseSensitive && mode === SearchMatchMode.Exact;
const fields = field ? [field] : searchFields;
const values = shouldLowercase ? rawValues.map((value) => value.toLowerCase()) : rawValues;
// Type check for the first value
assertThat(
values[0] && values.every(Boolean),
new TypeError(`Empty value found${conditionalString(field && ` for field ${field}`)}.`)
);
const valueExpression =
values.length === 1 ? sql`${values[0]}` : sql`any(${sql.array(values, 'varchar')})`;
return sql`(${sql.join(
fields.map(
(field) =>
sql`${
shouldLowercase ? sql`lower(${sql.identifier([field])})` : sql.identifier([field])
} ${getMatchModeOperator(mode, isCaseSensitive)} ${valueExpression}`
),
sql` or `
)})`;
});
if (conditions.length === 0) {
return sql``;
}
return sql.join(conditions, getJointModeSql(joint));
};

View file

@ -5,7 +5,10 @@ import Router from 'koa-router';
import type { Provider } from 'oidc-provider';
import type { QueryResult, QueryResultRow } from 'slonik';
import { createMockPool, createMockQueryResult } from 'slonik';
import type { PrimitiveValueExpression } from 'slonik/dist/src/types.js';
import type {
PrimitiveValueExpression,
TaggedTemplateLiteralInvocation,
} from 'slonik/dist/src/types.js';
import request from 'supertest';
import type { AuthedRouter, AnonymousRouter } from '#src/routes/types.js';
@ -29,6 +32,28 @@ export const expectSqlAssert = (sql: string, expectSql: string) => {
);
};
export const expectSqlTokenAssert = (
sql: TaggedTemplateLiteralInvocation,
expectSql: string,
values?: unknown[]
) => {
expect(
sql.sql
.split('\n')
.map((row) => row.trim())
.filter(Boolean)
).toEqual(
expectSql
.split('\n')
.map((row) => row.trim())
.filter(Boolean)
);
if (values) {
expect(sql.values).toStrictEqual(values);
}
};
export type QueryType = (
sql: string,
values: readonly PrimitiveValueExpression[]
@ -102,8 +127,6 @@ export function createRequester(
}
): request.SuperTest<request.Test>;
// TODO: Refacttor me
// eslint-disable-next-line complexity
export function createRequester({
anonymousRoutes,
authedRoutes,

View file

@ -55,8 +55,6 @@ export const translationSchemas: Record<string, OpenAPIV3.SchemaObject> = {
export type ZodStringCheck = ValuesOf<ZodStringDef['checks']>;
// Switch-clause
// eslint-disable-next-line complexity
const zodStringCheckToSwaggerFormat = (zodStringCheck: ZodStringCheck) => {
const { kind } = zodStringCheck;

View file

@ -2,13 +2,14 @@ import type { User } from '@logto/schemas';
import { authedAdminApi } from './api.js';
type CreateUserPayload = {
primaryPhone?: string;
primaryEmail?: string;
username?: string;
type CreateUserPayload = Partial<{
primaryEmail: string;
primaryPhone: string;
username: string;
password: string;
name?: string;
};
name: string;
isAdmin: boolean;
}>;
export const createUser = (payload: CreateUserPayload) =>
authedAdminApi

View file

@ -3,7 +3,7 @@ import path from 'path';
import type { User, SignIn, SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { HTTPError } from 'got';
import { HTTPError, RequestError } from 'got';
import {
createUser,
@ -17,12 +17,21 @@ import {
import MockClient from '#src/client/index.js';
import { generateUsername, generatePassword } from '#src/utils.js';
export const createUserByAdmin = (username?: string, password?: string, primaryEmail?: string) => {
export const createUserByAdmin = (
username?: string,
password?: string,
primaryEmail?: string,
primaryPhone?: string,
name?: string,
isAdmin = false
) => {
return createUser({
username: username ?? generateUsername(),
password: password ?? generatePassword(),
name: username ?? 'John',
password,
name: name ?? username ?? 'John',
primaryEmail,
primaryPhone,
isAdmin,
}).json<User>();
};
@ -141,3 +150,38 @@ export const bindSocialToNewCreatedUser = async (connectorId: string) => {
return sub;
};
export const expectRejects = async (
promise: Promise<unknown>,
code: string,
messageIncludes?: string
) => {
try {
await promise;
} catch (error: unknown) {
expectRequestError(error, code, messageIncludes);
return;
}
fail();
};
export const expectRequestError = (error: unknown, code: string, messageIncludes?: string) => {
if (!(error instanceof RequestError)) {
fail('Error should be an instance of RequestError');
}
// JSON.parse returns `any`. Directly use `as` since we've already know the response body structure.
// eslint-disable-next-line no-restricted-syntax
const body = JSON.parse(String(error.response?.body)) as {
code: string;
message: string;
};
expect(body.code).toEqual(code);
if (messageIncludes) {
expect(body.message.includes(messageIncludes)).toBeTruthy();
}
};

View file

@ -0,0 +1,250 @@
import type { IncomingHttpHeaders } from 'http';
import type { User } from '@logto/schemas';
import { authedAdminApi, deleteUser } from '#src/api/index.js';
import { createUserByAdmin, expectRejects } from '#src/helpers.js';
const getUsers = async <T>(
init: string[][] | Record<string, string> | URLSearchParams
): Promise<{ headers: IncomingHttpHeaders; json: T }> => {
const { headers, body } = await authedAdminApi.get('users', {
searchParams: new URLSearchParams(init),
});
return { headers, json: JSON.parse(body) as T };
};
describe('admin console user search params', () => {
// eslint-disable-next-line @silverhand/fp/no-let
let users: User[] = [];
beforeAll(async () => {
const prefix = `search_`;
const rawNames = [
'tom scott',
'tom scott 2',
'tom scott 3',
'tom scott 4',
'tom scott 5',
'jerry swift',
'jerry swift 1',
'jerry swift jr',
'jerry swift jr 2',
'jerry swift jr jr',
];
const emailSuffix = ['@gmail.com', '@foo.bar', '@geek.best'];
const phonePrefix = ['101', '102', '202'];
// We can make sure this
/* eslint-disable @typescript-eslint/no-non-null-assertion */
// eslint-disable-next-line @silverhand/fp/no-mutation
users = await Promise.all(
rawNames.map((raw, index) => {
const username = raw.split(' ').join('_');
const name = raw
.split(' ')
.filter((segment) => Number.isNaN(Number(segment)))
.map((segment) => segment[0]!.toUpperCase() + segment.slice(1))
.join(' ');
const primaryEmail = username + emailSuffix[index % emailSuffix.length]!;
const primaryPhone =
phonePrefix[index % phonePrefix.length]! + index.toString().padStart(5, '0');
return createUserByAdmin(
prefix + username,
undefined,
primaryEmail,
primaryPhone,
name,
index < 3
);
})
);
/* eslint-enable @typescript-eslint/no-non-null-assertion */
});
afterAll(async () => {
await Promise.all(users.map(({ id }) => deleteUser(id)));
});
it('should return all users if nothing specified', async () => {
const { headers } = await getUsers<User[]>([]);
expect(Number(headers['total-number'])).toBeGreaterThanOrEqual(10);
});
describe('falling back to `like` mode and matches all available fields if only `search` is specified', () => {
it('should search username', async () => {
const { headers, json } = await getUsers<User[]>([['search', '%search_tom%']]);
expect(headers['total-number']).toEqual('5');
expect(json.length === 5 && json.every((user) => user.name === 'Tom Scott')).toBeTruthy();
});
it('should search primaryPhone', async () => {
const { headers, json } = await getUsers<User[]>([['search', '%0000%']]);
expect(headers['total-number']).toEqual('10');
expect(
json.length === 10 && json.every((user) => user.username?.startsWith('search_'))
).toBeTruthy();
});
it('should be able to hide admin users', async () => {
const { headers, json } = await getUsers<User[]>([
['search', '%search_tom%'],
['hideAdminUser', 'true'],
]);
expect(headers['total-number']).toEqual('2');
expect(json.length === 2 && json.every((user) => user.name === 'Tom Scott')).toBeTruthy();
});
});
it('should be able to perform case sensitive exact search', async () => {
const { headers, json } = await getUsers<User[]>([
['search.name', 'jerry swift'],
['mode.name', 'exact'],
['isCaseSensitive', 'true'],
]);
expect(headers['total-number']).toEqual('0');
expect(json.length === 0).toBeTruthy();
});
it('should be able to perform exact search', async () => {
const { headers, json } = await getUsers<User[]>([
['search.name', 'jerry swift'],
['mode.name', 'exact'],
]);
expect(headers['total-number']).toEqual('2');
expect(json.length === 2 && json.every((user) => user.name === 'Jerry Swift')).toBeTruthy();
});
it('should be able to perform hybrid search', async () => {
const { headers, json } = await getUsers<User[]>([
['search.name', '^Jerry((?!Jr).)*Jr{1}((?!Jr).)*$'], // Only one "Jr" after "Jerry"
['mode.name', 'posix'],
['search.username', 'search_%'], // Should fall back to `like` mode
['search.primaryPhone', '%0{3,}%'],
['mode.primaryPhone', 'similar_to'],
['joint', 'and'],
['isCaseSensitive', 'true'],
]);
expect(headers['total-number']).toEqual('2');
expect(json.length === 2 && json.every((user) => user.name === 'Jerry Swift Jr')).toBeTruthy();
});
it('should be able to perform hybrid search 2', async () => {
const { headers, json } = await getUsers<User[]>([
['search.name', '^T.?m Scot+$'],
['mode.name', 'posix'],
['search.username', 'search_tom%'],
['mode.username', 'similar_to'],
['isCaseSensitive', 'true'],
['hideAdminUser', 'true'],
]);
expect(headers['total-number']).toEqual('2');
expect(
json.length === 2 && json.every((user) => user.username?.startsWith('search_'))
).toBeTruthy();
});
it('should accept multiple value for exact match', async () => {
const { headers, json } = await getUsers<User[]>([
['search.primaryEmail', 'jerry_swiFt_jr@foo.bar'],
['search.primaryEmail', 'jerry_swift_Jr_2@geek.best'],
['search.primaryEmail', 'jerry_swift_jr_jR@gmail.com'],
['mode.primaryEmail', 'exact'],
]);
expect(headers['total-number']).toEqual('3');
expect(
json.length === 3 && json.every((user) => user.name?.startsWith('Jerry Swift Jr'))
).toBeTruthy();
});
it('should accept multiple value for exact match 2', async () => {
// We can make sure this
/* eslint-disable @typescript-eslint/no-non-null-assertion */
const { headers, json } = await getUsers<User[]>([
['search.id', users[0]!.id],
['search.id', users[1]!.id],
['search.id', users[2]!.id],
['search.id', users[2]!.id],
['search.id', 'not_possible'],
['mode.id', 'exact'],
['isCaseSensitive', 'true'],
]);
/* eslint-enable @typescript-eslint/no-non-null-assertion */
expect(headers['total-number']).toEqual('3');
expect(
json.length === 3 && json.every((user) => user.username?.startsWith('search_'))
).toBeTruthy();
});
it('should throw if multiple values found for non-exact mode', async () => {
await expectRejects(
getUsers<User[]>([
['search.primaryEmail', 'jerry_swift_jr@foo.bar'],
['search.primaryEmail', 'jerry_swift_jr_2@geek.best'],
['search.primaryEmail', 'jerry_swift_jr_jr@gmail.com'],
]),
'request.invalid_input',
'`exact`'
);
});
it('should throw if empty value found', async () => {
await expectRejects(
getUsers<User[]>([
['search.primaryEmail', ''],
['search', 'tom'],
]),
'request.invalid_input',
'cannot be empty'
);
});
it('should throw if search is case-insensitive and uses `similar_to` mode', async () => {
await expectRejects(
getUsers<User[]>([
['search.primaryEmail', '%gmail%'],
['mode.primaryEmail', 'similar_to'],
]),
'request.invalid_input',
'case-insensitive'
);
});
it('should throw if invalid const found', async () => {
await Promise.all([
expectRejects(
getUsers<User[]>([
['search.primaryEmail', '%gmail%'],
['mode.primaryEmail', 'similar to'],
]),
'request.invalid_input',
'is not valid'
),
expectRejects(
getUsers<User[]>([['search.email', '%gmail%']]),
'request.invalid_input',
'is not valid'
),
expectRejects(
getUsers<User[]>([
['search.primaryEmail', '%gmail%'],
['joint', 'and1'],
]),
'request.invalid_input',
'is not valid'
),
]);
});
});

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
},
auth: {
authorization_header_missing: 'Autorisierungs-Header fehlt.',
authorization_token_type_not_supported: 'Autorisierungs-Typ wird nicht unterstützt.',

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}',
},
auth: {
authorization_header_missing: 'Authorization header is missing.',
authorization_token_type_not_supported: 'Authorization type is not supported.',

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
},
auth: {
authorization_header_missing: "L'en-tête d'autorisation est manquant.",
authorization_token_type_not_supported: "Le type d'autorisation n'est pas pris en charge.",

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
},
auth: {
authorization_header_missing: '인증 헤더가 존재하지 않아요.',
authorization_token_type_not_supported: '해당 인증 방법을 지원하지 않아요.',

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
},
auth: {
authorization_header_missing: 'O cabeçalho de autorização está ausente.',
authorization_token_type_not_supported: 'O tipo de autorização não é suportado.',

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
},
auth: {
authorization_header_missing: 'O cabeçalho de autorização está ausente.',
authorization_token_type_not_supported: 'O tipo de autorização não é suportado.',

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
},
auth: {
authorization_header_missing: 'Yetkilendirme başlığı eksik.',
authorization_token_type_not_supported: 'Yetkilendirme tipi desteklenmiyor.',

View file

@ -1,4 +1,7 @@
const errors = {
request: {
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
},
auth: {
authorization_header_missing: '缺少权限标题',
authorization_token_type_not_supported: '权限类型不支持',

View file

@ -4,3 +4,4 @@ export * from './oidc-config.js';
export * from './user.js';
export * from './logto-config.js';
export * from './interactions.js';
export * from './search.js';

View file

@ -0,0 +1,17 @@
/** Mode for matching the given value(s) and database entries. */
export enum SearchMatchMode {
/** Use `=` or in-array checking. */
Exact = 'exact',
/** Use the keyword `LIKE`. See [Postgres docs](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE). */
Like = 'like',
/** Use the keyword `SIMILAR TO` for regular expression matching. See [Postgres docs](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-SIMILARTO-REGEXP). */
SimilarTo = 'similar_to',
/** Use the keyword `POSIX` for regular expression matching. See [Postgres docs](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-POSIX-REGEXP). */
Posix = 'posix',
}
/** Mode for joining multiple expressions when searching. */
export enum SearchJointMode {
Or = 'or',
And = 'and',
}