Skip to content

Commit cec34fb

Browse files
authored
Merge pull request #135 from testower/feat/gbfs-auth
feat: Implement authentication for fetching GBFS files
2 parents 0c9df8f + 9f417ed commit cec34fb

9 files changed

Lines changed: 657 additions & 106 deletions

File tree

gbfs-validator-java-api/src/main/java/org/entur/gbfs/validator/api/handler/ValidateApiDelegateHandler.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,19 @@
3737
import org.entur.gbfs.validator.api.model.ValidationResultSummary;
3838
import org.entur.gbfs.validator.loader.LoadedFile;
3939
import org.entur.gbfs.validator.loader.Loader;
40+
import org.entur.gbfs.validator.loader.Authentication;
41+
import org.entur.gbfs.validator.loader.BasicAuth as LoaderBasicAuth;
42+
import org.entur.gbfs.validator.loader.BearerTokenAuth as LoaderBearerTokenAuth;
43+
import org.entur.gbfs.validator.loader.OAuthClientCredentialsGrantAuth as LoaderOAuthClientCredentialsGrantAuth;
4044
import org.entur.gbfs.validator.loader.SystemError as LoaderSystemError; // Explicitly for loader
4145
import org.entur.gbfs.validation.model.SystemError as ValidatorSystemError; // Explicitly for validator model
4246
import org.openapitools.jackson.nullable.JsonNullable;
47+
// OpenAPI generated auth models
48+
import org.entur.gbfs.validator.api.model.ValidatePostRequestAuth;
49+
import org.entur.gbfs.validator.api.model.BasicAuth;
50+
import org.entur.gbfs.validator.api.model.BearerTokenAuth;
51+
import org.entur.gbfs.validator.api.model.OAuthClientCredentialsGrantAuth;
52+
4353
import org.slf4j.Logger;
4454
import org.slf4j.LoggerFactory;
4555
import org.springframework.http.ResponseEntity;
@@ -80,7 +90,33 @@ public void destroy() {
8090
public ResponseEntity<org.entur.gbfs.validator.api.model.ValidationResult> validatePost(ValidatePostRequest validatePostRequest) {
8191
logger.debug("Received request for url: {}", validatePostRequest.getFeedUrl());
8292
try {
83-
List<LoadedFile> allLoadedFiles = loader.load(validatePostRequest.getFeedUrl());
93+
Authentication loaderAuth = null;
94+
ValidatePostRequestAuth apiAuth = validatePostRequest.getAuth();
95+
96+
if (apiAuth != null) {
97+
// The OpenAPI generator for 'oneOf' with discriminator typically creates a common wrapper
98+
// that holds the actual instance. We need to get that instance.
99+
Object actualAuth = apiAuth.getActualInstance(); // This is a common pattern
100+
101+
if (actualAuth instanceof BasicAuth) {
102+
BasicAuth basic = (BasicAuth) actualAuth;
103+
if (basic.getUsername() != null && basic.getPassword() != null) {
104+
loaderAuth = new LoaderBasicAuth(basic.getUsername(), basic.getPassword());
105+
}
106+
} else if (actualAuth instanceof BearerTokenAuth) {
107+
BearerTokenAuth bearer = (BearerTokenAuth) actualAuth;
108+
if (bearer.getToken() != null) {
109+
loaderAuth = new LoaderBearerTokenAuth(bearer.getToken());
110+
}
111+
} else if (actualAuth instanceof OAuthClientCredentialsGrantAuth) {
112+
OAuthClientCredentialsGrantAuth oauth = (OAuthClientCredentialsGrantAuth) actualAuth;
113+
if (oauth.getClientId() != null && oauth.getClientSecret() != null && oauth.getTokenUrl() != null) {
114+
loaderAuth = new LoaderOAuthClientCredentialsGrantAuth(oauth.getClientId(), oauth.getClientSecret(), oauth.getTokenUrl().toString());
115+
}
116+
}
117+
}
118+
119+
List<LoadedFile> allLoadedFiles = loader.load(validatePostRequest.getFeedUrl(), loaderAuth);
84120

85121
logger.debug("Loaded files: {}", allLoadedFiles.size());
86122

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package org.entur.gbfs.validator.api;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.github.tomakehurst.wiremock.WireMockServer;
5+
import com.github.tomakehurst.wiremock.client.WireMock;
6+
import org.entur.gbfs.validator.api.model.*;
7+
import org.junit.jupiter.api.AfterEach;
8+
import org.junit.jupiter.api.BeforeEach;
9+
import org.junit.jupiter.api.Test;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
12+
import org.springframework.boot.test.context.SpringBootTest;
13+
import org.springframework.http.MediaType;
14+
import org.springframework.test.web.servlet.MockMvc;
15+
16+
import java.nio.charset.StandardCharsets;
17+
import java.util.Base64;
18+
19+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
20+
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
21+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
22+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
23+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
24+
25+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
26+
@AutoConfigureMockMvc
27+
public class ValidateIntegrationTest {
28+
29+
@Autowired
30+
private MockMvc mockMvc;
31+
32+
@Autowired
33+
private ObjectMapper objectMapper;
34+
35+
private WireMockServer wireMockServer;
36+
private String wiremockBaseUrl;
37+
38+
private final String gbfsDiscoveryJson = "{\"version\": \"2.3\", \"data\": {\"en\": {\"feeds\": []}}}"; // v2.3 for simplicity, no feed list
39+
40+
@BeforeEach
41+
void setUp() {
42+
wireMockServer = new WireMockServer(wireMockConfig().dynamicPort());
43+
wireMockServer.start();
44+
WireMock.configureFor("localhost", wireMockServer.port());
45+
wiremockBaseUrl = wireMockServer.baseUrl();
46+
// In a real scenario, you might need to override properties
47+
// for the application's HTTP client to use wiremockBaseUrl.
48+
// For this test, we assume the Loader is picking up full URLs.
49+
}
50+
51+
@AfterEach
52+
void tearDown() {
53+
wireMockServer.stop();
54+
}
55+
56+
@Test
57+
void testValidate_NoAuth_Success() throws Exception {
58+
String feedPath = "/gbfs-noauth.json";
59+
String fullFeedUrl = wiremockBaseUrl + feedPath;
60+
61+
stubFor(get(urlEqualTo(feedPath))
62+
.willReturn(aResponse()
63+
.withHeader("Content-Type", "application/json")
64+
.withBody(gbfsDiscoveryJson)));
65+
66+
ValidatePostRequest requestBody = new ValidatePostRequest();
67+
requestBody.setFeedUrl(fullFeedUrl);
68+
// No auth object set
69+
70+
mockMvc.perform(post("/validate")
71+
.contentType(MediaType.APPLICATION_JSON)
72+
.content(objectMapper.writeValueAsString(requestBody)))
73+
.andExpect(status().isOk())
74+
.andExpect(jsonPath("$.summary.files[0].fileName").value("gbfs-noauth.json"))
75+
.andExpect(jsonPath("$.summary.files[0].systemErrors").isEmpty());
76+
77+
verify(getRequestedFor(urlEqualTo(feedPath))
78+
.withoutHeader("Authorization"));
79+
}
80+
81+
@Test
82+
void testValidate_BasicAuth_Success() throws Exception {
83+
String feedPath = "/gbfs-basic.json";
84+
String fullFeedUrl = wiremockBaseUrl + feedPath;
85+
String username = "testuser";
86+
String password = "testpassword";
87+
String expectedAuthHeader = "Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
88+
89+
stubFor(get(urlEqualTo(feedPath))
90+
.withHeader("Authorization", equalTo(expectedAuthHeader))
91+
.willReturn(aResponse()
92+
.withHeader("Content-Type", "application/json")
93+
.withBody(gbfsDiscoveryJson)));
94+
95+
ValidatePostRequest requestBody = new ValidatePostRequest();
96+
requestBody.setFeedUrl(fullFeedUrl);
97+
98+
BasicAuth basicAuth = new BasicAuth();
99+
basicAuth.setUsername(username);
100+
basicAuth.setPassword(password);
101+
basicAuth.setAuthType("BASIC"); // Set discriminator
102+
103+
ValidatePostRequestAuth requestAuth = new ValidatePostRequestAuth(basicAuth);
104+
requestBody.setAuth(requestAuth);
105+
106+
mockMvc.perform(post("/validate")
107+
.contentType(MediaType.APPLICATION_JSON)
108+
.content(objectMapper.writeValueAsString(requestBody)))
109+
.andExpect(status().isOk())
110+
.andExpect(jsonPath("$.summary.files[0].fileName").value("gbfs-basic.json"))
111+
.andExpect(jsonPath("$.summary.files[0].systemErrors").isEmpty());
112+
113+
verify(getRequestedFor(urlEqualTo(feedPath))
114+
.withHeader("Authorization", equalTo(expectedAuthHeader)));
115+
}
116+
117+
@Test
118+
void testValidate_BearerTokenAuth_Success() throws Exception {
119+
String feedPath = "/gbfs-bearer.json";
120+
String fullFeedUrl = wiremockBaseUrl + feedPath;
121+
String token = "test_bearer_token";
122+
String expectedAuthHeader = "Bearer " + token;
123+
124+
stubFor(get(urlEqualTo(feedPath))
125+
.withHeader("Authorization", equalTo(expectedAuthHeader))
126+
.willReturn(aResponse()
127+
.withHeader("Content-Type", "application/json")
128+
.withBody(gbfsDiscoveryJson)));
129+
130+
ValidatePostRequest requestBody = new ValidatePostRequest();
131+
requestBody.setFeedUrl(fullFeedUrl);
132+
133+
BearerTokenAuth bearerAuth = new BearerTokenAuth();
134+
bearerAuth.setToken(token);
135+
bearerAuth.setAuthType("BEARER_TOKEN"); // Set discriminator
136+
137+
ValidatePostRequestAuth requestAuth = new ValidatePostRequestAuth(bearerAuth);
138+
requestBody.setAuth(requestAuth);
139+
140+
mockMvc.perform(post("/validate")
141+
.contentType(MediaType.APPLICATION_JSON)
142+
.content(objectMapper.writeValueAsString(requestBody)))
143+
.andExpect(status().isOk())
144+
.andExpect(jsonPath("$.summary.files[0].fileName").value("gbfs-bearer.json"))
145+
.andExpect(jsonPath("$.summary.files[0].systemErrors").isEmpty());
146+
147+
verify(getRequestedFor(urlEqualTo(feedPath))
148+
.withHeader("Authorization", equalTo(expectedAuthHeader)));
149+
}
150+
151+
@Test
152+
void testValidate_OAuthClientCredentials_Success() throws Exception {
153+
String feedPath = "/gbfs-oauth.json";
154+
String tokenPath = "/oauth/token";
155+
String fullFeedUrl = wiremockBaseUrl + feedPath;
156+
String fullTokenUrl = wiremockBaseUrl + tokenPath;
157+
158+
String clientId = "test_client_id";
159+
String clientSecret = "test_client_secret";
160+
String accessToken = "oauth_integration_test_token";
161+
162+
// 1. Mock OAuth token endpoint
163+
stubFor(post(urlEqualTo(tokenPath))
164+
.withHeader("Content-Type", equalTo("application/x-www-form-urlencoded"))
165+
.withRequestBody(containing("grant_type=client_credentials"))
166+
.withRequestBody(containing("client_id=" + clientId))
167+
.withRequestBody(containing("client_secret=" + clientSecret))
168+
.willReturn(aResponse()
169+
.withHeader("Content-Type", "application/json")
170+
.withBody("{\"access_token\": \"" + accessToken + "\", \"token_type\": \"Bearer\"}")));
171+
172+
// 2. Mock GBFS feed endpoint
173+
stubFor(get(urlEqualTo(feedPath))
174+
.withHeader("Authorization", equalTo("Bearer " + accessToken))
175+
.willReturn(aResponse()
176+
.withHeader("Content-Type", "application/json")
177+
.withBody(gbfsDiscoveryJson)));
178+
179+
ValidatePostRequest requestBody = new ValidatePostRequest();
180+
requestBody.setFeedUrl(fullFeedUrl);
181+
182+
OAuthClientCredentialsGrantAuth oauthAuth = new OAuthClientCredentialsGrantAuth();
183+
oauthAuth.setClientId(clientId);
184+
oauthAuth.setClientSecret(clientSecret);
185+
oauthAuth.setTokenUrl(java.net.URI.create(fullTokenUrl));
186+
oauthAuth.setAuthType("OAUTH_CLIENT_CREDENTIALS"); // Set discriminator
187+
188+
ValidatePostRequestAuth requestAuth = new ValidatePostRequestAuth(oauthAuth);
189+
requestBody.setAuth(requestAuth);
190+
191+
mockMvc.perform(post("/validate")
192+
.contentType(MediaType.APPLICATION_JSON)
193+
.content(objectMapper.writeValueAsString(requestBody)))
194+
.andExpect(status().isOk())
195+
.andExpect(jsonPath("$.summary.files[0].fileName").value("gbfs-oauth.json"))
196+
.andExpect(jsonPath("$.summary.files[0].systemErrors").isEmpty());
197+
198+
verify(postRequestedFor(urlEqualTo(tokenPath)));
199+
verify(getRequestedFor(urlEqualTo(feedPath))
200+
.withHeader("Authorization", equalTo("Bearer " + accessToken)));
201+
}
202+
203+
@Test
204+
void testValidate_BasicAuth_Failure() throws Exception {
205+
String feedPath = "/gbfs-basic-fail.json";
206+
String fullFeedUrl = wiremockBaseUrl + feedPath;
207+
String username = "wronguser";
208+
String password = "wrongpassword";
209+
210+
// WireMock will return 404 by default if no stub matches.
211+
// To specifically test 401, we can make it more explicit,
212+
// or rely on the fact that a request with *any* basic auth header
213+
// that is not the "correct" one (if we had a success stub) would not match.
214+
// For this test, we'll assume any request to this path without a *specific matching*
215+
// auth header (which we don't provide a success case for) will effectively be an auth failure from client's POV.
216+
// Or, more robustly, stub a 401 for any Basic Auth.
217+
stubFor(get(urlEqualTo(feedPath))
218+
.willReturn(aResponse().withStatus(401).withBody("Unauthorized by test")));
219+
220+
221+
ValidatePostRequest requestBody = new ValidatePostRequest();
222+
requestBody.setFeedUrl(fullFeedUrl);
223+
224+
BasicAuth basicAuth = new BasicAuth();
225+
basicAuth.setUsername(username);
226+
basicAuth.setPassword(password);
227+
basicAuth.setAuthType("BASIC");
228+
229+
ValidatePostRequestAuth requestAuth = new ValidatePostRequestAuth(basicAuth);
230+
requestBody.setAuth(requestAuth);
231+
232+
mockMvc.perform(post("/validate")
233+
.contentType(MediaType.APPLICATION_JSON)
234+
.content(objectMapper.writeValueAsString(requestBody)))
235+
.andExpect(status().isOk()) // API itself returns 200
236+
.andExpect(jsonPath("$.summary.files[0].fileName").value("gbfs-basic-fail.json"))
237+
.andExpect(jsonPath("$.summary.files[0].systemErrors").isNotEmpty())
238+
.andExpect(jsonPath("$.summary.files[0].systemErrors[0].error").value("CONNECTION_ERROR"))
239+
.andExpect(jsonPath("$.summary.files[0].systemErrors[0].message").value("HTTP error fetching file: 401 Unauthorized by test"));
240+
241+
verify(getRequestedFor(urlEqualTo(feedPath))
242+
.withHeader("Authorization", containing("Basic "))); // Verify some basic auth header was sent
243+
}
244+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package org.entur.gbfs.validator.loader;
2+
3+
public enum AuthType {
4+
BASIC,
5+
BEARER_TOKEN,
6+
OAUTH_CLIENT_CREDENTIALS
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package org.entur.gbfs.validator.loader;
2+
3+
public interface Authentication {
4+
AuthType getAuthType();
5+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.entur.gbfs.validator.loader;
2+
3+
public class BasicAuth implements Authentication {
4+
private final String username;
5+
private final String password;
6+
7+
public BasicAuth(String username, String password) {
8+
this.username = username;
9+
this.password = password;
10+
}
11+
12+
public String getUsername() {
13+
return username;
14+
}
15+
16+
public String getPassword() {
17+
return password;
18+
}
19+
20+
@Override
21+
public AuthType getAuthType() {
22+
return AuthType.BASIC;
23+
}
24+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package org.entur.gbfs.validator.loader;
2+
3+
public class BearerTokenAuth implements Authentication {
4+
private final String token;
5+
6+
public BearerTokenAuth(String token) {
7+
this.token = token;
8+
}
9+
10+
public String getToken() {
11+
return token;
12+
}
13+
14+
@Override
15+
public AuthType getAuthType() {
16+
return AuthType.BEARER_TOKEN;
17+
}
18+
}

0 commit comments

Comments
 (0)