From 0801cfb01f3e56ec4526171ff0d37ca340fc5a61 Mon Sep 17 00:00:00 2001 From: vmuzikar Date: Mon, 27 Jan 2020 17:10:15 +0100 Subject: [PATCH] KEYCLOAK-12105 Add UI tests for Single page to manage credentials --- .../integration-arquillian/HOW-TO-RUN.md | 3 + .../keycloak/testsuite/WebAuthnAssume.java | 6 + .../auth/page/login/LoginActions.java | 7 + .../testsuite/auth/page/login/LoginForm.java | 7 + .../testsuite/auth/page/login/OAuthGrant.java | 1 + .../testsuite/auth/page/login/OTPSetup.java | 4 + .../account/messages/messages_en.properties | 3 + .../account/messages/messages_test.properties | 4 + .../account/theme.properties | 2 + .../localized-theme/account/theme.properties | 2 +- .../keycloak/testsuite/ui/AbstractUiTest.java | 9 +- .../ui/account2/AbstractAccountTest.java | 27 +- .../ui/account2/BaseAccountPageTest.java | 6 +- .../ui/account2/DeviceActivityTest.java | 2 +- .../testsuite/ui/account2/ReferrerTest.java | 2 +- .../testsuite/ui/account2/SigningInTest.java | 344 ++++++++++++++++++ .../ui/account2/page/SigningInPage.java | 228 ++++++++++++ .../account/resources/README.md | 2 +- .../content/signingin-page/SigningInPage.tsx | 24 +- 19 files changed, 654 insertions(+), 29 deletions(-) create mode 100644 testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_en.properties create mode 100644 testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_test.properties create mode 100644 testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/theme.properties create mode 100644 testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/SigningInTest.java create mode 100644 testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/page/SigningInPage.java diff --git a/testsuite/integration-arquillian/HOW-TO-RUN.md b/testsuite/integration-arquillian/HOW-TO-RUN.md index 3e559a7db8..4809a5afa5 100644 --- a/testsuite/integration-arquillian/HOW-TO-RUN.md +++ b/testsuite/integration-arquillian/HOW-TO-RUN.md @@ -427,6 +427,9 @@ mvn -f testsuite/integration-arquillian/tests/other/base-ui/pom.xml \ -Pandroid \ -Dappium.avd=Nexus_5X_API_27 ``` +**Note:** Some of the tests are covering WebAuthn functionality. Such tests are ignored by default, to ensure that all +tests in the Base UI testsuite are executed please use `-DchromeArguments=--enable-web-authentication-testing-api` as +specified in [WebAuthn tests](#webauthn-tests). ## WebAuthN tests The WebAuthN tests, in Keycloak, can be only executed with Chrome browser, because the Chrome has feature _WebAuthenticationTestingApi_, diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/WebAuthnAssume.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/WebAuthnAssume.java index 5fbedef46a..046d9e492c 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/WebAuthnAssume.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/WebAuthnAssume.java @@ -6,11 +6,17 @@ import org.openqa.selenium.WebDriver; import org.openqa.selenium.remote.BrowserType; import org.openqa.selenium.remote.RemoteWebDriver; +import static org.keycloak.testsuite.util.DroneUtils.getCurrentDriver; + public class WebAuthnAssume { public static final String CHROME_NAME = BrowserType.CHROME; public static final int CHROME_MIN_VERSION = 68; + public static void assumeChrome() { + assumeChrome(getCurrentDriver()); + } + public static void assumeChrome(WebDriver driver) { Assume.assumeNotNull(driver); String chromeArguments = System.getProperty("chromeArguments"); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginActions.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginActions.java index 6a7467ae32..f30a35a5b2 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginActions.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginActions.java @@ -39,10 +39,17 @@ public class LoginActions extends LoginBase { @FindBy(css = "input[type='submit']") private WebElement submitButton; + @FindBy(css = "button[type='submit']") + private WebElement cancelButton; + public void submit() { clickLink(submitButton); } + public void cancel() { + clickLink(cancelButton); + } + @Override public boolean isCurrent() { return URLUtils.currentUrlStartsWith(toString() + "?"); // ignore the query string diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginForm.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginForm.java index c0b30c0d7b..040a899efd 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginForm.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/LoginForm.java @@ -161,6 +161,9 @@ public class LoginForm extends Form { @FindBy(id = "totp") private WebElement totpInputField; + @FindBy(id = "userLabel") + private WebElement userLabelInputField; + @FindBy(id = "totpSecret") private WebElement totpSecret; @@ -170,6 +173,10 @@ public class LoginForm extends Form { public void setTotp(String value) { UIUtils.setTextInputValue(totpInputField, value); } + + public void setUserLabel(String value) { + UIUtils.setTextInputValue(userLabelInputField, value); + } public String getTotpSecret() { return totpSecret.getAttribute(UIUtils.VALUE_ATTR_NAME); diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OAuthGrant.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OAuthGrant.java index 3d4384a03f..4d055dfe59 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OAuthGrant.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OAuthGrant.java @@ -52,6 +52,7 @@ public class OAuthGrant extends RequiredActions { clickLink(acceptButton); } + @Override public void cancel() { clickLink(cancelButton); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OTPSetup.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OTPSetup.java index 89a9b500ab..f790515140 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OTPSetup.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OTPSetup.java @@ -113,6 +113,10 @@ public class OTPSetup extends RequiredActions { return otpCounter.getText(); } + public void setUserLabel(String value) { + form.setUserLabel(value); + } + @Override public String getActionId() { return UserModel.RequiredAction.CONFIGURE_TOTP.name(); diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_en.properties b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_en.properties new file mode 100644 index 0000000000..da90117b31 --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_en.properties @@ -0,0 +1,3 @@ +#encoding: utf-8 +locale_test=Přísný jazyk +client_localized-client=Přespříliš lokalizovaný klient diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_test.properties b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_test.properties new file mode 100644 index 0000000000..41173f4cc4 --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/messages/messages_test.properties @@ -0,0 +1,4 @@ +#encoding: utf-8 +locale_test=Přísný jazyk +accountManagementWelcomeMessage=Vítejte v Keycloaku +personalInfoHtmlTitle=Osobní údaje \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/theme.properties b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/theme.properties new file mode 100644 index 0000000000..7b4bbd8cc3 --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme-preview/account/theme.properties @@ -0,0 +1,2 @@ +parent=${theme-default-name}-preview +locales=en,lang01,lang02,lang03,lang04,lang05,test,lang06,lang07,lang08,lang09,lang10 \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme/account/theme.properties b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme/account/theme.properties index ba9a708e5d..39e87c7462 100644 --- a/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme/account/theme.properties +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/main/resources/themes/localized-theme/account/theme.properties @@ -1,2 +1,2 @@ -parent=keycloak-preview +parent=${theme-default-name} locales=en,lang01,lang02,lang03,lang04,lang05,test,lang06,lang07,lang08,lang09,lang10 \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/AbstractUiTest.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/AbstractUiTest.java index b42249fcae..b4545431aa 100644 --- a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/AbstractUiTest.java +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/AbstractUiTest.java @@ -35,6 +35,7 @@ import static org.junit.Assume.assumeFalse; */ public abstract class AbstractUiTest extends AbstractAuthTest { public static final String LOCALIZED_THEME = "localized-theme"; + public static final String LOCALIZED_THEME_PREVIEW = "localized-theme-preview"; public static final String CUSTOM_LOCALE = "test"; public static final String CUSTOM_LOCALE_NAME = "Přísný jazyk"; public static final String DEFAULT_LOCALE="en"; @@ -53,7 +54,13 @@ public abstract class AbstractUiTest extends AbstractAuthTest { createTestUserWithAdminClient(false); } + protected boolean isAccountPreviewTheme() { + return false; + } + protected void configureInternationalizationForRealm(RealmRepresentation realm) { + final String localizedTheme = isAccountPreviewTheme() ? LOCALIZED_THEME_PREVIEW : LOCALIZED_THEME; + // fetch the supported locales for the special test theme that includes some fake test locales Set supportedLocales = adminClient.serverInfo().getInfo().getThemes().get("login").stream() .filter(x -> x.getName().equals(LOCALIZED_THEME)) @@ -64,7 +71,7 @@ public abstract class AbstractUiTest extends AbstractAuthTest { realm.setSupportedLocales(supportedLocales); realm.setLoginTheme(LOCALIZED_THEME); realm.setAdminTheme(LOCALIZED_THEME); - realm.setAccountTheme(LOCALIZED_THEME); + realm.setAccountTheme(localizedTheme); realm.setEmailTheme(LOCALIZED_THEME); } diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/AbstractAccountTest.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/AbstractAccountTest.java index 790d4ddd2e..0dd2e456a9 100644 --- a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/AbstractAccountTest.java +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/AbstractAccountTest.java @@ -19,10 +19,9 @@ package org.keycloak.testsuite.ui.account2; import org.jboss.arquillian.graphene.page.Page; import org.junit.Before; -import org.junit.BeforeClass; import org.keycloak.common.Profile; import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.testsuite.ProfileAssume; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.ui.AbstractUiTest; import org.keycloak.testsuite.ui.account2.page.PageNotFound; import org.keycloak.testsuite.ui.account2.page.WelcomeScreen; @@ -34,8 +33,11 @@ import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWithLo /** * @author Vaclav Muzikar */ +@EnableFeature(value = Profile.Feature.ACCOUNT2, skipRestart = true) +@EnableFeature(value = Profile.Feature.ACCOUNT_API, skipRestart = true) public abstract class AbstractAccountTest extends AbstractUiTest { - public static final String ACCOUNT_THEME_NAME = "keycloak-preview"; + public static final String ACCOUNT_THEME_NAME_KC = "keycloak-preview"; + public static final String ACCOUNT_THEME_NAME_RHSSO = "rh-sso-preview"; @Page protected WelcomeScreen accountWelcomeScreen; @@ -47,22 +49,25 @@ public abstract class AbstractAccountTest extends AbstractUiTest { public void addTestRealms(List testRealms) { super.addTestRealms(testRealms); RealmRepresentation testRealmRep = testRealms.get(0); - testRealmRep.setAccountTheme(ACCOUNT_THEME_NAME); - } - - @BeforeClass - public static void assumeProfilesEnabled() { - ProfileAssume.assumeFeatureEnabled(Profile.Feature.ACCOUNT2); - ProfileAssume.assumeFeatureEnabled(Profile.Feature.ACCOUNT_API); + testRealmRep.setAccountTheme(getAccountThemeName()); } @Before - public void beforeAccountTest() { + public void navigateBeforeTest() { accountWelcomeScreen.navigateTo(); } + @Override + protected boolean isAccountPreviewTheme() { + return true; + } + protected void loginToAccount() { assertCurrentUrlStartsWithLoginUrlOf(accountWelcomeScreen); loginPage.form().login(testUser); } + + protected String getAccountThemeName() { + return suiteContext.getAuthServerInfo().isEAP() ? ACCOUNT_THEME_NAME_RHSSO : ACCOUNT_THEME_NAME_KC; + } } diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/BaseAccountPageTest.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/BaseAccountPageTest.java index 64af58062c..8c51894b80 100644 --- a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/BaseAccountPageTest.java +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/BaseAccountPageTest.java @@ -17,11 +17,9 @@ package org.keycloak.testsuite.ui.account2; -import org.junit.Before; import org.junit.Test; import org.keycloak.testsuite.ui.account2.page.AbstractLoggedInPage; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** @@ -30,8 +28,8 @@ import static org.junit.Assert.assertTrue; public abstract class BaseAccountPageTest extends AbstractAccountTest { protected abstract AbstractLoggedInPage getAccountPage(); - @Before - public void beforeBaseAccountPageTest() { + @Override + public void navigateBeforeTest() { getAccountPage().navigateTo(); loginToAccount(); getAccountPage().assertCurrent(); diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/DeviceActivityTest.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/DeviceActivityTest.java index 323209743c..94c6b76ca7 100644 --- a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/DeviceActivityTest.java +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/DeviceActivityTest.java @@ -98,7 +98,7 @@ public class DeviceActivityTest extends BaseAccountPageTest { )); - realm.setAccountTheme(LOCALIZED_THEME); // using localized custom theme for the client localized name + realm.setAccountTheme(LOCALIZED_THEME_PREVIEW); // using localized custom theme for the client localized name } @Before diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/ReferrerTest.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/ReferrerTest.java index b4258f2033..3556e6e39f 100644 --- a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/ReferrerTest.java +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/ReferrerTest.java @@ -56,7 +56,7 @@ public class ReferrerTest extends AbstractAccountTest { testClient.setEnabled(true); testRealm.setClients(Collections.singletonList(testClient)); - testRealm.setAccountTheme(LOCALIZED_THEME); // using localized custom theme for the fake client localized name + testRealm.setAccountTheme(LOCALIZED_THEME_PREVIEW); // using localized custom theme for the fake client localized name } @Test diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/SigningInTest.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/SigningInTest.java new file mode 100644 index 0000000000..7f67949049 --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/SigningInTest.java @@ -0,0 +1,344 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.ui.account2; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory; +import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; +import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; +import org.keycloak.models.credential.OTPCredentialModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.models.utils.Base32; +import org.keycloak.models.utils.TimeBasedOTP; +import org.keycloak.representations.idm.AuthenticationExecutionRepresentation; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderSimpleRepresentation; +import org.keycloak.testsuite.WebAuthnAssume; +import org.keycloak.testsuite.auth.page.login.OTPSetup; +import org.keycloak.testsuite.auth.page.login.UpdatePassword; +import org.keycloak.testsuite.pages.webauthn.WebAuthnRegisterPage; +import org.keycloak.testsuite.ui.account2.page.AbstractLoggedInPage; +import org.keycloak.testsuite.ui.account2.page.SigningInPage; + +import java.time.LocalDateTime; +import java.util.List; + +import static java.util.Collections.emptyList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED; +import static org.keycloak.models.UserModel.RequiredAction.CONFIGURE_TOTP; +import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; +import static org.keycloak.testsuite.util.UIUtils.refreshPageAndWaitForLoad; +import static org.keycloak.testsuite.util.WaitUtils.pause; +import static org.keycloak.testsuite.util.WaitUtils.waitForPageToLoad; + +/** + * @author Vaclav Muzikar + */ +public class SigningInTest extends BaseAccountPageTest { + public static final String PASSWORD_LABEL = "Password"; + public static final String WEBAUTHN_FLOW_ID = "75e2390e-f296-49e6-acf8-6d21071d7e10"; + + @Page + private SigningInPage signingInPage; + + @Page + private UpdatePassword updatePasswordPage; + + @Page + private OTPSetup otpSetupPage; + + @Page + private WebAuthnRegisterPage webAuthnRegisterPage; + + private SigningInPage.CredentialType passwordCredentialType; + private SigningInPage.CredentialType otpCredentialType; + private SigningInPage.CredentialType webAuthnCredentialType; + private SigningInPage.CredentialType webAuthnPwdlessCredentialType; + private TimeBasedOTP otpGenerator; + + @Override + protected AbstractLoggedInPage getAccountPage() { + return signingInPage; + } + + @Override + protected void afterAbstractKeycloakTestRealmImport() { + super.afterAbstractKeycloakTestRealmImport(); + + // configure WebAuthn + // we can't do this during the realm import because we'd need to specify all built-in flows as well + + AuthenticationFlowRepresentation flow = new AuthenticationFlowRepresentation(); + flow.setId(WEBAUTHN_FLOW_ID); + flow.setAlias("webauthn flow"); + flow.setProviderId("basic-flow"); + flow.setBuiltIn(false); + flow.setTopLevel(true); + testRealmResource().flows().createFlow(flow); + + AuthenticationExecutionRepresentation execution = new AuthenticationExecutionRepresentation(); + execution.setAuthenticator(WebAuthnAuthenticatorFactory.PROVIDER_ID); + execution.setPriority(10); + execution.setRequirement(REQUIRED.toString()); + execution.setParentFlow(WEBAUTHN_FLOW_ID); + testRealmResource().flows().addExecution(execution); + + execution.setAuthenticator(WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID); + testRealmResource().flows().addExecution(execution); + + RequiredActionProviderSimpleRepresentation requiredAction = new RequiredActionProviderSimpleRepresentation(); + requiredAction.setProviderId(WebAuthnRegisterFactory.PROVIDER_ID); + requiredAction.setName("blahblah"); + testRealmResource().flows().registerRequiredAction(requiredAction); + + requiredAction.setProviderId(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); + testRealmResource().flows().registerRequiredAction(requiredAction); + + // no need to actually configure the authentication, in Account Console tests we just verify the registration + } + + @Override + public void setDefaultPageUriParameters() { + super.setDefaultPageUriParameters(); + updatePasswordPage.setAuthRealm(TEST); + otpSetupPage.setAuthRealm(TEST); + } + + @Before + public void beforeSigningInTest() { + passwordCredentialType = signingInPage.getCredentialType(PasswordCredentialModel.TYPE); + otpCredentialType = signingInPage.getCredentialType(OTPCredentialModel.TYPE); + webAuthnCredentialType = signingInPage.getCredentialType(WebAuthnCredentialModel.TYPE_TWOFACTOR); + webAuthnPwdlessCredentialType = signingInPage.getCredentialType(WebAuthnCredentialModel.TYPE_PASSWORDLESS); + + RealmRepresentation realm = testRealmResource().toRepresentation(); + otpGenerator = new TimeBasedOTP(realm.getOtpPolicyAlgorithm(), realm.getOtpPolicyDigits(), realm.getOtpPolicyPeriod(), 0); + } + + @Test + public void categoriesTest() { + testContext.setTestRealmReps(emptyList()); // reimport realm after this test + + assertEquals(3, signingInPage.getCategoriesCount()); + + assertEquals("Password", signingInPage.getCategoryTitle("password")); + assertEquals("Two-Factor Authentication", signingInPage.getCategoryTitle("two-factor")); + assertEquals("Passwordless", signingInPage.getCategoryTitle("passwordless")); + + // Delete WebAuthn flow ==> Passwordless category should disappear + testRealmResource().flows().deleteFlow(WEBAUTHN_FLOW_ID); + refreshPageAndWaitForLoad(); + assertEquals(2, signingInPage.getCategoriesCount()); + } + + @Test + public void updatePasswordTest() { + SigningInPage.UserCredential passwordCred = + passwordCredentialType.getUserCredential(testUserResource().credentials().get(0).getId()); + + assertFalse(passwordCredentialType.isSetUpLinkVisible()); + assertTrue(passwordCredentialType.isSetUp()); + assertUserCredential(PASSWORD_LABEL, false, passwordCred); + + LocalDateTime previousCreatedAt = passwordCred.getCreatedAt(); + log.info("Waiting one minute to ensure createdAt will change"); + pause(60000); + + final String newPwd = "Keycloak is the best!"; + passwordCred.clickUpdateBtn(); + updatePasswordPage.assertCurrent(); + updatePasswordPage.updatePasswords(newPwd, newPwd); + // TODO uncomment this once KEYCLOAK-12852 is resolved + // signingInPage.assertCurrent(); + + assertUserCredential(PASSWORD_LABEL, false, passwordCred); + assertNotEquals(previousCreatedAt, passwordCred.getCreatedAt()); + + // TODO KEYCLOAK-12875 try to update/set up password when user has no password configured + } + + @Test + public void otpTest() { + assertFalse(otpCredentialType.isSetUp()); + otpCredentialType.clickSetUpLink(); + otpSetupPage.cancel(); + // TODO uncomment this once KEYCLOAK-12852 is resolved + // signingInPage.assertCurrent(); + assertFalse(otpCredentialType.isSetUp()); + + assertEquals("Authenticator Application", otpCredentialType.getTitle()); + + final String label1 = "OTP is secure"; + final String label2 = "OTP is inconvenient"; + + SigningInPage.UserCredential otp1 = addOtpCredential(label1); + assertTrue(otpCredentialType.isSetUp()); + assertEquals(1, otpCredentialType.getUserCredentialsCount()); + assertUserCredential(label1, true, otp1); + + SigningInPage.UserCredential otp2 = addOtpCredential(label2); + assertEquals(2, otpCredentialType.getUserCredentialsCount()); + assertUserCredential(label2, true, otp2); + + testRemoveCredential(otp1); + } + + @Test + public void twoFactorWebAuthnTest() { + testWebAuthn(false); + } + + @Test + public void passwordlessWebAuthnTest() { + testWebAuthn(true); + } + + private void testWebAuthn(boolean passwordless) { + WebAuthnAssume.assumeChrome(driver); // we need some special flags to be able to register security key + + SigningInPage.CredentialType credentialType; + final String expectedHelpText; + + if (passwordless) { + credentialType = webAuthnPwdlessCredentialType; + expectedHelpText = "Use your security key for passwordless log in."; + } + else { + credentialType = webAuthnCredentialType; + expectedHelpText = "Use your security key to log in."; + } + + assertFalse(credentialType.isSetUp()); + // no way to simulate registration cancellation + + assertEquals("Security Key", credentialType.getTitle()); + assertEquals(expectedHelpText, credentialType.getHelpText()); + + final String label1 = "WebAuthn is convenient"; + final String label2 = "but not yet widely adopted"; + + SigningInPage.UserCredential webAuthn1 = addWebAuthnCredential(label1, passwordless); + assertTrue(credentialType.isSetUp()); + assertEquals(1, credentialType.getUserCredentialsCount()); + assertUserCredential(label1, true, webAuthn1); + + SigningInPage.UserCredential webAuthn2 = addWebAuthnCredential(label2, passwordless); + assertEquals(2, credentialType.getUserCredentialsCount()); + assertUserCredential(label2, true, webAuthn2); + + testRemoveCredential(webAuthn1); + } + + @Test + public void setUpLinksTest() { + testSetUpLink(otpCredentialType, CONFIGURE_TOTP.name()); + testSetUpLink(webAuthnCredentialType, WebAuthnRegisterFactory.PROVIDER_ID); + testSetUpLink(webAuthnPwdlessCredentialType, WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); + } + + private void testSetUpLink(SigningInPage.CredentialType credentialType, String requiredActionProviderId) { + assertTrue("Set up link is visible", credentialType.isSetUpLinkVisible()); + + RequiredActionProviderRepresentation requiredAction = new RequiredActionProviderRepresentation(); + requiredAction.setEnabled(false); + testRealmResource().flows().updateRequiredAction(requiredActionProviderId, requiredAction); + + refreshPageAndWaitForLoad(); + assertFalse("Set up link is not visible", credentialType.isSetUpLinkVisible()); + + assertFalse("Credential type is not set up", credentialType.isSetUp()); // this also check the cred type is present + assertNotNull("Title is present", credentialType.getTitle()); + } + + private SigningInPage.UserCredential addOtpCredential(String label) { + otpCredentialType.clickSetUpLink(); + otpSetupPage.assertCurrent(); + otpSetupPage.clickManualMode(); + + String secret = new String(Base32.decode(otpSetupPage.getSecretKey())); + String code = otpGenerator.generateTOTP(secret); + otpSetupPage.setTotp(code); + otpSetupPage.setUserLabel(label); + otpSetupPage.submit(); + // TODO uncomment this once KEYCLOAK-12852 is resolved + // signingInPage.assertCurrent(); + + return getNewestUserCredential(otpCredentialType); + } + + private SigningInPage.UserCredential addWebAuthnCredential(String label, boolean passwordless) { + SigningInPage.CredentialType credentialType = passwordless ? webAuthnPwdlessCredentialType : webAuthnCredentialType; + + credentialType.clickSetUpLink(true); + webAuthnRegisterPage.registerWebAuthnCredential(label); + waitForPageToLoad(); + // TODO uncomment this once KEYCLOAK-12852 is resolved + // signingInPage.assertCurrent(); + + return getNewestUserCredential(credentialType); + } + + private SigningInPage.UserCredential getNewestUserCredential(SigningInPage.CredentialType credentialType) { + List credentials = testUserResource().credentials(); + SigningInPage.UserCredential userCredential = + credentialType.getUserCredential(credentials.get(credentials.size() - 1).getId()); + assertTrue(userCredential.isPresent()); + return userCredential; + } + + private void testRemoveCredential(SigningInPage.UserCredential userCredential) { + int countBeforeRemove = userCredential.getCredentialType().getUserCredentialsCount(); + + testModalDialog(userCredential::clickRemoveBtn, () -> { + assertTrue(userCredential.isPresent()); + assertEquals(countBeforeRemove, userCredential.getCredentialType().getUserCredentialsCount()); + }); + + assertFalse(userCredential.isPresent()); + assertEquals(countBeforeRemove - 1, userCredential.getCredentialType().getUserCredentialsCount()); + signingInPage.alert().assertSuccess(); + } + + private void assertUserCredential(String expectedUserLabel, boolean removable, SigningInPage.UserCredential userCredential) { + assertEquals(expectedUserLabel, userCredential.getUserLabel()); + + // we expect the credential was created/edited no longer that 2 minutes ago (1 minute might not be enough in some corner cases) + LocalDateTime beforeNow = LocalDateTime.now().minusMinutes(2); + LocalDateTime now = LocalDateTime.now(); + // createdAt doesn't specify seconds so it should be something like 12:47:00 + LocalDateTime createdAt = userCredential.getCreatedAt(); + + assertTrue("Creation time should be after time before update", createdAt.isAfter(beforeNow)); + assertTrue("Creation time should be before now", createdAt.isBefore(now)); + + assertEquals("Remove button visible", removable, userCredential.isRemoveBtnDisplayed()); + assertEquals("Update button visible", !removable, userCredential.isUpdateBtnDisplayed()); + } +} diff --git a/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/page/SigningInPage.java b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/page/SigningInPage.java new file mode 100644 index 0000000000..102090534c --- /dev/null +++ b/testsuite/integration-arquillian/tests/other/base-ui/src/test/java/org/keycloak/testsuite/ui/account2/page/SigningInPage.java @@ -0,0 +1,228 @@ +/* + * Copyright 2020 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.ui.account2.page; + +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +import static org.keycloak.testsuite.util.UIUtils.clickLink; +import static org.keycloak.testsuite.util.UIUtils.getTextFromElement; + +/** + * @author Vaclav Muzikar + */ +public class SigningInPage extends AbstractLoggedInPage { + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("MMMM d, yyyy h:mm a", Locale.ENGLISH); + + private static final String CATEG_TITLE = "-categ-title"; + + @Override + public String getPageId() { + return "signingin"; + } + + @Override + public String getParentPageId() { + return ACCOUNT_SECURITY_ID; + } + + public CredentialType getCredentialType(String type) { + return new CredentialType(type); + } + + public String getCategoryTitle(String categoryId) { + return getTextFromElement(driver.findElement(By.id(categoryId + CATEG_TITLE))); + } + + public int getCategoriesCount() { + String xpath = String.format("//*[contains(@id,'%s')]", CATEG_TITLE); + return driver.findElements(By.xpath(xpath)).size(); + } + + public class CredentialType { + private static final String NOT_SET_UP = "not-set-up"; + private static final String SET_UP = "set-up"; + private static final String TITLE = "cred-title"; + private static final String HELP = "cred-help"; + + private final String type; + + private CredentialType(String type) { + this.type = type; + } + + public String getType() { + return type; + } + + private WebElement getItemElement(String item) { + String elementId = String.format("%s-%s", type, item); + return driver.findElement(By.id(elementId)); + } + + public int getUserCredentialsCount() { + String xpath = String.format("//li[starts-with(@id,'%s')]", type + "-" + UserCredential.ROW); + return driver.findElements(By.xpath(xpath)).size(); + } + + public UserCredential getUserCredential(String id) { + return new UserCredential(id, this); + } + + public boolean isSetUp() { + boolean notSetUpLabelPresent; + try { + notSetUpLabelPresent = getItemElement(NOT_SET_UP).isDisplayed(); + } + catch (NoSuchElementException e) { + notSetUpLabelPresent = false; + } + + int userCredentialsCount = getUserCredentialsCount(); + + if (notSetUpLabelPresent && userCredentialsCount == 0) { + return false; + } + else if (!notSetUpLabelPresent && userCredentialsCount > 0) { + return true; + } + else { + throw new IllegalStateException("Unexpected \"not set up label\" state"); + } + } + + public void clickSetUpLink() { + clickSetUpLink(false); + } + + public void clickSetUpLink(boolean skipWaits) { + WebElement element = getItemElement(SET_UP); + if (skipWaits) { + // this is for the very special case of registering webauthn; chromedriver doesn't seem to like requesting + // getCurrentUrl during security key registration + element.click(); + } + else { + clickLink(element); + } + } + + public boolean isSetUpLinkVisible() { + try { + return getItemElement(SET_UP).isDisplayed(); + } + catch (NoSuchElementException e) { + return false; + } + } + + public String getTitle() { + return getTextFromElement(getItemElement(TITLE)); + } + + public String getHelpText() { + return getTextFromElement(getItemElement(HELP)); + } + } + + public class UserCredential { + private static final String ROW = "row"; + private static final String LABEL = "label"; + private static final String CREATED_AT = "created-at"; + private static final String UPDATE = "update"; + private static final String REMOVE = "remove"; + + private final String fullId; + private final String id; + private final CredentialType credentialType; + + private UserCredential(String id, CredentialType credentialType) { + this.fullId = id; + this.id = id.substring(0,8); + this.credentialType = credentialType; + } + + public String getId() { + return fullId; + } + + public CredentialType getCredentialType() { + return credentialType; + } + + private WebElement getItemElement(String item) { + String elementId = String.format("%s-%s-%s", credentialType.getType(), item, id); + return driver.findElement(By.id(elementId)); + } + + private boolean isItemDisplayed(String item) { + try { + return getItemElement(item).isDisplayed(); + } + catch (NoSuchElementException e) { + return false; + } + } + + private String getTextFromItem(String item) { + return getTextFromElement(getItemElement(item)); + } + + public String getUserLabel() { + return getTextFromItem(LABEL); + } + + public String getCreatedAtStr() { + return getTextFromItem(CREATED_AT).split("Created: ")[1]; + } + + public LocalDateTime getCreatedAt() { + return LocalDateTime.parse(getCreatedAtStr(), DATE_TIME_FORMATTER); + } + + public void clickUpdateBtn() { + clickLink(getItemElement(UPDATE)); + } + + public void clickRemoveBtn() { + clickLink(getItemElement(REMOVE)); + } + + public boolean isUpdateBtnDisplayed() { + return isItemDisplayed(UPDATE); + } + + public boolean isRemoveBtnDisplayed() { + return isItemDisplayed(REMOVE); + } + + public boolean isPresent() { + try { + return getItemElement(ROW).isDisplayed(); + } + catch (NoSuchElementException e) { + return false; + } + } + } +} diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/README.md b/themes/src/main/resources/theme/keycloak-preview/account/resources/README.md index b58e5b3c5d..9a947e4698 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/README.md +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/README.md @@ -65,7 +65,7 @@ Running tests 1. Build the project (no need to build the whole distribution). 1. Run: ``` -mvn clean verify -f testsuite/integration-arquillian/tests/other/base-ui -Dtest=**.account2.** -Dkeycloak.profile.feature.account2=enabled -Dkeycloak.profile.feature.account_api=enabled -Dbrowser=chrome +mvn clean verify -f testsuite/integration-arquillian/tests/other/base-ui -Dtest=**.account2.** -Dbrowser=chrome -DchromeArguments=--enable-web-authentication-testing-api ``` Use `chrome` or `firefox` as the browser, other browsers are currently broken for the testsuite. diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx index 4c38bb4ce8..495ef979a6 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/signingin-page/SigningInPage.tsx @@ -125,6 +125,10 @@ class SigningInPage extends React.Component { Array.from(this.state.credentialContainers.keys()).map(category => ( - + <Title id={`${category}-categ-title`} headingLevel={TitleLevel.h2} size='2xl'> <strong><Msg msgKey={category}/></strong> @@ -178,6 +182,7 @@ class SigningInPage extends React.Component, - , + , ]}/> @@ -209,12 +214,12 @@ class SigningInPage extends React.Component { userCredentials.map(credential => ( - + {credential.userLabel}, - : {credential.strCreatedDate}, + {credential.userLabel}, + : {credential.strCreatedDate}, ]}/> @@ -246,15 +251,15 @@ class SigningInPage extends React.Component - <strong><Msg msgKey={credContainer.displayName}/></strong> + <strong id={`${credContainer.type}-cred-title`}><Msg msgKey={credContainer.displayName}/></strong> - + , ]}/> {credContainer.createAction && - + ) } @@ -290,6 +295,7 @@ class CredentialAction extends React.Component { return ( this.props.credRemover(this.props.credential.id, userLabel)}