diff --git a/packages/core/src/routes/session/profile.test.ts b/packages/core/src/routes/session/profile.test.ts index 0fbf2827a..165585553 100644 --- a/packages/core/src/routes/session/profile.test.ts +++ b/packages/core/src/routes/session/profile.test.ts @@ -23,10 +23,7 @@ const encryptUserPassword = jest.fn(async (password: string) => ({ passwordEncryptionMethod: 'Argon2i', })); const mockArgon2Verify = jest.fn(async (password: string) => password === mockPasswordEncrypted); -const mockGetSession = jest.fn(async () => ({ - accountId: 'id', - loginTs: getUnixTime(new Date()) - 60, -})); +const mockGetSession = jest.fn(); jest.mock('oidc-provider', () => ({ Provider: jest.fn(() => ({ @@ -69,6 +66,10 @@ jest.mock('hash-wasm', () => ({ describe('session -> profileRoutes', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetSession.mockImplementation(async () => ({ + accountId: 'id', + loginTs: getUnixTime(new Date()) - 60, + })); }); const sessionRequest = createRequester({ @@ -84,10 +85,51 @@ describe('session -> profileRoutes', () => { ], }); - test('GET /session/profile should return current user data', async () => { - const response = await sessionRequest.get(profileRoute); - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual(mockUserResponse); + describe('GET /session/profile', () => { + it('should return current user data', async () => { + const response = await sessionRequest.get(profileRoute); + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual(mockUserResponse); + }); + + it('should throw when the user is not authenticated', async () => { + mockGetSession.mockImplementationOnce( + jest.fn(async () => ({ + accountId: undefined, + loginTs: undefined, + })) + ); + + const response = await sessionRequest.get(profileRoute); + expect(response.statusCode).toEqual(401); + }); + }); + + describe('PATCH /session/profile', () => { + it('should update current user with display name, avatar and custom data', async () => { + const updatedUserInfo = { + name: 'John Doe', + avatar: 'https://new-avatar.cdn.com', + customData: { gender: 'male', age: '30' }, + }; + + const response = await sessionRequest.patch(profileRoute).send(updatedUserInfo); + + expect(mockUpdateUserById).toBeCalledWith('id', expect.objectContaining(updatedUserInfo)); + expect(response.statusCode).toEqual(204); + }); + + it('should throw when the user is not authenticated', async () => { + mockGetSession.mockImplementationOnce( + jest.fn(async () => ({ + accountId: undefined, + loginTs: undefined, + })) + ); + + const response = await sessionRequest.patch(profileRoute).send({ name: 'John Doe' }); + expect(response.statusCode).toEqual(401); + }); }); describe('PATCH /session/profile/username', () => { diff --git a/packages/core/src/routes/session/profile.ts b/packages/core/src/routes/session/profile.ts index 8f032e37f..d2b862826 100644 --- a/packages/core/src/routes/session/profile.ts +++ b/packages/core/src/routes/session/profile.ts @@ -1,5 +1,5 @@ import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; -import { userInfoSelectFields } from '@logto/schemas'; +import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas'; import { argon2Verify } from 'hash-wasm'; import pick from 'lodash.pick'; import type { Provider } from 'oidc-provider'; @@ -20,19 +20,41 @@ export const profileRoute = '/session/profile'; export default function profileRoutes(router: T, provider: Provider) { router.get(profileRoute, async (ctx, next) => { - const { accountId } = await provider.Session.get(ctx); + const { accountId: userId } = await provider.Session.get(ctx); - if (!accountId) { - throw new RequestError('auth.unauthorized'); - } + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); - const user = await findUserById(accountId); + const user = await findUserById(userId); ctx.body = pick(user, ...userInfoSelectFields); return next(); }); + router.patch( + profileRoute, + koaGuard({ + body: object({ + name: string().nullable().optional(), + avatar: string().nullable().optional(), + customData: arbitraryObjectGuard.optional(), + }), + }), + async (ctx, next) => { + const { accountId: userId } = await provider.Session.get(ctx); + + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); + + const { name, avatar, customData } = ctx.guard.body; + + await updateUserById(userId, { name, avatar, customData }); + + ctx.status = 204; + + return next(); + } + ); + router.patch( `${profileRoute}/username`, koaGuard({ @@ -41,7 +63,7 @@ export default function profileRoutes(router: T, prov async (ctx, next) => { const userId = await checkSessionHealth(ctx, provider, verificationTimeout); - assertThat(userId, new RequestError('auth.unauthorized')); + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); const { username } = ctx.guard.body; @@ -63,7 +85,7 @@ export default function profileRoutes(router: T, prov async (ctx, next) => { const userId = await checkSessionHealth(ctx, provider, verificationTimeout); - assertThat(userId, new RequestError('auth.unauthorized')); + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); const { password } = ctx.guard.body; const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId); @@ -91,7 +113,7 @@ export default function profileRoutes(router: T, prov async (ctx, next) => { const userId = await checkSessionHealth(ctx, provider, verificationTimeout); - assertThat(userId, new RequestError('auth.unauthorized')); + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); const { primaryEmail } = ctx.guard.body; @@ -107,7 +129,7 @@ export default function profileRoutes(router: T, prov router.delete(`${profileRoute}/email`, async (ctx, next) => { const userId = await checkSessionHealth(ctx, provider, verificationTimeout); - assertThat(userId, new RequestError('auth.unauthorized')); + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); const { primaryEmail } = await findUserById(userId); @@ -128,7 +150,7 @@ export default function profileRoutes(router: T, prov async (ctx, next) => { const userId = await checkSessionHealth(ctx, provider, verificationTimeout); - assertThat(userId, new RequestError('auth.unauthorized')); + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); const { primaryPhone } = ctx.guard.body; @@ -144,7 +166,7 @@ export default function profileRoutes(router: T, prov router.delete(`${profileRoute}/phone`, async (ctx, next) => { const userId = await checkSessionHealth(ctx, provider, verificationTimeout); - assertThat(userId, new RequestError('auth.unauthorized')); + assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 })); const { primaryPhone } = await findUserById(userId);