0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-10 21:58:23 -05:00

refactor(core): remove deprecated library functions

This commit is contained in:
Gao Sun 2023-01-10 19:48:48 +08:00
parent 6abdd05a40
commit 338b0ed63f
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
22 changed files with 260 additions and 253 deletions

View file

@ -9,8 +9,6 @@ import { loadConnectorFactories } from '#src/utils/connectors/factories.js';
import { validateConnectorModule, parseMetadata } from '#src/utils/connectors/index.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
import { defaultQueries } from './shared.js';
export type ConnectorLibrary = ReturnType<typeof createConnectorLibrary>;
export const createConnectorLibrary = (queries: Queries) => {
@ -94,10 +92,3 @@ export const createConnectorLibrary = (queries: Queries) => {
return { getConnectorConfig, getLogtoConnectors, getLogtoConnectorById };
};
/** @deprecated Don't use. This is for transition only and will be removed soon. */
export const defaultConnectorLibrary = createConnectorLibrary(defaultQueries);
/** @deprecated Don't use. This is for transition only and will be removed soon. */
export const { getConnectorConfig, getLogtoConnectors, getLogtoConnectorById } =
defaultConnectorLibrary;

View file

@ -1,5 +0,0 @@
import envSet from '#src/env-set/index.js';
import Queries from '#src/tenants/Queries.js';
/** @deprecated Don't use. This is for transition only and will be removed soon. */
export const defaultQueries = new Queries(envSet.pool);

View file

@ -13,12 +13,9 @@ import i18next from 'i18next';
import RequestError from '#src/errors/RequestError/index.js';
import type { ConnectorLibrary } from '#src/libraries/connector.js';
import { defaultConnectorLibrary } from '#src/libraries/connector.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { defaultQueries } from '../shared.js';
export * from './sign-up.js';
export * from './sign-in.js';
@ -30,6 +27,8 @@ export const validateBranding = (branding: Branding) => {
assertThat(branding.logoUrl.trim(), 'sign_in_experiences.empty_logo');
};
export type SignInExperienceLibrary = ReturnType<typeof createSignInExperienceLibrary>;
export const createSignInExperienceLibrary = (
queries: Queries,
connectorLibrary: ConnectorLibrary
@ -111,10 +110,3 @@ export const createSignInExperienceLibrary = (
getSignInExperienceForApplication,
};
};
/** @deprecated Don't use. This is for transition only and will be removed soon. */
export const {
validateLanguageInfo,
removeUnavailableSocialConnectorTargets,
getSignInExperienceForApplication,
} = createSignInExperienceLibrary(defaultQueries, defaultConnectorLibrary);

View file

@ -14,8 +14,6 @@ import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { encryptPassword } from '#src/utils/password.js';
import { defaultQueries } from './shared.js';
const userId = buildIdGenerator(12);
const roleId = buildIdGenerator(21);
@ -35,6 +33,19 @@ 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;
assertThat(passwordEncrypted && passwordEncryptionMethod, 'session.invalid_credentials');
const result = await argon2Verify({ password, hash: passwordEncrypted });
assertThat(result, 'session.invalid_credentials');
return user;
};
export const createUserLibrary = (queries: Queries) => {
const {
pool,
@ -57,19 +68,6 @@ export const createUserLibrary = (queries: Queries) => {
{ retries, factor: 0 } // No need for exponential backoff
);
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,
});
@ -144,12 +142,7 @@ export const createUserLibrary = (queries: Queries) => {
return {
generateUserId,
verifyUserPassword,
insertUser,
checkIdentifierCollision,
};
};
/** @deprecated Don't use. This is for transition only and will be removed soon. */
export const { generateUserId, verifyUserPassword, insertUser, checkIdentifierCollision } =
createUserLibrary(defaultQueries);

View file

@ -16,11 +16,9 @@ import type {
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const { getLogtoConnectorById } = mockEsm('#src/libraries/connector.js', () => ({
getLogtoConnectorById: jest
.fn()
.mockResolvedValue({ metadata: { target: 'logto' }, dbEntry: { syncProfile: true } }),
}));
const getLogtoConnectorById = jest
.fn()
.mockResolvedValue({ metadata: { target: 'logto' }, dbEntry: { syncProfile: true } });
const { assignInteractionResults } = mockEsm('#src/libraries/session.js', () => ({
assignInteractionResults: jest.fn(),
@ -51,7 +49,11 @@ const now = Date.now();
jest.useFakeTimers().setSystemTime(now);
describe('submit action', () => {
const tenant = new MockTenant(undefined, { users: userQueries }, { users: userLibraries });
const tenant = new MockTenant(
undefined,
{ users: userQueries },
{ users: userLibraries, connectors: { getLogtoConnectorById } }
);
const ctx = {
...createContextWithRouteParameters(),
...createMockLogContext(),

View file

@ -2,7 +2,7 @@ import type { User, Profile } from '@logto/schemas';
import { InteractionEvent, UserRole, adminConsoleApplicationId } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { getLogtoConnectorById } from '#src/libraries/connector.js';
import type { ConnectorLibrary } from '#src/libraries/connector.js';
import { assignInteractionResults } from '#src/libraries/session.js';
import { encryptUserPassword } from '#src/libraries/user.js';
import type { LogEntry } from '#src/middleware/koa-audit-log.js';
@ -21,15 +21,18 @@ import { clearInteractionStorage, categorizeIdentifiers } from '../utils/interac
const filterSocialIdentifiers = (identifiers: Identifier[]): SocialIdentifier[] =>
identifiers.filter((identifier): identifier is SocialIdentifier => identifier.key === 'social');
const getNewSocialProfile = async ({
user,
connectorId,
identifiers,
}: {
user?: User;
connectorId: string;
identifiers: SocialIdentifier[];
}) => {
const getNewSocialProfile = async (
{ getLogtoConnectorById }: ConnectorLibrary,
{
user,
connectorId,
identifiers,
}: {
user?: User;
connectorId: string;
identifiers: SocialIdentifier[];
}
) => {
// TODO: @simeng refactor me. This step should be verified by the previous profile verification cycle Already.
// Should pickup the verified social user info result automatically
const socialIdentifier = identifiers.find((identifier) => identifier.connectorId === connectorId);
@ -60,7 +63,10 @@ const getNewSocialProfile = async ({
};
};
const getSyncedSocialUserProfile = async (socialIdentifier: SocialIdentifier) => {
const getSyncedSocialUserProfile = async (
{ getLogtoConnectorById }: ConnectorLibrary,
socialIdentifier: SocialIdentifier
) => {
const {
userInfo: { name, avatar },
connectorId,
@ -79,6 +85,7 @@ const getSyncedSocialUserProfile = async (socialIdentifier: SocialIdentifier) =>
};
const parseNewUserProfile = async (
connectorLibrary: ConnectorLibrary,
profile: Profile,
profileIdentifiers: Identifier[],
user?: User
@ -89,7 +96,7 @@ const parseNewUserProfile = async (
conditional(password && (await encryptUserPassword(password))),
conditional(
connectorId &&
(await getNewSocialProfile({
(await getNewSocialProfile(connectorLibrary, {
connectorId,
identifiers: filterSocialIdentifiers(profileIdentifiers),
user,
@ -107,18 +114,20 @@ const parseNewUserProfile = async (
};
const parseUserProfile = async (
connectorLibrary: ConnectorLibrary,
{ profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult,
user?: User
) => {
const { authIdentifiers, profileIdentifiers } = categorizeIdentifiers(identifiers ?? [], profile);
const newUserProfile = profile && (await parseNewUserProfile(profile, profileIdentifiers, user));
const newUserProfile =
profile && (await parseNewUserProfile(connectorLibrary, profile, profileIdentifiers, user));
// Sync the last social profile
const socialIdentifier = filterSocialIdentifiers(authIdentifiers).slice(-1)[0];
const syncedSocialUserProfile =
socialIdentifier && (await getSyncedSocialUserProfile(socialIdentifier));
socialIdentifier && (await getSyncedSocialUserProfile(connectorLibrary, socialIdentifier));
return {
...syncedSocialUserProfile,
@ -137,12 +146,13 @@ export default async function submitInteraction(
const {
users: { generateUserId, insertUser },
connectors,
} = libraries;
const { event, profile } = interaction;
if (event === InteractionEvent.Register) {
const id = await generateUserId();
const upsertProfile = await parseUserProfile(interaction);
const upsertProfile = await parseUserProfile(connectors, interaction);
const { client_id } = ctx.interactionDetails.params;
@ -168,7 +178,7 @@ export default async function submitInteraction(
if (event === InteractionEvent.SignIn) {
const user = await findUserById(accountId);
const upsertProfile = await parseUserProfile(interaction, user);
const upsertProfile = await parseUserProfile(connectors, interaction, user);
await updateUserById(accountId, upsertProfile);

View file

@ -1,5 +1,5 @@
import { ConnectorType } from '@logto/connector-kit';
import { InteractionEvent, demoAppApplicationId } from '@logto/schemas';
import { demoAppApplicationId, InteractionEvent } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
@ -7,7 +7,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createMockTenantWithInteraction, MockTenant } from '#src/test-utils/tenant.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -35,10 +35,6 @@ const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
};
});
await mockEsmWithActual('#src/libraries/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({
assignInteractionResults: jest.fn(),
}));
@ -95,37 +91,40 @@ await mockEsmWithActual(
})
);
const baseProviderMock = {
params: {},
jti: 'jti',
client_id: demoAppApplicationId,
};
const tenantContext = new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
undefined,
{
connectors: {
getLogtoConnectorById: async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
// @ts-expect-error
return connector as LogtoConnector;
},
},
signInExperiences: {
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
},
}
);
const { default: interactionRoutes } = await import('./index.js');
describe('interaction routes', () => {
const baseProviderMock = {
params: {},
jti: 'jti',
client_id: demoAppApplicationId,
};
const tenantContext = new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
undefined,
{
connectors: {
getLogtoConnectorById: async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
// @ts-expect-error
return connector as LogtoConnector;
},
},
}
);
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
tenantContext,
@ -266,7 +265,7 @@ describe('interaction routes', () => {
const path = `${interactionPrefix}/profile`;
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
tenantContext: createMockTenantWithInteraction(jest.fn().mockResolvedValue(baseProviderMock)),
tenantContext,
});
it('PUT /interaction/profile', async () => {

View file

@ -64,7 +64,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
profile: profileGuard.optional(),
}),
}),
koaInteractionSie(),
koaInteractionSie(libraries.signInExperiences),
async (ctx, next) => {
const { event, identifier, profile } = ctx.guard.body;
const { signInExperience, createLog } = ctx;
@ -114,7 +114,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
router.put(
`${interactionPrefix}/event`,
koaGuard({ body: z.object({ event: eventGuard }) }),
koaInteractionSie(),
koaInteractionSie(libraries.signInExperiences),
async (ctx, next) => {
const { event } = ctx.guard.body;
const { signInExperience, interactionDetails, createLog } = ctx;
@ -152,7 +152,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
koaGuard({
body: identifierPayloadGuard,
}),
koaInteractionSie(),
koaInteractionSie(libraries.signInExperiences),
async (ctx, next) => {
const identifierPayload = ctx.guard.body;
const { signInExperience, interactionDetails, createLog } = ctx;
@ -189,7 +189,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
koaGuard({
body: profileGuard,
}),
koaInteractionSie(),
koaInteractionSie(libraries.signInExperiences),
async (ctx, next) => {
const profilePayload = ctx.guard.body;
const { signInExperience, interactionDetails, createLog } = ctx;
@ -226,7 +226,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
koaGuard({
body: profileGuard,
}),
koaInteractionSie(),
koaInteractionSie(libraries.signInExperiences),
async (ctx, next) => {
const profilePayload = ctx.guard.body;
const { signInExperience, interactionDetails, createLog } = ctx;
@ -279,7 +279,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
// Submit Interaction
router.post(
`${interactionPrefix}/submit`,
koaInteractionSie(),
koaInteractionSie(libraries.signInExperiences),
koaInteractionHooks(tenant),
async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
@ -291,7 +291,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
const accountVerifiedInteraction = await verifyIdentifier(ctx, tenant, interactionStorage);
const verifiedInteraction = await verifyProfile(accountVerifiedInteraction);
const verifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
if (event !== InteractionEvent.ForgotPassword) {
await validateMandatoryUserProfile(ctx, verifiedInteraction);

View file

@ -2,7 +2,7 @@ import type { SignInExperience } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { MiddlewareType } from 'koa';
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
import type { SignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
@ -10,7 +10,9 @@ export type WithInteractionSieContext<ContextT> = WithInteractionDetailsContext<
signInExperience: SignInExperience;
};
export default function koaInteractionSie<StateT, ContextT, ResponseT>(): MiddlewareType<
export default function koaInteractionSie<StateT, ContextT, ResponseT>({
getSignInExperienceForApplication,
}: SignInExperienceLibrary): MiddlewareType<
StateT,
WithInteractionSieContext<ContextT>,
ResponseT

View file

@ -1,7 +1,8 @@
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import { pickDefault } from '@logto/shared/esm';
import { MockTenant } from '#src/test-utils/tenant.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const queries = {
findUserByEmail: jest.fn(),
@ -10,32 +11,39 @@ const queries = {
findUserByIdentity: jest.fn(),
};
mockEsm('#src/queries/user.js', () => queries);
const getLogtoConnectorById = jest.fn().mockResolvedValue({ metadata: { target: 'logto' } });
const { getLogtoConnectorById } = mockEsm('#src/libraries/connector.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({ metadata: { target: 'logto' } }),
}));
const tenantContext = new MockTenant(
undefined,
{
users: queries,
},
{ connectors: { getLogtoConnectorById } }
);
const findUserByIdentifier = await pickDefault(import('./find-user-by-identifier.js'));
describe('findUserByIdentifier', () => {
it('username', async () => {
await findUserByIdentifier({ username: 'foo' });
await findUserByIdentifier(tenantContext, { username: 'foo' });
expect(queries.findUserByUsername).toBeCalledWith('foo');
});
it('email', async () => {
await findUserByIdentifier({ email: 'foo@logto.io' });
await findUserByIdentifier(tenantContext, { email: 'foo@logto.io' });
expect(queries.findUserByEmail).toBeCalledWith('foo@logto.io');
});
it('phone', async () => {
await findUserByIdentifier({ phone: '123456' });
await findUserByIdentifier(tenantContext, { phone: '123456' });
expect(queries.findUserByPhone).toBeCalledWith('123456');
});
it('social', async () => {
await findUserByIdentifier({ connectorId: 'connector', userInfo: { id: 'foo' } });
await findUserByIdentifier(tenantContext, {
connectorId: 'connector',
userInfo: { id: 'foo' },
});
expect(getLogtoConnectorById).toBeCalledWith('connector');
expect(queries.findUserByIdentity).toBeCalledWith('logto', 'foo');
});

View file

@ -1,14 +1,15 @@
import { getLogtoConnectorById } from '#src/libraries/connector.js';
import {
findUserByEmail,
findUserByUsername,
findUserByPhone,
findUserByIdentity,
} from '#src/queries/user.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import type { UserIdentity } from '../types/index.js';
export default async function findUserByIdentifier(identity: UserIdentity) {
export default async function findUserByIdentifier(
{ queries, libraries }: TenantContext,
identity: UserIdentity
) {
const { findUserByEmail, findUserByUsername, findUserByPhone, findUserByIdentity } =
queries.users;
const { getLogtoConnectorById } = libraries.connectors;
if ('username' in identity) {
return findUserByUsername(identity.username);
}

View file

@ -28,13 +28,16 @@ const { verifySocialIdentity } = mockEsm('../utils/social-verification.js', () =
verifySocialIdentity: jest.fn().mockResolvedValue({ id: 'foo' }),
}));
const { verifyUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
verifyUserPassword: jest.fn(),
}));
const identifierPayloadVerification = await pickDefault(
import('./identifier-payload-verification.js')
);
const logContext = createMockLogContext();
const verifyUserPassword = jest.fn();
const tenant = new MockTenant(undefined, undefined, { users: { verifyUserPassword } });
const tenant = new MockTenant();
describe('identifier verification', () => {
const baseCtx = { ...createContextWithRouteParameters(), ...logContext };
@ -55,7 +58,7 @@ describe('identifier verification', () => {
await expect(
identifierPayloadVerification(baseCtx, tenant, identifier, interactionStorage)
).rejects.toThrow();
expect(findUserByIdentifier).toBeCalledWith({ username: 'username' });
expect(findUserByIdentifier).toBeCalledWith(tenant, { username: 'username' });
expect(verifyUserPassword).toBeCalledWith(null, 'password');
});
@ -72,7 +75,7 @@ describe('identifier verification', () => {
identifierPayloadVerification(baseCtx, tenant, identifier, interactionStorage)
).rejects.toMatchError(new RequestError({ code: 'user.suspended', status: 401 }));
expect(findUserByIdentifier).toBeCalledWith({ username: 'username' });
expect(findUserByIdentifier).toBeCalledWith(tenant, { username: 'username' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
});
@ -91,7 +94,7 @@ describe('identifier verification', () => {
identifier,
interactionStorage
);
expect(findUserByIdentifier).toBeCalledWith({ email: 'email' });
expect(findUserByIdentifier).toBeCalledWith(tenant, { email: 'email' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
expect(result).toEqual({ key: 'accountId', value: 'foo' });
});
@ -111,7 +114,7 @@ describe('identifier verification', () => {
identifier,
interactionStorage
);
expect(findUserByIdentifier).toBeCalledWith({ phone: 'phone' });
expect(findUserByIdentifier).toBeCalledWith(tenant, { phone: 'phone' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
expect(result).toEqual({ key: 'accountId', value: 'foo' });
});

View file

@ -6,6 +6,7 @@ import type {
} from '@logto/schemas';
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';
@ -33,15 +34,15 @@ const verifyPasswordIdentifier = async (
event: InteractionEvent,
identifier: PasswordIdentifierPayload,
ctx: WithLogContext,
{ libraries }: TenantContext
tenant: TenantContext
): Promise<AccountIdIdentifier> => {
const { password, ...identity } = identifier;
const log = ctx.createLog(`Interaction.${event}.Identifier.Password.Submit`);
log.append({ ...identity });
const user = await findUserByIdentifier(identity);
const verifiedUser = await libraries.users.verifyUserPassword(user, password);
const user = await findUserByIdentifier(tenant, identity);
const verifiedUser = await verifyUserPassword(user, password);
const { isSuspended, id } = verifiedUser;

View file

@ -56,7 +56,7 @@ describe('verifyIdentifier', () => {
const result = await verifyIdentifier(ctx, tenant, interactionRecord);
expect(result).toBe(verifiedRecord);
expect(verifyUserAccount).toBeCalledWith(interactionRecord, tenant.libraries.socials);
expect(verifyUserAccount).toBeCalledWith(tenant, interactionRecord);
expect(storeInteractionResult).toBeCalledWith(verifiedRecord, ctx, tenant.provider);
});
});

View file

@ -19,7 +19,7 @@ type InteractionResult =
export default async function verifyIdentifier(
ctx: Context,
{ provider, libraries }: TenantContext,
tenant: TenantContext,
interactionRecord: InteractionResult
): Promise<RegisterInteractionResult | AccountVerifiedInteractionResult> {
if (interactionRecord.event === InteractionEvent.Register) {
@ -27,11 +27,8 @@ export default async function verifyIdentifier(
}
// Verify the user account and assign the verified result to the interaction record
const accountVerifiedInteractionResult = await verifyUserAccount(
interactionRecord,
libraries.socials
);
await storeInteractionResult(accountVerifiedInteractionResult, ctx, provider);
const accountVerifiedInteractionResult = await verifyUserAccount(tenant, interactionRecord);
await storeInteractionResult(accountVerifiedInteractionResult, ctx, tenant.provider);
return accountVerifiedInteractionResult;
}

View file

@ -2,15 +2,16 @@ import { InteractionEvent } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import type { Identifier } from '../types/index.js';
const { jest } = import.meta;
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
const { mockEsm } = createMockUtils(jest);
const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn().mockResolvedValue({ id: 'foo', passwordEncrypted: 'passwordHash' }),
}));
const findUserById = jest.fn().mockResolvedValue({ id: 'foo', passwordEncrypted: 'passwordHash' });
const tenantContext = new MockTenant(undefined, { users: { findUserById } });
const { argon2Verify } = mockEsm('hash-wasm', () => ({
argon2Verify: jest.fn(),
@ -26,7 +27,7 @@ describe('forgot password interaction profile verification', () => {
};
it('missing profile', async () => {
await expect(verifyProfile(baseInteraction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, baseInteraction)).rejects.toMatchError(
new RequestError({
code: 'user.new_password_required_in_profile',
status: 422,
@ -43,7 +44,7 @@ describe('forgot password interaction profile verification', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.same_password',
status: 422,
@ -61,7 +62,7 @@ describe('forgot password interaction profile verification', () => {
},
};
await expect(verifyProfile(interaction)).resolves.not.toThrow();
await expect(verifyProfile(tenantContext, interaction)).resolves.not.toThrow();
expect(findUserById).toBeCalledWith(interaction.accountId);
expect(argon2Verify).toBeCalledWith({ password: 'password', hash: 'passwordHash' });
});

View file

@ -2,23 +2,26 @@ import { InteractionEvent } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import type { Identifier, IdentifierVerifiedInteractionResult } from '../types/index.js';
const { jest } = import.meta;
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
const { mockEsm } = createMockUtils(jest);
const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({
const userQueries = {
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
};
const { findUserById } = userQueries;
mockEsm('../utils/index.js', () => ({
isUserPasswordSet: jest.fn().mockResolvedValueOnce(true),
}));
const tenantContext = new MockTenant(undefined, { users: userQueries });
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('Should throw when providing existing identifiers in profile', () => {
@ -48,7 +51,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.username_exists_in_profile',
})
@ -65,7 +68,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.email_exists_in_profile',
})
@ -82,7 +85,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.phone_exists_in_profile',
})
@ -99,7 +102,7 @@ describe('Should throw when providing existing identifiers in profile', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.password_exists_in_profile',
})

View file

@ -1,28 +1,31 @@
import { InteractionEvent } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import { pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import type { Identifier, IdentifierVerifiedInteractionResult } from '../types/index.js';
const { jest } = import.meta;
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } =
await mockEsmWithActual('#src/queries/user.js', () => ({
hasUser: jest.fn().mockResolvedValue(false),
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
const userQueries = {
hasUser: jest.fn().mockResolvedValue(false),
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
};
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } = userQueries;
mockEsm('#src/libraries/connector.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
metadata: { target: 'logto' },
}),
}));
const getLogtoConnectorById = jest.fn().mockResolvedValue({
metadata: { target: 'logto' },
});
const tenantContext = new MockTenant(
undefined,
{ users: userQueries },
{ connectors: { getLogtoConnectorById } }
);
const verifyProfile = await pickDefault(import('./profile-verification.js'));
const identifiers: Identifier[] = [
@ -49,7 +52,7 @@ describe('profile registered validation', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.username_already_in_use',
status: 422,
@ -66,7 +69,7 @@ describe('profile registered validation', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.email_already_in_use',
status: 422,
@ -83,7 +86,7 @@ describe('profile registered validation', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.phone_already_in_use',
status: 422,
@ -101,7 +104,7 @@ describe('profile registered validation', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({
code: 'user.identity_already_in_use',
status: 422,

View file

@ -1,26 +1,31 @@
import { InteractionEvent } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import { pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import type { Identifier } from '../types/index.js';
const { jest } = import.meta;
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
mockEsm('#src/libraries/connector.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
metadata: { target: 'logto' },
}),
}));
const tenantContext = new MockTenant(
undefined,
{
users: {
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
},
},
{
connectors: {
getLogtoConnectorById: jest.fn().mockResolvedValue({
metadata: { target: 'logto' },
}),
},
}
);
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('profile protected identifier verification', () => {
@ -43,7 +48,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
});
@ -58,7 +63,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
});
@ -73,7 +78,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).resolves.not.toThrow();
await expect(verifyProfile(tenantContext, interaction)).resolves.not.toThrow();
});
it('phone without a verified identifier should throw', async () => {
@ -84,7 +89,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
});
@ -99,7 +104,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
});
@ -114,7 +119,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).resolves.not.toThrow();
await expect(verifyProfile(tenantContext, interaction)).resolves.not.toThrow();
});
it('connectorId without a verified identifier should throw', async () => {
@ -127,7 +132,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({ code: 'session.connector_session_not_found', status: 404 })
);
});
@ -144,7 +149,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
await expect(verifyProfile(tenantContext, interaction)).rejects.toMatchError(
new RequestError({ code: 'session.connector_session_not_found', status: 404 })
);
});
@ -163,7 +168,7 @@ describe('profile protected identifier verification', () => {
},
};
await expect(verifyProfile(interaction)).resolves.not.toThrow();
await expect(verifyProfile(tenantContext, interaction)).resolves.not.toThrow();
});
});
});

View file

@ -3,14 +3,7 @@ import { InteractionEvent } from '@logto/schemas';
import { argon2Verify } from 'hash-wasm';
import RequestError from '#src/errors/RequestError/index.js';
import { getLogtoConnectorById } from '#src/libraries/connector.js';
import {
findUserById,
hasUser,
hasUserWithEmail,
hasUserWithPhone,
hasUserWithIdentity,
} from '#src/queries/user.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { forgotPasswordProfileGuard } from '../types/guard.js';
@ -65,9 +58,12 @@ const verifyProfileIdentifiers = (
};
const verifyProfileNotRegisteredByOtherUserAccount = async (
{ queries, libraries }: TenantContext,
{ username, email, phone, connectorId }: Profile,
identifiers: Identifier[] = []
) => {
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } = queries.users;
if (username) {
assertThat(
!(await hasUser(username)),
@ -101,7 +97,7 @@ const verifyProfileNotRegisteredByOtherUserAccount = async (
if (connectorId) {
const {
metadata: { target },
} = await getLogtoConnectorById(connectorId);
} = await libraries.connectors.getLogtoConnectorById(connectorId);
const socialIdentifier = identifiers.find(
(identifier): identifier is SocialIdentifier => identifier.key === 'social'
@ -164,13 +160,15 @@ const verifyProfileNotExistInCurrentUserAccount = async (
};
export default async function verifyProfile(
tenant: TenantContext,
interaction: IdentifierVerifiedInteractionResult
): Promise<VerifiedInteractionResult> {
const { findUserById } = tenant.queries.users;
const { event, identifiers, accountId, profile = {} } = interaction;
if (event === InteractionEvent.Register) {
verifyProfileIdentifiers(profile, identifiers);
await verifyProfileNotRegisteredByOtherUserAccount(profile, identifiers);
await verifyProfileNotRegisteredByOtherUserAccount(tenant, profile, identifiers);
return interaction;
}
@ -180,7 +178,7 @@ export default async function verifyProfile(
// Find existing account
const user = await findUserById(accountId);
await verifyProfileNotExistInCurrentUserAccount(profile, user);
await verifyProfileNotRegisteredByOtherUserAccount(profile, identifiers);
await verifyProfileNotRegisteredByOtherUserAccount(tenant, profile, identifiers);
return interaction;
}

View file

@ -2,19 +2,20 @@ import { InteractionEvent } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import type { SocialLibrary } from '#src/libraries/social.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import type { SignInInteractionResult } from '../types/index.js';
const { jest } = import.meta;
const { mockEsm, mockEsmDefault } = createMockUtils(jest);
const { mockEsmDefault } = createMockUtils(jest);
const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn());
// @ts-expect-error
const socialLibrary: SocialLibrary = {
findSocialRelatedUser: jest.fn().mockResolvedValue(null),
};
const tenant = new MockTenant(
undefined,
{},
{ socials: { findSocialRelatedUser: jest.fn().mockResolvedValue(null) } }
);
const verifyUserAccount = await pickDefault(import('./user-identity-verification.js'));
@ -30,7 +31,7 @@ describe('verifyUserAccount', () => {
event: InteractionEvent.SignIn,
};
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
await expect(verifyUserAccount(tenant, interaction)).rejects.toMatchError(
new RequestError({ code: 'session.identifier_not_found', status: 404 })
);
});
@ -41,7 +42,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'accountId', value: 'foo' }],
};
const result = await verifyUserAccount(interaction, socialLibrary);
const result = await verifyUserAccount(tenant, interaction);
expect(result).toEqual({ ...interaction, accountId: 'foo' });
});
@ -54,8 +55,8 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'emailVerified', value: 'email' }],
};
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
const result = await verifyUserAccount(tenant, interaction);
expect(findUserByIdentifierMock).toBeCalledWith(tenant, { email: 'email' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });
});
@ -68,8 +69,8 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'phoneVerified', value: '123456' }],
};
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ phone: '123456' });
const result = await verifyUserAccount(tenant, interaction);
expect(findUserByIdentifierMock).toBeCalledWith(tenant, { phone: '123456' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });
});
@ -82,8 +83,8 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }],
};
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({
const result = await verifyUserAccount(tenant, interaction);
expect(findUserByIdentifierMock).toBeCalledWith(tenant, {
connectorId: 'connectorId',
userInfo: { id: 'foo' },
});
@ -99,7 +100,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }],
};
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
await expect(verifyUserAccount(tenant, interaction)).rejects.toMatchError(
new RequestError(
{
code: 'user.identity_not_exist',
@ -109,7 +110,7 @@ describe('verifyUserAccount', () => {
)
);
expect(findUserByIdentifierMock).toBeCalledWith({
expect(findUserByIdentifierMock).toBeCalledWith(tenant, {
connectorId: 'connectorId',
userInfo: { id: 'foo' },
});
@ -126,8 +127,8 @@ describe('verifyUserAccount', () => {
],
};
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
const result = await verifyUserAccount(tenant, interaction);
expect(findUserByIdentifierMock).toBeCalledWith(tenant, { email: 'email' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });
});
@ -143,11 +144,11 @@ describe('verifyUserAccount', () => {
],
};
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
await expect(verifyUserAccount(tenant, interaction)).rejects.toMatchError(
new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: 'email' })
);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(findUserByIdentifierMock).toBeCalledWith(tenant, { email: 'email' });
});
it('verify phoneVerified and emailVerified identifier with email user suspend', async () => {
@ -163,12 +164,12 @@ describe('verifyUserAccount', () => {
],
};
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
await expect(verifyUserAccount(tenant, interaction)).rejects.toMatchError(
new RequestError({ code: 'user.suspended', status: 401 })
);
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, tenant, { email: 'email' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, tenant, { phone: '123456' });
});
it('verify phoneVerified and emailVerified identifier returns inconsistent id', async () => {
@ -184,11 +185,11 @@ describe('verifyUserAccount', () => {
],
};
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
await expect(verifyUserAccount(tenant, interaction)).rejects.toMatchError(
new RequestError('session.verification_failed')
);
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, tenant, { email: 'email' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, tenant, { phone: '123456' });
});
it('verify emailVerified identifier returns inconsistent id with existing accountId', async () => {
@ -200,10 +201,10 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'emailVerified', value: 'email' }],
};
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
await expect(verifyUserAccount(tenant, interaction)).rejects.toMatchError(
new RequestError('session.verification_failed')
);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(findUserByIdentifierMock).toBeCalledWith(tenant, { email: 'email' });
});
it('profile use identifier should remain', async () => {
@ -222,8 +223,8 @@ describe('verifyUserAccount', () => {
},
};
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
const result = await verifyUserAccount(tenant, interaction);
expect(findUserByIdentifierMock).toBeCalledWith(tenant, { email: 'email' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });
});

View file

@ -1,7 +1,7 @@
import { deduplicate } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import type { SocialLibrary } from '#src/libraries/social.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { maskUserInfo } from '#src/utils/format.js';
@ -18,9 +18,11 @@ import findUserByIdentifier from '../utils/find-user-by-identifier.js';
import { categorizeIdentifiers } from '../utils/interaction.js';
const identifyUserByVerifiedEmailOrPhone = async (
tenant: TenantContext,
identifier: VerifiedEmailIdentifier | VerifiedPhoneIdentifier
) => {
const user = await findUserByIdentifier(
tenant,
identifier.key === 'emailVerified' ? { email: identifier.value } : { phone: identifier.value }
);
@ -37,15 +39,15 @@ const identifyUserByVerifiedEmailOrPhone = async (
};
const identifyUserBySocialIdentifier = async (
identifier: SocialIdentifier,
socialLibrary: SocialLibrary
tenant: TenantContext,
identifier: SocialIdentifier
) => {
const { connectorId, userInfo } = identifier;
const user = await findUserByIdentifier({ connectorId, userInfo });
const user = await findUserByIdentifier(tenant, { connectorId, userInfo });
if (!user) {
const relatedInfo = await socialLibrary.findSocialRelatedUser(userInfo);
const relatedInfo = await tenant.libraries.socials.findSocialRelatedUser(userInfo);
throw new RequestError(
{
@ -63,21 +65,21 @@ const identifyUserBySocialIdentifier = async (
return id;
};
const identifyUser = async (identifier: Identifier, socialLibrary: SocialLibrary) => {
const identifyUser = async (tenant: TenantContext, identifier: Identifier) => {
if (identifier.key === 'social') {
return identifyUserBySocialIdentifier(identifier, socialLibrary);
return identifyUserBySocialIdentifier(tenant, identifier);
}
if (identifier.key === 'accountId') {
return identifier.value;
}
return identifyUserByVerifiedEmailOrPhone(identifier);
return identifyUserByVerifiedEmailOrPhone(tenant, identifier);
};
export default async function verifyUserAccount(
interaction: SignInInteractionResult | ForgotPasswordInteractionResult,
socialLibrary: SocialLibrary
tenant: TenantContext,
interaction: SignInInteractionResult | ForgotPasswordInteractionResult
): Promise<AccountVerifiedInteractionResult> {
const { identifiers = [], accountId, profile } = interaction;
@ -95,7 +97,7 @@ export default async function verifyUserAccount(
// Verify authIdentifiers
const accountIds = await Promise.all(
authIdentifiers.map(async (identifier) => identifyUser(identifier, socialLibrary))
authIdentifiers.map(async (identifier) => identifyUser(tenant, identifier))
);
const deduplicateAccountIds = deduplicate(accountIds);