1818package com .nvidia .boot .mock .ngc ;
1919
2020import static com .github .tomakehurst .wiremock .client .WireMock .aResponse ;
21+ import static com .github .tomakehurst .wiremock .client .WireMock .absent ;
22+ import static com .github .tomakehurst .wiremock .client .WireMock .any ;
2123import static com .github .tomakehurst .wiremock .client .WireMock .equalTo ;
2224import static com .github .tomakehurst .wiremock .client .WireMock .get ;
25+ import static com .github .tomakehurst .wiremock .client .WireMock .matching ;
2326import static com .github .tomakehurst .wiremock .client .WireMock .urlPathEqualTo ;
27+ import static com .github .tomakehurst .wiremock .client .WireMock .urlPathMatching ;
2428import static com .nvidia .boot .mock .BootTestConstants .IMAGE_MEDIA_TYPES ;
2529import static com .nvidia .boot .mock .BootTestConstants .TEST_VALID_CONTAINER_HASH ;
2630import static com .nvidia .boot .mock .BootTestConstants .TEST_VALID_CONTAINER_NAME ;
3034import static com .nvidia .boot .mock .BootTestConstants .TEST_VALID_ORG_NAME ;
3135
3236import com .github .tomakehurst .wiremock .WireMockServer ;
37+ import com .github .tomakehurst .wiremock .client .ResponseDefinitionBuilder ;
3338import java .net .URI ;
3439import lombok .Getter ;
3540import lombok .SneakyThrows ;
3843
3944public class MockNgcContainerRegistryServer {
4045
46+ /**
47+ * Token endpoint advertised by the challenge. Deliberately not {@code /proxy_auth}: the
48+ * client must discover this path from the challenge rather than assume a well-known one.
49+ */
50+ public static final String MOCK_TOKEN_ENDPOINT_URL = "/mock-token-endpoint" ;
51+ public static final String V2_PING_URL = "/v2/" ;
52+ public static final String MOCK_BEARER_TOKEN = "mockBearerToken" ;
53+ public static final String MOCK_INVALID_REGISTRY_CRED = "invalid-registry-credential" ;
54+ private static final String DOCKER_CONTENT_DIGEST_HEADER = "Docker-Content-Digest" ;
55+
4156 @ Getter
4257 private static WireMockServer ngcContainerRegistryMockServer ;
43- private static final String PROXY_AUTH_URL = "/proxy_auth" ;
58+ private static final String MANIFEST_URL_PATTERN = "/v2/.+/manifests/.+" ;
59+ /**
60+ * Scope is templated from the request path, so any repository gets the scope a registry
61+ * would advertise for it: path segments are {@code /v2/{org}/{image}/manifests/{reference}}.
62+ */
63+ private static final String MANIFEST_CHALLENGE =
64+ "Bearer realm=\" %s\" ,scope=\" repository:"
65+ + "{{request.path.[1]}}/{{request.path.[2]}}:pull\" " ;
66+ private static final String CHALLENGE_TOKEN_RESPONSE = """
67+ {
68+ "expires_in": 3600,
69+ "token": "%s"
70+ }
71+ """ .formatted (MOCK_BEARER_TOKEN );
4472 private static final String VALIDATE_MANIFEST_URL =
4573 "/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME + "/manifests/" +
4674 TEST_VALID_CONTAINER_TAG ;
@@ -53,12 +81,6 @@ public class MockNgcContainerRegistryServer {
5381 private static final String VALIDATE_MANIFEST_NOT_EXISTS_URL =
5482 "/v2/" + TEST_VALID_ORG_NAME + "/" + TEST_VALID_CONTAINER_NAME +
5583 "/manifests/" + TEST_VALID_CONTAINER_NOT_EXIST_TAG ;
56- private static final String PROXY_AUTH = """
57- {
58- "expires_in": 600,
59- "token": "mockBearerToken"
60- }
61- """ ;
6284 private static final String VALIDATE_MANIFEST = """
6385 589386975/mega-dev/mega-scheduler-service@sha256:d3f9786af0f21490f55299ac0af2f2da871f927865b042def17c63a3699d8d51
6486 {
@@ -192,46 +214,113 @@ public class MockNgcContainerRegistryServer {
192214 @ SneakyThrows
193215 public static void start (String ngcRegistryBaseUrl ) {
194216 stop ();
195- ngcContainerRegistryMockServer = new WireMockServer (URI .create (ngcRegistryBaseUrl ).getPort ());
217+ var port = URI .create (ngcRegistryBaseUrl ).getPort ();
218+ ngcContainerRegistryMockServer = new WireMockServer (port );
196219 ngcContainerRegistryMockServer .start ();
197220
198- ngcContainerRegistryMockServer .stubFor (get (urlPathEqualTo (PROXY_AUTH_URL ))
199- .willReturn (aResponse ().withStatus (200 )
200- .withHeader (
201- HttpHeaders .CONTENT_TYPE ,
202- MediaType .APPLICATION_JSON_VALUE )
203- .withBody (PROXY_AUTH )));
204- ngcContainerRegistryMockServer .stubFor (get (urlPathEqualTo (VALIDATE_MANIFEST_URL ))
205- .withHeader (HttpHeaders .ACCEPT ,
206- equalTo (IMAGE_MEDIA_TYPES ))
207- .willReturn (aResponse ().withStatus (200 )
208- .withHeader (
209- HttpHeaders .CONTENT_TYPE ,
210- MediaType .APPLICATION_JSON_VALUE )
211- .withBody (
212- VALIDATE_MANIFEST )));
221+ // The realm must be a URL the client can actually dereference. Downstream suites pass
222+ // registry hostnames like localhost-ngc:<port> (the unique-hostname convention), which
223+ // do not resolve, so the challenge advertises the loopback address the server binds
224+ // instead of echoing the caller's hostname - which also exercises the client following
225+ // the realm rather than assuming the registry host.
226+ registerChallengeDiscoveryStubs ("http://localhost:" + port + MOCK_TOKEN_ENDPOINT_URL );
227+ registerTokenEndpointStubs ();
228+
229+ registerAuthenticatedManifestStub (VALIDATE_MANIFEST_URL , manifestFoundResponse ());
230+ registerAuthenticatedManifestStub (VALIDATE_MANIFEST_URL_WITH_DIGEST ,
231+ manifestFoundResponse ());
232+ registerAuthenticatedManifestStub (VALIDATE_MANIFEST_PERMISSION_DENIED_URL ,
233+ aResponse ().withStatus (403 ));
234+ registerAuthenticatedManifestStub (VALIDATE_MANIFEST_NOT_EXISTS_URL ,
235+ aResponse ().withStatus (404 ));
236+ }
237+
238+ private static ResponseDefinitionBuilder manifestFoundResponse () {
239+ return aResponse ().withStatus (200 )
240+ .withHeader (DOCKER_CONTENT_DIGEST_HEADER , TEST_VALID_CONTAINER_HASH )
241+ .withHeader (HttpHeaders .CONTENT_TYPE , MediaType .APPLICATION_JSON_VALUE )
242+ .withBody (VALIDATE_MANIFEST );
243+ }
244+
245+ /**
246+ * Answers a manifest request that carries a bearer token. Registered for both GET and HEAD:
247+ * the registry client validates with HEAD, while {@link #setResponse} callers still GET.
248+ */
249+ private static void registerAuthenticatedManifestStub (String url ,
250+ ResponseDefinitionBuilder response ) {
251+ ngcContainerRegistryMockServer .stubFor (
252+ any (urlPathEqualTo (url ))
253+ .withHeader (HttpHeaders .AUTHORIZATION ,
254+ equalTo ("Bearer " + MOCK_BEARER_TOKEN ))
255+ .withHeader (HttpHeaders .ACCEPT , equalTo (IMAGE_MEDIA_TYPES ))
256+ .willReturn (response ));
257+ }
258+
259+ /**
260+ * Call 1 - discover the authentication challenge. An unauthenticated {@code /v2/} ping
261+ * answers 401 with a Bearer challenge whose scope is empty and which advertises no service,
262+ * matching what NGC returns on {@code /v2/} today; an unauthenticated manifest request
263+ * answers 401 with a repository-scoped challenge. The realm points at
264+ * {@link #MOCK_TOKEN_ENDPOINT_URL} rather than the legacy {@code /proxy_auth} so that a
265+ * client which hardcodes the old path fails here.
266+ *
267+ * <p>Registries challenge any unauthenticated {@code /v2/} request, including one for a
268+ * repository that does not exist, so the manifest stub deliberately matches before
269+ * existence is considered. The response-template transformer derives the challenge scope
270+ * from the requested path, the way a registry does, instead of needing one stub per image;
271+ * it is applied per-stub so no other fixture's body is ever run through templating.
272+ */
273+ private static void registerChallengeDiscoveryStubs (String realm ) {
213274 ngcContainerRegistryMockServer .stubFor (
214- get (urlPathEqualTo (VALIDATE_MANIFEST_URL_WITH_DIGEST ))
215- .withHeader (HttpHeaders .ACCEPT ,
216- equalTo (IMAGE_MEDIA_TYPES ))
275+ get (urlPathEqualTo (V2_PING_URL ))
276+ .willReturn (aResponse ().withStatus (401 )
277+ .withHeader (HttpHeaders .WWW_AUTHENTICATE ,
278+ "Bearer realm=\" %s\" ,scope=\" \" "
279+ .formatted (realm ))));
280+
281+ ngcContainerRegistryMockServer .stubFor (
282+ any (urlPathMatching (MANIFEST_URL_PATTERN ))
283+ .withHeader (HttpHeaders .AUTHORIZATION , absent ())
284+ .willReturn (aResponse ().withStatus (401 )
285+ .withHeader (HttpHeaders .WWW_AUTHENTICATE ,
286+ MANIFEST_CHALLENGE .formatted (realm ))
287+ .withTransformers ("response-template" )));
288+ }
289+
290+ /**
291+ * Call 2 - exchange the credential for a token at the advertised realm. Any request
292+ * carrying an {@code Authorization} header receives a token - downstream suites use
293+ * arbitrary credentials, so no single valid secret is pinned; the exported
294+ * {@link #MOCK_INVALID_REGISTRY_CRED} is rejected with 401 via a higher-priority stub.
295+ *
296+ * <p>A request without the header matches no fixture and draws WireMock's 404 default, so
297+ * a client that drops the header still fails loudly. (Real NGC would instead answer 200
298+ * with an anonymous token - a silent false pass a credential check must never rely on.)
299+ */
300+ private static void registerTokenEndpointStubs () {
301+ ngcContainerRegistryMockServer .stubFor (
302+ get (urlPathEqualTo (MOCK_TOKEN_ENDPOINT_URL ))
303+ .withHeader (HttpHeaders .AUTHORIZATION , matching (".+" ))
217304 .willReturn (aResponse ().withStatus (200 )
218- .withHeader (
219- HttpHeaders .CONTENT_TYPE ,
220- MediaType .APPLICATION_JSON_VALUE )
221- .withBody (
222- VALIDATE_MANIFEST )));
305+ .withHeader (HttpHeaders .CONTENT_TYPE ,
306+ MediaType .APPLICATION_JSON_VALUE )
307+ .withBody (CHALLENGE_TOKEN_RESPONSE )));
308+
223309 ngcContainerRegistryMockServer .stubFor (
224- get (urlPathEqualTo (VALIDATE_MANIFEST_PERMISSION_DENIED_URL ))
225- .withHeader (HttpHeaders .ACCEPT , equalTo (IMAGE_MEDIA_TYPES ))
226- .willReturn (aResponse ().withStatus (403 )));
227- ngcContainerRegistryMockServer .stubFor (get (urlPathEqualTo (VALIDATE_MANIFEST_NOT_EXISTS_URL ))
228- .withHeader (HttpHeaders .ACCEPT ,
229- equalTo (IMAGE_MEDIA_TYPES ))
230- .willReturn (aResponse ().withStatus (404 )));
310+ get (urlPathEqualTo (MOCK_TOKEN_ENDPOINT_URL ))
311+ .withHeader (HttpHeaders .AUTHORIZATION ,
312+ equalTo ("Basic " + MOCK_INVALID_REGISTRY_CRED ))
313+ .atPriority (1 )
314+ .willReturn (aResponse ().withStatus (401 )));
231315 }
232316
317+ /**
318+ * Stubs a successful response for a URL, whatever the request method. Manifest paths are
319+ * validated with HEAD but fetched with GET, and callers only mean "this URL succeeds", so
320+ * matching any method keeps them working either way.
321+ */
233322 public static void setResponse (String url , byte [] body ) {
234- ngcContainerRegistryMockServer .stubFor (get (urlPathEqualTo (url ))
323+ ngcContainerRegistryMockServer .stubFor (any (urlPathEqualTo (url ))
235324 .willReturn (aResponse ().withStatus (200 )
236325 .withHeader (
237326 HttpHeaders .CONTENT_TYPE ,
0 commit comments