mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
refactor(core): migrate user library to factory mode
This commit is contained in:
parent
3c4aeec30a
commit
8561b6bc43
28 changed files with 481 additions and 423 deletions
|
@ -6,6 +6,7 @@ import { errors } from 'oidc-provider';
|
|||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { findUserById, updateUserById } from '#src/queries/user.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
export const assignInteractionResults = async (
|
||||
|
@ -37,7 +38,7 @@ export const assignInteractionResults = async (
|
|||
|
||||
export const checkSessionHealth = async (
|
||||
ctx: Context,
|
||||
provider: Provider,
|
||||
{ provider, queries: { users } }: TenantContext,
|
||||
tolerance = 10 * 60 // 10 mins
|
||||
) => {
|
||||
const { accountId, loginTs } = await provider.Session.get(ctx);
|
||||
|
@ -48,7 +49,7 @@ export const checkSessionHealth = async (
|
|||
);
|
||||
|
||||
if (!loginTs || loginTs < getUnixTime(new Date()) - tolerance) {
|
||||
const { passwordEncrypted, primaryPhone, primaryEmail } = await findUserById(accountId);
|
||||
const { passwordEncrypted, primaryPhone, primaryEmail } = await users.findUserById(accountId);
|
||||
|
||||
// No authenticated method configured for this user. Pass!
|
||||
if (!passwordEncrypted && !primaryPhone && !primaryEmail) {
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
import { UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { createMockPool } from 'slonik';
|
||||
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
const pool = createMockPool({
|
||||
query: jest.fn(),
|
||||
});
|
||||
|
||||
const { updateUserById, hasUserWithId } = await mockEsmWithActual('#src/queries/user.js', () => ({
|
||||
updateUserById: jest.fn(),
|
||||
hasUserWithId: jest.fn(),
|
||||
}));
|
||||
const { encryptUserPassword, createUserLibrary } = await import('./user.js');
|
||||
|
||||
const { encryptUserPassword, generateUserId } = await import('./user.js');
|
||||
const queries = new Queries(pool);
|
||||
|
||||
const hasUserWithId = jest.spyOn(queries.users, 'hasUserWithId');
|
||||
|
||||
describe('generateUserId()', () => {
|
||||
const { generateUserId } = createUserLibrary(queries);
|
||||
|
||||
afterEach(() => {
|
||||
hasUserWithId.mockClear();
|
||||
});
|
||||
|
@ -59,17 +64,3 @@ describe('encryptUserPassword()', () => {
|
|||
expect(passwordEncrypted).toContain('argon2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLastSignIn()', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2020-01-01'));
|
||||
});
|
||||
|
||||
it('calls updateUserById with current timestamp', async () => {
|
||||
await updateUserById('user-id', { lastSignInAt: Date.now() });
|
||||
expect(updateUserById).toHaveBeenCalledWith(
|
||||
'user-id',
|
||||
expect.objectContaining({ lastSignInAt: new Date('2020-01-01').getTime() })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,31 +7,18 @@ import { deduplicate } from '@silverhand/essentials';
|
|||
import { argon2Verify } from 'hash-wasm';
|
||||
import pRetry from 'p-retry';
|
||||
|
||||
import { buildInsertInto } from '#src/database/insert-into.js';
|
||||
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { findRolesByRoleNames, insertRoles, findRoleByRoleName } from '#src/queries/roles.js';
|
||||
import { hasUser, hasUserWithEmail, hasUserWithId, hasUserWithPhone } from '#src/queries/user.js';
|
||||
import { insertUsersRoles } from '#src/queries/users-roles.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { encryptPassword } from '#src/utils/password.js';
|
||||
|
||||
const userId = buildIdGenerator(12);
|
||||
const roleId = buildIdGenerator(21);
|
||||
|
||||
export const generateUserId = async (retries = 500) =>
|
||||
pRetry(
|
||||
async () => {
|
||||
const id = userId();
|
||||
|
||||
if (!(await hasUserWithId(id))) {
|
||||
return id;
|
||||
}
|
||||
|
||||
throw new Error('Cannot generate user ID in reasonable retries');
|
||||
},
|
||||
{ retries, factor: 0 } // No need for exponential backoff
|
||||
);
|
||||
/** @deprecated Don't use. This is for transition only and will be removed soon. */
|
||||
export const defaultQueries = new Queries(envSet.pool);
|
||||
|
||||
export const encryptUserPassword = async (
|
||||
password: string
|
||||
|
@ -49,87 +36,121 @@ export const encryptUserPassword = async (
|
|||
return { passwordEncrypted, passwordEncryptionMethod };
|
||||
};
|
||||
|
||||
export const verifyUserPassword = async (user: Nullable<User>, password: string): Promise<User> => {
|
||||
assertThat(user, 'session.invalid_credentials');
|
||||
const { passwordEncrypted, passwordEncryptionMethod } = user;
|
||||
export const createUserLibrary = (queries: Queries) => {
|
||||
const {
|
||||
pool,
|
||||
roles: { findRolesByRoleNames, insertRoles, findRoleByRoleName },
|
||||
users: { hasUser, hasUserWithEmail, hasUserWithId, hasUserWithPhone },
|
||||
usersRoles: { insertUsersRoles },
|
||||
} = queries;
|
||||
|
||||
assertThat(passwordEncrypted && passwordEncryptionMethod, 'session.invalid_credentials');
|
||||
const generateUserId = async (retries = 500) =>
|
||||
pRetry(
|
||||
async () => {
|
||||
const id = userId();
|
||||
|
||||
const result = await argon2Verify({ password, hash: passwordEncrypted });
|
||||
if (!(await hasUserWithId(id))) {
|
||||
return id;
|
||||
}
|
||||
|
||||
assertThat(result, 'session.invalid_credentials');
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
const insertUserQuery = buildInsertInto<CreateUser, User>(Users, {
|
||||
returning: true,
|
||||
});
|
||||
|
||||
// Temp solution since Hasura requires a role to proceed authn.
|
||||
// The source of default roles should be guarded and moved to database once we implement RBAC.
|
||||
export const insertUser = async ({
|
||||
roleNames,
|
||||
...rest
|
||||
}: OmitAutoSetFields<CreateUser> & { roleNames?: string[] }) => {
|
||||
const computedRoleNames = deduplicate(
|
||||
(roleNames ?? []).concat(envSet.values.userDefaultRoleNames)
|
||||
);
|
||||
|
||||
if (computedRoleNames.length > 0) {
|
||||
const existingRoles = await findRolesByRoleNames(computedRoleNames);
|
||||
const missingRoleNames = computedRoleNames.filter(
|
||||
(roleName) => !existingRoles.some(({ name }) => roleName === name)
|
||||
throw new Error('Cannot generate user ID in reasonable retries');
|
||||
},
|
||||
{ retries, factor: 0 } // No need for exponential backoff
|
||||
);
|
||||
|
||||
if (missingRoleNames.length > 0) {
|
||||
await insertRoles(
|
||||
missingRoleNames.map((name) => ({
|
||||
id: roleId(),
|
||||
name,
|
||||
description: 'User default role.',
|
||||
}))
|
||||
const verifyUserPassword = async (user: Nullable<User>, password: string): Promise<User> => {
|
||||
assertThat(user, 'session.invalid_credentials');
|
||||
const { passwordEncrypted, passwordEncryptionMethod } = user;
|
||||
|
||||
assertThat(passwordEncrypted && passwordEncryptionMethod, 'session.invalid_credentials');
|
||||
|
||||
const result = await argon2Verify({ password, hash: passwordEncrypted });
|
||||
|
||||
assertThat(result, 'session.invalid_credentials');
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
const insertUserQuery = buildInsertIntoWithPool(pool)<CreateUser, User>(Users, {
|
||||
returning: true,
|
||||
});
|
||||
|
||||
// Temp solution since Hasura requires a role to proceed authn.
|
||||
// The source of default roles should be guarded and moved to database once we implement RBAC.
|
||||
const insertUser = async ({
|
||||
roleNames,
|
||||
...rest
|
||||
}: OmitAutoSetFields<CreateUser> & { roleNames?: string[] }) => {
|
||||
const computedRoleNames = deduplicate(
|
||||
(roleNames ?? []).concat(envSet.values.userDefaultRoleNames)
|
||||
);
|
||||
|
||||
if (computedRoleNames.length > 0) {
|
||||
const existingRoles = await findRolesByRoleNames(computedRoleNames);
|
||||
const missingRoleNames = computedRoleNames.filter(
|
||||
(roleName) => !existingRoles.some(({ name }) => roleName === name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await insertUserQuery(rest);
|
||||
|
||||
await Promise.all([
|
||||
computedRoleNames.map(async (roleName) => {
|
||||
const role = await findRoleByRoleName(roleName);
|
||||
|
||||
if (!role) {
|
||||
// Not expected to happen, just inserted above, so is 500
|
||||
throw new Error(`Can not find role: ${roleName}`);
|
||||
if (missingRoleNames.length > 0) {
|
||||
await insertRoles(
|
||||
missingRoleNames.map((name) => ({
|
||||
id: roleId(),
|
||||
name,
|
||||
description: 'User default role.',
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await insertUsersRoles([{ userId: user.id, roleId: role.id }]);
|
||||
}),
|
||||
]);
|
||||
const user = await insertUserQuery(rest);
|
||||
|
||||
return user;
|
||||
await Promise.all([
|
||||
computedRoleNames.map(async (roleName) => {
|
||||
const role = await findRoleByRoleName(roleName);
|
||||
|
||||
if (!role) {
|
||||
// Not expected to happen, just inserted above, so is 500
|
||||
throw new Error(`Can not find role: ${roleName}`);
|
||||
}
|
||||
|
||||
await insertUsersRoles([{ userId: user.id, roleId: role.id }]);
|
||||
}),
|
||||
]);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
const checkIdentifierCollision = async (
|
||||
identifiers: {
|
||||
username?: Nullable<string>;
|
||||
primaryEmail?: Nullable<string>;
|
||||
primaryPhone?: Nullable<string>;
|
||||
},
|
||||
excludeUserId?: string
|
||||
) => {
|
||||
const { username, primaryEmail, primaryPhone } = identifiers;
|
||||
|
||||
if (username && (await hasUser(username, excludeUserId))) {
|
||||
throw new RequestError({ code: 'user.username_already_in_use', status: 422 });
|
||||
}
|
||||
|
||||
if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) {
|
||||
throw new RequestError({ code: 'user.email_already_in_use', status: 422 });
|
||||
}
|
||||
|
||||
if (primaryPhone && (await hasUserWithPhone(primaryPhone, excludeUserId))) {
|
||||
throw new RequestError({ code: 'user.phone_already_in_use', status: 422 });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
generateUserId,
|
||||
verifyUserPassword,
|
||||
insertUser,
|
||||
checkIdentifierCollision,
|
||||
};
|
||||
};
|
||||
|
||||
export const checkIdentifierCollision = async (
|
||||
identifiers: {
|
||||
username?: Nullable<string>;
|
||||
primaryEmail?: Nullable<string>;
|
||||
primaryPhone?: Nullable<string>;
|
||||
},
|
||||
excludeUserId?: string
|
||||
) => {
|
||||
const { username, primaryEmail, primaryPhone } = identifiers;
|
||||
|
||||
if (username && (await hasUser(username, excludeUserId))) {
|
||||
throw new RequestError({ code: 'user.username_already_in_use', status: 422 });
|
||||
}
|
||||
|
||||
if (primaryEmail && (await hasUserWithEmail(primaryEmail, excludeUserId))) {
|
||||
throw new RequestError({ code: 'user.email_already_in_use', status: 422 });
|
||||
}
|
||||
|
||||
if (primaryPhone && (await hasUserWithPhone(primaryPhone, excludeUserId))) {
|
||||
throw new RequestError({ code: 'user.phone_already_in_use', status: 422 });
|
||||
}
|
||||
};
|
||||
/** @deprecated Don't use. This is for transition only and will be removed soon. */
|
||||
export const { generateUserId, verifyUserPassword, insertUser, checkIdentifierCollision } =
|
||||
createUserLibrary(defaultQueries);
|
||||
|
|
|
@ -4,7 +4,6 @@ import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
|||
import { snakeCase } from 'snake-case';
|
||||
|
||||
import { mockApplication } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
@ -13,14 +12,13 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { createApplicationQueries } = await import('./application.js');
|
||||
const {
|
||||
findTotalNumberOfApplications,
|
||||
findAllApplications,
|
||||
|
@ -28,7 +26,7 @@ const {
|
|||
insertApplication,
|
||||
updateApplicationById,
|
||||
deleteApplicationById,
|
||||
} = await import('./application.js');
|
||||
} = createApplicationQueries(pool);
|
||||
|
||||
describe('application query', () => {
|
||||
const { table, fields } = convertToIdentifiers(Applications);
|
||||
|
|
|
@ -45,7 +45,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' });
|
||||
|
||||
const deleteApplicationById = async (id: string) => {
|
||||
const { rowCount } = await envSet.pool.query(sql`
|
||||
const { rowCount } = await pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.id}=${id}
|
||||
`);
|
||||
|
|
|
@ -3,7 +3,6 @@ import { convertToIdentifiers } from '@logto/shared';
|
|||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import { mockConnector } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
@ -12,14 +11,13 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { createConnectorQueries } = await import('./connector.js');
|
||||
const {
|
||||
findAllConnectors,
|
||||
findConnectorById,
|
||||
|
@ -28,7 +26,7 @@ const {
|
|||
deleteConnectorByIds,
|
||||
insertConnector,
|
||||
updateConnector,
|
||||
} = await import('./connector.js');
|
||||
} = createConnectorQueries(pool);
|
||||
|
||||
describe('connector queries', () => {
|
||||
const { table, fields } = convertToIdentifiers(Connectors);
|
||||
|
|
|
@ -1,31 +1,22 @@
|
|||
import type { CreateOidcModelInstance } from '@logto/schemas';
|
||||
import { OidcModelInstances } from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await mockEsmWithActual('@logto/shared', () => ({
|
||||
convertToTimestamp: () => 100,
|
||||
}));
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { createOidcModelInstanceQueries } = await import('./oidc-model-instance.js');
|
||||
const {
|
||||
upsertInstance,
|
||||
findPayloadById,
|
||||
|
@ -33,7 +24,7 @@ const {
|
|||
consumeInstanceById,
|
||||
destroyInstanceById,
|
||||
revokeInstanceByGrantId,
|
||||
} = await import('./oidc-model-instance.js');
|
||||
} = createOidcModelInstanceQueries(pool);
|
||||
|
||||
describe('oidc-model-instance query', () => {
|
||||
const { table, fields } = convertToIdentifiers(OidcModelInstances);
|
||||
|
@ -118,9 +109,11 @@ describe('oidc-model-instance query', () => {
|
|||
});
|
||||
|
||||
it('consumeInstanceById', async () => {
|
||||
jest.useFakeTimers().setSystemTime(100_000);
|
||||
|
||||
const expectSql = sql`
|
||||
update ${table}
|
||||
set ${fields.consumedAt}=$1
|
||||
set ${fields.consumedAt}=to_timestamp($1)
|
||||
where ${fields.modelName}=$2
|
||||
and ${fields.id}=$3
|
||||
`;
|
||||
|
@ -133,6 +126,8 @@ describe('oidc-model-instance query', () => {
|
|||
});
|
||||
|
||||
await consumeInstanceById(instance.modelName, instance.id);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('destroyInstanceById', async () => {
|
||||
|
|
|
@ -64,7 +64,7 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
|
|||
);
|
||||
|
||||
const findPayloadById = async (modelName: string, id: string) => {
|
||||
const result = await envSet.pool.maybeOne<QueryResult>(sql`
|
||||
const result = await pool.maybeOne<QueryResult>(sql`
|
||||
${findByModel(modelName)}
|
||||
and ${fields.id}=${id}
|
||||
`);
|
||||
|
@ -80,7 +80,7 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
|
|||
field: Field,
|
||||
value: T
|
||||
) => {
|
||||
const result = await envSet.pool.maybeOne<QueryResult>(sql`
|
||||
const result = await pool.maybeOne<QueryResult>(sql`
|
||||
${findByModel(modelName)}
|
||||
and ${fields.payload}->>${field}=${value}
|
||||
`);
|
||||
|
@ -89,7 +89,7 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
|
|||
};
|
||||
|
||||
const consumeInstanceById = async (modelName: string, id: string) => {
|
||||
await envSet.pool.query(sql`
|
||||
await pool.query(sql`
|
||||
update ${table}
|
||||
set ${fields.consumedAt}=${convertToTimestamp()}
|
||||
where ${fields.modelName}=${modelName}
|
||||
|
@ -98,7 +98,7 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
|
|||
};
|
||||
|
||||
const destroyInstanceById = async (modelName: string, id: string) => {
|
||||
await envSet.pool.query(sql`
|
||||
await pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.modelName}=${modelName}
|
||||
and ${fields.id}=${id}
|
||||
|
@ -106,7 +106,7 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
|
|||
};
|
||||
|
||||
const revokeInstanceByGrantId = async (modelName: string, grantId: string) => {
|
||||
await envSet.pool.query(sql`
|
||||
await pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.modelName}=${modelName}
|
||||
and ${fields.payload}->>'grantId'=${grantId}
|
||||
|
@ -114,7 +114,7 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => {
|
|||
};
|
||||
|
||||
const revokeInstanceByUserId = async (modelName: string, userId: string) => {
|
||||
await envSet.pool.query(sql`
|
||||
await pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.modelName}=${modelName}
|
||||
and ${fields.payload}->>'accountId'=${userId}
|
||||
|
|
|
@ -5,7 +5,6 @@ import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
|||
import { snakeCase } from 'snake-case';
|
||||
|
||||
import { mockPasscode } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
@ -14,21 +13,20 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { createPasscodeQueries } = await import('./passcode.js');
|
||||
const {
|
||||
findUnconsumedPasscodeByJtiAndType,
|
||||
findUnconsumedPasscodesByJtiAndType,
|
||||
insertPasscode,
|
||||
deletePasscodeById,
|
||||
deletePasscodesByIds,
|
||||
} = await import('./passcode.js');
|
||||
} = createPasscodeQueries(pool);
|
||||
|
||||
describe('passcode query', () => {
|
||||
const { table, fields } = convertToIdentifiers(Passcodes);
|
||||
|
|
|
@ -3,7 +3,6 @@ import { convertToIdentifiers, convertToPrimitiveOrSql } from '@logto/shared';
|
|||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import { mockResource } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
@ -12,14 +11,13 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { createResourceQueries } = await import('./resource.js');
|
||||
const {
|
||||
findTotalNumberOfResources,
|
||||
findAllResources,
|
||||
|
@ -28,7 +26,7 @@ const {
|
|||
insertResource,
|
||||
updateResourceById,
|
||||
deleteResourceById,
|
||||
} = await import('./resource.js');
|
||||
} = createResourceQueries(pool);
|
||||
|
||||
describe('resource query', () => {
|
||||
const { table, fields } = convertToIdentifiers(Resources);
|
||||
|
|
|
@ -3,7 +3,6 @@ import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } f
|
|||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import { mockRole } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
@ -12,14 +11,13 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { createRolesQueries } = await import('./roles.js');
|
||||
const {
|
||||
deleteRoleById,
|
||||
findRoleById,
|
||||
|
@ -29,7 +27,7 @@ const {
|
|||
insertRole,
|
||||
insertRoles,
|
||||
updateRoleById,
|
||||
} = await import('./roles.js');
|
||||
} = createRolesQueries(pool);
|
||||
|
||||
describe('roles query', () => {
|
||||
const { table, fields } = convertToIdentifiers(Roles);
|
||||
|
|
|
@ -3,7 +3,6 @@ import { convertToIdentifiers } from '@logto/shared';
|
|||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import { mockSetting } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -11,19 +10,18 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { defaultSettingId, getSetting, updateSetting } = await import('./setting.js');
|
||||
const { defaultSettingId, createSettingQueries } = await import('./setting.js');
|
||||
const { getSetting, updateSetting } = createSettingQueries(pool);
|
||||
|
||||
describe('setting query', () => {
|
||||
const { table, fields } = convertToIdentifiers(Settings);
|
||||
const dbvalue = { ...mockSetting, adminConsole: JSON.stringify(mockSetting.adminConsole) };
|
||||
const databaseValue = { ...mockSetting, adminConsole: JSON.stringify(mockSetting.adminConsole) };
|
||||
|
||||
it('getSetting', async () => {
|
||||
const expectSql = sql`
|
||||
|
@ -36,10 +34,10 @@ describe('setting query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([defaultSettingId]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
await expect(getSetting()).resolves.toEqual(dbvalue);
|
||||
await expect(getSetting()).resolves.toEqual(databaseValue);
|
||||
});
|
||||
|
||||
it('updateSetting', async () => {
|
||||
|
@ -58,9 +56,9 @@ describe('setting query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([JSON.stringify(adminConsole), defaultSettingId]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
await expect(updateSetting({ adminConsole })).resolves.toEqual(dbvalue);
|
||||
await expect(updateSetting({ adminConsole })).resolves.toEqual(databaseValue);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { createMockPool, createMockQueryResult } from 'slonik';
|
||||
|
||||
import { mockSignInExperience } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -9,17 +8,15 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { findDefaultSignInExperience, updateDefaultSignInExperience } = await import(
|
||||
'./sign-in-experience.js'
|
||||
);
|
||||
const { createSignInExperienceQueries } = await import('./sign-in-experience.js');
|
||||
const { findDefaultSignInExperience, updateDefaultSignInExperience } =
|
||||
createSignInExperienceQueries(pool);
|
||||
|
||||
describe('sign-in-experience query', () => {
|
||||
const id = 'default';
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { Roles, Users, UsersRoles } from '@logto/schemas';
|
||||
import { Users } from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||
|
||||
import { mockUser } from '#src/__mocks__/index.js';
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
import type { QueryType } from '#src/utils/test-utils.js';
|
||||
import { expectSqlAssert } from '#src/utils/test-utils.js';
|
||||
|
@ -12,14 +11,13 @@ const { jest } = import.meta;
|
|||
|
||||
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
|
||||
|
||||
jest.spyOn(envSet, 'pool', 'get').mockReturnValue(
|
||||
createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
})
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return mockQuery(sql, values);
|
||||
},
|
||||
});
|
||||
|
||||
const { createUserQueries } = await import('./user.js');
|
||||
const {
|
||||
findUserByUsername,
|
||||
findUserByEmail,
|
||||
|
@ -33,13 +31,11 @@ const {
|
|||
updateUserById,
|
||||
deleteUserById,
|
||||
deleteUserIdentity,
|
||||
} = await import('./user.js');
|
||||
} = createUserQueries(pool);
|
||||
|
||||
describe('user query', () => {
|
||||
const { table, fields } = convertToIdentifiers(Users);
|
||||
const { fields: rolesFields, table: rolesTable } = convertToIdentifiers(Roles);
|
||||
const { fields: usersRolesFields, table: usersRolesTable } = convertToIdentifiers(UsersRoles);
|
||||
const dbvalue = {
|
||||
const databaseValue = {
|
||||
...mockUser,
|
||||
roleNames: JSON.stringify(mockUser.roleNames),
|
||||
identities: JSON.stringify(mockUser.identities),
|
||||
|
@ -57,11 +53,11 @@ describe('user query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([mockUser.username]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(findUserByUsername(mockUser.username!)).resolves.toEqual(dbvalue);
|
||||
await expect(findUserByUsername(mockUser.username!)).resolves.toEqual(databaseValue);
|
||||
});
|
||||
|
||||
it('findUserByEmail', async () => {
|
||||
|
@ -75,11 +71,11 @@ describe('user query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([mockUser.primaryEmail]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(findUserByEmail(mockUser.primaryEmail!)).resolves.toEqual(dbvalue);
|
||||
await expect(findUserByEmail(mockUser.primaryEmail!)).resolves.toEqual(databaseValue);
|
||||
});
|
||||
|
||||
it('findUserByPhone', async () => {
|
||||
|
@ -93,11 +89,11 @@ describe('user query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([mockUser.primaryPhone]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await expect(findUserByPhone(mockUser.primaryPhone!)).resolves.toEqual(dbvalue);
|
||||
await expect(findUserByPhone(mockUser.primaryPhone!)).resolves.toEqual(databaseValue);
|
||||
});
|
||||
|
||||
it('findUserByIdentity', async () => {
|
||||
|
@ -113,10 +109,10 @@ describe('user query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([mockUser.id]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
await expect(findUserByIdentity(target, mockUser.id)).resolves.toEqual(dbvalue);
|
||||
await expect(findUserByIdentity(target, mockUser.id)).resolves.toEqual(databaseValue);
|
||||
});
|
||||
|
||||
it('hasUser', async () => {
|
||||
|
@ -233,10 +229,10 @@ describe('user query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([username, id]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
await expect(updateUserById(id, { username })).resolves.toEqual(dbvalue);
|
||||
await expect(updateUserById(id, { username })).resolves.toEqual(databaseValue);
|
||||
});
|
||||
|
||||
it('deleteUserById', async () => {
|
||||
|
@ -250,7 +246,7 @@ describe('user query', () => {
|
|||
expectSqlAssert(sql, expectSql.sql);
|
||||
expect(values).toEqual([id]);
|
||||
|
||||
return createMockQueryResult([dbvalue]);
|
||||
return createMockQueryResult([databaseValue]);
|
||||
});
|
||||
|
||||
await deleteUserById(id);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { CreateUser, Role, User } from '@logto/schemas';
|
||||
import type { CreateUser, Role, SignInExperience, User } from '@logto/schemas';
|
||||
import { userInfoSelectFields } from '@logto/schemas';
|
||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||
import { pick } from '@silverhand/essentials';
|
||||
|
@ -9,10 +9,13 @@ import {
|
|||
mockUserListResponse,
|
||||
mockUserResponse,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import Libraries from '#src/tenants/Libraries.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import { MockTenant, Partial2 } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
|
||||
const { mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const filterUsersWithSearch = (users: User[], search: string) =>
|
||||
users.filter((user) =>
|
||||
|
@ -21,30 +24,35 @@ const filterUsersWithSearch = (users: User[], search: string) =>
|
|||
)
|
||||
);
|
||||
|
||||
mockEsm('#src/queries/sign-in-experience.js', () => ({
|
||||
findDefaultSignInExperience: jest.fn(async () => ({
|
||||
signUp: {
|
||||
identifiers: [],
|
||||
password: false,
|
||||
verify: false,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockHasUser = jest.fn(async () => false);
|
||||
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||
|
||||
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
|
||||
await mockEsmWithActual('#src/queries/user.js', () => ({
|
||||
const mockedQueries = {
|
||||
oidcModelInstances: { revokeInstanceByUserId: jest.fn() },
|
||||
signInExperiences: {
|
||||
findDefaultSignInExperience: jest.fn(
|
||||
async () =>
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
({
|
||||
signUp: {
|
||||
identifiers: [],
|
||||
password: false,
|
||||
verify: false,
|
||||
},
|
||||
} as SignInExperience)
|
||||
),
|
||||
},
|
||||
users: {
|
||||
countUsers: jest.fn(async (search) => ({
|
||||
count: search ? filterUsersWithSearch(mockUserList, search).length : mockUserList.length,
|
||||
count: search
|
||||
? filterUsersWithSearch(mockUserList, String(search)).length
|
||||
: mockUserList.length,
|
||||
})),
|
||||
findUsers: jest.fn(
|
||||
async (limit, offset, search): Promise<User[]> =>
|
||||
search ? filterUsersWithSearch(mockUserList, search) : mockUserList
|
||||
// For testing, type should be `Search` but we use `string` in `filterUsersWithSearch()` here
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
search ? filterUsersWithSearch(mockUserList, String(search)) : mockUserList
|
||||
),
|
||||
findUserById: jest.fn(async (): Promise<User> => mockUser),
|
||||
findUserById: jest.fn(async (id: string) => mockUser),
|
||||
hasUser: jest.fn(async () => mockHasUser()),
|
||||
hasUserWithEmail: jest.fn(async () => mockHasUserWithEmail()),
|
||||
hasUserWithPhone: jest.fn(async () => mockHasUserWithPhone()),
|
||||
|
@ -56,36 +64,45 @@ const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserByI
|
|||
),
|
||||
deleteUserById: jest.fn(),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
}));
|
||||
},
|
||||
roles: {
|
||||
findRolesByRoleNames: jest.fn(
|
||||
async (): Promise<Role[]> => [{ id: 'role_id', name: 'admin', description: 'none' }]
|
||||
),
|
||||
},
|
||||
} satisfies Partial2<Queries>;
|
||||
|
||||
const mockHasUser = jest.fn(async () => false);
|
||||
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||
|
||||
const { revokeInstanceByUserId } = mockedQueries.oidcModelInstances;
|
||||
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
|
||||
mockedQueries.users;
|
||||
const { findRolesByRoleNames } = mockedQueries.roles;
|
||||
|
||||
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
|
||||
generateUserId: jest.fn(() => 'fooId'),
|
||||
encryptUserPassword: jest.fn(() => ({
|
||||
passwordEncrypted: 'password',
|
||||
passwordEncryptionMethod: 'Argon2i',
|
||||
})),
|
||||
}));
|
||||
|
||||
const usersLibraries = {
|
||||
generateUserId: jest.fn(async () => 'fooId'),
|
||||
insertUser: jest.fn(
|
||||
async (user: CreateUser): Promise<User> => ({
|
||||
...mockUser,
|
||||
...user,
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
const { findRolesByRoleNames } = mockEsm('#src/queries/roles.js', () => ({
|
||||
findRolesByRoleNames: jest.fn(
|
||||
async (): Promise<Role[]> => [{ id: 'role_id', name: 'admin', description: 'none' }]
|
||||
),
|
||||
}));
|
||||
|
||||
const { revokeInstanceByUserId } = mockEsm('#src/queries/oidc-model-instance.js', () => ({
|
||||
revokeInstanceByUserId: jest.fn(),
|
||||
}));
|
||||
} satisfies Partial<Libraries['users']>;
|
||||
|
||||
const adminUserRoutes = await pickDefault(import('./admin-user.js'));
|
||||
|
||||
describe('adminUserRoutes', () => {
|
||||
const userRequest = createRequester({ authedRoutes: adminUserRoutes });
|
||||
const tenantContext = new MockTenant(undefined, mockedQueries, { users: usersLibraries });
|
||||
const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext });
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -259,8 +276,7 @@ describe('adminUserRoutes', () => {
|
|||
const name = 'Michael';
|
||||
const avatar = 'http://www.michael.png';
|
||||
|
||||
const mockFindUserById = findUserById as jest.Mock;
|
||||
mockFindUserById.mockImplementationOnce(() => {
|
||||
findUserById.mockImplementationOnce(() => {
|
||||
throw new Error(' ');
|
||||
});
|
||||
|
||||
|
@ -296,8 +312,7 @@ describe('adminUserRoutes', () => {
|
|||
});
|
||||
|
||||
it('PATCH /users/:userId should throw if role names are invalid', async () => {
|
||||
const mockedFindRolesByRoleNames = findRolesByRoleNames as jest.Mock;
|
||||
mockedFindRolesByRoleNames.mockImplementationOnce(
|
||||
findRolesByRoleNames.mockImplementationOnce(
|
||||
async (): Promise<Role[]> => [
|
||||
{ id: 'role_id1', name: 'worker', description: 'none' },
|
||||
{ id: 'role_id2', name: 'cleaner', description: 'none' },
|
||||
|
@ -333,11 +348,13 @@ describe('adminUserRoutes', () => {
|
|||
it('PATCH /users/:userId/password should throw if user cannot be found', async () => {
|
||||
const notExistedUserId = 'notExistedUserId';
|
||||
const dummyPassword = '123456';
|
||||
const mockedFindUserById = findUserById as jest.Mock;
|
||||
mockedFindUserById.mockImplementationOnce((userId) => {
|
||||
|
||||
findUserById.mockImplementationOnce(async (userId) => {
|
||||
if (userId === notExistedUserId) {
|
||||
throw new Error(' ');
|
||||
}
|
||||
|
||||
return mockUser;
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
|
|
@ -6,39 +6,38 @@ import { boolean, literal, object, string } from 'zod';
|
|||
|
||||
import { isTrue } from '#src/env-set/parameters.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import {
|
||||
checkIdentifierCollision,
|
||||
encryptUserPassword,
|
||||
generateUserId,
|
||||
insertUser,
|
||||
} from '#src/libraries/user.js';
|
||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
import { revokeInstanceByUserId } from '#src/queries/oidc-model-instance.js';
|
||||
import { findRolesByRoleNames } from '#src/queries/roles.js';
|
||||
import {
|
||||
deleteUserById,
|
||||
deleteUserIdentity,
|
||||
findUsers,
|
||||
countUsers,
|
||||
findUserById,
|
||||
hasUser,
|
||||
updateUserById,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
findUsersByRoleName,
|
||||
} from '#src/queries/user.js';
|
||||
import {
|
||||
deleteUsersRolesByUserIdAndRoleId,
|
||||
findUsersRolesByRoleId,
|
||||
insertUsersRoles,
|
||||
} from '#src/queries/users-roles.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
export default function adminUserRoutes<T extends AuthedRouter>(...[router]: RouterInitArgs<T>) {
|
||||
export default function adminUserRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
oidcModelInstances: { revokeInstanceByUserId },
|
||||
roles: { findRolesByRoleNames },
|
||||
users: {
|
||||
deleteUserById,
|
||||
deleteUserIdentity,
|
||||
findUsers,
|
||||
countUsers,
|
||||
findUserById,
|
||||
hasUser,
|
||||
updateUserById,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
findUsersByRoleName,
|
||||
},
|
||||
usersRoles: { deleteUsersRolesByUserIdAndRoleId, findUsersRolesByRoleId, insertUsersRoles },
|
||||
} = queries;
|
||||
const {
|
||||
users: { checkIdentifierCollision, generateUserId, insertUser },
|
||||
} = libraries;
|
||||
|
||||
router.get('/users', koaPagination(), async (ctx, next) => {
|
||||
const { limit, offset } = ctx.pagination;
|
||||
const { searchParams } = ctx.request.URL;
|
||||
|
@ -191,14 +190,14 @@ export default function adminUserRoutes<T extends AuthedRouter>(...[router]: Rou
|
|||
koaGuard({
|
||||
params: object({ userId: string() }),
|
||||
body: object({
|
||||
username: string().regex(usernameRegEx).or(literal('')).nullable().optional(),
|
||||
primaryEmail: string().regex(emailRegEx).or(literal('')).nullable().optional(),
|
||||
primaryPhone: string().regex(phoneRegEx).or(literal('')).nullable().optional(),
|
||||
name: string().or(literal('')).nullable().optional(),
|
||||
avatar: string().url().or(literal('')).nullable().optional(),
|
||||
customData: arbitraryObjectGuard.optional(),
|
||||
roleNames: string().array().optional(),
|
||||
}),
|
||||
username: string().regex(usernameRegEx).or(literal('')).nullable(),
|
||||
primaryEmail: string().regex(emailRegEx).or(literal('')).nullable(),
|
||||
primaryPhone: string().regex(phoneRegEx).or(literal('')).nullable(),
|
||||
name: string().or(literal('')).nullable(),
|
||||
avatar: string().url().or(literal('')).nullable(),
|
||||
customData: arbitraryObjectGuard,
|
||||
roleNames: string().array(),
|
||||
}).partial(),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
|
|
@ -5,9 +5,9 @@ import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
|||
import { mockApplication } from '#src/__mocks__/index.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const { findApplicationById } = mockEsm('#src/queries/application.js', () => ({
|
||||
const { findApplicationById } = await mockEsmWithActual('#src/queries/application.js', () => ({
|
||||
findTotalNumberOfApplications: jest.fn(async () => ({ count: 10 })),
|
||||
findAllApplications: jest.fn(async () => [mockApplication]),
|
||||
findApplicationById: jest.fn(async () => mockApplication),
|
||||
|
|
|
@ -3,7 +3,7 @@ import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type {
|
||||
|
@ -26,25 +26,24 @@ const { assignInteractionResults } = mockEsm('#src/libraries/session.js', () =>
|
|||
assignInteractionResults: jest.fn(),
|
||||
}));
|
||||
|
||||
const { encryptUserPassword, generateUserId, insertUser } = mockEsm(
|
||||
'#src/libraries/user.js',
|
||||
() => ({
|
||||
encryptUserPassword: jest.fn().mockResolvedValue({
|
||||
passwordEncrypted: 'passwordEncrypted',
|
||||
passwordEncryptionMethod: 'plain',
|
||||
}),
|
||||
generateUserId: jest.fn().mockResolvedValue('uid'),
|
||||
insertUser: jest.fn(),
|
||||
})
|
||||
);
|
||||
const { encryptUserPassword } = mockEsm('#src/libraries/user.js', () => ({
|
||||
encryptUserPassword: jest.fn().mockResolvedValue({
|
||||
passwordEncrypted: 'passwordEncrypted',
|
||||
passwordEncryptionMethod: 'plain',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { hasActiveUsers, updateUserById } = mockEsm('#src/queries/user.js', () => ({
|
||||
const userQueries = {
|
||||
findUserById: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ identities: { google: { userId: 'googleId', details: {} } } }),
|
||||
updateUserById: jest.fn(),
|
||||
hasActiveUsers: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
};
|
||||
const { hasActiveUsers, updateUserById } = userQueries;
|
||||
|
||||
const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() };
|
||||
const { generateUserId, insertUser } = userLibraries;
|
||||
|
||||
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
|
||||
const now = Date.now();
|
||||
|
@ -52,7 +51,7 @@ const now = Date.now();
|
|||
jest.useFakeTimers().setSystemTime(now);
|
||||
|
||||
describe('submit action', () => {
|
||||
const provider = createMockProvider();
|
||||
const tenant = new MockTenant(undefined, { users: userQueries }, { users: userLibraries });
|
||||
const ctx = {
|
||||
...createContextWithRouteParameters(),
|
||||
...createMockLogContext(),
|
||||
|
@ -102,7 +101,7 @@ describe('submit action', () => {
|
|||
identifiers,
|
||||
};
|
||||
|
||||
await submitInteraction(interaction, ctx, provider);
|
||||
await submitInteraction(interaction, ctx, tenant);
|
||||
|
||||
expect(generateUserId).toBeCalled();
|
||||
expect(hasActiveUsers).not.toBeCalled();
|
||||
|
@ -113,7 +112,9 @@ describe('submit action', () => {
|
|||
id: 'uid',
|
||||
...upsertProfile,
|
||||
});
|
||||
expect(assignInteractionResults).toBeCalledWith(ctx, provider, { login: { accountId: 'uid' } });
|
||||
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
|
||||
login: { accountId: 'uid' },
|
||||
});
|
||||
});
|
||||
|
||||
it('admin user register', async () => {
|
||||
|
@ -135,7 +136,7 @@ describe('submit action', () => {
|
|||
identifiers,
|
||||
};
|
||||
|
||||
await submitInteraction(interaction, adminConsoleCtx, provider);
|
||||
await submitInteraction(interaction, adminConsoleCtx, tenant);
|
||||
|
||||
expect(generateUserId).toBeCalled();
|
||||
expect(hasActiveUsers).toBeCalled();
|
||||
|
@ -147,7 +148,7 @@ describe('submit action', () => {
|
|||
roleNames: [UserRole.Admin],
|
||||
...upsertProfile,
|
||||
});
|
||||
expect(assignInteractionResults).toBeCalledWith(adminConsoleCtx, provider, {
|
||||
expect(assignInteractionResults).toBeCalledWith(adminConsoleCtx, tenant.provider, {
|
||||
login: { accountId: 'uid' },
|
||||
});
|
||||
});
|
||||
|
@ -164,7 +165,7 @@ describe('submit action', () => {
|
|||
identifiers,
|
||||
};
|
||||
|
||||
await submitInteraction(interaction, ctx, provider);
|
||||
await submitInteraction(interaction, ctx, tenant);
|
||||
|
||||
expect(encryptUserPassword).toBeCalledWith('password');
|
||||
expect(getLogtoConnectorById).toBeCalledWith('logto');
|
||||
|
@ -178,7 +179,9 @@ describe('submit action', () => {
|
|||
},
|
||||
lastSignInAt: now,
|
||||
});
|
||||
expect(assignInteractionResults).toBeCalledWith(ctx, provider, { login: { accountId: 'foo' } });
|
||||
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
|
||||
login: { accountId: 'foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sign-in and sync new Social', async () => {
|
||||
|
@ -194,7 +197,7 @@ describe('submit action', () => {
|
|||
identifiers,
|
||||
};
|
||||
|
||||
await submitInteraction(interaction, ctx, provider);
|
||||
await submitInteraction(interaction, ctx, tenant);
|
||||
expect(getLogtoConnectorById).toBeCalledWith('logto');
|
||||
expect(updateUserById).toBeCalledWith('foo', {
|
||||
primaryEmail: 'email',
|
||||
|
@ -202,7 +205,9 @@ describe('submit action', () => {
|
|||
avatar: userInfo.avatar,
|
||||
lastSignInAt: now,
|
||||
});
|
||||
expect(assignInteractionResults).toBeCalledWith(ctx, provider, { login: { accountId: 'foo' } });
|
||||
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
|
||||
login: { accountId: 'foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reset password', async () => {
|
||||
|
@ -212,7 +217,7 @@ describe('submit action', () => {
|
|||
identifiers: [{ key: 'accountId', value: 'foo' }],
|
||||
profile: { password: 'password' },
|
||||
};
|
||||
await submitInteraction(interaction, ctx, provider);
|
||||
await submitInteraction(interaction, ctx, tenant);
|
||||
|
||||
expect(encryptUserPassword).toBeCalledWith('password');
|
||||
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import type { User, Profile } from '@logto/schemas';
|
||||
import { InteractionEvent, UserRole, adminConsoleApplicationId } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { getLogtoConnectorById } from '#src/connectors/index.js';
|
||||
import { assignInteractionResults } from '#src/libraries/session.js';
|
||||
import { encryptUserPassword, generateUserId, insertUser } from '#src/libraries/user.js';
|
||||
import { hasActiveUsers, findUserById, updateUserById } from '#src/queries/user.js';
|
||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
||||
import type { WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
|
||||
import type {
|
||||
|
@ -130,8 +129,12 @@ const parseUserProfile = async (
|
|||
export default async function submitInteraction(
|
||||
interaction: VerifiedInteractionResult,
|
||||
ctx: WithInteractionDetailsContext,
|
||||
provider: Provider
|
||||
{ provider, libraries, queries }: TenantContext
|
||||
) {
|
||||
const { hasActiveUsers, findUserById, updateUserById } = queries.users;
|
||||
const {
|
||||
users: { generateUserId, insertUser },
|
||||
} = libraries;
|
||||
const { event, profile } = interaction;
|
||||
|
||||
if (event === InteractionEvent.Register) {
|
||||
|
|
|
@ -44,8 +44,9 @@ export const verificationPath = 'verification';
|
|||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
|
||||
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||
...[anonymousRouter, { provider }]: RouterInitArgs<T>
|
||||
...[anonymousRouter, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const { provider } = tenant;
|
||||
const router =
|
||||
// @ts-expect-error for good koa types
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -83,7 +84,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
}
|
||||
|
||||
const verifiedIdentifier = identifier && [
|
||||
await verifyIdentifierPayload(ctx, provider, identifier, {
|
||||
await verifyIdentifierPayload(ctx, tenant, identifier, {
|
||||
event,
|
||||
}),
|
||||
];
|
||||
|
@ -166,7 +167,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
|
||||
const verifiedIdentifier = await verifyIdentifierPayload(
|
||||
ctx,
|
||||
provider,
|
||||
tenant,
|
||||
identifierPayload,
|
||||
interactionStorage
|
||||
);
|
||||
|
@ -301,7 +302,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
await validateMandatoryUserProfile(ctx, verifiedInteraction);
|
||||
}
|
||||
|
||||
await submitInteraction(verifiedInteraction, ctx, provider);
|
||||
await submitInteraction(verifiedInteraction, ctx, tenant);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
|||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type { AnonymousInteractionResult } from '../types/index.js';
|
||||
|
@ -11,10 +11,6 @@ import type { AnonymousInteractionResult } from '../types/index.js';
|
|||
const { jest } = import.meta;
|
||||
const { mockEsm, mockEsmDefault, mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const { verifyUserPassword } = mockEsm('#src/libraries/user.js', () => ({
|
||||
verifyUserPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn());
|
||||
|
||||
await mockEsmWithActual('../utils/interaction.js', () => ({
|
||||
|
@ -37,6 +33,8 @@ const identifierPayloadVerification = await pickDefault(
|
|||
);
|
||||
|
||||
const logContext = createMockLogContext();
|
||||
const verifyUserPassword = jest.fn();
|
||||
const tenant = new MockTenant(undefined, undefined, { users: { verifyUserPassword } });
|
||||
|
||||
describe('identifier verification', () => {
|
||||
const baseCtx = { ...createContextWithRouteParameters(), ...logContext };
|
||||
|
@ -55,7 +53,7 @@ describe('identifier verification', () => {
|
|||
};
|
||||
|
||||
await expect(
|
||||
identifierPayloadVerification(baseCtx, createMockProvider(), identifier, interactionStorage)
|
||||
identifierPayloadVerification(baseCtx, tenant, identifier, interactionStorage)
|
||||
).rejects.toThrow();
|
||||
expect(findUserByIdentifier).toBeCalledWith({ username: 'username' });
|
||||
expect(verifyUserPassword).toBeCalledWith(null, 'password');
|
||||
|
@ -71,7 +69,7 @@ describe('identifier verification', () => {
|
|||
};
|
||||
|
||||
await expect(
|
||||
identifierPayloadVerification(baseCtx, createMockProvider(), identifier, interactionStorage)
|
||||
identifierPayloadVerification(baseCtx, tenant, identifier, interactionStorage)
|
||||
).rejects.toMatchError(new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
expect(findUserByIdentifier).toBeCalledWith({ username: 'username' });
|
||||
|
@ -89,7 +87,7 @@ describe('identifier verification', () => {
|
|||
|
||||
const result = await identifierPayloadVerification(
|
||||
baseCtx,
|
||||
createMockProvider(),
|
||||
tenant,
|
||||
identifier,
|
||||
interactionStorage
|
||||
);
|
||||
|
@ -109,7 +107,7 @@ describe('identifier verification', () => {
|
|||
|
||||
const result = await identifierPayloadVerification(
|
||||
baseCtx,
|
||||
createMockProvider(),
|
||||
tenant,
|
||||
identifier,
|
||||
interactionStorage
|
||||
);
|
||||
|
@ -123,7 +121,7 @@ describe('identifier verification', () => {
|
|||
|
||||
const result = await identifierPayloadVerification(
|
||||
baseCtx,
|
||||
createMockProvider(),
|
||||
tenant,
|
||||
identifier,
|
||||
interactionStorage
|
||||
);
|
||||
|
@ -141,7 +139,7 @@ describe('identifier verification', () => {
|
|||
|
||||
const result = await identifierPayloadVerification(
|
||||
baseCtx,
|
||||
createMockProvider(),
|
||||
tenant,
|
||||
identifier,
|
||||
interactionStorage
|
||||
);
|
||||
|
@ -158,15 +156,14 @@ describe('identifier verification', () => {
|
|||
it('social', async () => {
|
||||
const identifier = { connectorId: 'logto', connectorData: {} };
|
||||
|
||||
const provider = createMockProvider();
|
||||
const result = await identifierPayloadVerification(
|
||||
baseCtx,
|
||||
provider,
|
||||
tenant,
|
||||
identifier,
|
||||
interactionStorage
|
||||
);
|
||||
|
||||
expect(verifySocialIdentity).toBeCalledWith(identifier, baseCtx, provider);
|
||||
expect(verifySocialIdentity).toBeCalledWith(identifier, baseCtx, tenant.provider);
|
||||
expect(findUserByIdentifier).not.toBeCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
|
@ -195,7 +192,7 @@ describe('identifier verification', () => {
|
|||
|
||||
const result = await identifierPayloadVerification(
|
||||
baseCtx,
|
||||
createMockProvider(),
|
||||
tenant,
|
||||
identifierPayload,
|
||||
interactionRecord
|
||||
);
|
||||
|
@ -209,12 +206,7 @@ describe('identifier verification', () => {
|
|||
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
|
||||
|
||||
await expect(
|
||||
identifierPayloadVerification(
|
||||
baseCtx,
|
||||
createMockProvider(),
|
||||
identifierPayload,
|
||||
interactionStorage
|
||||
)
|
||||
identifierPayloadVerification(baseCtx, tenant, identifierPayload, interactionStorage)
|
||||
).rejects.toMatchError(new RequestError('session.connector_session_not_found'));
|
||||
});
|
||||
|
||||
|
@ -235,12 +227,7 @@ describe('identifier verification', () => {
|
|||
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
|
||||
|
||||
await expect(
|
||||
identifierPayloadVerification(
|
||||
baseCtx,
|
||||
createMockProvider(),
|
||||
identifierPayload,
|
||||
interactionRecord
|
||||
)
|
||||
identifierPayloadVerification(baseCtx, tenant, identifierPayload, interactionRecord)
|
||||
).rejects.toMatchError(new RequestError('session.connector_session_not_found'));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,11 +4,10 @@ import type {
|
|||
SocialConnectorPayload,
|
||||
SocialIdentityPayload,
|
||||
} from '@logto/schemas';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { verifyUserPassword } from '#src/libraries/user.js';
|
||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type {
|
||||
|
@ -33,7 +32,8 @@ import { verifyIdentifierByVerificationCode } from '../utils/verification-code-v
|
|||
const verifyPasswordIdentifier = async (
|
||||
event: InteractionEvent,
|
||||
identifier: PasswordIdentifierPayload,
|
||||
ctx: WithLogContext
|
||||
ctx: WithLogContext,
|
||||
{ libraries }: TenantContext
|
||||
): Promise<AccountIdIdentifier> => {
|
||||
const { password, ...identity } = identifier;
|
||||
|
||||
|
@ -41,7 +41,7 @@ const verifyPasswordIdentifier = async (
|
|||
log.append({ ...identity });
|
||||
|
||||
const user = await findUserByIdentifier(identity);
|
||||
const verifiedUser = await verifyUserPassword(user, password);
|
||||
const verifiedUser = await libraries.users.verifyUserPassword(user, password);
|
||||
|
||||
const { isSuspended, id } = verifiedUser;
|
||||
|
||||
|
@ -54,7 +54,7 @@ const verifyVerificationCodeIdentifier = async (
|
|||
event: InteractionEvent,
|
||||
identifier: VerificationCodeIdentifierPayload,
|
||||
ctx: WithLogContext,
|
||||
provider: Provider
|
||||
{ provider }: TenantContext
|
||||
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
|
@ -68,7 +68,7 @@ const verifyVerificationCodeIdentifier = async (
|
|||
const verifySocialIdentifier = async (
|
||||
identifier: SocialConnectorPayload,
|
||||
ctx: WithLogContext,
|
||||
provider: Provider
|
||||
{ provider }: TenantContext
|
||||
): Promise<SocialIdentifier> => {
|
||||
const userInfo = await verifySocialIdentity(identifier, ctx, provider);
|
||||
|
||||
|
@ -101,22 +101,22 @@ const verifySocialIdentityInInteractionRecord = async (
|
|||
|
||||
export default async function identifierPayloadVerification(
|
||||
ctx: WithLogContext,
|
||||
provider: Provider,
|
||||
tenant: TenantContext,
|
||||
identifierPayload: IdentifierPayload,
|
||||
interactionStorage: AnonymousInteractionResult
|
||||
): Promise<Identifier> {
|
||||
const { event } = interactionStorage;
|
||||
|
||||
if (isPasswordIdentifier(identifierPayload)) {
|
||||
return verifyPasswordIdentifier(event, identifierPayload, ctx);
|
||||
return verifyPasswordIdentifier(event, identifierPayload, ctx, tenant);
|
||||
}
|
||||
|
||||
if (isVerificationCodeIdentifier(identifierPayload)) {
|
||||
return verifyVerificationCodeIdentifier(event, identifierPayload, ctx, provider);
|
||||
return verifyVerificationCodeIdentifier(event, identifierPayload, ctx, tenant);
|
||||
}
|
||||
|
||||
if (isSocialIdentifier(identifierPayload)) {
|
||||
return verifySocialIdentifier(identifierPayload, ctx, provider);
|
||||
return verifySocialIdentifier(identifierPayload, ctx, tenant);
|
||||
}
|
||||
|
||||
// Sign-In with social verified email or phone
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
mockUser,
|
||||
mockUserResponse,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
@ -36,18 +37,11 @@ const { getUserInfoByAuthCode } = await mockEsmWithActual('#src/libraries/social
|
|||
getUserInfoByAuthCode: jest.fn(),
|
||||
}));
|
||||
|
||||
const {
|
||||
findUserById,
|
||||
hasUser,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
updateUserById,
|
||||
deleteUserIdentity,
|
||||
} = await mockEsmWithActual('#src/queries/user.js', () => ({
|
||||
findUserById: jest.fn(async (): Promise<User> => mockUser),
|
||||
hasUser: jest.fn(async () => false),
|
||||
hasUserWithEmail: jest.fn(async () => false),
|
||||
hasUserWithPhone: jest.fn(async () => false),
|
||||
const usersQueries = {
|
||||
findUserById: jest.fn(async () => mockUser),
|
||||
hasUser: jest.fn(async (): Promise<boolean> => false),
|
||||
hasUserWithEmail: jest.fn(async (): Promise<boolean> => false),
|
||||
hasUserWithPhone: jest.fn(async (): Promise<boolean> => false),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
|
@ -55,7 +49,15 @@ const {
|
|||
})
|
||||
),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
}));
|
||||
} satisfies Partial<Queries['users']>;
|
||||
const {
|
||||
findUserById,
|
||||
hasUser,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
updateUserById,
|
||||
deleteUserIdentity,
|
||||
} = usersQueries;
|
||||
|
||||
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
|
||||
encryptUserPassword: jest.fn(async (password: string) => ({
|
||||
|
@ -86,7 +88,7 @@ describe('session -> profileRoutes', () => {
|
|||
const mockGetSession: jest.Mock = jest.spyOn(provider.Session, 'get');
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: profileRoutes,
|
||||
tenantContext: new MockTenant(provider),
|
||||
tenantContext: new MockTenant(provider, { users: usersQueries }),
|
||||
middlewares: [
|
||||
async (ctx, next) => {
|
||||
ctx.addLogContext = jest.fn();
|
||||
|
@ -105,7 +107,7 @@ describe('session -> profileRoutes', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
describe('GET /session/profile', () => {
|
||||
describe('GET /profile', () => {
|
||||
it('should return current user data', async () => {
|
||||
const response = await sessionRequest.get(profileRoute);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
@ -125,7 +127,7 @@ describe('session -> profileRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PATCH /session/profile', () => {
|
||||
describe('PATCH /profile', () => {
|
||||
it('should update current user with display name, avatar and custom data', async () => {
|
||||
const updatedUserInfo = {
|
||||
name: 'John Doe',
|
||||
|
@ -152,7 +154,7 @@ describe('session -> profileRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PATCH /session/profile/username', () => {
|
||||
describe('PATCH /profile/username', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
|
@ -188,7 +190,7 @@ describe('session -> profileRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PATCH /session/profile/password', () => {
|
||||
describe('PATCH /profile/password', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
|
|
|
@ -8,9 +8,8 @@ import { getLogtoConnectorById } from '#src/connectors/index.js';
|
|||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { checkSessionHealth } from '#src/libraries/session.js';
|
||||
import { getUserInfoByAuthCode } from '#src/libraries/social.js';
|
||||
import { checkIdentifierCollision, encryptUserPassword } from '#src/libraries/user.js';
|
||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import { deleteUserIdentity, findUserById, updateUserById } from '#src/queries/user.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { verificationTimeout } from './consts.js';
|
||||
|
@ -19,8 +18,14 @@ import type { AnonymousRouter, RouterInitArgs } from './types.js';
|
|||
export const profileRoute = '/profile';
|
||||
|
||||
export default function profileRoutes<T extends AnonymousRouter>(
|
||||
...[router, { provider }]: RouterInitArgs<T>
|
||||
...[router, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const { provider, libraries, queries } = tenant;
|
||||
const { deleteUserIdentity, findUserById, updateUserById } = queries.users;
|
||||
const {
|
||||
users: { checkIdentifierCollision },
|
||||
} = libraries;
|
||||
|
||||
router.get(profileRoute, async (ctx, next) => {
|
||||
const { accountId: userId } = await provider.Session.get(ctx);
|
||||
|
||||
|
@ -68,11 +73,14 @@ export default function profileRoutes<T extends AnonymousRouter>(
|
|||
body: object({ username: string().regex(usernameRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
console.log('?0');
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { username } = ctx.guard.body;
|
||||
console.log('?1');
|
||||
await checkIdentifierCollision({ username }, userId);
|
||||
console.log('?2');
|
||||
await updateUserById(userId, { username }, 'replace');
|
||||
|
||||
ctx.status = 204;
|
||||
|
@ -87,7 +95,7 @@ export default function profileRoutes<T extends AnonymousRouter>(
|
|||
body: object({ password: string().regex(passwordRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
|
@ -115,7 +123,7 @@ export default function profileRoutes<T extends AnonymousRouter>(
|
|||
body: object({ primaryEmail: string().regex(emailRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
|
@ -131,7 +139,7 @@ export default function profileRoutes<T extends AnonymousRouter>(
|
|||
);
|
||||
|
||||
router.delete(`${profileRoute}/email`, async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
|
@ -152,7 +160,7 @@ export default function profileRoutes<T extends AnonymousRouter>(
|
|||
body: object({ primaryPhone: string().regex(phoneRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
|
@ -168,7 +176,7 @@ export default function profileRoutes<T extends AnonymousRouter>(
|
|||
);
|
||||
|
||||
router.delete(`${profileRoute}/phone`, async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
|
@ -192,7 +200,7 @@ export default function profileRoutes<T extends AnonymousRouter>(
|
|||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
|
|
9
packages/core/src/tenants/Libraries.ts
Normal file
9
packages/core/src/tenants/Libraries.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createUserLibrary } from '#src/libraries/user.js';
|
||||
|
||||
import type Queries from './Queries.js';
|
||||
|
||||
export default class Libraries {
|
||||
users = createUserLibrary(this.queries);
|
||||
|
||||
constructor(public readonly queries: Queries) {}
|
||||
}
|
|
@ -19,12 +19,14 @@ import koaWelcomeProxy from '#src/middleware/koa-welcome-proxy.js';
|
|||
import initOidc from '#src/oidc/init.js';
|
||||
import initRouter from '#src/routes/init.js';
|
||||
|
||||
import Libraries from './Libraries.js';
|
||||
import Queries from './Queries.js';
|
||||
import type TenantContext from './TenantContext.js';
|
||||
|
||||
export default class Tenant implements TenantContext {
|
||||
public readonly provider: Provider;
|
||||
public readonly queries: Queries;
|
||||
public readonly libraries: Libraries;
|
||||
|
||||
public readonly app: Koa;
|
||||
|
||||
|
@ -34,8 +36,10 @@ export default class Tenant implements TenantContext {
|
|||
|
||||
constructor(public id: string) {
|
||||
const queries = new Queries(envSet.pool);
|
||||
const libraries = new Libraries(queries);
|
||||
|
||||
this.queries = queries;
|
||||
this.libraries = libraries;
|
||||
|
||||
// Init app
|
||||
const app = new Koa();
|
||||
|
@ -50,7 +54,7 @@ export default class Tenant implements TenantContext {
|
|||
app.use(koaConnectorErrorHandler());
|
||||
app.use(koaI18next());
|
||||
|
||||
const apisApp = initRouter({ provider, queries });
|
||||
const apisApp = initRouter({ provider, queries, libraries });
|
||||
app.use(mount('/api', apisApp));
|
||||
|
||||
app.use(mount('/', koaRootProxy()));
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import type Provider from 'oidc-provider';
|
||||
|
||||
import type Libraries from './Libraries.js';
|
||||
import type Queries from './Queries.js';
|
||||
|
||||
export default abstract class TenantContext {
|
||||
public abstract readonly provider: Provider;
|
||||
public abstract readonly queries: Queries;
|
||||
public abstract readonly libraries: Libraries;
|
||||
}
|
||||
|
|
|
@ -1,29 +1,61 @@
|
|||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { createMockPool, createMockQueryResult } from 'slonik';
|
||||
|
||||
import Libraries from '#src/tenants/Libraries.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
||||
import { createMockProvider } from './oidc-provider.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
|
||||
const proxy: Queries = new Proxy<any>(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
return jest.fn();
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
const pool = createMockPool({
|
||||
query: async (sql, values) => {
|
||||
return createMockQueryResult([]);
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
export type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
|
||||
|
||||
export class MockTenant implements TenantContext {
|
||||
constructor(public provider = createMockProvider(), public queries = proxy) {}
|
||||
public queries: Queries;
|
||||
public libraries: Libraries;
|
||||
|
||||
constructor(
|
||||
public provider = createMockProvider(),
|
||||
queriesOverride?: Partial2<Queries>,
|
||||
librariesOverride?: Partial2<Libraries>
|
||||
) {
|
||||
this.queries = new Queries(pool);
|
||||
this.setPartial('queries', queriesOverride);
|
||||
this.libraries = new Libraries(this.queries);
|
||||
this.setPartial('libraries', librariesOverride);
|
||||
}
|
||||
|
||||
setPartialKey<Type extends 'queries' | 'libraries', Key extends keyof this[Type]>(
|
||||
type: Type,
|
||||
key: Key,
|
||||
value: Partial<this[Type][Key]>
|
||||
) {
|
||||
this[type][key] = { ...this[type][key], ...value };
|
||||
}
|
||||
|
||||
setPartial<Type extends 'queries' | 'libraries'>(type: Type, value?: Partial2<this[Type]>) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const key of Object.keys(value) as Array<keyof this[Type]>) {
|
||||
this.setPartialKey(type, key, { ...this[type][key], ...value[key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createMockTenantWithInteraction = (interactionDetails?: jest.Mock) =>
|
||||
|
|
Loading…
Add table
Reference in a new issue