diff --git a/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.test.ts b/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.test.ts index 8a5a621ce48f2..07efbd34ed845 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.test.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.test.ts @@ -67,25 +67,178 @@ describe('Update profile routes', () => { it('correctly defines route.', () => { const bodySchema = (routeConfig.validate as any).body as ObjectType; expect(() => bodySchema.validate(0)).toThrowErrorMatchingInlineSnapshot( - `"expected value of type [object] but got [number]"` + `"expected a plain object value, but found [number] instead."` ); expect(() => bodySchema.validate('avatar')).toThrowErrorMatchingInlineSnapshot( - `"could not parse record value from json input"` + `"could not parse object value from json input"` ); expect(() => bodySchema.validate(true)).toThrowErrorMatchingInlineSnapshot( - `"expected value of type [object] but got [boolean]"` - ); - expect(() => bodySchema.validate(null)).toThrowErrorMatchingInlineSnapshot( - `"expected value of type [object] but got [null]"` - ); - expect(() => bodySchema.validate(undefined)).toThrowErrorMatchingInlineSnapshot( - `"expected value of type [object] but got [undefined]"` + `"expected a plain object value, but found [boolean] instead."` ); expect(bodySchema.validate({})).toEqual({}); expect( - bodySchema.validate({ title: 'some-title', content: { deepProperty: { type: 'basic' } } }) - ).toEqual({ title: 'some-title', content: { deepProperty: { type: 'basic' } } }); + bodySchema.validate({ + avatar: { initials: 'some-initials', color: 'some-color', imageUrl: 'some-image-url' }, + userSettings: { darkMode: 'dark', contrastMode: 'high' }, + }) + ).toEqual({ + avatar: { initials: 'some-initials', color: 'some-color', imageUrl: 'some-image-url' }, + userSettings: { darkMode: 'dark', contrastMode: 'high' }, + }); + }); + + it('rejects invalid darkMode enum values.', () => { + const bodySchema = (routeConfig.validate as any).body as ObjectType; + + // Valid values should pass + expect( + bodySchema.validate({ + userSettings: { darkMode: 'system' }, + }) + ).toEqual({ userSettings: { darkMode: 'system' } }); + + expect( + bodySchema.validate({ + userSettings: { darkMode: 'dark' }, + }) + ).toEqual({ userSettings: { darkMode: 'dark' } }); + + expect( + bodySchema.validate({ + userSettings: { darkMode: 'light' }, + }) + ).toEqual({ userSettings: { darkMode: 'light' } }); + + expect( + bodySchema.validate({ + userSettings: { darkMode: 'space_default' }, + }) + ).toEqual({ userSettings: { darkMode: 'space_default' } }); + + // Invalid values should fail + expect(() => + bodySchema.validate({ + userSettings: { darkMode: 'invalid' }, + }) + ).toThrow(); + + expect(() => + bodySchema.validate({ + userSettings: { darkMode: 'INVALID' }, + }) + ).toThrow(); + + expect(() => + bodySchema.validate({ + userSettings: { darkMode: 123 }, + }) + ).toThrow(); + }); + + it('rejects invalid contrastMode enum values.', () => { + const bodySchema = (routeConfig.validate as any).body as ObjectType; + + // Valid values should pass + expect( + bodySchema.validate({ + userSettings: { contrastMode: 'system' }, + }) + ).toEqual({ userSettings: { contrastMode: 'system' } }); + + expect( + bodySchema.validate({ + userSettings: { contrastMode: 'standard' }, + }) + ).toEqual({ userSettings: { contrastMode: 'standard' } }); + + expect( + bodySchema.validate({ + userSettings: { contrastMode: 'high' }, + }) + ).toEqual({ userSettings: { contrastMode: 'high' } }); + + // Invalid values should fail + expect(() => + bodySchema.validate({ + userSettings: { contrastMode: 'invalid' }, + }) + ).toThrow(); + + expect(() => + bodySchema.validate({ + userSettings: { contrastMode: 'INVALID' }, + }) + ).toThrow(); + + expect(() => + bodySchema.validate({ + userSettings: { contrastMode: 123 }, + }) + ).toThrow(); + }); + + it('rejects avatar initials exceeding max length.', () => { + const bodySchema = (routeConfig.validate as any).body as ObjectType; + const MAX_STRING_FIELD_LENGTH = 1024; + + // Valid length should pass + const validInitials = 'a'.repeat(MAX_STRING_FIELD_LENGTH); + expect( + bodySchema.validate({ + avatar: { initials: validInitials }, + }) + ).toEqual({ avatar: { color: null, imageUrl: null, initials: validInitials } }); + + const invalidInitials = 'a'.repeat(MAX_STRING_FIELD_LENGTH + 1); + expect(() => + bodySchema.validate({ + avatar: { initials: invalidInitials }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[avatar.initials]: types that failed validation: + - [avatar.initials.0]: value has length [1025] but it must have a maximum length of [1024]. + - [avatar.initials.1]: expected value to equal [null]" + `); + }); + + it('rejects avatar color exceeding max length.', () => { + const bodySchema = (routeConfig.validate as any).body as ObjectType; + const MAX_STRING_FIELD_LENGTH = 1024; + + const validColor = 'a'.repeat(MAX_STRING_FIELD_LENGTH); + expect( + bodySchema.validate({ + avatar: { color: validColor }, + }) + ).toEqual({ avatar: { color: validColor, imageUrl: null, initials: null } }); + + const invalidColor = 'a'.repeat(MAX_STRING_FIELD_LENGTH + 1); + expect(() => + bodySchema.validate({ + avatar: { color: invalidColor }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[avatar.color]: types that failed validation: + - [avatar.color.0]: value has length [1025] but it must have a maximum length of [1024]. + - [avatar.color.1]: expected value to equal [null]" + `); + }); + + it('allows null values for avatar initials and color.', () => { + const bodySchema = (routeConfig.validate as any).body as ObjectType; + + expect( + bodySchema.validate({ + avatar: { initials: null, color: null }, + }) + ).toEqual({ avatar: { imageUrl: null, initials: null, color: null } }); + }); + + it('validates body size limit is configured correctly.', () => { + const MAX_USER_PROFILE_DATA_SIZE_BYTES = 1000 * 1024; + + expect(routeConfig.options?.body?.maxBytes).toBe(MAX_USER_PROFILE_DATA_SIZE_BYTES); }); it('fails if session is not found.', async () => { diff --git a/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts b/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts index c941fad1eb472..3489e3a669311 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/user_profile/update.ts @@ -21,6 +21,36 @@ const ALLOWED_KEYS_UPDATE_CLOUD = [ 'solutionNavigationTour:completed', // TODO: remove with https://github.com/elastic/kibana/issues/239313 ]; +const MAX_STRING_FIELD_LENGTH = 1024; + +const MAX_USER_PROFILE_DATA_SIZE_BYTES = 1000 * 1024; + +const userProfileUpdateSchema = schema.object({ + avatar: schema.maybe( + schema.object({ + initials: schema.nullable(schema.string({ maxLength: MAX_STRING_FIELD_LENGTH })), + color: schema.nullable(schema.string({ maxLength: MAX_STRING_FIELD_LENGTH })), + imageUrl: schema.nullable(schema.string()), + }) + ), + userSettings: schema.maybe( + schema.object({ + darkMode: schema.maybe( + schema.oneOf([ + schema.literal('system'), + schema.literal('dark'), + schema.literal('light'), + schema.literal('space_default'), + ]) + ), + contrastMode: schema.maybe( + schema.oneOf([schema.literal('system'), schema.literal('standard'), schema.literal('high')]) + ), + 'solutionNavigationTour:completed': schema.maybe(schema.boolean()), + }) + ), +}); + export function defineUpdateUserProfileDataRoute({ router, getSession, @@ -39,7 +69,12 @@ export function defineUpdateUserProfileDataRoute({ }, }, validate: { - body: schema.recordOf(schema.string(), schema.any()), + body: userProfileUpdateSchema, + }, + options: { + body: { + maxBytes: MAX_USER_PROFILE_DATA_SIZE_BYTES, + }, }, }, createLicensedRouteHandler(async (context, request, response) => { diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts index 5ee7f2b26d59d..f2ddbc984bd16 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts @@ -470,11 +470,10 @@ export default ({ getService }: FtrProviderContext): void => { supertest, req: { // @ts-expect-error: types are not correct - initials: 4, - // @ts-expect-error: types are not correct - color: true, + initials: null, // @ts-expect-error: types are not correct - imageUrl: [], + color: null, + imageUrl: null, }, headers: superUserHeaders, }); diff --git a/x-pack/platform/test/security_api_integration/tests/user_profiles/bulk_get.ts b/x-pack/platform/test/security_api_integration/tests/user_profiles/bulk_get.ts index 7c5accb39937c..20accdfc369ae 100644 --- a/x-pack/platform/test/security_api_integration/tests/user_profiles/bulk_get.ts +++ b/x-pack/platform/test/security_api_integration/tests/user_profiles/bulk_get.ts @@ -104,7 +104,13 @@ export default function ({ getService }: FtrProviderContext) { .post('/internal/security/user_profile/_data') .set('kbn-xsrf', 'xxx') .set('Cookie', usersSessions.get(`user_${userPrefix}`)!.cookie.cookieString()) - .send({ some: `data-${userPrefix}` }) + .send({ + avatar: { + initials: `some-initials-${userPrefix}`, + color: `some-color-${userPrefix}`, + }, + userSettings: { darkMode: `dark`, contrastMode: `high` }, + }) .expect(200) ) ); @@ -145,7 +151,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ uids: [usersSessions.get('user_one')!.uid, usersSessions.get('user_two')!.uid], - dataPath: 'some', + dataPath: 'avatar', }) .expect(200); expect(profiles.body).to.have.length(2); @@ -155,7 +161,11 @@ export default function ({ getService }: FtrProviderContext) { Array [ Object { "data": Object { - "some": "data-one", + "avatar": Object { + "color": "some-color-one", + "imageUrl": null, + "initials": "some-initials-one", + }, }, "user": Object { "email": "one@elastic.co", @@ -165,7 +175,11 @@ export default function ({ getService }: FtrProviderContext) { }, Object { "data": Object { - "some": "data-two", + "avatar": Object { + "color": "some-color-two", + "imageUrl": null, + "initials": "some-initials-two", + }, }, "user": Object { "email": "two@elastic.co", diff --git a/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts b/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts index ec41bc3d80648..a883072e62a6b 100644 --- a/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts +++ b/x-pack/platform/test/security_api_integration/tests/user_profiles/get_current.ts @@ -51,7 +51,10 @@ export default function ({ getService }: FtrProviderContext) { .post('/internal/security/user_profile/_data') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .send({ some: 'data', another: 'another-data' }) + .send({ + avatar: { initials: 'some-initials', color: 'some-color' }, + userSettings: { darkMode: 'dark', contrastMode: 'high' }, + }) .expect(200); const { body: profileWithoutData } = await supertestWithoutAuth @@ -96,8 +99,15 @@ export default function ({ getService }: FtrProviderContext) { expectSnapshot(profileWithAllData).toMatchInline(` Object { "data": Object { - "another": "another-data", - "some": "data", + "avatar": Object { + "color": "some-color", + "imageUrl": null, + "initials": "some-initials", + }, + "userSettings": Object { + "contrastMode": "high", + "darkMode": "dark", + }, }, "enabled": true, "labels": Object {}, @@ -119,9 +129,7 @@ export default function ({ getService }: FtrProviderContext) { `); expectSnapshot(profileWithSomeData).toMatchInline(` Object { - "data": Object { - "some": "data", - }, + "data": Object {}, "enabled": true, "labels": Object {}, "uid": "u_K1WXIRQbRoHiuJylXp842IEhAO_OdqT7SDHrJSzUIjU_0", diff --git a/x-pack/platform/test/security_api_integration/tests/user_profiles/suggest.ts b/x-pack/platform/test/security_api_integration/tests/user_profiles/suggest.ts index 26b60abfc04d3..f44103321efa5 100644 --- a/x-pack/platform/test/security_api_integration/tests/user_profiles/suggest.ts +++ b/x-pack/platform/test/security_api_integration/tests/user_profiles/suggest.ts @@ -308,7 +308,10 @@ export default function ({ getService }: FtrProviderContext) { .post('/internal/security/user_profile/_data') .set('kbn-xsrf', 'xxx') .set('Cookie', usersSessions.get('user_one')!.cookie.cookieString()) - .send({ some: 'data', some_more: 'data', some_nested: { data: 'nested_data' } }) + .send({ + avatar: { initials: 'some-initials', color: 'some-color' }, + userSettings: { darkMode: 'dark', contrastMode: 'high' }, + }) .expect(200); // 2. Data is not returned by default @@ -337,7 +340,7 @@ export default function ({ getService }: FtrProviderContext) { suggestions = await supertest .post('/internal/user_profiles_consumer/_suggest') .set('kbn-xsrf', 'xxx') - .send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: 'some,some_more' }) + .send({ name: 'one', requiredAppPrivileges: ['discover'], dataPath: 'avatar,userSettings' }) .expect(200); expect(suggestions.body).to.have.length(1); expectSnapshot( @@ -346,8 +349,15 @@ export default function ({ getService }: FtrProviderContext) { Array [ Object { "data": Object { - "some": "data", - "some_more": "data", + "avatar": Object { + "color": "some-color", + "imageUrl": null, + "initials": "some-initials", + }, + "userSettings": Object { + "contrastMode": "high", + "darkMode": "dark", + }, }, "user": Object { "email": "one@elastic.co", @@ -371,10 +381,14 @@ export default function ({ getService }: FtrProviderContext) { Array [ Object { "data": Object { - "some": "data", - "some_more": "data", - "some_nested": Object { - "data": "nested_data", + "avatar": Object { + "color": "some-color", + "imageUrl": null, + "initials": "some-initials", + }, + "userSettings": Object { + "contrastMode": "high", + "darkMode": "dark", }, }, "user": Object { diff --git a/x-pack/platform/test/security_functional/tests/user_profiles/client_side_apis.ts b/x-pack/platform/test/security_functional/tests/user_profiles/client_side_apis.ts index c6675eb526632..01412b1dc0ca6 100644 --- a/x-pack/platform/test/security_functional/tests/user_profiles/client_side_apis.ts +++ b/x-pack/platform/test/security_functional/tests/user_profiles/client_side_apis.ts @@ -50,7 +50,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { .post('/internal/security/user_profile/_data') .set('kbn-xsrf', 'xxx') .set('Cookie', cookie) - .send({ some: `data-${userPrefix}` }) + .send({ + avatar: { initials: `some-initials-${userPrefix}` }, + }) .expect(200); const { body: profile } = await supertestWithoutAuth @@ -95,7 +97,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const userProfileText = await testSubjects.getVisibleText( `testEndpointsUserProfilesAppUserProfile_user_${userPrefix}` ); - expect(userProfileText).to.equal(`user_${userPrefix}:{"some":"data-${userPrefix}"}`); + expect(userProfileText).to.equal( + `user_${userPrefix}:{"avatar":{"color":null,"initials":"some-initials-${userPrefix}","imageUrl":null}}` + ); } }); });