diff --git a/packages/core/src/libraries/session.ts b/packages/core/src/libraries/session.ts index fe17f3588..a19182588 100644 --- a/packages/core/src/libraries/session.ts +++ b/packages/core/src/libraries/session.ts @@ -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) { diff --git a/packages/core/src/libraries/user.test.ts b/packages/core/src/libraries/user.test.ts index 528ce63e6..be9e9293d 100644 --- a/packages/core/src/libraries/user.test.ts +++ b/packages/core/src/libraries/user.test.ts @@ -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() }) - ); - }); -}); diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 86d9a7482..c2e1403b0 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -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, password: string): Promise => { - 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(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 & { 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, password: string): Promise => { + 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)(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 & { 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; + primaryEmail?: Nullable; + primaryPhone?: Nullable; + }, + 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; - primaryEmail?: Nullable; - primaryPhone?: Nullable; - }, - 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); diff --git a/packages/core/src/queries/application.test.ts b/packages/core/src/queries/application.test.ts index 30bfab90e..3cbbcd330 100644 --- a/packages/core/src/queries/application.test.ts +++ b/packages/core/src/queries/application.test.ts @@ -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 = 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); diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 69c36a336..8b3ad15b0 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -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} `); diff --git a/packages/core/src/queries/connector.test.ts b/packages/core/src/queries/connector.test.ts index dc5b29723..bb82d241a 100644 --- a/packages/core/src/queries/connector.test.ts +++ b/packages/core/src/queries/connector.test.ts @@ -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 = 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); diff --git a/packages/core/src/queries/oidc-model-instance.test.ts b/packages/core/src/queries/oidc-model-instance.test.ts index 562ec3dc6..abbdc047d 100644 --- a/packages/core/src/queries/oidc-model-instance.test.ts +++ b/packages/core/src/queries/oidc-model-instance.test.ts @@ -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 = 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 () => { diff --git a/packages/core/src/queries/oidc-model-instance.ts b/packages/core/src/queries/oidc-model-instance.ts index 47e91e720..f91ee9b8a 100644 --- a/packages/core/src/queries/oidc-model-instance.ts +++ b/packages/core/src/queries/oidc-model-instance.ts @@ -64,7 +64,7 @@ export const createOidcModelInstanceQueries = (pool: CommonQueryMethods) => { ); const findPayloadById = async (modelName: string, id: string) => { - const result = await envSet.pool.maybeOne(sql` + const result = await pool.maybeOne(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(sql` + const result = await pool.maybeOne(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} diff --git a/packages/core/src/queries/passcode.test.ts b/packages/core/src/queries/passcode.test.ts index a3f259117..25755e50f 100644 --- a/packages/core/src/queries/passcode.test.ts +++ b/packages/core/src/queries/passcode.test.ts @@ -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 = 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); diff --git a/packages/core/src/queries/resource.test.ts b/packages/core/src/queries/resource.test.ts index 955baa923..09cde5641 100644 --- a/packages/core/src/queries/resource.test.ts +++ b/packages/core/src/queries/resource.test.ts @@ -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 = 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); diff --git a/packages/core/src/queries/roles.test.ts b/packages/core/src/queries/roles.test.ts index d3f797c83..e7376dbeb 100644 --- a/packages/core/src/queries/roles.test.ts +++ b/packages/core/src/queries/roles.test.ts @@ -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 = 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); diff --git a/packages/core/src/queries/setting.test.ts b/packages/core/src/queries/setting.test.ts index 664999f4e..fef26b91d 100644 --- a/packages/core/src/queries/setting.test.ts +++ b/packages/core/src/queries/setting.test.ts @@ -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 = 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); }); }); diff --git a/packages/core/src/queries/sign-in-experience.test.ts b/packages/core/src/queries/sign-in-experience.test.ts index e22b07e8b..1f64c06d2 100644 --- a/packages/core/src/queries/sign-in-experience.test.ts +++ b/packages/core/src/queries/sign-in-experience.test.ts @@ -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 = 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'; diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index 900e022db..65670f35c 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -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 = 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); diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index 6d5b3587e..eccd90836 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -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 => - 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 => 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 => [{ id: 'role_id', name: 'admin', description: 'none' }] + ), + }, +} satisfies Partial2; + +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 => ({ ...mockUser, ...user, }) ), -})); - -const { findRolesByRoleNames } = mockEsm('#src/queries/roles.js', () => ({ - findRolesByRoleNames: jest.fn( - async (): Promise => [{ id: 'role_id', name: 'admin', description: 'none' }] - ), -})); - -const { revokeInstanceByUserId } = mockEsm('#src/queries/oidc-model-instance.js', () => ({ - revokeInstanceByUserId: jest.fn(), -})); +} satisfies Partial; 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 => [ { 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( diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index a958e5cdb..2b421366f 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -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(...[router]: RouterInitArgs) { +export default function adminUserRoutes( + ...[router, { queries, libraries }]: RouterInitArgs +) { + 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(...[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 { diff --git a/packages/core/src/routes/application.test.ts b/packages/core/src/routes/application.test.ts index 687bfef2e..282ef467f 100644 --- a/packages/core/src/routes/application.test.ts +++ b/packages/core/src/routes/application.test.ts @@ -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), diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index e8e5297d5..2b670eace 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -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'); diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 52eb50354..1c6eb1fc3 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -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) { diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index a568b0678..9269fa11f 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -44,8 +44,9 @@ export const verificationPath = 'verification'; type RouterContext = T extends Router ? Context : never; export default function interactionRoutes( - ...[anonymousRouter, { provider }]: RouterInitArgs + ...[anonymousRouter, tenant]: RouterInitArgs ) { + 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( } const verifiedIdentifier = identifier && [ - await verifyIdentifierPayload(ctx, provider, identifier, { + await verifyIdentifierPayload(ctx, tenant, identifier, { event, }), ]; @@ -166,7 +167,7 @@ export default function interactionRoutes( const verifiedIdentifier = await verifyIdentifierPayload( ctx, - provider, + tenant, identifierPayload, interactionStorage ); @@ -301,7 +302,7 @@ export default function interactionRoutes( await validateMandatoryUserProfile(ctx, verifiedInteraction); } - await submitInteraction(verifiedInteraction, ctx, provider); + await submitInteraction(verifiedInteraction, ctx, tenant); return next(); } diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts index c1eff9454..8714863ad 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts @@ -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')); }); }); diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts index 653520522..9ea5be42e 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -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 => { 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 => { 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 => { 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 { 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 diff --git a/packages/core/src/routes/profile.test.ts b/packages/core/src/routes/profile.test.ts index 60561f092..8f0dec8ec 100644 --- a/packages/core/src/routes/profile.test.ts +++ b/packages/core/src/routes/profile.test.ts @@ -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 => 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 => false), + hasUserWithEmail: jest.fn(async (): Promise => false), + hasUserWithPhone: jest.fn(async (): Promise => false), updateUserById: jest.fn( async (_, data: Partial): Promise => ({ ...mockUser, @@ -55,7 +49,15 @@ const { }) ), deleteUserIdentity: jest.fn(), -})); +} satisfies Partial; +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', diff --git a/packages/core/src/routes/profile.ts b/packages/core/src/routes/profile.ts index 17fa7cc20..a1ade1252 100644 --- a/packages/core/src/routes/profile.ts +++ b/packages/core/src/routes/profile.ts @@ -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( - ...[router, { provider }]: RouterInitArgs + ...[router, tenant]: RouterInitArgs ) { + 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( 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( 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( 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( ); 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( 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( ); 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( }), }), 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 })); diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts new file mode 100644 index 000000000..a68ef784f --- /dev/null +++ b/packages/core/src/tenants/Libraries.ts @@ -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) {} +} diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index 9b6d043cd..86ca6167b 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -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())); diff --git a/packages/core/src/tenants/TenantContext.ts b/packages/core/src/tenants/TenantContext.ts index fd15118dc..07419823b 100644 --- a/packages/core/src/tenants/TenantContext.ts +++ b/packages/core/src/tenants/TenantContext.ts @@ -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; } diff --git a/packages/core/src/test-utils/tenant.ts b/packages/core/src/test-utils/tenant.ts index 89d5c4109..8d2a6abd5 100644 --- a/packages/core/src/test-utils/tenant.ts +++ b/packages/core/src/test-utils/tenant.ts @@ -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( - {}, - { - 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 extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +export type Partial2 = { [key in keyof T]?: Partial }; export class MockTenant implements TenantContext { - constructor(public provider = createMockProvider(), public queries = proxy) {} + public queries: Queries; + public libraries: Libraries; + + constructor( + public provider = createMockProvider(), + queriesOverride?: Partial2, + librariesOverride?: Partial2 + ) { + this.queries = new Queries(pool); + this.setPartial('queries', queriesOverride); + this.libraries = new Libraries(this.queries); + this.setPartial('libraries', librariesOverride); + } + + setPartialKey( + type: Type, + key: Key, + value: Partial + ) { + this[type][key] = { ...this[type][key], ...value }; + } + + setPartial(type: Type, value?: Partial2) { + if (!value) { + return; + } + + // eslint-disable-next-line no-restricted-syntax + for (const key of Object.keys(value) as Array) { + this.setPartialKey(type, key, { ...this[type][key], ...value[key] }); + } + } } export const createMockTenantWithInteraction = (interactionDetails?: jest.Mock) =>