0
Fork 0
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:
Charles Zhao 2022-11-16 16:39:53 +08:00 committed by GitHub
commit f55bc43a52
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 163 additions and 11 deletions

View file

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

View file

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

View file

@ -226,7 +226,7 @@ export const checkSignUpIdentifierCollision = async (
primaryEmail?: Nullable<string>;
primaryPhone?: Nullable<string>;
},
excludeUserId: string
excludeUserId?: string
) => {
const { username, primaryEmail, primaryPhone } = identifiers;