11package fr .cnieg .keycloak .providers .login .attribute ;
22
3- import com .microsoft .playwright .Browser ;
4- import com .microsoft .playwright .BrowserContext ;
5- import com .microsoft .playwright .Page ;
6- import com .microsoft .playwright .Playwright ;
7- import com .microsoft .playwright .options .AriaRole ;
83import dasniko .testcontainers .keycloak .KeycloakContainer ;
94import io .restassured .common .mapper .TypeRef ;
105import io .restassured .http .ContentType ;
6+ import io .restassured .response .Response ;
7+ import io .restassured .specification .RequestSpecification ;
8+ import org .jsoup .Jsoup ;
9+ import org .jsoup .nodes .Document ;
10+ import org .jsoup .nodes .Element ;
1111import org .junit .jupiter .api .*;
1212import org .subethamail .wiser .Wiser ;
1313import org .testcontainers .junit .jupiter .Container ;
1414import org .testcontainers .junit .jupiter .Testcontainers ;
1515
1616import java .time .Duration ;
17+ import java .util .HashMap ;
1718import java .util .Collections ;
1819import java .util .List ;
1920import java .util .Map ;
2021import java .util .Objects ;
2122import java .util .function .Predicate ;
23+ import java .util .UUID ;
2224import java .util .stream .Collectors ;
2325
2426import static io .restassured .RestAssured .given ;
2527import static org .junit .jupiter .api .Assertions .assertEquals ;
2628import static org .junit .jupiter .api .Assertions .assertNull ;
29+ import static org .junit .jupiter .api .Assertions .assertNotNull ;
2730import static org .junit .jupiter .api .Assertions .fail ;
2831
2932@ Testcontainers
3033class KeycloakLoginAttributeProviderTest {
3134 private static final int SMTP_PORT = 2525 ;
35+ private static final String REALM = "testloginattribute" ;
36+ private static final String CLIENT_ID = "account-console" ;
3237
3338 static {
3439 org .testcontainers .Testcontainers .exposeHostPorts (SMTP_PORT );
@@ -40,49 +45,36 @@ class KeycloakLoginAttributeProviderTest {
4045 .withAdminPassword ("admin" )
4146 .withDefaultProviderClasses ()
4247 .withRealmImportFile ("/testloginattribute-realm.json" );
43- private static Playwright playwright ;
44- private static Browser browser ;
4548 private static KeycloakEventsClient eventsClient ;
4649 private static Wiser smtpServer ;
47- BrowserContext context ;
48- Page page ;
4950
5051 @ BeforeAll
51- static void launchBrowser () {
52+ static void startInfrastructure () {
5253 smtpServer = new Wiser ();
5354 smtpServer .setPort (SMTP_PORT );
5455 smtpServer .start ();
55- playwright = Playwright .create ();
56- browser = playwright .chromium ().launch ();
57- eventsClient = new KeycloakEventsClient (KEYCLOAK_CONTAINER , "testloginattribute" );
56+ eventsClient = new KeycloakEventsClient (KEYCLOAK_CONTAINER , REALM );
5857 }
5958
6059 @ AfterAll
61- static void closeBrowser () {
60+ static void stopInfrastructure () {
6261 smtpServer .stop ();
63- playwright .close ();
6462 }
6563
6664 @ BeforeEach
67- void createContextAndPage () {
68- context = browser .newContext ();
69- page = context .newPage ();
65+ void clearEvents () {
7066 eventsClient .clearEvents ();
7167 }
7268
73- @ AfterEach
74- void closeContext () {
75- context .close ();
76- }
77-
7869 @ Test
7970 void test_should_publish_login_event_for_jane_with_login_name () {
8071 // Given
8172 String username = "janedoe" ;
8273 String password = "s3cr3t" ;
8374 // When
84- openAccountConsole ();
85- submitLoginForm (username , password );
75+ HttpSession session = new HttpSession ();
76+ LoginForm loginForm = loadLoginForm (session );
77+ submitLoginForm (session , loginForm , username , password );
8678 // Then
8779 KeycloakEvent loginEvent = awaitLoginEvent (username );
8880 assertNull (loginEvent .error ());
@@ -95,8 +87,9 @@ void test_should_publish_login_event_for_john_with_attribute() {
9587 String attributeValueOfJohnDoe = "SHOULDBEOKFORLOGIN" ;
9688 String password = "s3cr3t" ;
9789 // When
98- openAccountConsole ();
99- submitLoginForm (attributeValueOfJohnDoe , password );
90+ HttpSession session = new HttpSession ();
91+ LoginForm loginForm = loadLoginForm (session );
92+ submitLoginForm (session , loginForm , attributeValueOfJohnDoe , password );
10093 // Then
10194 KeycloakEvent loginEvent = awaitLoginEvent (attributeValueOfJohnDoe , "johndoe" );
10295 assertNull (loginEvent .error ());
@@ -109,8 +102,9 @@ void test_should_publish_login_error_for_unknown_attribute() {
109102 String attributeValueOfJaneDoe = "SHOULDBEkoFORLOGIN" ;
110103 String password = "s3cr3t" ;
111104 // When
112- openAccountConsole ();
113- submitLoginForm (attributeValueOfJaneDoe , password );
105+ HttpSession session = new HttpSession ();
106+ LoginForm loginForm = loadLoginForm (session );
107+ submitLoginForm (session , loginForm , attributeValueOfJaneDoe , password );
114108 // Then
115109 KeycloakEvent loginError = awaitLoginErrorEvent (attributeValueOfJaneDoe , "user_not_found" );
116110 assertEquals (attributeValueOfJaneDoe , loginError .details ().get ("username" ));
@@ -122,12 +116,13 @@ void test_user_bill_should_be_locked_after_two_invalid_attempts_with_attribute()
122116 String attributeValueOfBillDoe = "SHOULDBEOKFORLOGINTOO" ;
123117 String invalidPassword = "fakes3cr3t" ;
124118 // When
125- openAccountConsole ();
126- submitLoginForm (attributeValueOfBillDoe , invalidPassword );
127- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (invalidPassword );
128- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
129- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill ("s3cr3t" );
130- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
119+ HttpSession session = new HttpSession ();
120+ LoginForm loginForm = loadLoginForm (session );
121+ loginForm = submitLoginForm (session , loginForm , attributeValueOfBillDoe , invalidPassword );
122+ assertNotNull (loginForm , "Expected to remain on login form after invalid password" );
123+ loginForm = submitLoginForm (session , loginForm , attributeValueOfBillDoe , invalidPassword );
124+ assertNotNull (loginForm , "Expected to remain on login form after second invalid password" );
125+ submitLoginForm (session , loginForm , attributeValueOfBillDoe , "s3cr3t" );
131126 // Then
132127 KeycloakEvent lockEvent = awaitLoginErrorEvent (attributeValueOfBillDoe , "user_temporarily_disabled" );
133128 assertEquals (attributeValueOfBillDoe , lockEvent .details ().get ("username" ));
@@ -138,8 +133,10 @@ void test_should_publish_reset_event_for_jane_with_login_name() {
138133 // Given
139134 String username = "janedoe" ;
140135 // When
141- openForgotPasswordForm ();
142- submitResetForm (username );
136+ HttpSession session = new HttpSession ();
137+ LoginForm loginForm = loadLoginForm (session );
138+ ResetPasswordForm resetForm = loadResetPasswordForm (session , loginForm .resetPasswordUrl ());
139+ submitResetForm (session , resetForm , username );
143140 // Then
144141 KeycloakEvent resetEvent = awaitResetPasswordEvent (username );
145142 assertNull (resetEvent .error ());
@@ -150,8 +147,10 @@ void test_should_publish_reset_event_for_john_with_attribute() {
150147 // Given
151148 String attributeValueOfJohnDoe = "SHOULDBEOKFORLOGIN" ;
152149 // When
153- openForgotPasswordForm ();
154- submitResetForm (attributeValueOfJohnDoe );
150+ HttpSession session = new HttpSession ();
151+ LoginForm loginForm = loadLoginForm (session );
152+ ResetPasswordForm resetForm = loadResetPasswordForm (session , loginForm .resetPasswordUrl ());
153+ submitResetForm (session , resetForm , attributeValueOfJohnDoe );
155154 // Then
156155 KeycloakEvent resetEvent = awaitResetPasswordEvent (attributeValueOfJohnDoe , "johndoe" );
157156 assertNull (resetEvent .error ());
@@ -162,8 +161,10 @@ void test_should_publish_reset_error_for_unknown_attribute() {
162161 // Given
163162 String attributeValueOfJaneDoe = "SHOULDBEkoFORLOGIN" ;
164163 // When
165- openForgotPasswordForm ();
166- submitResetForm (attributeValueOfJaneDoe );
164+ HttpSession session = new HttpSession ();
165+ LoginForm loginForm = loadLoginForm (session );
166+ ResetPasswordForm resetForm = loadResetPasswordForm (session , loginForm .resetPasswordUrl ());
167+ submitResetForm (session , resetForm , attributeValueOfJaneDoe );
167168 // Then
168169 KeycloakEvent resetError = awaitResetPasswordErrorEvent (attributeValueOfJaneDoe , "user_not_found" );
169170 assertEquals (attributeValueOfJaneDoe , resetError .details ().get ("username" ));
@@ -228,31 +229,134 @@ private boolean matchesUsernamesIfPresent(KeycloakEvent event, String... usernam
228229 return false ;
229230 }
230231
231- private void openAccountConsole () {
232- page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
233- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
232+ private LoginForm loadLoginForm (HttpSession session ) {
233+ Response response = session .get (authorizationEndpoint (), Map .of (
234+ "client_id" , CLIENT_ID ,
235+ "redirect_uri" , accountRedirectUri (),
236+ "response_type" , "code" ,
237+ "scope" , "openid" ,
238+ "state" , UUID .randomUUID ().toString (),
239+ "nonce" , UUID .randomUUID ().toString ()
240+ ));
241+ assertEquals (200 , response .statusCode (), "Unable to load login page" );
242+ return LoginForm .parse (response .getBody ().asString ());
243+ }
244+
245+ private LoginForm submitLoginForm (HttpSession session , LoginForm form , String username , String password ) {
246+ Response response = session .postForm (form .action (), Map .of (
247+ "username" , username ,
248+ "password" , password ,
249+ "credentialId" , form .credentialId ()
250+ ));
251+ if (response .statusCode () == 200 ) {
252+ return LoginForm .parse (response .getBody ().asString ());
253+ }
254+ if (response .statusCode () == 302 || response .statusCode () == 303 ) {
255+ return null ;
256+ }
257+ fail ("Unexpected status code when submitting login form: " + response .statusCode ());
258+ return null ;
259+ }
260+
261+ private ResetPasswordForm loadResetPasswordForm (HttpSession session , String url ) {
262+ Response response = session .get (url , Collections .emptyMap ());
263+ assertEquals (200 , response .statusCode (), "Unable to load reset password form" );
264+ return ResetPasswordForm .parse (response .getBody ().asString ());
265+ }
266+
267+ private void submitResetForm (HttpSession session , ResetPasswordForm form , String username ) {
268+ Response response = session .postForm (form .action (), Map .of ("username" , username ));
269+ if (response .statusCode () == 200 || response .statusCode () == 302 ) {
270+ return ;
271+ }
272+ fail ("Unexpected status code when submitting reset password form: " + response .statusCode ());
234273 }
235274
236- private void submitLoginForm (String username , String password ) {
237- page .getByLabel ("Username" ).fill (username );
238- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
239- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
275+ private String authorizationEndpoint () {
276+ return KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/" + REALM + "/protocol/openid-connect/auth" ;
240277 }
241278
242- private void openForgotPasswordForm () {
243- openAccountConsole ();
244- page .getByRole (AriaRole .LINK , new Page .GetByRoleOptions ().setName ("Forgot Password?" ))
245- .click ();
279+ private String accountRedirectUri () {
280+ return KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/" + REALM + "/account/" ;
246281 }
247282
248- private void submitResetForm (String username ) {
249- page .getByLabel ("Username" ).fill (username );
250- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Submit" )).click ();
283+ private static String toAbsoluteUrl (String url ) {
284+ if (url == null || url .isBlank ()) {
285+ return url ;
286+ }
287+ if (url .startsWith ("http://" ) || url .startsWith ("https://" )) {
288+ return url ;
289+ }
290+ if (!url .startsWith ("/" )) {
291+ url = "/" + url ;
292+ }
293+ return KEYCLOAK_CONTAINER .getAuthServerUrl () + url ;
251294 }
252295
253296 private record KeycloakEvent (String type , String error , Map <String , String > details ) {
254297 }
255298
299+ private record LoginForm (String action , String resetPasswordUrl , String credentialId ) {
300+ static LoginForm parse (String html ) {
301+ Document document = Jsoup .parse (html );
302+ Element form = document .getElementById ("kc-form-login" );
303+ if (form == null ) {
304+ fail ("Login form not found in response" );
305+ }
306+ Element resetLink = document .getElementById ("kc-reset-password" );
307+ if (resetLink == null ) {
308+ fail ("Reset password link not found in login page" );
309+ }
310+ Element credentialInput = form .selectFirst ("input[name=credentialId]" );
311+ String credentialId = credentialInput != null ? credentialInput .attr ("value" ) : "" ;
312+ return new LoginForm (
313+ toAbsoluteUrl (form .attr ("action" )),
314+ toAbsoluteUrl (resetLink .attr ("href" )),
315+ credentialId
316+ );
317+ }
318+ }
319+
320+ private record ResetPasswordForm (String action ) {
321+ static ResetPasswordForm parse (String html ) {
322+ Document document = Jsoup .parse (html );
323+ Element form = document .getElementById ("kc-reset-password-form" );
324+ if (form == null ) {
325+ fail ("Reset password form not found in response" );
326+ }
327+ return new ResetPasswordForm (toAbsoluteUrl (form .attr ("action" )));
328+ }
329+ }
330+
331+ private static class HttpSession {
332+ private final Map <String , String > cookies = new HashMap <>();
333+
334+ Response get (String url , Map <String , ?> queryParams ) {
335+ RequestSpecification specification = given ()
336+ .redirects ().follow (false )
337+ .cookies (cookies );
338+ if (queryParams != null && !queryParams .isEmpty ()) {
339+ specification .queryParams (queryParams );
340+ }
341+ Response response = specification .get (url );
342+ cookies .putAll (response .getCookies ());
343+ return response ;
344+ }
345+
346+ Response postForm (String url , Map <String , ?> formParams ) {
347+ RequestSpecification specification = given ()
348+ .redirects ().follow (false )
349+ .cookies (cookies )
350+ .contentType (ContentType .URLENC );
351+ if (formParams != null && !formParams .isEmpty ()) {
352+ specification .formParams (formParams );
353+ }
354+ Response response = specification .post (url );
355+ cookies .putAll (response .getCookies ());
356+ return response ;
357+ }
358+ }
359+
256360 private static class KeycloakEventsClient {
257361 private final String authServerUrl ;
258362 private final String realm ;
0 commit comments