mirror of
https://github.com/logto-io/logto.git
synced 2025-02-10 21:58:23 -05:00
* 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
295 lines
9.2 KiB
TypeScript
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);
|
|
});
|
|
});
|