4545import org .springframework .web .bind .annotation .RequestMapping ;
4646import org .springframework .web .bind .annotation .RestController ;
4747import org .springframework .web .servlet .view .RedirectView ;
48- import sh .ory .hydra .ApiException ;
49- import sh .ory .hydra .Configuration ;
50- import sh .ory .hydra .api .OAuth2Api ;
51- import sh .ory .hydra .model .OAuth2Client ;
5248
5349@ Slf4j
5450@ SpringBootTest (webEnvironment = SpringBootTest .WebEnvironment .RANDOM_PORT )
5753 ForwardingController .class ,
5854})
5955@ TestPropertySource (properties = {"debug=true" })
56+ @ TestInstance (TestInstance .Lifecycle .PER_CLASS )
6057public class OryHydraReferenceApplicationFunctionalTests {
6158
62- // Shared between all tests in this class.
63- private static Playwright playwright ;
64- private static Browser browser ;
59+ // Shared between all tests in this class — plain instance fields: under
60+ // @TestInstance(PER_CLASS) the single test instance lives for the whole class.
61+ private Playwright playwright ;
62+ private Browser browser ;
6563
6664 @ LocalServerPort int springBootAppPort ;
6765
6866 OryHydraContainer dockerComposeEnvironment ;
6967
70- OAuth2Client oAuth2Client ;
68+ String clientId ;
69+ String clientSecret ;
70+ String redirectUri ;
7171
7272 @ Autowired HydraAdminClient .Properties properties ;
7373
@@ -76,25 +76,26 @@ public class OryHydraReferenceApplicationFunctionalTests {
7676 @ Captor
7777 ArgumentCaptor <String > queryStringConsumerArgumentCaptor = ArgumentCaptor .forClass (String .class );
7878
79+ @ Autowired ForwardingController forwardingController ;
80+
81+ // Lifecycle: the Spring context (and its random-port Tomcat) boots before JUnit creates the
82+ // test instance; @TestInstance(PER_CLASS) reuses that single instance for the whole class,
83+ // which is what allows @BeforeAll to be non-static — and therefore able to read the injected
84+ // @LocalServerPort — while still running exactly once before all tests.
85+ //
86+ // Deliberately NOT the idiomatic @Testcontainers/@Container-on-a-static-field pattern: that
87+ // constructs the container at class-load time, but this container's configuration needs the
88+ // injected @LocalServerPort, which only exists after the context boots. The alternatives all
89+ // cost more than they save (a DEFINED_PORT app breaks parallel safety; an instance @Container
90+ // restarts per test regardless of @TestInstance).
7991 @ BeforeAll
80- static void beforeAll () {
92+ void startTestEnvironment () {
8193 playwright = Playwright .create ();
8294 browser = playwright .chromium ().launch ();
83- }
84-
85- @ AfterAll
86- static void afterAll () {
87- playwright .close ();
88- }
8995
90- @ BeforeEach
91- public void beforeEachTest () throws ApiException {
92- // Start Ory Hydra, passing it the port of the reference application to configure urls.
93- // An alternative approach would be to start the container once and re-use it across all tests.
94- // However, @BeforeAllTests requires the method be static but @LocalServerPort is unavailable
95- // statically.
96- // Creating the containers for each test increases test execution time but keeps all tests
97- // isolated.
96+ // One Hydra container serves every test in this class — containers are the expensive
97+ // resource. Test isolation is preserved by registering a unique OAuth2 client per test
98+ // instead (see registerTestClient).
9899 dockerComposeEnvironment =
99100 OryHydraContainer .builder ()
100101 .urlsLogin ("http://localhost:" + springBootAppPort + "/login" )
@@ -104,54 +105,37 @@ public void beforeEachTest() throws ApiException {
104105 .build ();
105106 dockerComposeEnvironment .start ();
106107
107- // A "cheat" to break a circular dependency where the reference application needs to know the
108- // URI of Ory Hydra
109- // and Ory Hydra needs to know the URI of the reference application. In a production application
110- // these two URIs
111- // should be static and well known. However, in the context of these tests the ports of both the
112- // reference
113- // application and Ory Hydra are randomized and are unknown until after the application is
114- // already running
115- // (this follows testing best practices where hard coding ports should be avoided in case the
116- // host machine is
117- // already using that port). There may be a cleaner approach out there (perhaps using Docker
118- // Networking?) but
119- // in the meantime this is a low cost and sufficient work around.
120- properties .setBasePath (dockerComposeEnvironment .publicBaseUriString ());
121-
122- oAuth2Client = createOAuthClient ();
108+ // Late-bind the two Hydra endpoints now that the mapped ports exist (the circular
109+ // port dependency: the app and Hydra each need the other's randomized URI). Two consumers,
110+ // two different endpoints: the app's HydraAdminClient speaks the admin API, while the
111+ // test's public proxy forwards browsers to the public authorize endpoint.
112+ properties .setBasePath (dockerComposeEnvironment .adminBaseUriString ());
113+ forwardingController .hydraPublicBaseUri = dockerComposeEnvironment .publicBaseUriString ();
123114 }
124115
125- @ AfterEach
126- public void afterEachTest () {
127- // Test containers must be stopped to avoid a port conflict error when starting them up again
128- // for the next test.
116+ @ AfterAll
117+ void stopTestEnvironment () {
118+ playwright .close ();
129119 dockerComposeEnvironment .stop ();
130120 }
131121
132- private OAuth2Client createOAuthClient () throws ApiException {
133- val oAuth2Client = new OAuth2Client ();
134- oAuth2Client .clientName ("test-client" );
135- oAuth2Client .redirectUris (
136- List .of ("http://localhost:" + springBootAppPort + CLIENT_CALL_BACK_PATH ));
137- oAuth2Client .grantTypes (List .of ("authorization_code" , "refresh_token" ));
138- oAuth2Client .responseTypes (List .of ("code" , "id_token" ));
139- oAuth2Client .clientSecret ("client-secret" );
140- oAuth2Client .scope (String .join (" " , "offline_access" , "openid" , "offline" , "profile" ));
141-
142- // Initialize API
143- val oauth2Api =
144- new OAuth2Api (
145- Configuration .getDefaultApiClient ()
146- .setBasePath (dockerComposeEnvironment .adminBaseUriString ()));
147-
148- // Create client
149- val oauth2Client = oauth2Api .createOAuth2Client (oAuth2Client );
150-
151- // Basic assertions on created client
152- assertThat (oauth2Api .getOAuth2Client (oauth2Client .getClientId ())).isNotNull ();
153-
154- return oauth2Client ;
122+ @ BeforeEach
123+ public void registerTestClient () {
124+ // A unique client per test keeps tests isolated on the shared container: Hydra remembers
125+ // consent per subject + client, so a shared client would leak remembered consent between
126+ // tests. Registration is a cheap upserting admin API call via the container.
127+ clientId = "test-client-" + UUID .randomUUID ();
128+ clientSecret = "client-secret" ;
129+ redirectUri = "http://localhost:" + springBootAppPort + CLIENT_CALL_BACK_PATH ;
130+ dockerComposeEnvironment .createOrReplaceClient (
131+ client ->
132+ client
133+ .clientId (clientId )
134+ .clientSecret (clientSecret )
135+ .redirectUris (redirectUri )
136+ .grantTypes ("authorization_code" , "refresh_token" )
137+ .responseTypes ("code" , "id_token" )
138+ .scope ("offline_access" , "openid" , "offline" , "profile" ));
155139 }
156140
157141 /**
@@ -198,9 +182,8 @@ private URI getUriToInitiateFlow() {
198182 try {
199183 return new URIBuilder (dockerComposeEnvironment .publicBaseUriString () + "/oauth2/auth" )
200184 .addParameter ("response_type" , "code" )
201- .addParameter ("client_id" , oAuth2Client .getClientId ())
202- .addParameter (
203- "redirect_uri" , Objects .requireNonNull (oAuth2Client .getRedirectUris ()).get (0 ))
185+ .addParameter ("client_id" , clientId )
186+ .addParameter ("redirect_uri" , redirectUri )
204187 .addParameter ("scope" , "offline_access openid offline profile" )
205188 .addParameter ("state" , "12345678901234567890" )
206189 .build ();
@@ -292,14 +275,10 @@ public void completeFlowWithPartialScopeSelection() {
292275 private CodeExchangeResponse exchangeCode (String code ) {
293276 val encodedParams =
294277 Map .of (
295- "client_id" ,
296- Objects .requireNonNull (oAuth2Client .getClientId ()),
297- "code" ,
298- code ,
299- "grant_type" ,
300- Objects .requireNonNull (Objects .requireNonNull (oAuth2Client .getGrantTypes ()).get (0 )),
301- "redirect_uri" ,
302- Objects .requireNonNull (oAuth2Client .getRedirectUris ()).get (0 ))
278+ "client_id" , clientId ,
279+ "code" , code ,
280+ "grant_type" , "authorization_code" ,
281+ "redirect_uri" , redirectUri )
303282 .entrySet ()
304283 .stream ()
305284 .map (
@@ -318,9 +297,7 @@ private CodeExchangeResponse exchangeCode(String code) {
318297 "authorization" ,
319298 "Basic "
320299 + Base64 .getEncoder ()
321- .encodeToString (
322- (oAuth2Client .getClientId () + ":" + oAuth2Client .getClientSecret ())
323- .getBytes ()))
300+ .encodeToString ((clientId + ":" + clientSecret ).getBytes ()))
324301 .POST (HttpRequest .BodyPublishers .ofString (encodedParams ))
325302 .build ();
326303
@@ -475,11 +452,14 @@ record CodeExchangeResponse(
475452@ RequestMapping ("/integration-test-public-proxy" )
476453class ForwardingController {
477454
478- @ Autowired HydraAdminClient .Properties properties ;
455+ // Set by the test once Hydra's mapped public port is known; the proxy forwards browsers to
456+ // Hydra's public authorize endpoint (the admin base in HydraAdminClient.Properties is a
457+ // different endpoint for a different consumer).
458+ String hydraPublicBaseUri ;
479459
480460 @ GetMapping ("oauth2/auth" )
481461 public RedirectView oauth2Auth () {
482- val redirectView = new RedirectView (properties . getBasePath () + "/oauth2/auth" );
462+ val redirectView = new RedirectView (hydraPublicBaseUri + "/oauth2/auth" );
483463 redirectView .setPropagateQueryParams (true );
484464 return redirectView ;
485465 }
0 commit comments