66import com .microsoft .playwright .Playwright ;
77import com .microsoft .playwright .options .AriaRole ;
88import dasniko .testcontainers .keycloak .KeycloakContainer ;
9+ import io .restassured .common .mapper .TypeRef ;
10+ import io .restassured .http .ContentType ;
911import org .junit .jupiter .api .*;
1012import org .testcontainers .junit .jupiter .Container ;
1113import org .testcontainers .junit .jupiter .Testcontainers ;
1214
13- import static com .microsoft .playwright .assertions .PlaywrightAssertions .assertThat ;
15+ import java .time .Duration ;
16+ import java .util .Collections ;
17+ import java .util .List ;
18+ import java .util .Map ;
19+ import java .util .Objects ;
20+ import java .util .function .Predicate ;
21+ import java .util .stream .Collectors ;
22+
23+ import static io .restassured .RestAssured .given ;
24+ import static org .junit .jupiter .api .Assertions .assertEquals ;
25+ import static org .junit .jupiter .api .Assertions .assertNull ;
26+ import static org .junit .jupiter .api .Assertions .fail ;
1427
1528@ Testcontainers
1629class KeycloakLoginAttributeProviderTest {
1730 @ Container
1831 private static final KeycloakContainer KEYCLOAK_CONTAINER = new KeycloakContainer ()
32+ .withAdminUsername ("admin" )
33+ .withAdminPassword ("admin" )
1934 .withDefaultProviderClasses ()
2035 .withRealmImportFile ("/testloginattribute-realm.json" );
2136 private static Playwright playwright ;
2237 private static Browser browser ;
38+ private static KeycloakEventsClient eventsClient ;
2339 BrowserContext context ;
2440 Page page ;
2541
2642 @ BeforeAll
2743 static void launchBrowser () {
2844 playwright = Playwright .create ();
2945 browser = playwright .chromium ().launch ();
46+ eventsClient = new KeycloakEventsClient (KEYCLOAK_CONTAINER , "testloginattribute" );
3047 }
3148
3249 @ AfterAll
@@ -38,6 +55,7 @@ static void closeBrowser() {
3855 void createContextAndPage () {
3956 context = browser .newContext ();
4057 page = context .newPage ();
58+ eventsClient .clearEvents ();
4159 }
4260
4361 @ AfterEach
@@ -46,120 +64,209 @@ void closeContext() {
4664 }
4765
4866 @ Test
49- void test_should_identify_jane_with_Login_name () {
67+ void test_should_publish_login_event_for_jane_with_login_name () {
5068 // Given
5169 String username = "janedoe" ;
5270 String password = "s3cr3t" ;
53- String expected = username ;
5471 // When
55- page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
56- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
57- page .getByLabel ("Username" ).fill (username );
58- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
59- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
72+ openAccountConsole ();
73+ submitLoginForm (username , password );
6074 // Then
61- assertThat (page .getByRole (AriaRole .TEXTBOX , new Page .GetByRoleOptions ().setName ("username" ))).hasValue (expected );
75+ KeycloakEvent loginEvent = eventsClient .awaitEvent (event -> "LOGIN" .equals (event .type ()));
76+ assertNull (loginEvent .error ());
77+ assertEquals (username , loginEvent .details ().get ("username" ));
6278 }
6379
6480 @ Test
65- void test_should_identify_john_with_attribute () {
81+ void test_should_publish_login_event_for_john_with_attribute () {
6682 // Given
6783 String attributeValueOfJohnDoe = "SHOULDBEOKFORLOGIN" ;
6884 String password = "s3cr3t" ;
69- String expected = "johndoe" ;
7085 // When
71- page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
72- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
73- page .getByLabel ("Username" ).fill (attributeValueOfJohnDoe );
74- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
75- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
86+ openAccountConsole ();
87+ submitLoginForm (attributeValueOfJohnDoe , password );
7688 // Then
77- assertThat (page .getByRole (AriaRole .TEXTBOX , new Page .GetByRoleOptions ().setName ("username" ))).hasValue (expected );
89+ KeycloakEvent loginEvent = eventsClient .awaitEvent (event -> "LOGIN" .equals (event .type ()));
90+ assertNull (loginEvent .error ());
91+ assertEquals (attributeValueOfJohnDoe , loginEvent .details ().get ("username" ));
7892 }
7993
8094 @ Test
81- void test_should_not_identify_jane_with_attribute () {
95+ void test_should_publish_login_error_for_unknown_attribute () {
8296 // Given
8397 String attributeValueOfJaneDoe = "SHOULDBEkoFORLOGIN" ;
8498 String password = "s3cr3t" ;
85- String expected = "Invalid username or password." ;
8699 // When
87- page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
88- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
89- page .getByLabel ("Username" ).fill (attributeValueOfJaneDoe );
90- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
91- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
100+ openAccountConsole ();
101+ submitLoginForm (attributeValueOfJaneDoe , password );
92102 // Then
93- assertThat (page .getByText (expected )).isVisible ();
103+ KeycloakEvent loginError = eventsClient .awaitEvent (event -> "LOGIN_ERROR" .equals (event .type ())
104+ && "user_not_found" .equals (event .error ()));
105+ assertEquals (attributeValueOfJaneDoe , loginError .details ().get ("username" ));
94106 }
95107
96108 @ Test
97- void test_user_bill_should_be_locked_after_two_attempts_with_attribute () {
109+ void test_user_bill_should_be_locked_after_two_invalid_attempts_with_attribute () {
98110 // Given
99111 String attributeValueOfBillDoe = "SHOULDBEOKFORLOGINTOO" ;
100- String password = "fakes3cr3t" ;
101- String expected = "Invalid username or password." ;
112+ String invalidPassword = "fakes3cr3t" ;
102113 // When
103- page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
104- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
105- page .getByLabel ("Username" ).fill (attributeValueOfBillDoe );
106- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
114+ openAccountConsole ();
115+ submitLoginForm (attributeValueOfBillDoe , invalidPassword );
116+ page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (invalidPassword );
107117 page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
108- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
118+ page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill ("s3cr3t" );
109119 page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
110120 // Then
111- assertThat (page .getByText (expected )).isVisible ();
112- // Given
113- password = "s3cr3t" ;
114- // When
115- page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
116- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
117-
118- assertThat (page .getByText (expected )).isVisible ();
121+ KeycloakEvent lockEvent = eventsClient .awaitEvent (event -> "LOGIN_ERROR" .equals (event .type ())
122+ && "user_temporarily_disabled" .equals (event .error ()));
123+ assertEquals (attributeValueOfBillDoe , lockEvent .details ().get ("username" ));
119124 }
120125
121126 @ Test
122- void test_should_reset_jane_with_login_name () {
127+ void test_should_publish_reset_event_for_jane_with_login_name () {
123128 // Given
124129 String username = "janedoe" ;
125- String expected = "You should receive an email shortly with further instructions." ;
126130 // When
127- page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
128- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
129- page .getByRole (AriaRole .LINK , new Page .GetByRoleOptions ().setName ("Forgot Password?" )).click ();
130- page .getByLabel ("Username" ).fill (username );
131- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Submit" )).click ();
131+ openForgotPasswordForm ();
132+ submitResetForm (username );
132133 // Then
133- assertThat (page .getByText (expected )).isVisible ();
134+ KeycloakEvent resetEvent = eventsClient .awaitEvent (event -> "SEND_RESET_PASSWORD" .equals (event .type ())
135+ && event .error () == null );
136+ assertNull (resetEvent .error ());
134137 }
135138
136139 @ Test
137- void test_should_reset_john_with_attribute () {
140+ void test_should_publish_reset_event_for_john_with_attribute () {
138141 // Given
139142 String attributeValueOfJohnDoe = "SHOULDBEOKFORLOGIN" ;
140- String expected = "You should receive an email shortly with further instructions." ;
141143 // When
142- page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
143- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
144- page .getByRole (AriaRole .LINK , new Page .GetByRoleOptions ().setName ("Forgot Password?" )).click ();
145- page .getByLabel ("Username" ).fill (attributeValueOfJohnDoe );
146- page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Submit" )).click ();
144+ openForgotPasswordForm ();
145+ submitResetForm (attributeValueOfJohnDoe );
147146 // Then
148- assertThat (page .getByText (expected )).isVisible ();
147+ KeycloakEvent resetEvent = eventsClient .awaitEvent (event -> "SEND_RESET_PASSWORD" .equals (event .type ())
148+ && event .error () == null );
149+ assertNull (resetEvent .error ());
149150 }
150151
151152 @ Test
152- void test_should_not_reset_jane_with_attribute () {
153+ void test_should_publish_reset_error_for_unknown_attribute () {
153154 // Given
154155 String attributeValueOfJaneDoe = "SHOULDBEkoFORLOGIN" ;
155- String expected = "You should receive an email shortly with further instructions." ;
156156 // When
157+ openForgotPasswordForm ();
158+ submitResetForm (attributeValueOfJaneDoe );
159+ // Then
160+ KeycloakEvent resetError = eventsClient .awaitEvent (event -> "SEND_RESET_PASSWORD_ERROR" .equals (event .type ())
161+ && "user_not_found" .equals (event .error ()));
162+ assertEquals (attributeValueOfJaneDoe , resetError .details ().get ("username" ));
163+ }
164+
165+ private void openAccountConsole () {
157166 page .navigate (KEYCLOAK_CONTAINER .getAuthServerUrl () + "/realms/testloginattribute/account" );
158167 page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign in" )).click ();
159- page .getByRole (AriaRole .LINK , new Page .GetByRoleOptions ().setName ("Forgot Password?" )).click ();
160- page .getByLabel ("Username" ).fill (attributeValueOfJaneDoe );
168+ }
169+
170+ private void submitLoginForm (String username , String password ) {
171+ page .getByLabel ("Username" ).fill (username );
172+ page .getByLabel ("Password" , new Page .GetByLabelOptions ().setExact (true )).fill (password );
173+ page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Sign In" )).click ();
174+ }
175+
176+ private void openForgotPasswordForm () {
177+ openAccountConsole ();
178+ page .getByRole (AriaRole .LINK , new Page .GetByRoleOptions ().setName ("Forgot Password?" ))
179+ .click ();
180+ }
181+
182+ private void submitResetForm (String username ) {
183+ page .getByLabel ("Username" ).fill (username );
161184 page .getByRole (AriaRole .BUTTON , new Page .GetByRoleOptions ().setName ("Submit" )).click ();
162- // Then
163- assertThat (page .getByText (expected )).isVisible ();
185+ }
186+
187+ private record KeycloakEvent (String type , String error , Map <String , String > details ) {
188+ }
189+
190+ private static class KeycloakEventsClient {
191+ private final String authServerUrl ;
192+ private final String realm ;
193+ private final String adminUsername ;
194+ private final String adminPassword ;
195+
196+ KeycloakEventsClient (KeycloakContainer container , String realm ) {
197+ this .authServerUrl = container .getAuthServerUrl ();
198+ this .realm = realm ;
199+ this .adminUsername = container .getAdminUsername ();
200+ this .adminPassword = container .getAdminPassword ();
201+ }
202+
203+ void clearEvents () {
204+ given ()
205+ .auth ().oauth2 (adminAccessToken ())
206+ .delete (authServerUrl + "/admin/realms/" + realm + "/events" )
207+ .then ()
208+ .statusCode (204 );
209+ }
210+
211+ KeycloakEvent awaitEvent (Predicate <KeycloakEvent > predicate ) {
212+ long deadline = System .currentTimeMillis () + Duration .ofSeconds (5 ).toMillis ();
213+ while (System .currentTimeMillis () < deadline ) {
214+ List <KeycloakEvent > events = events ();
215+ for (KeycloakEvent event : events ) {
216+ if (predicate .test (event )) {
217+ return event ;
218+ }
219+ }
220+ try {
221+ Thread .sleep (200 );
222+ } catch (InterruptedException e ) {
223+ Thread .currentThread ().interrupt ();
224+ fail ("Interrupted while waiting for Keycloak event" );
225+ }
226+ }
227+ fail ("No Keycloak event matched predicate before timeout" );
228+ return null ;
229+ }
230+
231+ private List <KeycloakEvent > events () {
232+ List <Map <String , Object >> rawEvents = given ()
233+ .auth ().oauth2 (adminAccessToken ())
234+ .get (authServerUrl + "/admin/realms/" + realm + "/events" )
235+ .then ()
236+ .statusCode (200 )
237+ .extract ()
238+ .as (new TypeRef <>() {
239+ });
240+ return rawEvents .stream ()
241+ .map (this ::toEvent )
242+ .collect (Collectors .toList ());
243+ }
244+
245+ private KeycloakEvent toEvent (Map <String , Object > rawEvent ) {
246+ String type = Objects .toString (rawEvent .get ("type" ), null );
247+ String error = Objects .toString (rawEvent .get ("error" ), null );
248+ Object details = rawEvent .getOrDefault ("details" , Collections .emptyMap ());
249+ Map <String , String > stringDetails = Collections .emptyMap ();
250+ if (details instanceof Map <?, ?> mapDetails ) {
251+ stringDetails = mapDetails .entrySet ().stream ()
252+ .collect (Collectors .toMap (entry -> Objects .toString (entry .getKey (), null ),
253+ entry -> Objects .toString (entry .getValue (), null )));
254+ }
255+ return new KeycloakEvent (type , error , stringDetails );
256+ }
257+
258+ private String adminAccessToken () {
259+ return given ()
260+ .contentType (ContentType .URLENC )
261+ .formParam ("grant_type" , "password" )
262+ .formParam ("client_id" , "admin-cli" )
263+ .formParam ("username" , adminUsername )
264+ .formParam ("password" , adminPassword )
265+ .post (authServerUrl + "/realms/master/protocol/openid-connect/token" )
266+ .then ()
267+ .statusCode (200 )
268+ .extract ()
269+ .path ("access_token" );
270+ }
164271 }
165272}
0 commit comments