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 <variant> > T <variant> > RL <region> > T <region> > RL <language> > T <language> > 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
This commit is contained in:
danielFesenmeyer 2022-11-25 22:11:15 +01:00 committed by Marek Posolda
parent 74dd370906
commit d543ba5b56
26 changed files with 1030 additions and 276 deletions

View file

@ -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. 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. 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 <variant> > T <variant> > RL <region> > T <region> > RL <language> > T <language> > 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.

View file

@ -37,10 +37,35 @@ public interface RealmLocalizationResource {
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
List<String> getRealmSpecificLocales(); List<String> getRealmSpecificLocales();
/**
* Get the localization texts for the given locale.
*
* @param locale the locale
* @return the localization texts
*/
@Path("{locale}") @Path("{locale}")
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
Map<String, String> getRealmLocalizationTexts(final @PathParam("locale") String locale, @QueryParam("useRealmDefaultLocaleFallback") Boolean useRealmDefaultLocaleFallback); Map<String, String> 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<String, String> getRealmLocalizationTexts(final @PathParam("locale") String locale,
@QueryParam("useRealmDefaultLocaleFallback") Boolean useRealmDefaultLocaleFallback);
@Path("{locale}/{key}") @Path("{locale}/{key}")

View file

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

View file

@ -142,4 +142,8 @@ export default class SidebarPage extends CommonElements {
cy.get('[role="progressbar"]').should("not.exist"); cy.get('[role="progressbar"]').should("not.exist");
return this; return this;
} }
checkRealmSettingsLinkContainsText(expectedText: string) {
cy.get(this.realmSettingsBtn).should("contain", expectedText);
}
} }

View file

@ -430,6 +430,11 @@ export default class ProviderPage {
return this; return this;
} }
assertCardContainsText(providerType: string, expectedText: string) {
cy.findByTestId(`${providerType}-card`).should("contain", expectedText);
return this;
}
disableEnabledSwitch(providerType: string) { disableEnabledSwitch(providerType: string) {
cy.get(`#${providerType}-switch`).uncheck({ force: true }); cy.get(`#${providerType}-switch`).uncheck({ force: true });
return this; return this;

View file

@ -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 RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig"; import type UserProfileConfig from "@keycloak/keycloak-admin-client/lib/defs/userProfileConfig";
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; 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"; import { merge } from "lodash-es";
class AdminClient { class AdminClient {
@ -41,6 +42,11 @@ class AdminClient {
await this.client.realms.update({ realm }, payload); 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) { async deleteRealm(realm: string) {
await this.login(); await this.login();
await this.client.realms.del({ realm }); await this.client.realms.del({ realm });
@ -130,6 +136,17 @@ class AdminClient {
await this.client.users.addToGroup({ id: user.id!, groupId }); 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) { async deleteUser(username: string) {
await this.login(); await this.login();
const user = await this.client.users.find({ username }); const user = await this.client.users.find({ username });
@ -260,6 +277,29 @@ class AdminClient {
federatedIdentity: fedIdentity, 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(); const adminClient = new AdminClient();

View file

@ -12,7 +12,9 @@ export class WhoAmI {
constructor(private me?: WhoAmIRepresentation) { constructor(private me?: WhoAmIRepresentation) {
if (this.me?.locale) { if (this.me?.locale) {
i18n.changeLanguage(this.me.locale, (error) => { 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);
}
}); });
} }
} }

View file

@ -17,6 +17,8 @@
package org.keycloak.theme; package org.keycloak.theme;
import org.keycloak.models.RealmModel;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URL; import java.net.URL;
@ -63,6 +65,20 @@ public interface Theme {
*/ */
Properties getMessages(String baseBundlename, Locale locale) throws IOException; 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.
* <p>
* 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.
* </p>
*
* @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; Properties getProperties() throws IOException;
} }

View file

@ -47,7 +47,6 @@ import org.keycloak.theme.Theme;
import org.keycloak.theme.beans.LinkExpirationFormatterMethod; import org.keycloak.theme.beans.LinkExpirationFormatterMethod;
import org.keycloak.theme.beans.MessageFormatterMethod; import org.keycloak.theme.beans.MessageFormatterMethod;
import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.utils.StringUtil;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -212,20 +211,17 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
Theme theme = getTheme(); Theme theme = getTheme();
Locale locale = session.getContext().resolveLocale(user); Locale locale = session.getContext().resolveLocale(user);
attributes.put("locale", locale); attributes.put("locale", locale);
KeycloakUriInfo uriInfo = session.getContext().getUri();
Properties rb = new Properties(); Properties messages = theme.getEnhancedMessages(realm, locale);
if(!StringUtil.isNotBlank(realm.getDefaultLocale())) attributes.put("msg", new MessageFormatterMethod(locale, messages));
{
rb.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale()));
}
rb.putAll(theme.getMessages(locale));
rb.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag()));
attributes.put("msg", new MessageFormatterMethod(locale, rb));
attributes.put("properties", theme.getProperties()); attributes.put("properties", theme.getProperties());
attributes.put("realmName", getRealmName()); attributes.put("realmName", getRealmName());
attributes.put("user", new ProfileBean(user)); attributes.put("user", new ProfileBean(user));
KeycloakUriInfo uriInfo = session.getContext().getUri();
attributes.put("url", new UrlBean(realm, theme, uriInfo.getBaseUri(), null)); 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 textTemplate = String.format("text/%s", template);
String textBody; String textBody;
try { try {

View file

@ -48,7 +48,6 @@ import org.keycloak.theme.beans.MessageType;
import org.keycloak.theme.beans.MessagesPerFieldBean; import org.keycloak.theme.beans.MessagesPerFieldBean;
import org.keycloak.theme.freemarker.FreeMarkerProvider; import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.utils.MediaType; import org.keycloak.utils.MediaType;
import org.keycloak.utils.StringUtil;
import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
@ -217,14 +216,9 @@ public class FreeMarkerAccountProvider implements AccountProvider {
* @return message bundle for other use * @return message bundle for other use
*/ */
protected Properties handleThemeResources(Theme theme, Locale locale, Map<String, Object> attributes) { protected Properties handleThemeResources(Theme theme, Locale locale, Map<String, Object> attributes) {
Properties messagesBundle = new Properties(); Properties messagesBundle;
try { try {
if(!StringUtil.isNotBlank(realm.getDefaultLocale())) messagesBundle = theme.getEnhancedMessages(realm, locale);
{
messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale()));
}
messagesBundle.putAll(theme.getMessages(locale));
messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag()));
attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle)); attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle));
} catch (IOException e) { } catch (IOException e) {
logger.warn("Failed to load messages", e); logger.warn("Failed to load messages", e);

View file

@ -92,7 +92,6 @@ import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.userprofile.UserProfileContext; import org.keycloak.userprofile.UserProfileContext;
import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.userprofile.UserProfileProvider;
import org.keycloak.utils.MediaType; import org.keycloak.utils.MediaType;
import org.keycloak.utils.StringUtil;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -379,14 +378,9 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
* @return message bundle for other use * @return message bundle for other use
*/ */
protected Properties handleThemeResources(Theme theme, Locale locale) { protected Properties handleThemeResources(Theme theme, Locale locale) {
Properties messagesBundle = new Properties(); Properties messagesBundle;
try { try {
if(!StringUtil.isNotBlank(realm.getDefaultLocale())) messagesBundle = theme.getEnhancedMessages(realm, locale);
{
messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale()));
}
messagesBundle.putAll(theme.getMessages(locale));
messagesBundle.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag()));
attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle)); attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle));
attributes.put("advancedMsg", new AdvancedMessageFormatterMethod(locale, messagesBundle)); attributes.put("advancedMsg", new AdvancedMessageFormatterMethod(locale, messagesBundle));
} catch (IOException e) { } catch (IOException e) {
@ -441,8 +435,6 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
Locale locale = session.getContext().resolveLocale(user); Locale locale = session.getContext().resolveLocale(user);
Properties messagesBundle = handleThemeResources(theme, locale); Properties messagesBundle = handleThemeResources(theme, locale);
Map<String, String> localizationTexts = realm.getRealmLocalizationTextsByLocale(locale.getCountry());
messagesBundle.putAll(localizationTexts);
FormMessage msg = new FormMessage(null, message); FormMessage msg = new FormMessage(null, message);
return formatMessage(msg, messagesBundle, locale); return formatMessage(msg, messagesBundle, locale);
} }

View file

@ -166,18 +166,19 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
private Locale findLocale(RealmModel realm, String... localeStrings) { private Locale findLocale(RealmModel realm, String... localeStrings) {
List<Locale> supportedLocales = realm.getSupportedLocalesStream() List<Locale> supportedLocales = realm.getSupportedLocalesStream()
.map(Locale::forLanguageTag).collect(Collectors.toList()); .map(Locale::forLanguageTag).collect(Collectors.toList());
return findBestMatchingLocale(supportedLocales, localeStrings);
}
static Locale findBestMatchingLocale(List<Locale> supportedLocales, String... localeStrings) {
for (String localeString : localeStrings) { for (String localeString : localeStrings) {
if (localeString != null) { if (localeString != null) {
Locale result = null; Locale result = null;
Locale search = Locale.forLanguageTag(localeString); Locale search = Locale.forLanguageTag(localeString);
for (Locale supportedLocale : supportedLocales) { for (Locale supportedLocale : supportedLocales) {
if (supportedLocale.getLanguage().equals(search.getLanguage())) { if (doesLocaleMatch(search, supportedLocale) && (result == null
if (search.getCountry().equals("") ^ supportedLocale.getCountry().equals("") && result == null) { || doesFirstLocaleBetterMatchThanSecondLocale(supportedLocale, result, search))) {
result = supportedLocale; result = supportedLocale;
}
if (supportedLocale.getCountry().equals(search.getCountry())) {
return supportedLocale;
}
} }
} }
if (result != null) { if (result != null) {
@ -188,6 +189,28 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
return null; 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 @Override
public void close() { public void close() {
} }

View file

@ -45,7 +45,6 @@ import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.urls.UrlType; import org.keycloak.urls.UrlType;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType; import org.keycloak.utils.MediaType;
import org.keycloak.utils.StringUtil;
/** /**
* Created by st on 29/03/17. * Created by st on 29/03/17.
@ -111,12 +110,7 @@ public class AccountConsole {
if (auth != null) user = auth.getUser(); if (auth != null) user = auth.getUser();
Locale locale = session.getContext().resolveLocale(user); Locale locale = session.getContext().resolveLocale(user);
map.put("locale", locale.toLanguageTag()); map.put("locale", locale.toLanguageTag());
Properties messages = new Properties(); Properties messages = theme.getEnhancedMessages(realm, locale);
messages.putAll(theme.getMessages(locale));
if(StringUtil.isNotBlank(realm.getDefaultLocale())) {
messages.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale()));
}
messages.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag()));
map.put("msg", new MessageFormatterMethod(locale, messages)); map.put("msg", new MessageFormatterMethod(locale, messages));
map.put("msgJSON", messagesToJsonString(messages)); map.put("msgJSON", messagesToJsonString(messages));
map.put("supportedLocales", supportedLocales(messages)); map.put("supportedLocales", supportedLocales(messages));

View file

@ -27,7 +27,6 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.theme.Theme; import org.keycloak.theme.Theme;
import org.keycloak.utils.StringUtil;
/** /**
* Message formatter for Admin GUI/API messages. * Message formatter for Admin GUI/API messages.
@ -48,13 +47,8 @@ public class AdminMessageFormatter implements BiFunction<String, Object[], Strin
try { try {
KeycloakContext context = session.getContext(); KeycloakContext context = session.getContext();
locale = context.resolveLocale(user); locale = context.resolveLocale(user);
messages = new Properties();
messages.putAll(getTheme(session).getMessages(locale));
RealmModel realm = context.getRealm(); RealmModel realm = context.getRealm();
if(StringUtil.isNotBlank(realm.getDefaultLocale())) { messages = getTheme(session).getEnhancedMessages(realm, locale);
messages.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale()));
}
messages.putAll(realm.getRealmLocalizationTextsByLocale(locale.toLanguageTag()));
} catch (IOException cause) { } catch (IOException cause) {
throw new RuntimeException("Failed to configure error messages", cause); throw new RuntimeException("Failed to configure error messages", cause);
} }

View file

@ -136,18 +136,23 @@ public class RealmLocalizationResource {
@Path("{locale}") @Path("{locale}")
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Map<String, String> getRealmLocalizationTexts(@PathParam("locale") String locale, @QueryParam("useRealmDefaultLocaleFallback") Boolean useFallback) { public Map<String, String> getRealmLocalizationTexts(@PathParam("locale") String locale,
@Deprecated @QueryParam("useRealmDefaultLocaleFallback") Boolean useFallback) {
auth.requireAnyAdminRole(); auth.requireAnyAdminRole();
Map<String, String> realmLocalizationTexts = new HashMap<>(); // this fallback is no longer needed since the fix for #15845, don't forget to remove it from the API
if(useFallback != null && useFallback && StringUtil.isNotBlank(realm.getDefaultLocale())) { if (useFallback != null && useFallback) {
realmLocalizationTexts.putAll(realm.getRealmLocalizationTextsByLocale(realm.getDefaultLocale())); Map<String, String> 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 realm.getRealmLocalizationTextsByLocale(locale);
return realmLocalizationTexts;
} }
@Path("{locale}/{key}") @Path("{locale}/{key}")

View file

@ -24,11 +24,24 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.sessions.AuthenticationSessionModel; 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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
* @author <a href="mailto:daniel.fesenmeyer@bosch.com">Daniel Fesenmeyer</a>
*/ */
public class LocaleUtil { public class LocaleUtil {
private LocaleUtil() {
// noop
}
public static void processLocaleParam(KeycloakSession session, RealmModel realm, AuthenticationSessionModel authSession) { public static void processLocaleParam(KeycloakSession session, RealmModel realm, AuthenticationSessionModel authSession) {
if (authSession != null && realm.isInternationalizationEnabled()) { if (authSession != null && realm.isInternationalizationEnabled()) {
String locale = session.getContext().getUri().getQueryParameters().getFirst(LocaleSelectorProvider.KC_LOCALE_PARAM); 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.
* <p>
* 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<Locale> getApplicableLocales(Locale locale) {
List<Locale> 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<Locale, Properties> messages) {
return mergeGroupedMessages(locale, messages, null);
}
/**
* Merge the given (locale-)grouped messages into one instance of {@link Properties}, applicable for the given
* {@code locale}.
* <p>
* The priority of the messages is as follows (abbreviations: F = firstMessages, S = secondMessages):
* <ol>
* <li>F &lt;language-region-variant&gt;</li>
* <li>S &lt;language-region-variant&gt;</li>
* <li>F &lt;language-region&gt;</li>
* <li>S &lt;language-region&gt;</li>
* <li>F &lt;language&gt;</li>
* <li>S &lt;language&gt;</li>
* <li>F en</li>
* <li>S en</li>
* </ol>
* <p>
* Example for the message priority for locale "de-CH-1996" (language "de", region "CH", variant "1996):
* <ol>
* <li>F de-CH-1996</li>
* <li>S de-CH-1996</li>
* <li>F de-CH</li>
* <li>S de-CH</li>
* <li>F de</li>
* <li>S de</li>
* <li>F en</li>
* <li>S en</li>
* </ol>
*
* @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<Locale, Properties> firstMessages,
Map<Locale, Properties> secondMessages) {
List<Locale> 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<Locale> 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.
* <p>
* 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<Locale, Properties> themeMessages) {
Map<Locale, Properties> realmLocalizationMessages = getRealmLocalizationTexts(realm, locale);
return mergeGroupedMessages(locale, realmLocalizationMessages, themeMessages);
}
private static Map<Locale, Properties> getRealmLocalizationTexts(RealmModel realm, Locale locale) {
LinkedHashMap<Locale, Properties> groupedMessages = new LinkedHashMap<>();
List<Locale> applicableLocales = getApplicableLocales(locale);
for (Locale applicableLocale : applicableLocales) {
Map<String, String> currentRealmLocalizationTexts =
realm.getRealmLocalizationTextsByLocale(applicableLocale.toLanguageTag());
Properties currentMessages = new Properties();
currentMessages.putAll(currentRealmLocalizationTexts);
groupedMessages.put(applicableLocale, currentMessages);
}
return groupedMessages;
}
} }

View file

@ -17,13 +17,18 @@
package org.keycloak.theme; package org.keycloak.theme;
import org.keycloak.models.RealmModel;
import org.keycloak.services.util.LocaleUtil;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.Reader; import java.io.Reader;
import java.net.URL; import java.net.URL;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.util.Collections;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Properties; import java.util.Properties;
/** /**
@ -148,6 +153,16 @@ public class ClassLoaderTheme implements Theme {
return m; return m;
} }
@Override
public Properties getEnhancedMessages(RealmModel realm, Locale locale) throws IOException {
if (locale == null){
return null;
}
Map<Locale, Properties> localeMessages = Collections.singletonMap(locale, getMessages(locale));
return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, localeMessages);
}
@Override @Override
public Properties getProperties() { public Properties getProperties() {
return properties; return properties;

View file

@ -18,26 +18,28 @@
package org.keycloak.theme; package org.keycloak.theme;
import org.jboss.logging.Logger; 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.StringPropertyReplacer;
import org.keycloak.common.util.SystemEnvProperties; import org.keycloak.common.util.SystemEnvProperties;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.ThemeManager; import org.keycloak.models.ThemeManager;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URL; import java.net.URL;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.ListIterator; import java.util.ListIterator;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.services.util.LocaleUtil;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -158,7 +160,8 @@ public class DefaultThemeManager implements ThemeManager {
private Properties properties; private Properties properties;
private ConcurrentHashMap<String, ConcurrentHashMap<Locale, Properties>> messages = new ConcurrentHashMap<>(); private ConcurrentHashMap<String, ConcurrentHashMap<Locale, Map<Locale, Properties>>> messages =
new ConcurrentHashMap<>();
public ExtendingTheme(List<Theme> themes, Set<ThemeResourceProvider> themeResourceProviders) { public ExtendingTheme(List<Theme> themes, Set<ThemeResourceProvider> themeResourceProviders) {
this.themes = themes; this.themes = themes;
@ -230,31 +233,44 @@ public class DefaultThemeManager implements ThemeManager {
@Override @Override
public Properties getMessages(String baseBundlename, Locale locale) throws IOException { public Properties getMessages(String baseBundlename, Locale locale) throws IOException {
if (messages.get(baseBundlename) == null || messages.get(baseBundlename).get(locale) == null) { Map<Locale, Properties> messagesByLocale = getMessagesByLocale(baseBundlename, locale);
Properties messages = new Properties(); return LocaleUtil.mergeGroupedMessages(locale, messagesByLocale);
}
@Override
public Properties getEnhancedMessages(RealmModel realm, Locale locale) throws IOException {
Map<Locale, Properties> messagesByLocale = getMessagesByLocale("messages", locale);
return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, messagesByLocale);
}
private Map<Locale, Properties> getMessagesByLocale(String baseBundlename, Locale locale) throws IOException {
if (messages.get(baseBundlename) == null || messages.get(baseBundlename).get(locale) == null) {
Locale parent = getParent(locale); Locale parent = getParent(locale);
if (parent != null) { Map<Locale, Properties> parentMessages =
messages.putAll(getMessages(baseBundlename, parent)); parent == null ? Collections.emptyMap() : getMessagesByLocale(baseBundlename, parent);
}
for (ThemeResourceProvider t : themeResourceProviders ){ Properties currentMessages = new Properties();
messages.putAll(t.getMessages(baseBundlename, locale)); Map<Locale, Properties> groupedMessages = new HashMap<>(parentMessages);
groupedMessages.put(locale, currentMessages);
for (ThemeResourceProvider t : themeResourceProviders) {
currentMessages.putAll(t.getMessages(baseBundlename, locale));
} }
ListIterator<Theme> itr = themes.listIterator(themes.size()); ListIterator<Theme> itr = themes.listIterator(themes.size());
while (itr.hasPrevious()) { while (itr.hasPrevious()) {
Properties m = itr.previous().getMessages(baseBundlename, locale); Properties m = itr.previous().getMessages(baseBundlename, locale);
if (m != null) { if (m != null) {
messages.putAll(m); currentMessages.putAll(m);
} }
} }
this.messages.putIfAbsent(baseBundlename, new ConcurrentHashMap<Locale, Properties>());
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 { } else {
return messages.get(baseBundlename).get(locale); return messages.get(baseBundlename).get(locale);
} }
@ -291,19 +307,7 @@ public class DefaultThemeManager implements ThemeManager {
} }
private static Locale getParent(Locale locale) { private static Locale getParent(Locale locale) {
if (Locale.ENGLISH.equals(locale)) { return LocaleUtil.getParentLocale(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;
} }
private List<ThemeProvider> getProviders() { private List<ThemeProvider> getProviders() {

View file

@ -17,6 +17,9 @@
package org.keycloak.theme; package org.keycloak.theme;
import org.keycloak.models.RealmModel;
import org.keycloak.services.util.LocaleUtil;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
@ -25,7 +28,9 @@ import java.io.InputStreamReader;
import java.io.Reader; import java.io.Reader;
import java.net.URL; import java.net.URL;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.util.Collections;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Properties; import java.util.Properties;
/** /**
@ -107,7 +112,7 @@ public class FolderTheme implements Theme {
@Override @Override
public Properties getMessages(String baseBundlename, Locale locale) throws IOException { public Properties getMessages(String baseBundlename, Locale locale) throws IOException {
if(locale == null){ if (locale == null){
return null; return null;
} }
@ -123,6 +128,15 @@ public class FolderTheme implements Theme {
return m; return m;
} }
public Properties getEnhancedMessages(RealmModel realm, Locale locale) throws IOException {
if (locale == null){
return null;
}
Map<Locale, Properties> localeMessages = Collections.singletonMap(locale, getMessages(locale));
return LocaleUtil.enhancePropertiesWithRealmLocalizationTexts(realm, locale, localeMessages);
}
@Override @Override
public Properties getProperties() { public Properties getProperties() {
return properties; return properties;

View file

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

View file

@ -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 <a href="mailto:daniel.fesenmeyer@bosch.com">Daniel Fesenmeyer</a>
*/
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<Locale, Properties> 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<Locale, Properties> groupedMessages1 = new HashMap<>();
// messages with priority 2
Map<Locale, Properties> 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<String> 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<String> 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;
}
}

View file

@ -1813,99 +1813,13 @@ public class PermissionsTest extends AbstractKeycloakTest {
@Test @Test
public void localizations() { public void localizations() {
invoke(new Invocation() { verifyAnyAdminRoleReqired(realm -> realm.localization().getRealmSpecificLocales());
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);
invoke(new Invocation() { verifyAnyAdminRoleReqired(realm -> realm.localization().getRealmLocalizationText("en", "test"));
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().getRealmLocalizationTexts("en"));
verifyAnyAdminRoleReqired(realm -> realm.localization().getRealmLocalizationTexts("en", false));
invoke(new Invocation() { invoke(new Invocation() {
public void invoke(RealmResource realm) { public void invoke(RealmResource realm) {
realm.localization().createOrUpdateRealmLocalizationTexts("en", Collections.<String, String>emptyMap()); realm.localization().createOrUpdateRealmLocalizationTexts("en", Collections.<String, String>emptyMap());
@ -1985,6 +1899,15 @@ public class PermissionsTest extends AbstractKeycloakTest {
}, clients.get("REALM2"), false); }, 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) { private void invoke(final Invocation invocation, Resource resource, boolean manage) {
invoke(new InvocationWithResponse() { invoke(new InvocationWithResponse() {
public void invoke(RealmResource realm, AtomicReference<Response> response) { public void invoke(RealmResource realm, AtomicReference<Response> response) {

View file

@ -64,7 +64,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest {
@Test @Test
public void getRealmLocalizationTexts() { public void getRealmLocalizationTexts() {
Map<String, String> localizations = resource.getRealmLocalizationTexts("en", false); Map<String, String> localizations = resource.getRealmLocalizationTexts("en");
assertNotNull(localizations); assertNotNull(localizations);
assertEquals(2, localizations.size()); assertEquals(2, localizations.size());
@ -84,7 +84,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest {
@Test @Test
public void getRealmLocalizationsNotExists() { public void getRealmLocalizationsNotExists() {
Map<String, String> localizations = resource.getRealmLocalizationTexts("zz", false); Map<String, String> localizations = resource.getRealmLocalizationTexts("zz");
assertNotNull(localizations); assertNotNull(localizations);
assertEquals(0, localizations.size()); assertEquals(0, localizations.size());
} }
@ -125,7 +125,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest {
public void deleteRealmLocalizationText() { public void deleteRealmLocalizationText() {
resource.deleteRealmLocalizationText("en", "key-a"); resource.deleteRealmLocalizationText("en", "key-a");
Map<String, String> localizations = resource.getRealmLocalizationTexts("en", false); Map<String, String> localizations = resource.getRealmLocalizationTexts("en");
assertEquals(1, localizations.size()); assertEquals(1, localizations.size());
assertEquals("text-b_en", localizations.get("key-b")); assertEquals("text-b_en", localizations.get("key-b"));
} }
@ -153,7 +153,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest {
resource.createOrUpdateRealmLocalizationTexts("es", newLocalizationTexts); resource.createOrUpdateRealmLocalizationTexts("es", newLocalizationTexts);
final Map<String, String> persistedLocalizationTexts = resource.getRealmLocalizationTexts("es", false); final Map<String, String> persistedLocalizationTexts = resource.getRealmLocalizationTexts("es");
assertEquals(newLocalizationTexts, persistedLocalizationTexts); assertEquals(newLocalizationTexts, persistedLocalizationTexts);
} }
@ -168,7 +168,7 @@ public class RealmLocalizationResourceTest extends AbstractAdminTest {
final Map<String, String> expectedLocalizationTexts = new HashMap<>(); final Map<String, String> expectedLocalizationTexts = new HashMap<>();
expectedLocalizationTexts.put("key-a", "text-a_en"); expectedLocalizationTexts.put("key-a", "text-a_en");
expectedLocalizationTexts.putAll(newLocalizationTexts); expectedLocalizationTexts.putAll(newLocalizationTexts);
final Map<String, String> persistedLocalizationTexts = resource.getRealmLocalizationTexts("en", false); final Map<String, String> persistedLocalizationTexts = resource.getRealmLocalizationTexts("en");
assertEquals(expectedLocalizationTexts, persistedLocalizationTexts); assertEquals(expectedLocalizationTexts, persistedLocalizationTexts);
} }
} }

View file

@ -16,14 +16,19 @@
*/ */
package org.keycloak.testsuite.i18n; package org.keycloak.testsuite.i18n;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is; 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.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
import java.util.Locale;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Rule; import org.junit.Rule;
@ -31,7 +36,6 @@ import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.ProfileAssume;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.InfoPage; import org.keycloak.testsuite.pages.InfoPage;
import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginPage;
@ -70,52 +74,80 @@ public class EmailTest extends AbstractI18NTest {
} }
@Test @Test
public void restPasswordEmail() throws IOException, MessagingException { public void restPasswordEmail() throws MessagingException, IOException {
loginPage.open(); String expectedBodyContent = "Someone just requested to change";
loginPage.resetPassword(); verifyResetPassword("Reset password", expectedBodyContent, 1);
resetPasswordPage.changePassword("login-test");
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
Assert.assertEquals("Reset password", message.getSubject());
changeUserLocale("en"); changeUserLocale("en");
loginPage.open(); verifyResetPassword("Reset password", expectedBodyContent, 2);
loginPage.resetPassword();
resetPasswordPage.changePassword("login-test");
Assert.assertEquals(2, greenMail.getReceivedMessages().length);
message = greenMail.getReceivedMessages()[1];
Assert.assertEquals("Reset password", message.getSubject());
} }
@Test @Test
public void restPasswordEmailGerman() throws IOException, MessagingException { public void realmLocalizationMessagesAreApplied() throws MessagingException, IOException {
changeUserLocale("de"); 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.open();
loginPage.resetPassword(); loginPage.resetPassword();
resetPasswordPage.changePassword("login-test"); 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 String textBody = MailUtils.getBody(message).getText();
changeUserLocale("en"); assertThat(textBody, containsString(expectedTextBodyContent));
// make sure all placeholders have been replaced
assertThat(textBody, not(containsString("{")));
assertThat(textBody, not(containsString("}")));
} }
//KEYCLOAK-7478 //KEYCLOAK-7478
@Test @Test
public void changeLocaleOnInfoPage() throws InterruptedException, IOException, MessagingException { public void changeLocaleOnInfoPage() throws InterruptedException, IOException {
UserResource testUser = ApiUtil.findUserByUsernameId(testRealm(), "login-test"); UserResource testUser = ApiUtil.findUserByUsernameId(testRealm(), "login-test");
testUser.executeActionsEmail(Arrays.asList(UserModel.RequiredAction.UPDATE_PASSWORD.toString())); testUser.executeActionsEmail(Arrays.asList(UserModel.RequiredAction.UPDATE_PASSWORD.toString()));
@ -132,11 +164,11 @@ public class EmailTest extends AbstractI18NTest {
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
Assert.assertTrue("Expected to be on InfoPage, but it was on " + DroneUtils.getCurrentDriver().getTitle(), infoPage.isCurrent()); 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"); infoPage.openLanguage("Deutsch");
Assert.assertThat(DroneUtils.getCurrentDriver().getPageSource(), containsString("Passwort aktualisieren")); assertThat(DroneUtils.getCurrentDriver().getPageSource(), containsString("Passwort aktualisieren"));
infoPage.clickToContinueDe(); infoPage.clickToContinueDe();
@ -145,6 +177,6 @@ public class EmailTest extends AbstractI18NTest {
WaitUtils.waitForPageToLoad(); WaitUtils.waitForPageToLoad();
Assert.assertTrue("Expected to be on InfoPage, but it was on " + DroneUtils.getCurrentDriver().getTitle(), infoPage.isCurrent()); 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."));
} }
} }

View file

@ -21,7 +21,6 @@ import java.util.Arrays;
import java.util.Locale; import java.util.Locale;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.CloseableHttpClient;
import org.hamcrest.Matchers;
import org.jboss.resteasy.client.jaxrs.ResteasyClient; import org.jboss.resteasy.client.jaxrs.ResteasyClient;
import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder;
import org.jboss.resteasy.client.jaxrs.engines.ApacheHttpClient43Engine; 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 jakarta.ws.rs.core.Response;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.keycloak.testsuite.ProfileAssume;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.OAuthGrantPage; import org.keycloak.testsuite.pages.OAuthGrantPage;
import org.keycloak.testsuite.util.IdentityProviderBuilder; import org.keycloak.testsuite.util.IdentityProviderBuilder;
import org.openqa.selenium.Cookie; import org.openqa.selenium.Cookie;
import static org.hamcrest.MatcherAssert.assertThat; 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 <a href="mailto:gerbermichi@me.com">Michael Gerber</a> * @author <a href="mailto:gerbermichi@me.com">Michael Gerber</a>
@ -89,7 +90,7 @@ public class LoginPageTest extends AbstractI18NTest {
@Test @Test
public void languageDropdown() { public void languageDropdown() {
loginPage.open(); loginPage.open();
Assert.assertEquals("English", loginPage.getLanguageDropdownText()); assertEquals("English", loginPage.getLanguageDropdownText());
switchLanguageToGermanAndBack("Username or email", "Benutzername oder E-Mail", loginPage); switchLanguageToGermanAndBack("Username or email", "Benutzername oder E-Mail", loginPage);
} }
@ -97,26 +98,26 @@ public class LoginPageTest extends AbstractI18NTest {
@Test @Test
public void uiLocalesParameter() { public void uiLocalesParameter() {
loginPage.open(); loginPage.open();
Assert.assertEquals("English", loginPage.getLanguageDropdownText()); assertEquals("English", loginPage.getLanguageDropdownText());
//test if cookie works //test if cookie works
oauth.uiLocales("de"); oauth.uiLocales("de");
loginPage.open(); loginPage.open();
Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); assertEquals("Deutsch", loginPage.getLanguageDropdownText());
driver.manage().deleteAllCookies(); driver.manage().deleteAllCookies();
loginPage.open(); loginPage.open();
Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); assertEquals("Deutsch", loginPage.getLanguageDropdownText());
oauth.uiLocales("en de"); oauth.uiLocales("en de");
driver.manage().deleteAllCookies(); driver.manage().deleteAllCookies();
loginPage.open(); loginPage.open();
Assert.assertEquals("English", loginPage.getLanguageDropdownText()); assertEquals("English", loginPage.getLanguageDropdownText());
oauth.uiLocales("fr de"); oauth.uiLocales("fr de");
driver.manage().deleteAllCookies(); driver.manage().deleteAllCookies();
loginPage.open(); loginPage.open();
Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); assertEquals("Deutsch", loginPage.getLanguageDropdownText());
} }
@Test @Test
@ -142,10 +143,9 @@ public class LoginPageTest extends AbstractI18NTest {
@Test @Test
public void testIdentityProviderCapitalization(){ public void testIdentityProviderCapitalization(){
loginPage.open(); loginPage.open();
Assert.assertEquals("GitHub", loginPage.findSocialButton("github").getText()); assertEquals("GitHub", loginPage.findSocialButton("github").getText());
Assert.assertEquals("mysaml", loginPage.findSocialButton("mysaml").getText()); assertEquals("mysaml", loginPage.findSocialButton("mysaml").getText());
Assert.assertEquals("MyOIDC", loginPage.findSocialButton("myoidc").getText()); assertEquals("MyOIDC", loginPage.findSocialButton("myoidc").getText());
} }
@ -161,7 +161,7 @@ public class LoginPageTest extends AbstractI18NTest {
loginPage.login("test-user@localhost", "password"); loginPage.login("test-user@localhost", "password");
changePasswordPage.assertCurrent(); changePasswordPage.assertCurrent();
Assert.assertEquals("English", changePasswordPage.getLanguageDropdownText()); assertEquals("English", changePasswordPage.getLanguageDropdownText());
// Switch language // Switch language
switchLanguageToGermanAndBack("Update password", "Passwort aktualisieren", changePasswordPage); switchLanguageToGermanAndBack("Update password", "Passwort aktualisieren", changePasswordPage);
@ -169,7 +169,7 @@ public class LoginPageTest extends AbstractI18NTest {
// Update password // Update password
changePasswordPage.changePassword("password", "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)); Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
} }
@ -185,7 +185,7 @@ public class LoginPageTest extends AbstractI18NTest {
loginPage.login("test-user@localhost", "password"); loginPage.login("test-user@localhost", "password");
grantPage.assertCurrent(); grantPage.assertCurrent();
Assert.assertEquals("English", grantPage.getLanguageDropdownText()); assertEquals("English", grantPage.getLanguageDropdownText());
// Switch language // Switch language
switchLanguageToGermanAndBack("Do you grant these access privileges?", "Wollen Sie diese Zugriffsrechte", changePasswordPage); switchLanguageToGermanAndBack("Do you grant these access privileges?", "Wollen Sie diese Zugriffsrechte", changePasswordPage);
@ -193,7 +193,7 @@ public class LoginPageTest extends AbstractI18NTest {
// Confirm grant // Confirm grant
grantPage.accept(); grantPage.accept();
Assert.assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType()); assertEquals(AppPage.RequestType.AUTH_RESPONSE, appPage.getRequestType());
Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE)); Assert.assertNotNull(oauth.getCurrentQuery().get(OAuth2Constants.CODE));
// Revert client // Revert client
@ -205,16 +205,16 @@ public class LoginPageTest extends AbstractI18NTest {
loginPage.open(); loginPage.open();
loginPage.openLanguage("Deutsch"); loginPage.openLanguage("Deutsch");
Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); assertEquals("Deutsch", loginPage.getLanguageDropdownText());
Cookie localeCookie = driver.manage().getCookieNamed(LocaleSelectorProvider.LOCALE_COOKIE); Cookie localeCookie = driver.manage().getCookieNamed(LocaleSelectorProvider.LOCALE_COOKIE);
Assert.assertEquals("de", localeCookie.getValue()); assertEquals("de", localeCookie.getValue());
loginPage.login("test-user@localhost", "password"); loginPage.login("test-user@localhost", "password");
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost"); UserResource user = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost");
UserRepresentation userRep = user.toRepresentation(); 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 code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
String idTokenHint = oauth.doAccessTokenRequest(code, "password").getIdToken(); String idTokenHint = oauth.doAccessTokenRequest(code, "password").getIdToken();
@ -222,7 +222,7 @@ public class LoginPageTest extends AbstractI18NTest {
loginPage.open(); loginPage.open();
Assert.assertEquals("Deutsch", loginPage.getLanguageDropdownText()); assertEquals("Deutsch", loginPage.getLanguageDropdownText());
userRep.getAttributes().remove("locale"); userRep.getAttributes().remove("locale");
user.update(userRep); user.update(userRep);
@ -245,59 +245,56 @@ public class LoginPageTest extends AbstractI18NTest {
Assert.assertNull(localeCookie); 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 // KEYCLOAK-18590
@Test @Test
public void realmLocalizationMessagesAreNotCachedWithinTheTheme() throws IOException { public void realmLocalizationMessagesAreNotCachedWithinTheTheme() {
final String locale = Locale.ENGLISH.toLanguageTag(); final String locale = Locale.ENGLISH.toLanguageTag();
final String realmLocalizationMessageKey = "loginAccountTitle"; final String realmLocalizationMessageKey = "loginAccountTitle";
final String realmLocalizationMessageValue = "Localization Test"; final String realmLocalizationMessageValue = "Localization Test";
saveLocalizationText(locale, realmLocalizationMessageKey, realmLocalizationMessageValue);
loginPage.open();
assertThat(driver.getPageSource(), containsString(realmLocalizationMessageValue));
try(CloseableHttpClient httpClient = (CloseableHttpClient) new HttpClientBuilder().build()) { testRealm().localization().deleteRealmLocalizationText(locale, realmLocalizationMessageKey);
ApacheHttpClient43Engine engine = new ApacheHttpClient43Engine(httpClient); loginPage.open();
assertThat(driver.getPageSource(), not(containsString(realmLocalizationMessageValue)));
}
testRealm().localization().saveRealmLocalizationText(locale, realmLocalizationMessageKey, private void saveLocalizationText(String locale, String key, String value) {
realmLocalizationMessageValue); testRealm().localization().saveRealmLocalizationText(locale, key, value);
getCleanup().addLocalization(locale);
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 switchLanguageToGermanAndBack(String expectedEnglishMessage, String expectedGermanMessage, LanguageComboboxAwarePage page) { private void switchLanguageToGermanAndBack(String expectedEnglishMessage, String expectedGermanMessage, LanguageComboboxAwarePage page) {
// Switch language to Deutsch // Switch language to Deutsch
page.openLanguage("Deutsch"); page.openLanguage("Deutsch");
Assert.assertEquals("Deutsch", page.getLanguageDropdownText()); assertEquals("Deutsch", page.getLanguageDropdownText());
String pageSource = driver.getPageSource(); String pageSource = driver.getPageSource();
Assert.assertFalse(pageSource.contains(expectedEnglishMessage)); assertThat(pageSource, not(containsString(expectedEnglishMessage)));
Assert.assertTrue(pageSource.contains(expectedGermanMessage)); assertThat(pageSource, containsString(expectedGermanMessage));
// Revert language // Revert language
page.openLanguage("English"); page.openLanguage("English");
Assert.assertEquals("English", page.getLanguageDropdownText()); assertEquals("English", page.getLanguageDropdownText());
pageSource = driver.getPageSource(); pageSource = driver.getPageSource();
Assert.assertTrue(pageSource.contains(expectedEnglishMessage)); assertThat(pageSource, containsString(expectedEnglishMessage));
Assert.assertFalse(pageSource.contains(expectedGermanMessage)); assertThat(pageSource, not(containsString(expectedGermanMessage)));
} }
} }

View file

@ -85,6 +85,37 @@ public class InternationalizationTest extends AbstractAccountTest {
assertCustomLocaleWelcomeScreen(); 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 @Test
@Ignore @Ignore
public void loginFormTest() { public void loginFormTest() {
@ -141,11 +172,11 @@ public class InternationalizationTest extends AbstractAccountTest {
} }
private void assertCustomLocalePersonalInfo() { private void assertCustomLocalePersonalInfo() {
assertEquals("Osobní údaje", personalInfoPage.getPageTitle()); assertPersonalInfo("Osobní údaje");
} }
private void assertCustomLocaleLoginPage() { private void assertPersonalInfo(String expectedText) {
assertEquals(CUSTOM_LOCALE_NAME, loginPage.localeDropdown().getSelected()); assertEquals(expectedText, personalInfoPage.getPageTitle());
} }
private void assertTestUserLocale(String expectedLocale) { private void assertTestUserLocale(String expectedLocale) {