0
Fork 0
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:
Gao Sun 2023-01-08 22:45:09 +08:00
parent 3c4aeec30a
commit 8561b6bc43
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
28 changed files with 481 additions and 423 deletions

View file

@ -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) {

View file

@ -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() })
);
});
});

View file

@ -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);

View file

@ -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);

View file

@ -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}
`);

View file

@ -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);

View file

@ -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 () => {

View file

@ -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}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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';

View file

@ -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);

View file

@ -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(

View file

@ -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 {

View file

@ -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),

View file

@ -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');

View file

@ -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) {

View file

@ -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();
}

View file

@ -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'));
});
});

View file

@ -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

View file

@ -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',

View file

@ -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 }));

View 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) {}
}

View file

@ -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()));

View file

@ -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;
}

View file

@ -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) =>