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:
parent
c7add49165
commit
7bf52aa7ad
32 changed files with 900 additions and 270 deletions
|
@ -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;
|
||||
|
|
|
@ -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/",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
`
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 */
|
||||
}
|
||||
|
|
208
packages/core/src/utils/search.test.ts
Normal file
208
packages/core/src/utils/search.test.ts
Normal 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)']
|
||||
);
|
||||
});
|
||||
});
|
242
packages/core/src/utils/search.ts
Normal file
242
packages/core/src/utils/search.ts
Normal 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));
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
const errors = {
|
||||
request: {
|
||||
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
|
||||
},
|
||||
auth: {
|
||||
authorization_header_missing: '인증 헤더가 존재하지 않아요.',
|
||||
authorization_token_type_not_supported: '해당 인증 방법을 지원하지 않아요.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
const errors = {
|
||||
request: {
|
||||
invalid_input: 'Input is invalid. {{details}}', // UNTRANSLATED
|
||||
},
|
||||
auth: {
|
||||
authorization_header_missing: '缺少权限标题',
|
||||
authorization_token_type_not_supported: '权限类型不支持',
|
||||
|
|
|
@ -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';
|
||||
|
|
17
packages/schemas/src/types/search.ts
Normal file
17
packages/schemas/src/types/search.ts
Normal 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',
|
||||
}
|
Loading…
Add table
Reference in a new issue