mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #2455 from logto-io/charles-log-4073-log-4074-log-4075-email-session-apis
feat: email related profile session apis
This commit is contained in:
commit
f55bc43a52
3 changed files with 163 additions and 11 deletions
|
@ -23,15 +23,15 @@ const encryptUserPassword = jest.fn(async (password: string) => ({
|
|||
passwordEncryptionMethod: 'Argon2i',
|
||||
}));
|
||||
const mockArgon2Verify = jest.fn(async (password: string) => password === mockPasswordEncrypted);
|
||||
|
||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({
|
||||
result: { login: { accountId: 'id', ts: getUnixTime(new Date()) - 60 } },
|
||||
const mockGetSession = jest.fn(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 60,
|
||||
}));
|
||||
|
||||
jest.mock('oidc-provider', () => ({
|
||||
Provider: jest.fn(() => ({
|
||||
Session: {
|
||||
get: jest.fn(async () => ({ accountId: 'id', loginTs: getUnixTime(new Date()) - 60 })),
|
||||
get: async () => mockGetSession(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
@ -67,6 +67,10 @@ jest.mock('hash-wasm', () => ({
|
|||
}));
|
||||
|
||||
describe('session -> profileRoutes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: profileRoutes,
|
||||
provider: new Provider(''),
|
||||
|
@ -87,6 +91,20 @@ describe('session -> profileRoutes', () => {
|
|||
});
|
||||
|
||||
describe('PATCH /session/profile/username', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/username`)
|
||||
.send({ username: 'test' });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should update username with the new value', async () => {
|
||||
const newUsername = 'charles';
|
||||
|
||||
|
@ -109,10 +127,24 @@ describe('session -> profileRoutes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('POST /session/profile/password', () => {
|
||||
describe('PATCH /session/profile/password', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/password`)
|
||||
.send({ password: mockPasswordEncrypted });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should update password with the new value', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${profileRoute}/password`)
|
||||
.patch(`${profileRoute}/password`)
|
||||
.send({ password: mockPasswordEncrypted });
|
||||
|
||||
expect(mockUpdateUserById).toBeCalledWith(
|
||||
|
@ -126,7 +158,6 @@ describe('session -> profileRoutes', () => {
|
|||
});
|
||||
|
||||
it('should throw if new password is identical to old password', async () => {
|
||||
jest.clearAllMocks();
|
||||
encryptUserPassword.mockImplementationOnce(async (password: string) => ({
|
||||
passwordEncrypted: password,
|
||||
passwordEncryptionMethod: 'Argon2i',
|
||||
|
@ -134,11 +165,95 @@ describe('session -> profileRoutes', () => {
|
|||
mockArgon2Verify.mockResolvedValueOnce(true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.post(`${profileRoute}/password`)
|
||||
.patch(`${profileRoute}/password`)
|
||||
.send({ password: 'password' });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('email related APIs', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago on linking email', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const updateResponse = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: 'test@logto.io' });
|
||||
|
||||
expect(updateResponse.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should link email address to the user profile', async () => {
|
||||
const mockEmailAddress = 'bar@logto.io';
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: mockEmailAddress });
|
||||
|
||||
expect(mockUpdateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
primaryEmail: mockEmailAddress,
|
||||
})
|
||||
);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
});
|
||||
|
||||
it('should throw when email address already exists', async () => {
|
||||
mockHasUserWithEmail.mockImplementationOnce(async () => true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: mockUser.primaryEmail });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw when email address is invalid', async () => {
|
||||
mockHasUserWithEmail.mockImplementationOnce(async () => true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: 'test' });
|
||||
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw if last authentication time is over 10 mins ago on unlinking email', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const deleteResponse = await sessionRequest.delete(`${profileRoute}/email`);
|
||||
|
||||
expect(deleteResponse.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unlink email address from user', async () => {
|
||||
const response = await sessionRequest.delete(`${profileRoute}/email`);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(mockUpdateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
primaryEmail: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when no email address found in user', async () => {
|
||||
mockFindUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryEmail: null }));
|
||||
const response = await sessionRequest.delete(`${profileRoute}/email`);
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(mockUpdateUserById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { passwordRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { userInfoSelectFields } from '@logto/schemas';
|
||||
import { argon2Verify } from 'hash-wasm';
|
||||
import pick from 'lodash.pick';
|
||||
|
@ -55,7 +55,7 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
router.patch(
|
||||
`${profileRoute}/password`,
|
||||
koaGuard({
|
||||
body: object({ password: string().regex(passwordRegEx) }),
|
||||
|
@ -82,4 +82,41 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
`${profileRoute}/email`,
|
||||
koaGuard({
|
||||
body: object({ primaryEmail: string().regex(emailRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError('auth.unauthorized'));
|
||||
|
||||
const { primaryEmail } = ctx.guard.body;
|
||||
|
||||
await checkSignUpIdentifierCollision({ primaryEmail });
|
||||
await updateUserById(userId, { primaryEmail });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(`${profileRoute}/email`, async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError('auth.unauthorized'));
|
||||
|
||||
const { primaryEmail } = await findUserById(userId);
|
||||
|
||||
assertThat(primaryEmail, new RequestError({ code: 'user.email_not_exists', status: 422 }));
|
||||
|
||||
await updateUserById(userId, { primaryEmail: null });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -226,7 +226,7 @@ export const checkSignUpIdentifierCollision = async (
|
|||
primaryEmail?: Nullable<string>;
|
||||
primaryPhone?: Nullable<string>;
|
||||
},
|
||||
excludeUserId: string
|
||||
excludeUserId?: string
|
||||
) => {
|
||||
const { username, primaryEmail, primaryPhone } = identifiers;
|
||||
|
||||
|
|
Loading…
Reference in a new issue