0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-10 21:58:23 -05:00
logto/packages/core/src/libraries/passcode.test.ts
Gao Sun 570a4ea9e2
feat: create invitation (#5245)
* feat: create invitation

* refactor: update test imports

* refactor: update unit tests

* refactor: update docs

* refactor: update api tests

* chore: add changesets

* refactor: add comments

* refactor: fix swagger check

* refactor: keep compatibility
2024-01-25 19:44:20 +08:00

295 lines
9.2 KiB
TypeScript

import { defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
import { ConnectorType, TemplateType } from '@logto/connector-kit';
import { type Passcode } from '@logto/schemas';
import { any } from 'zod';
import { mockConnector, mockMetadata } from '#src/__mocks__/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { MockQueries } from '#src/test-utils/tenant.js';
import {
createPasscodeLibrary,
passcodeExpiration,
passcodeMaxTryCount,
passcodeLength,
} from './passcode.js';
const { jest } = import.meta;
const passcodeQueries = {
findUnconsumedPasscodesByJtiAndType: jest.fn(),
findUnconsumedPasscodeByJtiAndType: jest.fn(),
findUnconsumedPasscodeByIdentifierAndType: jest.fn(),
findUnconsumedPasscodesByIdentifierAndType: jest.fn(),
deletePasscodesByIds: jest.fn(),
insertPasscode: jest.fn(),
consumePasscode: jest.fn(),
increasePasscodeTryCount: jest.fn(),
};
const {
findUnconsumedPasscodeByJtiAndType,
findUnconsumedPasscodesByJtiAndType,
findUnconsumedPasscodeByIdentifierAndType,
findUnconsumedPasscodesByIdentifierAndType,
deletePasscodesByIds,
increasePasscodeTryCount,
insertPasscode,
consumePasscode,
} = passcodeQueries;
const getMessageConnector = jest.fn();
const { createPasscode, sendPasscode, verifyPasscode } = createPasscodeLibrary(
new MockQueries({ passcodes: passcodeQueries }),
// @ts-expect-error
{ getMessageConnector }
);
beforeAll(() => {
findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]);
findUnconsumedPasscodesByIdentifierAndType.mockResolvedValue([]);
insertPasscode.mockImplementation(async (data): Promise<Passcode> => {
return {
phone: null,
email: null,
consumed: false,
tryCount: 0,
...data,
createdAt: Date.now(),
};
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe('createPasscode', () => {
it('should generate `passcodeLength` digits code for phone with valid session and insert to database', async () => {
const phone = '13000000000';
const passcode = await createPasscode('jti', TemplateType.SignIn, {
phone,
});
expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
expect(passcode.phone).toEqual(phone);
});
it('should generate `passcodeLength` digits code for email with valid session and insert to database', async () => {
const email = 'jony@example.com';
const passcode = await createPasscode('jti', TemplateType.SignIn, {
email,
});
expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
expect(passcode.email).toEqual(email);
});
it('should generate `passcodeLength` digits code for phone and insert to database, without session', async () => {
const phone = '13000000000';
const passcode = await createPasscode(undefined, TemplateType.Generic, {
phone,
});
expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
expect(passcode.phone).toEqual(phone);
});
it('should generate `passcodeLength` digits code for email and insert to database, without session', async () => {
const email = 'jony@example.com';
const passcode = await createPasscode(undefined, TemplateType.Generic, {
email,
});
expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy();
expect(passcode.email).toEqual(email);
});
it('should remove unconsumed passcode from the same device before sending a new one', async () => {
const email = 'jony@example.com';
const jti = 'jti';
findUnconsumedPasscodesByJtiAndType.mockResolvedValue([
{
id: 'id',
interactionJti: jti,
code: '1234',
type: TemplateType.SignIn,
createdAt: Date.now(),
phone: '',
email,
consumed: false,
tryCount: 0,
},
]);
await createPasscode(jti, TemplateType.SignIn, {
email,
});
expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']);
});
it('should remove unconsumed passcode from the same device before sending a new one, without session', async () => {
const phone = '1234567890';
findUnconsumedPasscodesByIdentifierAndType.mockResolvedValue([
{
id: 'id',
interactionJti: null,
code: '123456',
type: TemplateType.Generic,
createdAt: Date.now(),
phone,
email: null,
consumed: false,
tryCount: 0,
},
]);
await createPasscode(undefined, TemplateType.Generic, {
phone,
});
expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']);
});
});
describe('sendPasscode', () => {
it('should throw error when email and phone are both empty', async () => {
const passcode: Passcode = {
tenantId: 'fake_tenant',
id: 'id',
interactionJti: 'jti',
phone: null,
email: null,
type: TemplateType.SignIn,
code: '1234',
consumed: false,
tryCount: 0,
createdAt: Date.now(),
};
await expect(sendPasscode(passcode)).rejects.toThrowError(
new RequestError('verification_code.phone_email_empty')
);
});
it('should call sendPasscode with params matching', async () => {
const sendMessage = jest.fn();
getMessageConnector.mockResolvedValueOnce({
...defaultConnectorMethods,
configGuard: any(),
dbEntry: {
...mockConnector,
id: 'id0',
},
metadata: {
...mockMetadata,
platform: null,
},
type: ConnectorType.Sms,
sendMessage,
});
const passcode: Passcode = {
tenantId: 'fake_tenant',
id: 'passcode_id',
interactionJti: 'jti',
phone: 'phone',
email: null,
type: TemplateType.SignIn,
code: '1234',
consumed: false,
tryCount: 0,
createdAt: Date.now(),
};
await sendPasscode(passcode);
expect(sendMessage).toHaveBeenCalledWith({
to: passcode.phone,
type: passcode.type,
payload: {
code: passcode.code,
},
});
});
});
describe('verifyPasscode', () => {
const passcode = {
tenantId: 'fake_tenant',
id: 'id',
interactionJti: 'jti',
phone: 'phone',
email: null,
type: TemplateType.SignIn,
code: '1234',
consumed: false,
tryCount: 0,
createdAt: Date.now(),
} satisfies Passcode;
it('should mark as consumed on successful verification', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
await verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' });
expect(consumePasscode).toHaveBeenCalledWith(passcode.id);
});
it('should fail when passcode not found', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(null);
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' })
).rejects.toThrow(new RequestError('verification_code.not_found'));
});
it('should mark as consumed on successful verification without jti', async () => {
const passcodeWithoutJti = {
...passcode,
type: TemplateType.Generic,
interactionJti: null,
};
findUnconsumedPasscodeByIdentifierAndType.mockResolvedValue(passcodeWithoutJti);
await verifyPasscode(undefined, passcodeWithoutJti.type, passcodeWithoutJti.code, {
phone: 'phone',
});
expect(consumePasscode).toHaveBeenCalledWith(passcodeWithoutJti.id);
});
it('should fail when phone mismatch', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, {
phone: 'invalid_phone',
})
).rejects.toThrow(new RequestError('verification_code.phone_mismatch'));
});
it('should fail when email mismatch', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue({
...passcode,
phone: null,
email: 'email',
});
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, {
email: 'invalid_email',
})
).rejects.toThrow(new RequestError('verification_code.email_mismatch'));
});
it('should fail when expired', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue({
...passcode,
createdAt: Date.now() - passcodeExpiration - 100,
});
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' })
).rejects.toThrow(new RequestError('verification_code.expired'));
});
it('should fail when exceed max count', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue({
...passcode,
tryCount: passcodeMaxTryCount,
});
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, passcode.code, { phone: 'phone' })
).rejects.toThrow(new RequestError('verification_code.exceed_max_try'));
});
it('should fail when invalid code, and should increase try_count', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
await expect(
verifyPasscode(passcode.interactionJti, passcode.type, 'invalid', { phone: 'phone' })
).rejects.toThrow(new RequestError('verification_code.code_mismatch'));
expect(increasePasscodeTryCount).toHaveBeenCalledWith(passcode.id);
});
});