Skip to content

Commit 72870f0

Browse files
authored
test: share one Hydra container across the functional suite (#181)
* test: share one Hydra container across the functional suite Starts Hydra once per class (@testinstance(PER_CLASS) makes the injected @LocalServerPort available to a non-static @BeforeAll) and registers a unique OAuth2 client per test via the container's upserting createOrReplaceClient — Hydra keys remembered consent by subject + client, so per-test clients preserve the isolation the remember-me tests rely on. Functional suite drops from ~40-50s to ~7s. The refactor exposed three latent defects that the per-test containers and a global-singleton side channel had been masking, all fixed here: HydraAdminClient captured its base path at construction (now resolved lazily per call, with a dedicated ApiClient instead of the SDK's mutable global default); the test pointed that base path at Hydra's public port — the admin API only ever worked because the deleted SDK helper had re-pointed the global client at the admin port; and the test's public proxy read the same property for a different endpoint (it now has its own explicit public base URI). * test: unify the class lifecycle and remove static state Under @testinstance(PER_CLASS) the single test instance lives for the whole class, so Playwright joins Hydra as plain instance state in one documented non-static @BeforeAll (which is what can read the injected @LocalServerPort), and the test proxy's public base URI becomes a field on the autowired bean — set beside properties.setBasePath, the same late-binding pattern — instead of a mutable static. * test: document why the container lifecycle is manual The idiomatic @Testcontainers/@container static-field pattern constructs containers at class-load time, but this container's configuration needs the injected @LocalServerPort, which only exists after the Spring context boots. Recording the reasoning where the deviation lives so it is not refactored into breakage.
1 parent d21e00e commit 72870f0

2 files changed

Lines changed: 79 additions & 92 deletions

File tree

reference-app/src/main/java/com/ardetrick/oryhydrareference/hydra/HydraAdminClient.java

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import org.springframework.boot.context.properties.ConfigurationProperties;
1212
import org.springframework.stereotype.Service;
1313
import sh.ory.hydra.ApiException;
14-
import sh.ory.hydra.Configuration;
1514
import sh.ory.hydra.api.OAuth2Api;
1615
import sh.ory.hydra.model.*;
1716

@@ -20,16 +19,23 @@
2019
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
2120
public class HydraAdminClient {
2221

23-
@NonNull OAuth2Api oAuth2Api;
22+
@NonNull Properties properties;
2423

2524
HydraAdminClient(@NonNull final HydraAdminClient.Properties properties) {
26-
val apiClient = Configuration.getDefaultApiClient().setBasePath(properties.getBasePath());
27-
oAuth2Api = new OAuth2Api(apiClient);
25+
this.properties = properties;
26+
}
27+
28+
// The base path is resolved on every call rather than captured at construction: the bean is
29+
// built when the Spring context boots, but the configured base path may legitimately change
30+
// afterwards (integration tests only learn Hydra's randomized port once it is running). A
31+
// dedicated ApiClient instance also avoids mutating the SDK's global default client.
32+
private OAuth2Api oAuth2Api() {
33+
return new OAuth2Api(new sh.ory.hydra.ApiClient().setBasePath(properties.getBasePath()));
2834
}
2935

3036
public List<OAuth2Client> listOAuth2Clients() {
3137
try {
32-
return oAuth2Api.listOAuth2Clients(1000L, null, null, null);
38+
return oAuth2Api().listOAuth2Clients(1000L, null, null, null);
3339
} catch (ApiException e) {
3440
throw new RuntimeException(e);
3541
}
@@ -41,7 +47,7 @@ public List<OAuth2Client> listOAuth2Clients() {
4147
*/
4248
public Optional<OAuth2LoginRequest> getLoginRequest(@NonNull String loginChallenge) {
4349
try {
44-
return Optional.of(oAuth2Api.getOAuth2LoginRequest(loginChallenge));
50+
return Optional.of(oAuth2Api().getOAuth2LoginRequest(loginChallenge));
4551
} catch (ApiException e) {
4652
return switch (e.getCode()) {
4753
case 410 -> Optional.empty(); // requestWasHandledResponse
@@ -59,7 +65,7 @@ public OAuth2RedirectTo acceptLoginRequest(
5965
// ...
6066
acceptLoginRequest.subject(loginEmail);
6167
try {
62-
return oAuth2Api.acceptOAuth2LoginRequest(loginChallenge, acceptLoginRequest);
68+
return oAuth2Api().acceptOAuth2LoginRequest(loginChallenge, acceptLoginRequest);
6369
} catch (ApiException e) {
6470
switch (e.getCode()) {
6571
case 400, 401, 404, 500 ->
@@ -71,7 +77,7 @@ public OAuth2RedirectTo acceptLoginRequest(
7177

7278
public OAuth2ConsentRequest getConsentRequest(@NonNull String consentChallenge) {
7379
try {
74-
return oAuth2Api.getOAuth2ConsentRequest(consentChallenge);
80+
return oAuth2Api().getOAuth2ConsentRequest(consentChallenge);
7581
} catch (ApiException e) {
7682
switch (e.getCode()) {
7783
case 400, 404 -> throw new RuntimeException("code: " + e.getCode(), e); // jsonError
@@ -84,8 +90,9 @@ public OAuth2RedirectTo acceptConsentRequest(@NonNull AcceptConsentRequest accep
8490
val acceptOAuth2ConsentRequest = OryHydraRequestMapper.map(acceptConsentRequest);
8591

8692
try {
87-
return oAuth2Api.acceptOAuth2ConsentRequest(
88-
acceptConsentRequest.consentChallenge(), acceptOAuth2ConsentRequest);
93+
return oAuth2Api()
94+
.acceptOAuth2ConsentRequest(
95+
acceptConsentRequest.consentChallenge(), acceptOAuth2ConsentRequest);
8996
} catch (ApiException e) {
9097
switch (e.getCode()) {
9198
case 404, 500 -> throw new RuntimeException("code: " + e.getCode(), e); // jsonError

reference-app/src/test/java/com/ardetrick/oryhydrareference/OryHydraReferenceApplicationFunctionalTests.java

Lines changed: 62 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,6 @@
4545
import org.springframework.web.bind.annotation.RequestMapping;
4646
import org.springframework.web.bind.annotation.RestController;
4747
import 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)
@@ -57,17 +53,21 @@
5753
ForwardingController.class,
5854
})
5955
@TestPropertySource(properties = {"debug=true"})
56+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
6057
public 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")
476453
class 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

Comments
 (0)