From d543ba5b56db9d301dd000fb462370d98e77e601 Mon Sep 17 00:00:00 2001 From: danielFesenmeyer Date: Fri, 25 Nov 2022 22:11:15 +0100 Subject: [PATCH] Consistent message resolving regarding language fallbacks for all themes - the prio of messages is now as follows for all themes (RL = realm localization, T = Theme i18n files): RL > T > RL > T > RL > T > RL en > T en - centralize the message resolving logic in helper methods in LocaleUtil and use it for all themes, add unit tests in LocaleUtilTest - add basic integration tests to check whether realm localization can be used in all supported contexts: - Account UI V2: org.keycloak.testsuite.ui.account2.InternationalizationTest - Login theme: LoginPageTest - Email theme: EmailTest - deprecate the param useRealmDefaultLocaleFallback=true of endpoint /admin/realms/{realm}/localization/{locale}, because it does not resolve fallbacks as expected and is no longer used in admin-ui v2 - fix locale selection in DefaultLocaleSelectorProvider that a supported region (like "de-CH") will no longer selected instead of a supported language (like "de"), when just the language is requested, add corresponding unit tests - improvements regarding message resolving in Admin UI V2: - add cypress test i18n_test.spec.ts, which checks the fallback implementation - log a warning instead of an error, when messages for some languages/namespaces cannot be loaded (the page will probably work with fallbacks in that case) Closes #15845 --- .../topics/keycloak/changes-22_0_0.adoc | 10 + .../resource/RealmLocalizationResource.java | 27 ++- .../admin-ui/cypress/e2e/i18n_test.spec.ts | 178 +++++++++++++++ .../support/pages/admin-ui/SidebarPage.ts | 4 + .../admin-ui/manage/providers/ProviderPage.ts | 5 + .../cypress/support/util/AdminClient.ts | 40 ++++ .../admin-ui/src/context/whoami/WhoAmI.tsx | 4 +- .../main/java/org/keycloak/theme/Theme.java | 16 ++ .../FreeMarkerEmailTemplateProvider.java | 18 +- .../freemarker/FreeMarkerAccountProvider.java | 10 +- .../FreeMarkerLoginFormsProvider.java | 12 +- .../locale/DefaultLocaleSelectorProvider.java | 37 +++- .../resources/account/AccountConsole.java | 8 +- .../admin/AdminMessageFormatter.java | 8 +- .../admin/RealmLocalizationResource.java | 21 +- .../keycloak/services/util/LocaleUtil.java | 171 ++++++++++++++ .../org/keycloak/theme/ClassLoaderTheme.java | 15 ++ .../keycloak/theme/DefaultThemeManager.java | 60 ++--- .../java/org/keycloak/theme/FolderTheme.java | 16 +- .../DefaultLocaleSelectorProviderTest.java | 81 +++++++ .../services/util/LocaleUtilTest.java | 208 ++++++++++++++++++ .../testsuite/admin/PermissionsTest.java | 105 ++------- .../admin/RealmLocalizationResourceTest.java | 10 +- .../keycloak/testsuite/i18n/EmailTest.java | 96 +++++--- .../testsuite/i18n/LoginPageTest.java | 109 +++++---- .../ui/account2/InternationalizationTest.java | 37 +++- 26 files changed, 1030 insertions(+), 276 deletions(-) create mode 100644 js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts create mode 100644 services/src/test/java/org/keycloak/locale/DefaultLocaleSelectorProviderTest.java create mode 100644 services/src/test/java/org/keycloak/services/util/LocaleUtilTest.java diff --git a/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc b/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc index ea0c569d5d..117eb807ab 100644 --- a/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc +++ b/docs/documentation/upgrading/topics/keycloak/changes-22_0_0.adoc @@ -185,3 +185,13 @@ Keycloak's proxy configuration setting for mode *passthrough* no longer parses H Installations that want the HTTP headers in the client's request to be parsed should use the **edge** or **reencrypt** setting. See https://www.keycloak.org/server/reverseproxy[Using a reverse proxy] for details. + += Consistent fallback message resolving for all themes + +This change only may affect you when you are using realm localization messages. + +Up to this version, the resolving of fallback messages was inconsistent across themes, when realm localization messages were used. More information can be found in the following https://github.com/keycloak/keycloak/issues/15845[issue]. + +The implementation has now been unified for all themes. In general, the message for the most specific matching language tag has the highest priority. If there are both a realm localization message and a Theme 18n message, the realm localization message has the higher priority. Summarized, the priority of the messages is as follows (RL = realm localization, T = Theme i18n files): `RL > T > RL > T > RL > T > RL en > T en`. + +Probably this can be better explained with an example: When the variant `de-CH-1996` is requested and there is a realm localization message for the variant, this message will be used. If such a realm localization message does not exist, the Theme i18n files are searched for a corresponding message for that variant. If such a message does not exist, a realm localization message for the region (`de-CH`) will be searched. If such a realm localization message does not exist, the Theme i18n files are searched for a message for that region. If still no message is found, a realm localization message for the language (`de`) will be searched. If there is no matching realm localization message, the Theme i18n files are be searched for a message for that language. As last fallback, the English (`en`) translation is used: First, an English realm localization will be searched - if not found, the Theme 18n files are searched for an English message. diff --git a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmLocalizationResource.java b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmLocalizationResource.java index b26dc7fb6e..401ef71c21 100755 --- a/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmLocalizationResource.java +++ b/integration/admin-client-jee/src/main/java/org/keycloak/admin/client/resource/RealmLocalizationResource.java @@ -37,10 +37,35 @@ public interface RealmLocalizationResource { @Produces(MediaType.APPLICATION_JSON) List getRealmSpecificLocales(); + /** + * Get the localization texts for the given locale. + * + * @param locale the locale + * @return the localization texts + */ @Path("{locale}") @GET @Produces(MediaType.APPLICATION_JSON) - Map getRealmLocalizationTexts(final @PathParam("locale") String locale, @QueryParam("useRealmDefaultLocaleFallback") Boolean useRealmDefaultLocaleFallback); + Map getRealmLocalizationTexts(final @PathParam("locale") String locale); + + + /** + * DEPRECATED - Get the localization texts for the given locale. + * + * @param locale the locale + * @param useRealmDefaultLocaleFallback whether the localization texts for the realm default locale should be used + * as fallbacks in the result + * @return the localization texts + * @deprecated use {@link #getRealmLocalizationTexts(String)}, in order to retrieve localization texts without + * fallbacks. If you need fallbacks, call the endpoint multiple time with all the relevant locales (e.g. + * "de" in case of "de-CH") - the realm default locale is NOT the only fallback to be considered. + */ + @Deprecated + @Path("{locale}") + @GET + @Produces(MediaType.APPLICATION_JSON) + Map getRealmLocalizationTexts(final @PathParam("locale") String locale, + @QueryParam("useRealmDefaultLocaleFallback") Boolean useRealmDefaultLocaleFallback); @Path("{locale}/{key}") diff --git a/js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts b/js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts new file mode 100644 index 0000000000..cbc1c1ccfa --- /dev/null +++ b/js/apps/admin-ui/cypress/e2e/i18n_test.spec.ts @@ -0,0 +1,178 @@ +import LoginPage from "../support/pages/LoginPage"; +import SidebarPage from "../support/pages/admin-ui/SidebarPage"; +import adminClient from "../support/util/AdminClient"; +import { keycloakBefore } from "../support/util/keycloak_hooks"; +import ProviderPage from "../support/pages/admin-ui/manage/providers/ProviderPage"; +import RealmRepresentation from "libs/keycloak-admin-client/lib/defs/realmRepresentation"; + +const loginPage = new LoginPage(); +const sidebarPage = new SidebarPage(); + +const providersPage = new ProviderPage(); + +const usernameI18nTest = "user_i18n_test"; +let usernameI18nId: string; + +let originalMasterRealm: RealmRepresentation; + +describe("i18n tests", () => { + before(() => { + cy.wrap(null).then(async () => { + const realm = (await adminClient.getRealm("master"))!; + originalMasterRealm = realm; + realm.supportedLocales = ["en", "de", "de-CH", "fo"]; + realm.internationalizationEnabled = true; + await adminClient.updateRealm("master", realm); + + const { id: userId } = await adminClient.createUser({ + username: usernameI18nTest, + enabled: true, + credentials: [ + { type: "password", temporary: false, value: usernameI18nTest }, + ], + }); + usernameI18nId = userId!; + + await adminClient.addRealmRoleToUser(usernameI18nId, "admin"); + }); + }); + + after(async () => { + await adminClient.deleteUser(usernameI18nTest); + + if (originalMasterRealm) { + await adminClient.updateRealm("master", originalMasterRealm); + } + }); + + afterEach(async () => { + await adminClient.removeAllLocalizationTexts(); + }); + + const realmLocalizationEn = "realmSettings en"; + const themeLocalizationEn = "Realm settings"; + const realmLocalizationDe = "realmSettings de"; + const themeLocalizationDe = "Realm-Einstellungen"; + const realmLocalizationDeCh = "realmSettings de-CH"; + + it("should use THEME localization for fallback (en) when language without theme localization is requested and no realm localization exists", () => { + updateUserLocale("fo"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(themeLocalizationEn); + }); + + it("should use THEME localization for language when language with theme localization is requested and no realm localization exists", () => { + updateUserLocale("de"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(themeLocalizationDe); + }); + + it("should use REALM localization for fallback (en) when language without theme localization is requested and realm localization exists for fallback (en)", () => { + addCommonRealmSettingsLocalizationText("en", realmLocalizationEn); + updateUserLocale("fo"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(realmLocalizationEn); + }); + + it("should use THEME localization for language when language with theme localization is requested and realm localization exists for fallback (en) only", () => { + addCommonRealmSettingsLocalizationText("en", realmLocalizationEn); + updateUserLocale("de"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(themeLocalizationDe); + }); + + it("should use REALM localization for language when language is requested and realm localization exists for language", () => { + addCommonRealmSettingsLocalizationText("de", realmLocalizationDe); + updateUserLocale("de"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(realmLocalizationDe); + }); + + // TODO: currently skipped due to https://github.com/keycloak/keycloak/issues/20412 + it.skip("should use REALM localization for region when region is requested and realm localization exists for region", () => { + addCommonRealmSettingsLocalizationText("de-CH", realmLocalizationDeCh); + updateUserLocale("de-CH"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(realmLocalizationDeCh); + }); + + it("should use REALM localization for language when language is requested and realm localization exists for fallback (en), language, region", () => { + addCommonRealmSettingsLocalizationText("en", realmLocalizationEn); + addCommonRealmSettingsLocalizationText("de", realmLocalizationDe); + addCommonRealmSettingsLocalizationText("de-CH", realmLocalizationDeCh); + updateUserLocale("de"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(realmLocalizationDe); + }); + + it("should use REALM localization for language when region is requested and realm localization exists for fallback (en), language", () => { + addCommonRealmSettingsLocalizationText("en", realmLocalizationEn); + addCommonRealmSettingsLocalizationText("de", realmLocalizationDe); + updateUserLocale("de-CH"); + + goToUserFederationPage(); + + sidebarPage.checkRealmSettingsLinkContainsText(realmLocalizationDe); + }); + + it("should apply plurals and interpolation for THEME localization", () => { + updateUserLocale("en"); + + goToUserFederationPage(); + + // check key "user-federation:addProvider_other" + providersPage.assertCardContainsText("ldap", "Add Ldap providers"); + }); + + it("should apply plurals and interpolation for REALM localization", () => { + addLocalization( + "en", + "user-federation:addProvider_other", + "addProvider_other en: {{provider}}" + ); + updateUserLocale("en"); + + goToUserFederationPage(); + + providersPage.assertCardContainsText("ldap", "addProvider_other en: Ldap"); + }); + + function goToUserFederationPage() { + loginPage.logIn(usernameI18nTest, usernameI18nTest); + keycloakBefore(); + sidebarPage.goToUserFederation(); + } + + function updateUserLocale(locale: string) { + cy.wrap(null).then(() => + adminClient.updateUser(usernameI18nId, { attributes: { locale: locale } }) + ); + } + + function addCommonRealmSettingsLocalizationText( + locale: string, + value: string + ) { + addLocalization(locale, "common:realmSettings", value); + } + + function addLocalization(locale: string, key: string, value: string) { + cy.wrap(null).then(() => + adminClient.addLocalizationText(locale, key, value) + ); + } +}); diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/SidebarPage.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/SidebarPage.ts index 1b2d2e32aa..a45049d113 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/SidebarPage.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/SidebarPage.ts @@ -142,4 +142,8 @@ export default class SidebarPage extends CommonElements { cy.get('[role="progressbar"]').should("not.exist"); return this; } + + checkRealmSettingsLinkContainsText(expectedText: string) { + cy.get(this.realmSettingsBtn).should("contain", expectedText); + } } diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/providers/ProviderPage.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/providers/ProviderPage.ts index e4b9863004..7c98d8c7ad 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/providers/ProviderPage.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/manage/providers/ProviderPage.ts @@ -430,6 +430,11 @@ export default class ProviderPage { return this; } + assertCardContainsText(providerType: string, expectedText: string) { + cy.findByTestId(`${providerType}-card`).should("contain", expectedText); + return this; + } + disableEnabledSwitch(providerType: string) { cy.get(`#${providerType}-switch`).uncheck({ force: true }); return this; diff --git a/js/apps/admin-ui/cypress/support/util/AdminClient.ts b/js/apps/admin-ui/cypress/support/util/AdminClient.ts index 2c23ac0bb1..bde07dbef9 100644 --- a/js/apps/admin-ui/cypress/support/util/AdminClient.ts +++ b/js/apps/admin-ui/cypress/support/util/AdminClient.ts @@ -5,6 +5,7 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/ import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; +import type { RoleMappingPayload } from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; import { merge } from "lodash-es"; class AdminClient { @@ -41,6 +42,11 @@ class AdminClient { await this.client.realms.update({ realm }, payload); } + async getRealm(realm: string) { + await this.login(); + return await this.client.realms.findOne({ realm }); + } + async deleteRealm(realm: string) { await this.login(); await this.client.realms.del({ realm }); @@ -130,6 +136,17 @@ class AdminClient { await this.client.users.addToGroup({ id: user.id!, groupId }); } + async addRealmRoleToUser(userId: string, roleName: string) { + await this.login(); + + const realmRole = await this.client.roles.findOneByName({ name: roleName }); + + await this.client.users.addRealmRoleMappings({ + id: userId, + roles: [realmRole as RoleMappingPayload], + }); + } + async deleteUser(username: string) { await this.login(); const user = await this.client.users.find({ username }); @@ -260,6 +277,29 @@ class AdminClient { federatedIdentity: fedIdentity, }); } + + async addLocalizationText(locale: string, key: string, value: string) { + await this.login(); + await this.client.realms.addLocalization( + { realm: this.client.realmName, selectedLocale: locale, key: key }, + value + ); + } + + async removeAllLocalizationTexts() { + await this.login(); + const localesWithTexts = await this.client.realms.getRealmSpecificLocales({ + realm: this.client.realmName, + }); + await Promise.all( + localesWithTexts.map((locale) => + this.client.realms.deleteRealmLocalizationTexts({ + realm: this.client.realmName, + selectedLocale: locale, + }) + ) + ); + } } const adminClient = new AdminClient(); diff --git a/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx b/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx index 45995d51cd..6eedb790ee 100644 --- a/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx +++ b/js/apps/admin-ui/src/context/whoami/WhoAmI.tsx @@ -12,7 +12,9 @@ export class WhoAmI { constructor(private me?: WhoAmIRepresentation) { if (this.me?.locale) { i18n.changeLanguage(this.me.locale, (error) => { - if (error) console.error("Unable to set locale to", this.me?.locale); + if (error) { + console.warn("Error(s) loading locale", this.me?.locale, error); + } }); } } diff --git a/server-spi/src/main/java/org/keycloak/theme/Theme.java b/server-spi/src/main/java/org/keycloak/theme/Theme.java index c37b7e79df..cf2fe18dbf 100755 --- a/server-spi/src/main/java/org/keycloak/theme/Theme.java +++ b/server-spi/src/main/java/org/keycloak/theme/Theme.java @@ -17,6 +17,8 @@ package org.keycloak.theme; +import org.keycloak.models.RealmModel; + import java.io.IOException; import java.io.InputStream; import java.net.URL; @@ -63,6 +65,20 @@ public interface Theme { */ Properties getMessages(String baseBundlename, Locale locale) throws IOException; + /** + * Retrieve localized messages from a message bundle named "messages" and enhance those messages with messages from + * realm localization. + *

+ * In general, the translation for the most specific applicable language is used. If a translation exists both in the message bundle and realm localization, the realm localization translation is used. + *

+ * + * @param realm The realm from which the localization should be retrieved + * @param locale The locale of the desired message bundle. + * @return The localized messages from the bundle, enhanced with realm localization + * @throws IOException If bundle can not be read. + */ + Properties getEnhancedMessages(RealmModel realm, Locale locale) throws IOException; + Properties getProperties() throws IOException; } diff --git a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java index 47c47d1b15..b90b382110 100755 --- a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java +++ b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java @@ -47,7 +47,6 @@ import org.keycloak.theme.Theme; import org.keycloak.theme.beans.LinkExpirationFormatterMethod; import org.keycloak.theme.beans.MessageFormatterMethod; import org.keycloak.theme.freemarker.FreeMarkerProvider; -import org.keycloak.utils.StringUtil; /** * @author Stian Thorgersen @@ -212,20 +211,17 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider { Theme theme = getTheme(); Locale locale = session.getContext().resolveLocale(user); attributes.put("locale", locale); - KeycloakUriInfo uriInfo = session.getContext().getUri(); - Properties rb = new Properties(); - if(!StringUtil.isNotBlank(realm.getDefaultLocale())) - { - rb.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); - } - rb.putAll(theme.getMessages(locale)); - rb.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag())); - attributes.put("msg", new MessageFormatterMethod(locale, rb)); + + Properties messages = theme.getEnhancedMessages(realm, locale); + attributes.put("msg", new MessageFormatterMethod(locale, messages)); + attributes.put("properties", theme.getProperties()); attributes.put("realmName", getRealmName()); attributes.put("user", new ProfileBean(user)); + KeycloakUriInfo uriInfo = session.getContext().getUri(); attributes.put("url", new UrlBean(realm, theme, uriInfo.getBaseUri(), null)); - String subject = new MessageFormat(rb.getProperty(subjectKey, subjectKey), locale).format(subjectAttributes.toArray()); + + String subject = new MessageFormat(messages.getProperty(subjectKey, subjectKey), locale).format(subjectAttributes.toArray()); String textTemplate = String.format("text/%s", template); String textBody; try { diff --git a/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java b/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java index f119be2ecb..52fd229deb 100755 --- a/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java +++ b/services/src/main/java/org/keycloak/forms/account/freemarker/FreeMarkerAccountProvider.java @@ -48,7 +48,6 @@ import org.keycloak.theme.beans.MessageType; import org.keycloak.theme.beans.MessagesPerFieldBean; import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.utils.MediaType; -import org.keycloak.utils.StringUtil; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MultivaluedMap; @@ -217,14 +216,9 @@ public class FreeMarkerAccountProvider implements AccountProvider { * @return message bundle for other use */ protected Properties handleThemeResources(Theme theme, Locale locale, Map attributes) { - Properties messagesBundle = new Properties(); + Properties messagesBundle; try { - if(!StringUtil.isNotBlank(realm.getDefaultLocale())) - { - messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); - } - messagesBundle.putAll(theme.getMessages(locale)); - messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag())); + messagesBundle = theme.getEnhancedMessages(realm, locale); attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle)); } catch (IOException e) { logger.warn("Failed to load messages", e); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 013f730b25..5f91e4fd97 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -92,7 +92,6 @@ import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.utils.MediaType; -import org.keycloak.utils.StringUtil; /** * @author Stian Thorgersen @@ -379,14 +378,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { * @return message bundle for other use */ protected Properties handleThemeResources(Theme theme, Locale locale) { - Properties messagesBundle = new Properties(); + Properties messagesBundle; try { - if(!StringUtil.isNotBlank(realm.getDefaultLocale())) - { - messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); - } - messagesBundle.putAll(theme.getMessages(locale)); - messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag())); + messagesBundle = theme.getEnhancedMessages(realm, locale); attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle)); attributes.put("advancedMsg", new AdvancedMessageFormatterMethod(locale, messagesBundle)); } catch (IOException e) { @@ -441,8 +435,6 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { Locale locale = session.getContext().resolveLocale(user); Properties messagesBundle = handleThemeResources(theme, locale); - Map localizationTexts = realm.getRealmLocalizationTextsByLocale(locale.getCountry()); - messagesBundle.putAll(localizationTexts); FormMessage msg = new FormMessage(null, message); return formatMessage(msg, messagesBundle, locale); } diff --git a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java index 248c0d7c5d..20b86e4ea3 100644 --- a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java +++ b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java @@ -166,18 +166,19 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider { private Locale findLocale(RealmModel realm, String... localeStrings) { List supportedLocales = realm.getSupportedLocalesStream() .map(Locale::forLanguageTag).collect(Collectors.toList()); + + return findBestMatchingLocale(supportedLocales, localeStrings); + } + + static Locale findBestMatchingLocale(List supportedLocales, String... localeStrings) { for (String localeString : localeStrings) { if (localeString != null) { Locale result = null; Locale search = Locale.forLanguageTag(localeString); for (Locale supportedLocale : supportedLocales) { - if (supportedLocale.getLanguage().equals(search.getLanguage())) { - if (search.getCountry().equals("") ^ supportedLocale.getCountry().equals("") && result == null) { - result = supportedLocale; - } - if (supportedLocale.getCountry().equals(search.getCountry())) { - return supportedLocale; - } + if (doesLocaleMatch(search, supportedLocale) && (result == null + || doesFirstLocaleBetterMatchThanSecondLocale(supportedLocale, result, search))) { + result = supportedLocale; } } if (result != null) { @@ -188,6 +189,28 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider { return null; } + private static boolean doesLocaleMatch(Locale candidate, Locale supportedLocale) { + return candidate.getLanguage().equals(supportedLocale.getLanguage()) + && ((candidate.getCountry().equals("") ^ supportedLocale.getCountry().equals("")) + || candidate.getCountry().equals(supportedLocale.getCountry())); + } + + private static boolean doesFirstLocaleBetterMatchThanSecondLocale(Locale firstLocale, Locale secondLocale, + Locale supportedLocale) { + if (firstLocale.getLanguage().equals(supportedLocale.getLanguage()) + && !secondLocale.getLanguage().equals(supportedLocale.getLanguage())) { + return true; + } + + if (firstLocale.getCountry().equals(supportedLocale.getCountry()) + && !secondLocale.getCountry().equals(supportedLocale.getCountry())) { + return true; + } + + return firstLocale.getVariant().equals(supportedLocale.getVariant()) + && !secondLocale.getVariant().equals(supportedLocale.getVariant()); + } + @Override public void close() { } diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index 7e91719117..b5e0041b5a 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -45,7 +45,6 @@ import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.urls.UrlType; import org.keycloak.util.JsonSerialization; import org.keycloak.utils.MediaType; -import org.keycloak.utils.StringUtil; /** * Created by st on 29/03/17. @@ -111,12 +110,7 @@ public class AccountConsole { if (auth != null) user = auth.getUser(); Locale locale = session.getContext().resolveLocale(user); map.put("locale", locale.toLanguageTag()); - Properties messages = new Properties(); - messages.putAll(theme.getMessages(locale)); - if(StringUtil.isNotBlank(realm.getDefaultLocale())) { - messages.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); - } - messages.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag())); + Properties messages = theme.getEnhancedMessages(realm, locale); map.put("msg", new MessageFormatterMethod(locale, messages)); map.put("msgJSON", messagesToJsonString(messages)); map.put("supportedLocales", supportedLocales(messages)); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AdminMessageFormatter.java b/services/src/main/java/org/keycloak/services/resources/admin/AdminMessageFormatter.java index 3883eb27a8..a68177ee0a 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/AdminMessageFormatter.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/AdminMessageFormatter.java @@ -27,7 +27,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.theme.Theme; -import org.keycloak.utils.StringUtil; /** * Message formatter for Admin GUI/API messages. @@ -48,13 +47,8 @@ public class AdminMessageFormatter implements BiFunction getRealmLocalizationTexts(@PathParam("locale") String locale, @QueryParam("useRealmDefaultLocaleFallback") Boolean useFallback) { + public Map getRealmLocalizationTexts(@PathParam("locale") String locale, + @Deprecated @QueryParam("useRealmDefaultLocaleFallback") Boolean useFallback) { auth.requireAnyAdminRole(); - Map realmLocalizationTexts = new HashMap<>(); - if(useFallback != null && useFallback && StringUtil.isNotBlank(realm.getDefaultLocale())) { - realmLocalizationTexts.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); + // this fallback is no longer needed since the fix for #15845, don't forget to remove it from the API + if (useFallback != null && useFallback) { + Map realmLocalizationTexts = new HashMap<>(); + if (StringUtil.isNotBlank(realm.getDefaultLocale())) { + realmLocalizationTexts.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); + } + + realmLocalizationTexts.putAll(realm.getRealmLocalizationTextsByLocale(locale)); + + return realmLocalizationTexts; } - realmLocalizationTexts.putAll(realm.getRealmLocalizationTextsByLocale(locale)); - - return realmLocalizationTexts; - + return realm.getRealmLocalizationTextsByLocale(locale); } @Path("{locale}/{key}") diff --git a/services/src/main/java/org/keycloak/services/util/LocaleUtil.java b/services/src/main/java/org/keycloak/services/util/LocaleUtil.java index 4eaebed43a..7f541b5335 100644 --- a/services/src/main/java/org/keycloak/services/util/LocaleUtil.java +++ b/services/src/main/java/org/keycloak/services/util/LocaleUtil.java @@ -24,11 +24,24 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.sessions.AuthenticationSessionModel; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.ListIterator; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; + /** * @author Marek Posolda + * @author Daniel Fesenmeyer */ public class LocaleUtil { + private LocaleUtil() { + // noop + } + public static void processLocaleParam(KeycloakSession session, RealmModel realm, AuthenticationSessionModel authSession) { if (authSession != null && realm.isInternationalizationEnabled()) { String locale = session.getContext().getUri().getQueryParameters().getFirst(LocaleSelectorProvider.KC_LOCALE_PARAM); @@ -40,4 +53,162 @@ public class LocaleUtil { } } } + + /** + * Returns the parent locale of the given {@code locale}. If the locale just contains a language (e.g. "de"), + * returns the fallback locale "en". For "en" no parent exists, {@code null} is returned. + * + * @param locale the locale + * @return the parent locale, may be {@code null} + */ + public static Locale getParentLocale(Locale locale) { + if (locale.getVariant() != null && !locale.getVariant().isEmpty()) { + return new Locale(locale.getLanguage(), locale.getCountry()); + } + + if (locale.getCountry() != null && !locale.getCountry().isEmpty()) { + return new Locale(locale.getLanguage()); + } + + if (!Locale.ENGLISH.equals(locale)) { + return Locale.ENGLISH; + } + + return null; + } + + /** + * Gets the applicable locales for the given locale. + *

+ * Example: Locale "de-CH" has the applicable locales "de-CH", "de" and "en" (in exactly that order). + * + * @param locale the locale + * @return the applicable locales + */ + static List getApplicableLocales(Locale locale) { + List applicableLocales = new ArrayList<>(); + + for (Locale currentLocale = locale; currentLocale != null; currentLocale = getParentLocale(currentLocale)) { + applicableLocales.add(currentLocale); + } + + return applicableLocales; + } + + /** + * Merge the given (locale-)grouped messages into one instance of {@link Properties}, applicable for the given + * {@code locale}. + * + * @param locale the locale + * @param messages the (locale-)grouped messages + * @return the merged properties + * @see #mergeGroupedMessages(Locale, Map, Map) + */ + public static Properties mergeGroupedMessages(Locale locale, Map messages) { + return mergeGroupedMessages(locale, messages, null); + } + + /** + * Merge the given (locale-)grouped messages into one instance of {@link Properties}, applicable for the given + * {@code locale}. + *

+ * The priority of the messages is as follows (abbreviations: F = firstMessages, S = secondMessages): + *

    + *
  1. F <language-region-variant>
  2. + *
  3. S <language-region-variant>
  4. + *
  5. F <language-region>
  6. + *
  7. S <language-region>
  8. + *
  9. F <language>
  10. + *
  11. S <language>
  12. + *
  13. F en
  14. + *
  15. S en
  16. + *
+ *

+ * Example for the message priority for locale "de-CH-1996" (language "de", region "CH", variant "1996): + *

    + *
  1. F de-CH-1996
  2. + *
  3. S de-CH-1996
  4. + *
  5. F de-CH
  6. + *
  7. S de-CH
  8. + *
  9. F de
  10. + *
  11. S de
  12. + *
  13. F en
  14. + *
  15. S en
  16. + *
+ * + * @param locale the locale + * @param firstMessages the first (locale-)grouped messages, having higher priority (per locale) than + * {@code secondMessages} + * @param secondMessages may be {@code null}, the second (locale-)grouped messages, having lower priority (per + * locale) than {@code firstMessages} + * @return the merged properties + * @see #mergeGroupedMessages(Locale, Map) + */ + public static Properties mergeGroupedMessages(Locale locale, Map firstMessages, + Map secondMessages) { + List applicableLocales = getApplicableLocales(locale); + + Properties mergedProperties = new Properties(); + + /* + * iterate starting from the end of the list in order to add the least relevant messages first (in order to be + * overwritten by more relevant messages) + */ + ListIterator itr = applicableLocales.listIterator(applicableLocales.size()); + while (itr.hasPrevious()) { + Locale currentLocale = itr.previous(); + + // add secondMessages first, if specified (to be overwritten by firstMessages) + if (secondMessages != null) { + Properties currentLocaleSecondMessages = secondMessages.get(currentLocale); + if (currentLocaleSecondMessages != null) { + mergedProperties.putAll(currentLocaleSecondMessages); + } + } + + // add firstMessages, overwriting secondMessages (if specified) + Properties currentLocaleFirstMessages = firstMessages.get(currentLocale); + if (currentLocaleFirstMessages != null) { + mergedProperties.putAll(currentLocaleFirstMessages); + } + } + + return mergedProperties; + } + + /** + * Enhance the properties from a theme with realm localization texts. Realm localization texts take precedence over + * the theme properties, but only when defined for the same locale. In general, texts for a more specific locale + * take precedence over texts for a less specific locale. + *

+ * For implementation details, see {@link #mergeGroupedMessages(Locale, Map, Map)}. + * + * @param realm the realm from which the localization texts should be used + * @param locale the locale for which the relevant texts should be retrieved + * @param themeMessages the theme messages, which should be enhanced and maybe overwritten + * @return the enhanced properties + */ + public static Properties enhancePropertiesWithRealmLocalizationTexts(RealmModel realm, Locale locale, + Map themeMessages) { + Map realmLocalizationMessages = getRealmLocalizationTexts(realm, locale); + + return mergeGroupedMessages(locale, realmLocalizationMessages, themeMessages); + } + + private static Map getRealmLocalizationTexts(RealmModel realm, Locale locale) { + LinkedHashMap groupedMessages = new LinkedHashMap<>(); + + List applicableLocales = getApplicableLocales(locale); + for (Locale applicableLocale : applicableLocales) { + Map currentRealmLocalizationTexts = + realm.getRealmLocalizationTextsByLocale(applicableLocale.toLanguageTag()); + Properties currentMessages = new Properties(); + currentMessages.putAll(currentRealmLocalizationTexts); + + groupedMessages.put(applicableLocale, currentMessages); + } + + return groupedMessages; + } + } diff --git a/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java b/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java index 659b68a65c..0ad232de3c 100644 --- a/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java +++ b/services/src/main/java/org/keycloak/theme/ClassLoaderTheme.java @@ -17,13 +17,18 @@ package org.keycloak.theme; +import org.keycloak.models.RealmModel; +import org.keycloak.services.util.LocaleUtil; + import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.net.URL; import java.nio.charset.Charset; +import java.util.Collections; import java.util.Locale; +import java.util.Map; import java.util.Properties; /** @@ -148,6 +153,16 @@ public class ClassLoaderTheme implements Theme { return m; } + @Override + public Properties getEnhancedMessages(RealmModel realm, Locale locale) throws IOException { + if (locale == null){ + return null; + } + + Map localeMessages = Collections.singletonMap(locale, getMessages(locale)); + return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, localeMessages); + } + @Override public Properties getProperties() { return properties; diff --git a/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java b/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java index ef3b757f6c..0d4c5b7403 100755 --- a/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java +++ b/services/src/main/java/org/keycloak/theme/DefaultThemeManager.java @@ -18,26 +18,28 @@ package org.keycloak.theme; import org.jboss.logging.Logger; -import org.keycloak.Config; -import org.keycloak.common.Version; import org.keycloak.common.util.StringPropertyReplacer; import org.keycloak.common.util.SystemEnvProperties; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; import org.keycloak.models.ThemeManager; import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Locale; +import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.keycloak.common.Profile; +import org.keycloak.services.util.LocaleUtil; /** * @author Stian Thorgersen @@ -158,7 +160,8 @@ public class DefaultThemeManager implements ThemeManager { private Properties properties; - private ConcurrentHashMap> messages = new ConcurrentHashMap<>(); + private ConcurrentHashMap>> messages = + new ConcurrentHashMap<>(); public ExtendingTheme(List themes, Set themeResourceProviders) { this.themes = themes; @@ -230,31 +233,44 @@ public class DefaultThemeManager implements ThemeManager { @Override public Properties getMessages(String baseBundlename, Locale locale) throws IOException { - if (messages.get(baseBundlename) == null || messages.get(baseBundlename).get(locale) == null) { - Properties messages = new Properties(); + Map messagesByLocale = getMessagesByLocale(baseBundlename, locale); + return LocaleUtil.mergeGroupedMessages(locale, messagesByLocale); + } + + @Override + public Properties getEnhancedMessages(RealmModel realm, Locale locale) throws IOException { + Map messagesByLocale = getMessagesByLocale("messages", locale); + return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, messagesByLocale); + } + private Map getMessagesByLocale(String baseBundlename, Locale locale) throws IOException { + if (messages.get(baseBundlename) == null || messages.get(baseBundlename).get(locale) == null) { Locale parent = getParent(locale); - if (parent != null) { - messages.putAll(getMessages(baseBundlename, parent)); - } + Map parentMessages = + parent == null ? Collections.emptyMap() : getMessagesByLocale(baseBundlename, parent); - for (ThemeResourceProvider t : themeResourceProviders ){ - messages.putAll(t.getMessages(baseBundlename, locale)); + Properties currentMessages = new Properties(); + Map groupedMessages = new HashMap<>(parentMessages); + groupedMessages.put(locale, currentMessages); + + for (ThemeResourceProvider t : themeResourceProviders) { + currentMessages.putAll(t.getMessages(baseBundlename, locale)); } ListIterator itr = themes.listIterator(themes.size()); while (itr.hasPrevious()) { Properties m = itr.previous().getMessages(baseBundlename, locale); if (m != null) { - messages.putAll(m); + currentMessages.putAll(m); } } - - this.messages.putIfAbsent(baseBundlename, new ConcurrentHashMap()); - this.messages.get(baseBundlename).putIfAbsent(locale, messages); - return messages; + + this.messages.putIfAbsent(baseBundlename, new ConcurrentHashMap<>()); + this.messages.get(baseBundlename).putIfAbsent(locale, groupedMessages); + + return groupedMessages; } else { return messages.get(baseBundlename).get(locale); } @@ -291,19 +307,7 @@ public class DefaultThemeManager implements ThemeManager { } private static Locale getParent(Locale locale) { - if (Locale.ENGLISH.equals(locale)) { - return null; - } - - if (locale.getVariant() != null && !locale.getVariant().isEmpty()) { - return new Locale(locale.getLanguage(), locale.getCountry()); - } - - if (locale.getCountry() != null && !locale.getCountry().isEmpty()) { - return new Locale(locale.getLanguage()); - } - - return Locale.ENGLISH; + return LocaleUtil.getParentLocale(locale); } private List getProviders() { diff --git a/services/src/main/java/org/keycloak/theme/FolderTheme.java b/services/src/main/java/org/keycloak/theme/FolderTheme.java index 28a1c11201..9ff459fd3b 100644 --- a/services/src/main/java/org/keycloak/theme/FolderTheme.java +++ b/services/src/main/java/org/keycloak/theme/FolderTheme.java @@ -17,6 +17,9 @@ package org.keycloak.theme; +import org.keycloak.models.RealmModel; +import org.keycloak.services.util.LocaleUtil; + import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -25,7 +28,9 @@ import java.io.InputStreamReader; import java.io.Reader; import java.net.URL; import java.nio.charset.Charset; +import java.util.Collections; import java.util.Locale; +import java.util.Map; import java.util.Properties; /** @@ -107,7 +112,7 @@ public class FolderTheme implements Theme { @Override public Properties getMessages(String baseBundlename, Locale locale) throws IOException { - if(locale == null){ + if (locale == null){ return null; } @@ -123,6 +128,15 @@ public class FolderTheme implements Theme { return m; } + public Properties getEnhancedMessages(RealmModel realm, Locale locale) throws IOException { + if (locale == null){ + return null; + } + + Map localeMessages = Collections.singletonMap(locale, getMessages(locale)); + return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, localeMessages); + } + @Override public Properties getProperties() { return properties; diff --git a/services/src/test/java/org/keycloak/locale/DefaultLocaleSelectorProviderTest.java b/services/src/test/java/org/keycloak/locale/DefaultLocaleSelectorProviderTest.java new file mode 100644 index 0000000000..8a4c14ce30 --- /dev/null +++ b/services/src/test/java/org/keycloak/locale/DefaultLocaleSelectorProviderTest.java @@ -0,0 +1,81 @@ +package org.keycloak.locale; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Locale; + +public class DefaultLocaleSelectorProviderTest { + + private static final Locale LOCALE_DE_CH = Locale.forLanguageTag("de-CH"); + private static final Locale LOCALE_DE_CH_1996 = Locale.forLanguageTag("de-CH-1996"); + private static final Locale LOCALE_DE_AT = Locale.forLanguageTag("de-AT"); + + @Test + public void findBestMatchingLocaleReturnsExactLocaleInCaseOfExactMatch() { + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale(Arrays.asList(Locale.FRENCH, Locale.GERMAN), + "de-CH-1996", "de-CH", "de"), equalTo(Locale.GERMAN)); + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale(Arrays.asList(Locale.FRENCH, LOCALE_DE_CH), + "de-CH-1996", "de-CH", "de"), equalTo(LOCALE_DE_CH)); + assertThat( + DefaultLocaleSelectorProvider.findBestMatchingLocale(Arrays.asList(Locale.FRENCH, LOCALE_DE_CH_1996), + "de-CH-1996", "de-CH", "de"), + equalTo(LOCALE_DE_CH_1996)); + + + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale( + Arrays.asList(Locale.FRENCH, LOCALE_DE_CH_1996, LOCALE_DE_CH, Locale.GERMAN), + "de"), equalTo(Locale.GERMAN)); + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale( + Arrays.asList(Locale.FRENCH, LOCALE_DE_CH_1996, LOCALE_DE_CH, Locale.GERMAN), + "de-CH"), equalTo(LOCALE_DE_CH)); + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale( + Arrays.asList(Locale.FRENCH, LOCALE_DE_CH_1996, LOCALE_DE_CH, Locale.GERMAN), + "de-CH-1996"), equalTo(LOCALE_DE_CH_1996)); + } + + @Test + public void findBestMatchingLocaleForRegionReturnsLanguageWhenNoLocaleForRegionDefined() { + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale( + Arrays.asList(Locale.FRENCH, Locale.GERMAN), + "de-CH"), equalTo(Locale.GERMAN)); + } + + @Test + public void findBestMatchingLocaleForLanguageReturnsLanguageWhenLocalesForBothLanguageAndRegionDefined() { + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale( + Arrays.asList(Locale.FRENCH, LOCALE_DE_CH, Locale.GERMAN), + "de"), equalTo(Locale.GERMAN)); + } + + /* + * TODO: + * Unclear whether this is really expected behavior: when just a language is requested ("de"), and the language + * is not in the supported locales, but a language with region is specified ("de-CH"), the language with region + * will be used. The other option would be to return null which would fall back to english. + */ + @Test + public void findBestMatchingLocaleForLanguageReturnsRegionWhenNoLocaleForLanguageDefined() { + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale( + Arrays.asList(Locale.FRENCH, LOCALE_DE_CH), + "de"), equalTo(LOCALE_DE_CH)); + } + + @Test + public void findBestMatchingLocaleReturnsNullWhenNoMatchingLanguageIsFound() { + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale(Arrays.asList(Locale.FRENCH, Locale.GERMAN), + "cs"), nullValue()); + } + + @Test + public void findBestMatchingLocaleForRegionReturnsNullInCaseOfDifferentRegionsForSameLanguage() { + assertThat(DefaultLocaleSelectorProvider.findBestMatchingLocale( + Arrays.asList(Locale.FRENCH, LOCALE_DE_AT), + "de-CH"), nullValue()); + } + +} diff --git a/services/src/test/java/org/keycloak/services/util/LocaleUtilTest.java b/services/src/test/java/org/keycloak/services/util/LocaleUtilTest.java new file mode 100644 index 0000000000..a5eb8779d9 --- /dev/null +++ b/services/src/test/java/org/keycloak/services/util/LocaleUtilTest.java @@ -0,0 +1,208 @@ +package org.keycloak.services.util; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; + +/** + * @author Daniel Fesenmeyer + */ +public class LocaleUtilTest { + + private static final Locale LOCALE_DE_CH = Locale.forLanguageTag("de-CH"); + private static final Locale LOCALE_DE_CH_1996 = Locale.forLanguageTag("de-CH-1996"); + + @Test + public void getParentLocale() { + assertThat(LocaleUtil.getParentLocale(LOCALE_DE_CH_1996), equalTo(LOCALE_DE_CH)); + assertThat(LocaleUtil.getParentLocale(LOCALE_DE_CH), equalTo(Locale.GERMAN)); + assertThat(LocaleUtil.getParentLocale(Locale.GERMAN), equalTo(Locale.ENGLISH)); + + assertThat(LocaleUtil.getParentLocale(Locale.ENGLISH), nullValue()); + } + + @Test + public void getApplicableLocales() { + assertThat(LocaleUtil.getApplicableLocales(LOCALE_DE_CH_1996), + equalTo(Arrays.asList(LOCALE_DE_CH_1996, LOCALE_DE_CH, Locale.GERMAN, Locale.ENGLISH))); + assertThat(LocaleUtil.getApplicableLocales(LOCALE_DE_CH), + equalTo(Arrays.asList(LOCALE_DE_CH, Locale.GERMAN, Locale.ENGLISH))); + assertThat(LocaleUtil.getApplicableLocales(Locale.GERMAN), + equalTo(Arrays.asList(Locale.GERMAN, Locale.ENGLISH))); + + assertThat(LocaleUtil.getApplicableLocales(Locale.ENGLISH), equalTo(Collections.singletonList(Locale.ENGLISH))); + } + + @Test + public void mergeGroupedMessages() { + Map groupedMessages = new HashMap<>(); + + String keyDefinedEverywhere = "everywhere"; + String keyDefinedForRegionAndParents = "region-and-parents"; + String keyDefinedForLanguageAndParents = "language-and-parents"; + String keyDefinedForEnglishOnly = "english-only"; + + // add messages for an irrelevant locale, in order to check that such messages are not in the merged result + Properties irrelevantMessages = new Properties(); + addTestValue(irrelevantMessages, "french-only", Locale.FRENCH); + groupedMessages.put(Locale.FRENCH, irrelevantMessages); + + Properties variantMessages = new Properties(); + addTestValue(variantMessages, keyDefinedEverywhere, LOCALE_DE_CH_1996); + groupedMessages.put(LOCALE_DE_CH_1996, variantMessages); + + Properties regionMessages = new Properties(); + addTestValues(regionMessages, Arrays.asList(keyDefinedEverywhere, keyDefinedForRegionAndParents), LOCALE_DE_CH); + groupedMessages.put(LOCALE_DE_CH, regionMessages); + + Properties languageMessages = new Properties(); + addTestValues(languageMessages, Arrays.asList(keyDefinedEverywhere, keyDefinedForRegionAndParents, + keyDefinedForLanguageAndParents), Locale.GERMAN); + groupedMessages.put(Locale.GERMAN, languageMessages); + + Properties englishMessages = new Properties(); + addTestValues(englishMessages, Arrays.asList(keyDefinedEverywhere, keyDefinedForRegionAndParents, + keyDefinedForLanguageAndParents, keyDefinedForEnglishOnly), Locale.ENGLISH); + groupedMessages.put(Locale.ENGLISH, englishMessages); + + Properties mergedMessages = LocaleUtil.mergeGroupedMessages(LOCALE_DE_CH_1996, groupedMessages); + + Properties expectedMergedMessages = new Properties(); + addTestValue(expectedMergedMessages, keyDefinedEverywhere, LOCALE_DE_CH_1996); + addTestValue(expectedMergedMessages, keyDefinedForRegionAndParents, LOCALE_DE_CH); + addTestValue(expectedMergedMessages, keyDefinedForLanguageAndParents, Locale.GERMAN); + addTestValue(expectedMergedMessages, keyDefinedForEnglishOnly, Locale.ENGLISH); + + assertThat(mergedMessages, equalTo(expectedMergedMessages)); + } + + @Test + public void mergeGroupedMessagesFromTwoSources() { + // messages with priority 1 + Map groupedMessages1 = new HashMap<>(); + // messages with priority 2 + Map groupedMessages2 = new HashMap<>(); + + String messages1Prefix = "msg1"; + String messages2Prefix = "msg2"; + + String keyDefinedForVariantFromMessages1AndFallbacks = "variant1-and-fallbacks"; + String keyDefinedForVariantFromMessages2AndFallbacks = "variant2-and-fallbacks"; + String keyDefinedForRegionFromMessages1AndFallbacks = "region1-and-fallbacks"; + String keyDefinedForRegionFromMessages2AndFallbacks = "region2-and-fallbacks"; + String keyDefinedForLanguageFromMessages1AndFallbacks = "language1-and-fallbacks"; + String keyDefinedForLanguageFromMessages2AndFallbacks = "language2-and-fallbacks"; + String keyDefinedForEnglishFromMessages1AndFallback = "english1-and-fallback"; + String keyDefinedForEnglishFromMessages2only = "english2-only"; + + // add messages for an irrelevant locale, in order to check that such messages are not in the merged result + Properties irrelevantMessages = new Properties(); + addTestValue(irrelevantMessages, "french-only", Locale.FRENCH); + groupedMessages1.put(Locale.FRENCH, irrelevantMessages); + groupedMessages2.put(Locale.FRENCH, irrelevantMessages); + + Properties variant1Messages = new Properties(); + addTestValue(variant1Messages, keyDefinedForVariantFromMessages1AndFallbacks, LOCALE_DE_CH_1996, + messages1Prefix); + groupedMessages1.put(LOCALE_DE_CH_1996, variant1Messages); + + Properties variant2Messages = new Properties(); + addTestValues(variant2Messages, Arrays.asList(keyDefinedForVariantFromMessages1AndFallbacks, + keyDefinedForVariantFromMessages2AndFallbacks), LOCALE_DE_CH_1996, messages2Prefix); + groupedMessages2.put(LOCALE_DE_CH_1996, variant2Messages); + + Properties region1Messages = new Properties(); + addTestValues(region1Messages, Arrays.asList(keyDefinedForVariantFromMessages1AndFallbacks, + keyDefinedForVariantFromMessages2AndFallbacks, keyDefinedForRegionFromMessages1AndFallbacks), + LOCALE_DE_CH, messages1Prefix); + groupedMessages1.put(LOCALE_DE_CH, region1Messages); + + Properties region2Messages = new Properties(); + addTestValues(region2Messages, Arrays.asList(keyDefinedForVariantFromMessages1AndFallbacks, + keyDefinedForVariantFromMessages2AndFallbacks, keyDefinedForRegionFromMessages1AndFallbacks, + keyDefinedForRegionFromMessages2AndFallbacks), LOCALE_DE_CH, messages2Prefix); + groupedMessages2.put(LOCALE_DE_CH, region2Messages); + + Properties language1Messages = new Properties(); + addTestValues(language1Messages, Arrays.asList(keyDefinedForVariantFromMessages1AndFallbacks, + keyDefinedForVariantFromMessages2AndFallbacks, keyDefinedForRegionFromMessages1AndFallbacks, + keyDefinedForRegionFromMessages2AndFallbacks, keyDefinedForLanguageFromMessages1AndFallbacks), + Locale.GERMAN, messages1Prefix); + groupedMessages1.put(Locale.GERMAN, language1Messages); + + Properties language2Messages = new Properties(); + addTestValues(language2Messages, Arrays.asList(keyDefinedForVariantFromMessages1AndFallbacks, + keyDefinedForVariantFromMessages2AndFallbacks, keyDefinedForRegionFromMessages1AndFallbacks, + keyDefinedForRegionFromMessages2AndFallbacks, keyDefinedForLanguageFromMessages1AndFallbacks, + keyDefinedForLanguageFromMessages2AndFallbacks), Locale.GERMAN, messages2Prefix); + groupedMessages2.put(Locale.GERMAN, language2Messages); + + Properties english1Messages = new Properties(); + addTestValues(english1Messages, Arrays.asList(keyDefinedForVariantFromMessages1AndFallbacks, + keyDefinedForVariantFromMessages2AndFallbacks, keyDefinedForRegionFromMessages1AndFallbacks, + keyDefinedForRegionFromMessages2AndFallbacks, keyDefinedForLanguageFromMessages1AndFallbacks, + keyDefinedForLanguageFromMessages2AndFallbacks, keyDefinedForEnglishFromMessages1AndFallback), + Locale.ENGLISH, messages1Prefix); + groupedMessages1.put(Locale.ENGLISH, english1Messages); + + Properties english2Messages = new Properties(); + addTestValues(english2Messages, Arrays.asList(keyDefinedForVariantFromMessages1AndFallbacks, + keyDefinedForVariantFromMessages2AndFallbacks, keyDefinedForRegionFromMessages1AndFallbacks, + keyDefinedForRegionFromMessages2AndFallbacks, keyDefinedForLanguageFromMessages1AndFallbacks, + keyDefinedForLanguageFromMessages2AndFallbacks, keyDefinedForEnglishFromMessages1AndFallback, + keyDefinedForEnglishFromMessages2only), Locale.ENGLISH, messages2Prefix); + groupedMessages2.put(Locale.ENGLISH, english2Messages); + + Properties mergedMessages = + LocaleUtil.mergeGroupedMessages(LOCALE_DE_CH_1996, groupedMessages1, groupedMessages2); + + Properties expectedMergedMessages = new Properties(); + addTestValue(expectedMergedMessages, keyDefinedForVariantFromMessages1AndFallbacks, LOCALE_DE_CH_1996, + messages1Prefix); + addTestValue(expectedMergedMessages, keyDefinedForVariantFromMessages2AndFallbacks, LOCALE_DE_CH_1996, + messages2Prefix); + addTestValue(expectedMergedMessages, keyDefinedForRegionFromMessages1AndFallbacks, LOCALE_DE_CH, + messages1Prefix); + addTestValue(expectedMergedMessages, keyDefinedForRegionFromMessages2AndFallbacks, LOCALE_DE_CH, + messages2Prefix); + addTestValue(expectedMergedMessages, keyDefinedForLanguageFromMessages1AndFallbacks, Locale.GERMAN, + messages1Prefix); + addTestValue(expectedMergedMessages, keyDefinedForLanguageFromMessages2AndFallbacks, Locale.GERMAN, + messages2Prefix); + addTestValue(expectedMergedMessages, keyDefinedForEnglishFromMessages1AndFallback, Locale.ENGLISH, + messages1Prefix); + addTestValue(expectedMergedMessages, keyDefinedForEnglishFromMessages2only, Locale.ENGLISH, messages2Prefix); + + assertThat(mergedMessages, equalTo(expectedMergedMessages)); + } + + private static void addTestValues(Properties messages, List keys, Locale locale) { + keys.forEach(k -> addTestValue(messages, k, locale)); + } + + private static void addTestValue(Properties messages, String key, Locale locale) { + messages.put(key, createTestValue(key, locale, null)); + } + + private static void addTestValues(Properties messages, List keys, Locale locale, String prefix) { + keys.forEach(k -> addTestValue(messages, k, locale, prefix)); + } + + private static void addTestValue(Properties messages, String key, Locale locale, String prefix) { + messages.put(key, createTestValue(key, locale, prefix)); + } + + private static String createTestValue(String key, Locale locale, String prefix) { + return (prefix != null ? prefix + ":" : "") + locale.toLanguageTag() + ":" + key; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java index b0f758ccb5..f4d7044608 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java @@ -1813,99 +1813,13 @@ public class PermissionsTest extends AbstractKeycloakTest { @Test public void localizations() { - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmSpecificLocales(); - } - }, clients.get("view-realm"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmSpecificLocales(); - } - }, clients.get("manage-realm"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmSpecificLocales(); - } - }, clients.get("multi"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmSpecificLocales(); - } - }, clients.get("master-admin"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmSpecificLocales(); - } - }, clients.get("none"), false); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmSpecificLocales(); - } - }, clients.get("REALM2"), false); + verifyAnyAdminRoleReqired(realm -> realm.localization().getRealmSpecificLocales()); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationText("en", "test"); - } - }, clients.get("view-realm"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationText("en", "test"); - } - }, clients.get("manage-realm"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationText("en", "test"); - } - }, clients.get("multi"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationText("en", "test"); - } - }, clients.get("master-admin"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationText("en", "test"); - } - }, clients.get("none"), false); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationText("en", "test"); - } - }, clients.get("REALM2"), false); - - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationTexts("en", false); - } - }, clients.get("view-realm"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationTexts("en", false); - } - }, clients.get("manage-realm"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationTexts("en", false); - } - }, clients.get("multi"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationTexts("en", false); - } - }, clients.get("master-admin"), true); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationTexts("en", false); - } - }, clients.get("none"), false); - invoke(new Invocation() { - public void invoke(RealmResource realm) { - realm.localization().getRealmLocalizationTexts("en", false); - } - }, clients.get("REALM2"), false); + verifyAnyAdminRoleReqired(realm -> realm.localization().getRealmLocalizationText("en", "test")); + verifyAnyAdminRoleReqired(realm -> realm.localization().getRealmLocalizationTexts("en")); + verifyAnyAdminRoleReqired(realm -> realm.localization().getRealmLocalizationTexts("en", false)); + invoke(new Invocation() { public void invoke(RealmResource realm) { realm.localization().createOrUpdateRealmLocalizationTexts("en", Collections.emptyMap()); @@ -1985,6 +1899,15 @@ public class PermissionsTest extends AbstractKeycloakTest { }, clients.get("REALM2"), false); } + private void verifyAnyAdminRoleReqired(Invocation invocation) { + invoke(invocation, clients.get("view-realm"), true); + invoke(invocation, clients.get("manage-realm"), true); + invoke(invocation, clients.get("multi"), true); + invoke(invocation, clients.get("master-admin"), true); + invoke(invocation, clients.get("none"), false); + invoke(invocation, clients.get("REALM2"), false); + } + private void invoke(final Invocation invocation, Resource resource, boolean manage) { invoke(new InvocationWithResponse() { public void invoke(RealmResource realm, AtomicReference response) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/RealmLocalizationResourceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/RealmLocalizationResourceTest.java index 1b4ac25b2f..29981bb3d8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/RealmLocalizationResourceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/RealmLocalizationResourceTest.java @@ -64,7 +64,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest { @Test public void getRealmLocalizationTexts() { - Map localizations = resource.getRealmLocalizationTexts("en", false); + Map localizations = resource.getRealmLocalizationTexts("en"); assertNotNull(localizations); assertEquals(2, localizations.size()); @@ -84,7 +84,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest { @Test public void getRealmLocalizationsNotExists() { - Map localizations = resource.getRealmLocalizationTexts("zz", false); + Map localizations = resource.getRealmLocalizationTexts("zz"); assertNotNull(localizations); assertEquals(0, localizations.size()); } @@ -125,7 +125,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest { public void deleteRealmLocalizationText() { resource.deleteRealmLocalizationText("en", "key-a"); - Map localizations = resource.getRealmLocalizationTexts("en", false); + Map localizations = resource.getRealmLocalizationTexts("en"); assertEquals(1, localizations.size()); assertEquals("text-b_en", localizations.get("key-b")); } @@ -153,7 +153,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest { resource.createOrUpdateRealmLocalizationTexts("es", newLocalizationTexts); - final Map persistedLocalizationTexts = resource.getRealmLocalizationTexts("es", false); + final Map persistedLocalizationTexts = resource.getRealmLocalizationTexts("es"); assertEquals(newLocalizationTexts, persistedLocalizationTexts); } @@ -168,7 +168,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest { final Map expectedLocalizationTexts = new HashMap<>(); expectedLocalizationTexts.put("key-a", "text-a_en"); expectedLocalizationTexts.putAll(newLocalizationTexts); - final Map persistedLocalizationTexts = resource.getRealmLocalizationTexts("en", false); + final Map persistedLocalizationTexts = resource.getRealmLocalizationTexts("en"); assertEquals(expectedLocalizationTexts, persistedLocalizationTexts); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java index 993df9c487..8ed08ed2d7 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java @@ -16,14 +16,19 @@ */ package org.keycloak.testsuite.i18n; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import java.io.IOException; import java.util.Arrays; +import java.util.Locale; + import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Rule; @@ -31,7 +36,6 @@ import org.junit.Test; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.models.UserModel; import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.testsuite.ProfileAssume; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.pages.LoginPage; @@ -70,52 +74,80 @@ public class EmailTest extends AbstractI18NTest { } @Test - public void restPasswordEmail() throws IOException, MessagingException { - loginPage.open(); - loginPage.resetPassword(); - resetPasswordPage.changePassword("login-test"); - - Assert.assertEquals(1, greenMail.getReceivedMessages().length); - - MimeMessage message = greenMail.getReceivedMessages()[0]; - - Assert.assertEquals("Reset password", message.getSubject()); + public void restPasswordEmail() throws MessagingException, IOException { + String expectedBodyContent = "Someone just requested to change"; + verifyResetPassword("Reset password", expectedBodyContent, 1); changeUserLocale("en"); - loginPage.open(); - loginPage.resetPassword(); - - resetPasswordPage.changePassword("login-test"); - - Assert.assertEquals(2, greenMail.getReceivedMessages().length); - - message = greenMail.getReceivedMessages()[1]; - - Assert.assertEquals("Reset password", message.getSubject()); + verifyResetPassword("Reset password", expectedBodyContent, 2); } @Test - public void restPasswordEmailGerman() throws IOException, MessagingException { - changeUserLocale("de"); + public void realmLocalizationMessagesAreApplied() throws MessagingException, IOException { + String subjectMessageKey = "passwordResetSubject"; + String bodyMessageKey = "passwordResetBody"; + String placeholders = "{0} {1} {2}"; + String subjectEn = "Subject EN"; + String expectedBodyContentEn = "Body EN"; + String bodyMessageEn = expectedBodyContentEn + placeholders; + testRealm().localization().saveRealmLocalizationText(Locale.ENGLISH.toLanguageTag(), subjectMessageKey, subjectEn); + testRealm().localization().saveRealmLocalizationText(Locale.ENGLISH.toLanguageTag(), bodyMessageKey, bodyMessageEn); + getCleanup().addLocalization(Locale.ENGLISH.toLanguageTag()); + + String subjectDe = "Subject DE"; + String expectedBodyContentDe = "Body DE"; + String bodyMessageDe = expectedBodyContentDe + placeholders; + testRealm().localization().saveRealmLocalizationText(Locale.GERMAN.toLanguageTag(), subjectMessageKey, subjectDe); + testRealm().localization().saveRealmLocalizationText(Locale.GERMAN.toLanguageTag(), bodyMessageKey, bodyMessageDe); + getCleanup().addLocalization(Locale.GERMAN.toLanguageTag()); + + try { + verifyResetPassword(subjectEn, expectedBodyContentEn, 1); + + changeUserLocale("de"); + + verifyResetPassword(subjectDe, expectedBodyContentDe, 2); + } finally { + // Revert + changeUserLocale("en"); + } + } + + @Test + public void restPasswordEmailGerman() throws MessagingException, IOException { + changeUserLocale("de"); + try { + verifyResetPassword("Passwort zurücksetzen", "Es wurde eine Änderung", 1); + } finally { + // Revert + changeUserLocale("en"); + } + } + + private void verifyResetPassword(String expectedSubject, String expectedTextBodyContent, int expectedMsgCount) + throws MessagingException, IOException { loginPage.open(); loginPage.resetPassword(); resetPasswordPage.changePassword("login-test"); - Assert.assertEquals(1, greenMail.getReceivedMessages().length); + assertEquals(expectedMsgCount, greenMail.getReceivedMessages().length); - MimeMessage message = greenMail.getReceivedMessages()[0]; + MimeMessage message = greenMail.getReceivedMessages()[expectedMsgCount - 1]; - Assert.assertEquals("Passwort zurücksetzen", message.getSubject()); + assertEquals(expectedSubject, message.getSubject()); - // Revert - changeUserLocale("en"); + String textBody = MailUtils.getBody(message).getText(); + assertThat(textBody, containsString(expectedTextBodyContent)); + // make sure all placeholders have been replaced + assertThat(textBody, not(containsString("{"))); + assertThat(textBody, not(containsString("}"))); } //KEYCLOAK-7478 @Test - public void changeLocaleOnInfoPage() throws InterruptedException, IOException, MessagingException { + public void changeLocaleOnInfoPage() throws InterruptedException, IOException { UserResource testUser = ApiUtil.findUserByUsernameId(testRealm(), "login-test"); testUser.executeActionsEmail(Arrays.asList(UserModel.RequiredAction.UPDATE_PASSWORD.toString())); @@ -132,11 +164,11 @@ public class EmailTest extends AbstractI18NTest { WaitUtils.waitForPageToLoad(); Assert.assertTrue("Expected to be on InfoPage, but it was on " + DroneUtils.getCurrentDriver().getTitle(), infoPage.isCurrent()); - Assert.assertThat(infoPage.getLanguageDropdownText(), is(equalTo("English"))); + assertThat(infoPage.getLanguageDropdownText(), is(equalTo("English"))); infoPage.openLanguage("Deutsch"); - Assert.assertThat(DroneUtils.getCurrentDriver().getPageSource(), containsString("Passwort aktualisieren")); + assertThat(DroneUtils.getCurrentDriver().getPageSource(), containsString("Passwort aktualisieren")); infoPage.clickToContinueDe(); @@ -145,6 +177,6 @@ public class EmailTest extends AbstractI18NTest { WaitUtils.waitForPageToLoad(); Assert.assertTrue("Expected to be on InfoPage, but it was on " + DroneUtils.getCurrentDriver().getTitle(), infoPage.isCurrent()); - Assert.assertThat(infoPage.getInfo(), containsString("Your account has been updated.")); + assertThat(infoPage.getInfo(), containsString("Your account has been updated.")); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java index e43f8cf7ba..f50780cfda 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java @@ -21,7 +21,6 @@ import java.util.Arrays; import java.util.Locale; import org.apache.http.impl.client.CloseableHttpClient; -import org.hamcrest.Matchers; import org.jboss.resteasy.client.jaxrs.ResteasyClient; import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; import org.jboss.resteasy.client.jaxrs.engines.ApacheHttpClient43Engine; @@ -41,13 +40,15 @@ import org.keycloak.testsuite.pages.LoginPage; import jakarta.ws.rs.core.Response; import org.jboss.arquillian.graphene.page.Page; -import org.keycloak.testsuite.ProfileAssume; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; import org.keycloak.testsuite.pages.OAuthGrantPage; import org.keycloak.testsuite.util.IdentityProviderBuilder; import org.openqa.selenium.Cookie; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; /** * @author Michael Gerber @@ -89,7 +90,7 @@ public class LoginPageTest extends AbstractI18NTest { @Test public void languageDropdown() { loginPage.open(); - Assert.assertEquals("English", loginPage.getLanguageDropdownText()); + assertEquals("English", loginPage.getLanguageDropdownText()); switchLanguageToGermanAndBack("Username or email", "Benutzername oder E-Mail", loginPage); } @@ -97,26 +98,26 @@ public class LoginPageTest extends AbstractI18NTest { @Test public void uiLocalesParameter() { loginPage.open(); - Assert.assertEquals("English", loginPage.getLanguageDropdownText()); + assertEquals("English", loginPage.getLanguageDropdownText()); //test if cookie works oauth.uiLocales("de"); loginPage.open(); - Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); + assertEquals("Deutsch", loginPage.getLanguageDropdownText()); driver.manage().deleteAllCookies(); loginPage.open(); - Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); + assertEquals("Deutsch", loginPage.getLanguageDropdownText()); oauth.uiLocales("en de"); driver.manage().deleteAllCookies(); loginPage.open(); - Assert.assertEquals("English", loginPage.getLanguageDropdownText()); + assertEquals("English", loginPage.getLanguageDropdownText()); oauth.uiLocales("fr de"); driver.manage().deleteAllCookies(); loginPage.open(); - Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); + assertEquals("Deutsch", loginPage.getLanguageDropdownText()); } @Test @@ -142,10 +143,9 @@ public class LoginPageTest extends AbstractI18NTest { @Test public void testIdentityProviderCapitalization(){ loginPage.open(); - Assert.assertEquals("GitHub", loginPage.findSocialButton("github").getText()); - Assert.assertEquals("mysaml", loginPage.findSocialButton("mysaml").getText()); - Assert.assertEquals("MyOIDC", loginPage.findSocialButton("myoidc").getText()); - + assertEquals("GitHub", loginPage.findSocialButton("github").getText()); + assertEquals("mysaml", loginPage.findSocialButton("mysaml").getText()); + assertEquals("MyOIDC", loginPage.findSocialButton("myoidc").getText()); } @@ -161,7 +161,7 @@ public class LoginPageTest extends AbstractI18NTest { loginPage.login("test-user@localhost", "password"); changePasswordPage.assertCurrent(); - Assert.assertEquals("English", changePasswordPage.getLanguageDropdownText()); + assertEquals("English", changePasswordPage.getLanguageDropdownText()); // Switch language switchLanguageToGermanAndBack("Update password", "Passwort aktualisieren", changePasswordPage); @@ -169,7 +169,7 @@ public class LoginPageTest extends AbstractI18NTest { // Update password changePasswordPage.changePassword("password", "password"); - Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); } @@ -185,7 +185,7 @@ public class LoginPageTest extends AbstractI18NTest { loginPage.login("test-user@localhost", "password"); grantPage.assertCurrent(); - Assert.assertEquals("English", grantPage.getLanguageDropdownText()); + assertEquals("English", grantPage.getLanguageDropdownText()); // Switch language switchLanguageToGermanAndBack("Do you grant these access privileges?", "Wollen Sie diese Zugriffsrechte", changePasswordPage); @@ -193,7 +193,7 @@ public class LoginPageTest extends AbstractI18NTest { // Confirm grant grantPage.accept(); - Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); + assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); // Revert client @@ -205,16 +205,16 @@ public class LoginPageTest extends AbstractI18NTest { loginPage.open(); loginPage.openLanguage("Deutsch"); - Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); + assertEquals("Deutsch", loginPage.getLanguageDropdownText()); Cookie localeCookie = driver.manage().getCookieNamed(LocaleSelectorProvider.LOCALE_COOKIE); - Assert.assertEquals("de", localeCookie.getValue()); + assertEquals("de", localeCookie.getValue()); loginPage.login("test-user@localhost", "password"); UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost"); UserRepresentation userRep = user.toRepresentation(); - Assert.assertEquals("de", userRep.getAttributes().get("locale").get(0)); + assertEquals("de", userRep.getAttributes().get("locale").get(0)); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); String idTokenHint = oauth.doAccessTokenRequest(code, "password").getIdToken(); @@ -222,7 +222,7 @@ public class LoginPageTest extends AbstractI18NTest { loginPage.open(); - Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); + assertEquals("Deutsch", loginPage.getLanguageDropdownText()); userRep.getAttributes().remove("locale"); user.update(userRep); @@ -245,59 +245,56 @@ public class LoginPageTest extends AbstractI18NTest { Assert.assertNull(localeCookie); } + @Test + public void realmLocalizationMessagesAreApplied() { + String realmLocalizationMessageKey = "loginAccountTitle"; + + String realmLocalizationMessageValueEn = "Localization Test EN"; + saveLocalizationText(Locale.ENGLISH.toLanguageTag(), realmLocalizationMessageKey, + realmLocalizationMessageValueEn); + String realmLocalizationMessageValueDe = "Localization Test DE"; + saveLocalizationText(Locale.GERMAN.toLanguageTag(), realmLocalizationMessageKey, + realmLocalizationMessageValueDe); + + loginPage.open(); + switchLanguageToGermanAndBack(realmLocalizationMessageValueEn, realmLocalizationMessageValueDe, loginPage); + } + // KEYCLOAK-18590 @Test - public void realmLocalizationMessagesAreNotCachedWithinTheTheme() throws IOException { + public void realmLocalizationMessagesAreNotCachedWithinTheTheme() { final String locale = Locale.ENGLISH.toLanguageTag(); final String realmLocalizationMessageKey = "loginAccountTitle"; final String realmLocalizationMessageValue = "Localization Test"; + + saveLocalizationText(locale, realmLocalizationMessageKey, realmLocalizationMessageValue); + loginPage.open(); + assertThat(driver.getPageSource(), containsString(realmLocalizationMessageValue)); - try(CloseableHttpClient httpClient = (CloseableHttpClient) new HttpClientBuilder().build()) { - ApacheHttpClient43Engine engine = new ApacheHttpClient43Engine(httpClient); + testRealm().localization().deleteRealmLocalizationText(locale, realmLocalizationMessageKey); + loginPage.open(); + assertThat(driver.getPageSource(), not(containsString(realmLocalizationMessageValue))); + } - testRealm().localization().saveRealmLocalizationText(locale, realmLocalizationMessageKey, - realmLocalizationMessageValue); - - ResteasyClient client = ((ResteasyClientBuilder) ResteasyClientBuilder.newBuilder()).httpEngine(engine).build(); - - loginPage.open(); - - try(Response responseWithLocalization = - client.target(driver.getCurrentUrl()).request().acceptLanguage(locale).get()) { - - assertThat(responseWithLocalization.readEntity(String.class), - Matchers.containsString(realmLocalizationMessageValue)); - - testRealm().localization().deleteRealmLocalizationText(locale, realmLocalizationMessageKey); - - loginPage.open(); - - try(Response responseWithoutLocalization = - client.target(driver.getCurrentUrl()).request().acceptLanguage(locale).get()) { - - assertThat(responseWithoutLocalization.readEntity(String.class), - Matchers.not(Matchers.containsString(realmLocalizationMessageValue))); - } - } - - client.close(); - } + private void saveLocalizationText(String locale, String key, String value) { + testRealm().localization().saveRealmLocalizationText(locale, key, value); + getCleanup().addLocalization(locale); } private void switchLanguageToGermanAndBack(String expectedEnglishMessage, String expectedGermanMessage, LanguageComboboxAwarePage page) { // Switch language to Deutsch page.openLanguage("Deutsch"); - Assert.assertEquals("Deutsch", page.getLanguageDropdownText()); + assertEquals("Deutsch", page.getLanguageDropdownText()); String pageSource = driver.getPageSource(); - Assert.assertFalse(pageSource.contains(expectedEnglishMessage)); - Assert.assertTrue(pageSource.contains(expectedGermanMessage)); + assertThat(pageSource, not(containsString(expectedEnglishMessage))); + assertThat(pageSource, containsString(expectedGermanMessage)); // Revert language page.openLanguage("English"); - Assert.assertEquals("English", page.getLanguageDropdownText()); + assertEquals("English", page.getLanguageDropdownText()); pageSource = driver.getPageSource(); - Assert.assertTrue(pageSource.contains(expectedEnglishMessage)); - Assert.assertFalse(pageSource.contains(expectedGermanMessage)); + assertThat(pageSource, containsString(expectedEnglishMessage)); + assertThat(pageSource, not(containsString(expectedGermanMessage))); } } diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/InternationalizationTest.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/InternationalizationTest.java index cc9ee6eb94..7d6d621116 100644 --- a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/InternationalizationTest.java +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/InternationalizationTest.java @@ -85,6 +85,37 @@ public class InternationalizationTest extends AbstractAccountTest { assertCustomLocaleWelcomeScreen(); } + @Test + public void realmLocalizationMessagesAreApplied() { + String messageKey = "personalInfoHtmlTitle"; + + String localeEn = Locale.ENGLISH.toLanguageTag(); + String messageEn = "personalInfoHtmlTitle EN"; + testRealmResource().localization().saveRealmLocalizationText(localeEn, + messageKey, messageEn); + getCleanup().addLocalization(localeEn); + + String localeDe = Locale.GERMAN.toLanguageTag(); + String messageDe = "personalInfoHtmlTitle DE"; + testRealmResource().localization().saveRealmLocalizationText(localeDe, + messageKey, messageDe); + getCleanup().addLocalization(localeDe); + + // default locale should be "en" + personalInfoPage.navigateTo(); + loginToAccount(); + assertTestUserLocale(null); + assertPersonalInfo(messageEn); + + // switch to locale "de" + personalInfoPage.selectLocale(localeDe); + personalInfoPage.clickSave(false); + WaitUtils.waitForPageToLoad(); + + assertTestUserLocale(localeDe); + assertPersonalInfo(messageDe); + } + @Test @Ignore public void loginFormTest() { @@ -141,11 +172,11 @@ public class InternationalizationTest extends AbstractAccountTest { } private void assertCustomLocalePersonalInfo() { - assertEquals("Osobní údaje", personalInfoPage.getPageTitle()); + assertPersonalInfo("Osobní údaje"); } - private void assertCustomLocaleLoginPage() { - assertEquals(CUSTOM_LOCALE_NAME, loginPage.localeDropdown().getSelected()); + private void assertPersonalInfo(String expectedText) { + assertEquals(expectedText, personalInfoPage.getPageTitle()); } private void assertTestUserLocale(String expectedLocale) {