Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DHIS2-18837: add headers and query params auth schemes to Route API #19804

Merged
merged 4 commits into from
Feb 3, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.common.auth;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.AbstractMap;
import java.util.HashMap;
import java.util.Map;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.springframework.util.MultiValueMap;

@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
public class ApiHeadersAuthScheme extends AuthScheme {
public static final String API_HEADERS_TYPE = "api-headers";

@JsonProperty(required = true)
private Map<String, String> headers = new HashMap<>();

public ApiHeadersAuthScheme() {
super(API_HEADERS_TYPE);
}

@Override
public void apply(
MultiValueMap<String, String> headers, MultiValueMap<String, String> queryParams) {
for (Map.Entry<String, String> header : this.headers.entrySet()) {
headers.set(header.getKey(), header.getValue());
}
}

@Override
public ApiHeadersAuthScheme encrypt(UnaryOperator<String> encryptFunc) {
Map<String, String> encryptedHeaders =
headers.entrySet().stream()
.map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), encryptFunc.apply(e.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return copy(encryptedHeaders);
}

@Override
public ApiHeadersAuthScheme decrypt(UnaryOperator<String> decryptFunc) {
Map<String, String> encryptedHeaders =
headers.entrySet().stream()
.map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), decryptFunc.apply(e.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return copy(encryptedHeaders);
}

protected ApiHeadersAuthScheme copy(Map<String, String> headers) {
ApiHeadersAuthScheme apiHeadersAuth = new ApiHeadersAuthScheme();
apiHeadersAuth.setHeaders(headers);

return apiHeadersAuth;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.common.auth;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.AbstractMap;
import java.util.HashMap;
import java.util.Map;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.springframework.util.MultiValueMap;

@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
public class ApiQueryParamsAuthScheme extends AuthScheme {
public static final String API_QUERY_PARAMS_TYPE = "api-query-params";

@JsonProperty(required = true)
private Map<String, String> queryParams = new HashMap<>();

public ApiQueryParamsAuthScheme() {
super(API_QUERY_PARAMS_TYPE);
}

@Override
public void apply(
MultiValueMap<String, String> headers, MultiValueMap<String, String> queryParams) {
for (Map.Entry<String, String> queryParam : this.queryParams.entrySet()) {
queryParams.set(queryParam.getKey(), queryParam.getValue());
}
}

@Override
public ApiQueryParamsAuthScheme encrypt(UnaryOperator<String> encryptFunc) {
Map<String, String> encryptedQueryParams =
queryParams.entrySet().stream()
.map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), encryptFunc.apply(e.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return copy(encryptedQueryParams);
}

@Override
public ApiQueryParamsAuthScheme decrypt(UnaryOperator<String> decryptFunc) {
Map<String, String> encryptedQueryParams =
queryParams.entrySet().stream()
.map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), decryptFunc.apply(e.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return copy(encryptedQueryParams);
}

protected ApiQueryParamsAuthScheme copy(Map<String, String> queryParams) {
ApiQueryParamsAuthScheme apiQueryParamsAuth = new ApiQueryParamsAuthScheme();
apiQueryParamsAuth.setQueryParams(queryParams);

return apiQueryParamsAuth;
}
}
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@
package org.hisp.dhis.common.auth;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.function.UnaryOperator;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
@@ -45,22 +46,40 @@
@Setter
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
public class ApiTokenAuth extends Auth {
public static final String TYPE = "api-token";
public class ApiTokenAuthScheme extends AuthScheme {
public static final String API_TOKEN_TYPE = "api-token";

@JsonProperty(required = true)
private String token;

public ApiTokenAuth() {
super(TYPE);
public ApiTokenAuthScheme() {
super(API_TOKEN_TYPE);
}

@Override
public void apply(MultiValueMap<String, String> headers) {
public void apply(
MultiValueMap<String, String> headers, MultiValueMap<String, String> queryParams) {
if (!StringUtils.hasText(token)) {
return;
}

headers.set("Authorization", "ApiToken " + token);
}

@Override
public ApiTokenAuthScheme encrypt(UnaryOperator<String> encryptFunc) {
return copy(encryptFunc.apply(token));
}

@Override
public AuthScheme decrypt(UnaryOperator<String> decryptFunc) {
return copy(decryptFunc.apply(token));
}

protected ApiTokenAuthScheme copy(String token) {
ApiTokenAuthScheme newApiTokenAuth = new ApiTokenAuthScheme();
newApiTokenAuth.setToken(token);

return newApiTokenAuth;
}
}
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.io.Serializable;
import java.util.function.UnaryOperator;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
@@ -50,16 +51,23 @@
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = HttpBasicAuth.class, name = "http-basic"),
@JsonSubTypes.Type(value = ApiTokenAuth.class, name = "api-token")
@JsonSubTypes.Type(value = HttpBasicAuthScheme.class, name = "http-basic"),
@JsonSubTypes.Type(value = ApiTokenAuthScheme.class, name = "api-token"),
@JsonSubTypes.Type(value = ApiHeadersAuthScheme.class, name = "api-headers"),
@JsonSubTypes.Type(value = ApiQueryParamsAuthScheme.class, name = "api-query-params")
})
public abstract class Auth implements Serializable {
public abstract class AuthScheme implements Serializable {
@JsonProperty protected final String type;

@JsonCreator
protected Auth(@JsonProperty("type") String type) {
protected AuthScheme(@JsonProperty("type") String type) {
this.type = type;
}

public abstract void apply(MultiValueMap<String, String> headers);
public abstract void apply(
MultiValueMap<String, String> headers, MultiValueMap<String, String> queryParams);

public abstract AuthScheme encrypt(UnaryOperator<String> encryptFunc);

public abstract AuthScheme decrypt(UnaryOperator<String> decryptFunc);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2004-2023, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.common.auth;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Base64;
import java.util.function.UnaryOperator;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;

/**
* @author Morten Olav Hansen
*/
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
public class HttpBasicAuthScheme extends AuthScheme {
public static final String HTTP_BASIC_TYPE = "http-basic";

@JsonProperty(required = true)
private String username;

@JsonProperty(required = true)
private String password;

public HttpBasicAuthScheme() {
super(HTTP_BASIC_TYPE);
}

@Override
public void apply(
MultiValueMap<String, String> headers, MultiValueMap<String, String> queryParams) {
if (!(StringUtils.hasText(username) && StringUtils.hasText(password))) {
return;
}

headers.add("Authorization", getBasicAuth(username, password));
}

@Override
public HttpBasicAuthScheme encrypt(UnaryOperator<String> encryptFunc) {
return copy(encryptFunc.apply(password));
}

@Override
public HttpBasicAuthScheme decrypt(UnaryOperator<String> decryptFunc) {
return copy(decryptFunc.apply(password));
}

protected HttpBasicAuthScheme copy(String password) {
HttpBasicAuthScheme newHttpBasicAuth = new HttpBasicAuthScheme();
newHttpBasicAuth.setUsername(username);
newHttpBasicAuth.setPassword(password);

return newHttpBasicAuth;
}

private String getBasicAuth(String username, String password) {
String string = String.format("%s:%s", username, password);
return "Basic " + Base64.getEncoder().encodeToString(string.getBytes());
}
}
Original file line number Diff line number Diff line change
@@ -35,7 +35,7 @@
import lombok.Setter;
import lombok.experimental.Accessors;
import org.hisp.dhis.common.CodeGenerator;
import org.hisp.dhis.common.auth.Auth;
import org.hisp.dhis.common.auth.AuthScheme;
import org.hisp.dhis.eventhook.Target;

/**
@@ -59,7 +59,7 @@ public class WebhookTarget extends Target {

@JsonProperty private Map<String, String> headers = new HashMap<>();

@JsonProperty private Auth auth;
@JsonProperty private AuthScheme auth;

public WebhookTarget() {
super(TYPE);
Original file line number Diff line number Diff line change
@@ -25,45 +25,29 @@
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.common.auth;
package org.hisp.dhis.icon;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Base64;
import lombok.EqualsAndHashCode;
import java.util.HashSet;
import java.util.Set;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import lombok.NoArgsConstructor;

/**
* @author Morten Olav Hansen
*/
/** User input when creating a new {@link Icon} */
@Builder(toBuilder = true)
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
public class HttpBasicAuth extends Auth {
public static final String TYPE = "http-basic";

@JsonProperty(required = true)
private String username;
@AllArgsConstructor
@NoArgsConstructor
public class AddIconRequest {

@JsonProperty(required = true)
private String password;
private String key;

public HttpBasicAuth() {
super(TYPE);
}
@JsonProperty private String description;
@JsonProperty private Set<String> keywords = new HashSet<>();

@Override
public void apply(MultiValueMap<String, String> headers) {
if (!(StringUtils.hasText(username) && StringUtils.hasText(password))) {
return;
}

headers.add(
"Authorization",
"Basic " + Base64.getEncoder().encodeToString((username + ":" + password).getBytes()));
}
@JsonProperty(required = true)
private String fileResourceId;
}
6 changes: 4 additions & 2 deletions dhis-2/dhis-api/src/main/java/org/hisp/dhis/route/Route.java
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@
import lombok.experimental.Accessors;
import org.hisp.dhis.common.BaseIdentifiableObject;
import org.hisp.dhis.common.MetadataObject;
import org.hisp.dhis.common.auth.Auth;
import org.hisp.dhis.common.auth.AuthScheme;

/**
* @author Morten Olav Hansen
@@ -61,8 +61,10 @@ public class Route extends BaseIdentifiableObject implements MetadataObject {
@JsonProperty(required = true)
private Map<String, String> headers = new HashMap<>();

@JsonProperty private Auth auth;
/** Optional. Authentication to be passed as part of the route request. */
@JsonProperty private AuthScheme auth;

/** Optional. Required authorities for invoking the route. */
@JsonProperty private List<String> authorities = new ArrayList<>();

/**
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.eventhook.targets.auth;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.function.Function;
import org.hisp.dhis.common.auth.AuthScheme;

public abstract class AbstractAuthSchemeTest {

protected <T extends AuthScheme> void assertEncrypt(
T authScheme, Function<T, String> secretProvider) {
T encryptedAuthScheme =
(T)
authScheme.encrypt(
value -> {
assertEquals(secretProvider.apply(authScheme), value);
return "bar";
});
assertEquals("bar", secretProvider.apply(encryptedAuthScheme));
}

protected <T extends AuthScheme> void assertDecrypt(
T authScheme, Function<T, String> secretProvider) {
T decryptedAuthScheme =
(T)
authScheme.decrypt(
value -> {
assertEquals(secretProvider.apply(authScheme), value);
return "foo";
});
assertEquals("foo", secretProvider.apply(decryptedAuthScheme));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.eventhook.targets.auth;

import java.util.Map;
import org.hisp.dhis.common.auth.ApiHeadersAuthScheme;
import org.junit.jupiter.api.Test;

class ApiHeadersAuthSchemeTest extends AbstractAuthSchemeTest {

@Test
void testEncrypt() {
assertEncrypt(
new ApiHeadersAuthScheme()
.setHeaders(
Map.of(
"X-API-KEY",
"T5pvst37VedtsoD70KlbumzI30Mo4pzzyAY0M6Ia8uYyPBLPeXlYzr4d3LPQD6oS")),
apiQueryParamsAuthScheme -> apiQueryParamsAuthScheme.getHeaders().get("X-API-KEY"));
}

@Test
void testDecrypt() {
assertDecrypt(
new ApiHeadersAuthScheme()
.setHeaders(
Map.of(
"X-API-KEY",
"3PB06m2bcr0blf81OEpcIDUMUYQYHJcdQsBJyOwbmelTYBQ6fuskAGJReGgM30Cv")),
apiQueryParamsAuthScheme -> apiQueryParamsAuthScheme.getHeaders().get("X-API-KEY"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.eventhook.targets.auth;

import java.util.Map;
import org.hisp.dhis.common.auth.ApiQueryParamsAuthScheme;
import org.junit.jupiter.api.Test;

class ApiQueryParamsAuthSchemeTest extends AbstractAuthSchemeTest {

@Test
void testEncrypt() {
assertEncrypt(
new ApiQueryParamsAuthScheme()
.setQueryParams(
Map.of(
"token", "T5pvst37VedtsoD70KlbumzI30Mo4pzzyAY0M6Ia8uYyPBLPeXlYzr4d3LPQD6oS")),
apiQueryParamsAuthScheme -> apiQueryParamsAuthScheme.getQueryParams().get("token"));
}

@Test
void testDecrypt() {
assertDecrypt(
new ApiQueryParamsAuthScheme()
.setQueryParams(
Map.of(
"token", "3PB06m2bcr0blf81OEpcIDUMUYQYHJcdQsBJyOwbmelTYBQ6fuskAGJReGgM30Cv")),
apiQueryParamsAuthScheme -> apiQueryParamsAuthScheme.getQueryParams().get("token"));
}
}
Original file line number Diff line number Diff line change
@@ -27,28 +27,46 @@
*/
package org.hisp.dhis.eventhook.targets.auth;

import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.hisp.dhis.common.auth.ApiTokenAuth;
import org.hisp.dhis.common.auth.ApiTokenAuthScheme;
import org.junit.jupiter.api.Test;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

/**
* @author Morten Olav Hansen
*/
class ApiTokenAuthTest {
class ApiTokenAuthSchemeTest extends AbstractAuthSchemeTest {

@Test
void testAuthorizationHeaderSet() {
ApiTokenAuth auth = new ApiTokenAuth().setToken("90619873-3287-4296-8C22-9E1D49C0201F");
ApiTokenAuthScheme auth =
new ApiTokenAuthScheme().setToken("90619873-3287-4296-8C22-9E1D49C0201F");

MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
auth.apply(headers);
auth.apply(headers, null);

assertTrue(headers.containsKey("Authorization"));
assertFalse(headers.get("Authorization").isEmpty());
assertEquals(
"ApiToken 90619873-3287-4296-8C22-9E1D49C0201F", headers.get("Authorization").get(0));
}

@Test
void testEncrypt() {
assertEncrypt(
new ApiTokenAuthScheme().setToken("90619873-3287-4296-8C22-9E1D49C0201F"),
ApiTokenAuthScheme::getToken);
}

@Test
void testDecrypt() {
assertDecrypt(
new ApiTokenAuthScheme()
.setToken("qkQzMuVWVGw5g3WcjhYuBZXL5r2DdlURaFMTkuya2OmfhLhgf9CPdqj5wvA2JE1t"),
ApiTokenAuthScheme::getToken);
}
}
Original file line number Diff line number Diff line change
@@ -27,26 +27,45 @@
*/
package org.hisp.dhis.eventhook.targets.auth;

import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.hisp.dhis.common.auth.HttpBasicAuth;
import org.hisp.dhis.common.auth.HttpBasicAuthScheme;
import org.junit.jupiter.api.Test;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

/**
* @author Morten Olav Hansen
*/
class HttpBasicAuthTest {
class HttpBasicAuthSchemeTest extends AbstractAuthSchemeTest {
@Test
void testAuthorizationHeaderSet() {
HttpBasicAuth auth = new HttpBasicAuth().setUsername("admin").setPassword("district");
HttpBasicAuthScheme auth =
new HttpBasicAuthScheme().setUsername("admin").setPassword("district");

MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
auth.apply(headers);
auth.apply(headers, null);

assertTrue(headers.containsKey("Authorization"));
assertFalse(headers.get("Authorization").isEmpty());
assertEquals("Basic YWRtaW46ZGlzdHJpY3Q=", headers.get("Authorization").get(0));
}

@Test
void testEncrypt() {
assertEncrypt(
new HttpBasicAuthScheme().setUsername("admin").setPassword("district"),
HttpBasicAuthScheme::getPassword);
}

@Test
void testDecrypt() {
assertDecrypt(
new HttpBasicAuthScheme()
.setUsername("admin")
.setPassword("qkQzMuVWVGw5g3WcjhYuBZXL5r2DdlURaFMTkuya2OmfhLhgf9CPdqj5wvA2JE1t"),
HttpBasicAuthScheme::getPassword);
}
}
Original file line number Diff line number Diff line change
@@ -39,24 +39,27 @@
import java.util.Optional;
import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.hisp.dhis.common.auth.ApiTokenAuth;
import org.hisp.dhis.common.auth.Auth;
import org.hisp.dhis.common.auth.HttpBasicAuth;
import org.hisp.dhis.feedback.BadRequestException;
import org.hisp.dhis.user.User;
import org.jasypt.encryption.pbe.PBEStringCleanablePasswordEncryptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
@@ -76,7 +79,7 @@ public class RouteService {
@Qualifier(AES_128_STRING_ENCRYPTOR)
private final PBEStringCleanablePasswordEncryptor encryptor;

private static final RestTemplate restTemplate = new RestTemplate();
@Autowired @Getter @Setter private RestTemplate restTemplate;

private static List<String> allowedRequestHeaders =
List.of(
@@ -109,16 +112,17 @@ public class RouteService {
"last-modified",
"etag");

static {
@PostConstruct
public void postConstruct() {
HttpComponentsClientHttpRequestFactory requestFactory =
new HttpComponentsClientHttpRequestFactory();
requestFactory.setConnectionRequestTimeout(1_000);
requestFactory.setConnectTimeout(5_000);
requestFactory.setReadTimeout(10_000);
requestFactory.setBufferRequestBody(true);

HttpClient httpClient =
HttpClientBuilder.create().disableCookieManagement().useSystemProperties().build();
HttpClient httpClient = HttpClientBuilder.create().disableCookieManagement().build();

requestFactory.setHttpClient(httpClient);

restTemplate.setRequestFactory(requestFactory);
@@ -144,12 +148,13 @@ public Route getDecryptedRoute(@Nonnull String id) {
try {
route = objectMapper.readValue(objectMapper.writeValueAsString(route), Route.class);
} catch (JsonProcessingException ex) {
log.error(
"Unable to create clone of Route with ID " + route.getUid() + ". Please check its data.");
log.error("Unable to create clone of route: '{}'", route.getUid());
return null;
}

decrypt(route);
if (route.getAuth() != null) {
route.setAuth(route.getAuth().decrypt(encryptor::decrypt));
}

return route;
}
@@ -169,20 +174,20 @@ public ResponseEntity<String> exec(
headers.add("X-Forwarded-User", user.getUsername());
}

MultiValueMap<String, String> queryParameters = new LinkedMultiValueMap<>();
request.getParameterMap().forEach((key, value) -> queryParameters.addAll(key, List.of(value)));

if (route.getAuth() != null) {
route.getAuth().apply(headers);
route.getAuth().apply(headers, queryParameters);
}

HttpHeaders queryParameters = new HttpHeaders();
request.getParameterMap().forEach((key, value) -> queryParameters.addAll(key, List.of(value)));

UriComponentsBuilder uriComponentsBuilder =
UriComponentsBuilder.fromHttpUrl(route.getBaseUrl()).queryParams(queryParameters);

if (subPath.isPresent()) {
if (!route.allowsSubpaths()) {
throw new BadRequestException(
String.format("Route %s does not allow subpaths", route.getId()));
String.format("Route '%s' does not allow sub-paths", route.getId()));
}
uriComponentsBuilder.path(subPath.get());
}
@@ -229,7 +234,7 @@ private HttpHeaders filterHeaders(
(String name) -> {
String lowercaseName = name.toLowerCase();
if (!allowedHeaders.contains(lowercaseName)) {
log.debug(String.format("Blocked header %s", name));
log.debug("Blocked header: '{}'", name);
return;
}
List<String> values = valuesGetter.apply(name);
@@ -248,20 +253,4 @@ private HttpHeaders filterRequestHeaders(HttpServletRequest request) {
private HttpHeaders filterResponseHeaders(HttpHeaders responseHeaders) {
return filterHeaders(responseHeaders.keySet(), allowedResponseHeaders, responseHeaders::get);
}

private void decrypt(Route route) {
Auth auth = route.getAuth();

if (auth == null) {
return;
}

if (auth.getType().equals(ApiTokenAuth.TYPE)) {
ApiTokenAuth apiTokenAuth = (ApiTokenAuth) auth;
apiTokenAuth.setToken(encryptor.decrypt(apiTokenAuth.getToken()));
} else if (auth.getType().equals(HttpBasicAuth.TYPE)) {
HttpBasicAuth httpBasicAuth = (HttpBasicAuth) auth;
httpBasicAuth.setPassword(encryptor.decrypt(httpBasicAuth.getPassword()));
}
}
}
Original file line number Diff line number Diff line change
@@ -30,9 +30,7 @@
import static org.hisp.dhis.config.HibernateEncryptionConfig.AES_128_STRING_ENCRYPTOR;

import lombok.AllArgsConstructor;
import org.hisp.dhis.common.auth.ApiTokenAuth;
import org.hisp.dhis.common.auth.Auth;
import org.hisp.dhis.common.auth.HttpBasicAuth;
import org.hisp.dhis.common.auth.AuthScheme;
import org.hisp.dhis.dxf2.metadata.objectbundle.ObjectBundle;
import org.hisp.dhis.route.Route;
import org.jasypt.encryption.pbe.PBEStringCleanablePasswordEncryptor;
@@ -60,24 +58,20 @@ public void preUpdate(Route route, Route persistedObject, ObjectBundle bundle) {
}

private void encrypt(Route route) {
Auth auth = route.getAuth();
AuthScheme auth = route.getAuth();

if (auth == null) {
return;
}

if (auth.getType().equals(ApiTokenAuth.TYPE)) {
ApiTokenAuth apiTokenAuth = (ApiTokenAuth) auth;

if (StringUtils.hasText(apiTokenAuth.getToken())) {
apiTokenAuth.setToken(encryptor.encrypt(apiTokenAuth.getToken()));
}
} else if (auth.getType().equals(HttpBasicAuth.TYPE)) {
HttpBasicAuth httpBasicAuth = (HttpBasicAuth) auth;

if (StringUtils.hasText(httpBasicAuth.getPassword())) {
httpBasicAuth.setPassword(encryptor.encrypt(httpBasicAuth.getPassword()));
}
}
route.setAuth(
auth.encrypt(
secret -> {
if (StringUtils.hasText(secret)) {
return encryptor.encrypt(secret);
} else {
return secret;
}
}));
}
}
Original file line number Diff line number Diff line change
@@ -31,9 +31,7 @@

import java.util.function.UnaryOperator;
import lombok.RequiredArgsConstructor;
import org.hisp.dhis.common.auth.ApiTokenAuth;
import org.hisp.dhis.common.auth.Auth;
import org.hisp.dhis.common.auth.HttpBasicAuth;
import org.hisp.dhis.common.auth.AuthScheme;
import org.hisp.dhis.eventhook.targets.JmsTarget;
import org.hisp.dhis.eventhook.targets.KafkaTarget;
import org.hisp.dhis.eventhook.targets.WebhookTarget;
@@ -52,49 +50,37 @@ public class EventHookSecretManager {
private final PBEStringCleanablePasswordEncryptor encryptor;

public void encrypt(EventHook eventHook) {
handleSecrets(eventHook, encryptor::encrypt);
handleSecrets(eventHook, true);
}

public void decrypt(EventHook eventHook) {
handleSecrets(eventHook, encryptor::decrypt);
handleSecrets(eventHook, false);
}

private void handleSecrets(EventHook eventHook, UnaryOperator<String> callback) {
private void handleSecrets(EventHook eventHook, boolean encrypt) {
for (Target target : eventHook.getTargets()) {
if (target.getType().equals(WebhookTarget.TYPE)) {
handleWebhook((WebhookTarget) target, callback);
} else if (target.getType().equals(JmsTarget.TYPE)) {
handleJms((JmsTarget) target, callback);
} else if (target.getType().equals(KafkaTarget.TYPE)) {
handleKafka((KafkaTarget) target, callback);
handleWebhook((WebhookTarget) target, encrypt);
} else {
UnaryOperator<String> callback = encrypt ? encryptor::encrypt : encryptor::decrypt;
if (target.getType().equals(JmsTarget.TYPE)) {
handleJms((JmsTarget) target, callback);
} else if (target.getType().equals(KafkaTarget.TYPE)) {
handleKafka((KafkaTarget) target, callback);
}
}
}
}

private void handleWebhook(WebhookTarget target, UnaryOperator<String> callback) {
Auth auth = target.getAuth();

if (auth == null) {
return;
}

switch (auth.getType()) {
case HttpBasicAuth.TYPE:
HttpBasicAuth httpBasicAuth = (HttpBasicAuth) auth;
private void handleWebhook(WebhookTarget target, boolean encrypt) {
AuthScheme auth = target.getAuth();

if (StringUtils.hasText(httpBasicAuth.getPassword())) {
httpBasicAuth.setPassword(callback.apply(httpBasicAuth.getPassword()));
}
break;
case ApiTokenAuth.TYPE:
ApiTokenAuth apiTokenAuth = (ApiTokenAuth) auth;

if (StringUtils.hasText(apiTokenAuth.getToken())) {
apiTokenAuth.setToken(callback.apply(apiTokenAuth.getToken()));
}
break;
default:
break;
if (auth != null) {
if (encrypt) {
target.setAuth(auth.encrypt(encryptor::encrypt));
} else {
target.setAuth(auth.decrypt(encryptor::decrypt));
}
}
}

Original file line number Diff line number Diff line change
@@ -39,8 +39,11 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.DefaultUriBuilderFactory;

/**
* @author Morten Olav Hansen
@@ -63,21 +66,27 @@ public void run(EventHook eventHook, Event event, String payload) {
httpHeaders.setContentType(MediaType.parseMediaType(webhookTarget.getContentType()));
httpHeaders.setAll(webhookTarget.getHeaders());

MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
if (webhookTarget.getAuth() != null) {
webhookTarget.getAuth().apply(httpHeaders);
webhookTarget.getAuth().apply(httpHeaders, queryParams);
}

HttpEntity<String> httpEntity = new HttpEntity<>(payload, httpHeaders);
String webhookUri =
new DefaultUriBuilderFactory(webhookTarget.getUrl())
.builder()
.queryParams(queryParams)
.build()
.toString();

try {
ResponseEntity<String> response =
restTemplate.postForEntity(webhookTarget.getUrl(), httpEntity, String.class);
restTemplate.postForEntity(webhookUri, httpEntity, String.class);

log.info(
"EventHook '{}' response status '{}' and body: {}",
"EventHook '{}' response status '{}'",
eventHook.getUid(),
response.getStatusCode().name(),
response.getBody());
response.getStatusCode().name());
} catch (RestClientException ex) {
log.error(ex.getMessage());
}
Original file line number Diff line number Diff line change
@@ -43,8 +43,10 @@
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.hisp.dhis.common.SystemDefaultMetadataObject;
import org.hisp.dhis.common.auth.ApiTokenAuth;
import org.hisp.dhis.common.auth.HttpBasicAuth;
import org.hisp.dhis.common.auth.ApiHeadersAuthScheme;
import org.hisp.dhis.common.auth.ApiQueryParamsAuthScheme;
import org.hisp.dhis.common.auth.ApiTokenAuthScheme;
import org.hisp.dhis.common.auth.HttpBasicAuthScheme;
import org.hisp.dhis.eventhook.targets.JmsTarget;
import org.hisp.dhis.eventhook.targets.KafkaTarget;
import org.hisp.dhis.scheduling.JobParameters;
@@ -74,8 +76,10 @@ public class FieldFilterSimpleBeanPropertyFilter extends SimpleBeanPropertyFilte
*/
private static final Map<Class<?>, Set<String>> IGNORE_LIST =
Map.of(
HttpBasicAuth.class, Set.of("auth.password", "targets.auth.password"),
ApiTokenAuth.class, Set.of("auth.token", "targets.auth.token"),
HttpBasicAuthScheme.class, Set.of("auth.password", "targets.auth.password"),
ApiTokenAuthScheme.class, Set.of("auth.token", "targets.auth.token"),
ApiHeadersAuthScheme.class, Set.of("auth.headers", "targets.auth.headers"),
ApiQueryParamsAuthScheme.class, Set.of("auth.queryParams", "targets.auth.queryParams"),
JmsTarget.class, Set.of("targets.password"),
KafkaTarget.class, Set.of("targets.password"));

Original file line number Diff line number Diff line change
@@ -188,7 +188,7 @@
</typedef>

<typedef class="org.hisp.dhis.hibernate.jsonb.type.JsonBinaryType" name="jbAuth">
<param name="clazz">org.hisp.dhis.common.auth.Auth</param>
<param name="clazz">org.hisp.dhis.common.auth.AuthScheme</param>
</typedef>

<typedef class="org.hisp.dhis.hibernate.jsonb.type.JsonBinaryType" name="jblEventHookSource">
Original file line number Diff line number Diff line change
@@ -27,13 +27,16 @@
*/
package org.hisp.dhis.webapi.controller;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.hisp.dhis.external.conf.ConfigurationKey;
import org.hisp.dhis.external.conf.DhisConfigurationProvider;
import org.hisp.dhis.jsontree.JsonArray;
import org.hisp.dhis.jsontree.JsonResponse;
import org.hisp.dhis.web.HttpStatus;
import org.hisp.dhis.webapi.DhisControllerConvenienceTest;
import org.hisp.dhis.webapi.json.domain.JsonWebMessage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -64,12 +67,13 @@ void testListAppHub_ClientError() {
configuration
.getProperties()
.setProperty(ConfigurationKey.APPHUB_API_URL.getKey(), "http://localhost/doesnotwork");
assertWebMessage(
"Service Unavailable",
503,
"ERROR",
"I/O error on GET request for \"http://localhost/doesnotwork/apps\": Connection refused (Connection refused); nested exception is java.net.ConnectException: Connection refused (Connection refused)",
GET("/appHub").content(HttpStatus.SERVICE_UNAVAILABLE));

JsonResponse jsonResponse = GET("/appHub").content(HttpStatus.SERVICE_UNAVAILABLE);
JsonWebMessage jsonWebMessage = jsonResponse.as(JsonWebMessage.class);

assertEquals(503, jsonWebMessage.getHttpStatusCode());
assertEquals("ERROR", jsonWebMessage.getStatus());
assertTrue(jsonWebMessage.getMessage().startsWith("I/O error on GET request for "));
}

@Test
Original file line number Diff line number Diff line change
@@ -33,8 +33,8 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.hisp.dhis.common.auth.ApiTokenAuth;
import org.hisp.dhis.common.auth.HttpBasicAuth;
import org.hisp.dhis.common.auth.ApiTokenAuthScheme;
import org.hisp.dhis.common.auth.HttpBasicAuthScheme;
import org.hisp.dhis.eventhook.targets.WebhookTarget;
import org.hisp.dhis.jsontree.JsonList;
import org.hisp.dhis.jsontree.JsonObject;
@@ -80,7 +80,7 @@ void testCreateEventHookWebhookApiToken() {

JsonObject auth = target.getObject("auth");
assertFalse(auth.has("token"));
assertEquals(ApiTokenAuth.TYPE, auth.getString("type").string());
assertEquals(ApiTokenAuthScheme.API_TOKEN_TYPE, auth.getString("type").string());
}

@Test
@@ -107,7 +107,7 @@ void testCreateEventHookWebhookHttpBasic() {
JsonObject auth = target.getObject("auth");
assertTrue(auth.has("type", "username"));
assertFalse(auth.has("password"));
assertEquals(HttpBasicAuth.TYPE, auth.getString("type").string());
assertEquals(HttpBasicAuthScheme.HTTP_BASIC_TYPE, auth.getString("type").string());
assertEquals("admin", auth.getString("username").string());
}

@@ -136,7 +136,7 @@ void testCreateEventHookWebhookHttpBasicDefaultEnabled() {
JsonObject auth = target.getObject("auth");
assertTrue(auth.has("type", "username"));
assertFalse(auth.has("password"));
assertEquals(HttpBasicAuth.TYPE, auth.getString("type").string());
assertEquals(HttpBasicAuthScheme.HTTP_BASIC_TYPE, auth.getString("type").string());
assertEquals("admin", auth.getString("username").string());
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.webapi.controller;

import static org.hisp.dhis.web.WebClientUtils.assertStatus;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import org.hisp.dhis.common.auth.ApiHeadersAuthScheme;
import org.hisp.dhis.common.auth.ApiQueryParamsAuthScheme;
import org.hisp.dhis.jsontree.JsonObject;
import org.hisp.dhis.jsontree.JsonString;
import org.hisp.dhis.route.Route;
import org.hisp.dhis.route.RouteService;
import org.hisp.dhis.webapi.DhisControllerIntegrationTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;

@Transactional
class RouteControllerTest extends DhisControllerIntegrationTest {

@Autowired private RouteService service;

@Autowired private ObjectMapper jsonMapper;

@Test
void testRunRouteGivenApiQueryParamsAuthScheme()
throws JsonProcessingException, MalformedURLException {
ArgumentCaptor<String> urlArgumentCaptor = ArgumentCaptor.forClass(String.class);

RestTemplate mockRestTemplate = mock(RestTemplate.class);
when(mockRestTemplate.exchange(
urlArgumentCaptor.capture(),
any(HttpMethod.class),
any(HttpEntity.class),
any(Class.class)))
.thenReturn(
new ResponseEntity<>(
jsonMapper.writeValueAsString(Map.of("name", "John Doe")), HttpStatus.OK));
service.setRestTemplate(mockRestTemplate);

Map<String, Object> route = new HashMap<>();
route.put("name", "route-under-test");
route.put("auth", Map.of("type", "api-query-params", "queryParams", Map.of("token", "foo")));
route.put("url", "http://stub");

HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route));
HttpResponse runHttpResponse =
GET(
"/routes/{id}/run",
postHttpResponse.content().get("response.uid").as(JsonString.class).string());
assertStatus(org.hisp.dhis.web.HttpStatus.OK, runHttpResponse);
assertEquals("John Doe", runHttpResponse.content().get("name").as(JsonString.class).string());

assertEquals("token=foo", new URL(urlArgumentCaptor.getValue()).getQuery());
}

@Test
void testRunRouteGivenApiHeadersAuthScheme() throws JsonProcessingException {
ArgumentCaptor<HttpEntity<?>> httpEntityArgumentCaptor =
ArgumentCaptor.forClass(HttpEntity.class);

RestTemplate mockRestTemplate = mock(RestTemplate.class);
when(mockRestTemplate.exchange(
anyString(),
any(HttpMethod.class),
httpEntityArgumentCaptor.capture(),
any(Class.class)))
.thenReturn(
new ResponseEntity<>(
jsonMapper.writeValueAsString(Map.of("name", "John Doe")), HttpStatus.OK));
service.setRestTemplate(mockRestTemplate);

Map<String, Object> route = new HashMap<>();
route.put("name", "route-under-test");
route.put("auth", Map.of("type", "api-headers", "headers", Map.of("X-API-KEY", "foo")));
route.put("url", "http://stub");

HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route));
HttpResponse runHttpResponse =
GET(
"/routes/{id}/run",
postHttpResponse.content().get("response.uid").as(JsonString.class).string());
assertStatus(org.hisp.dhis.web.HttpStatus.OK, runHttpResponse);
assertEquals("John Doe", runHttpResponse.content().get("name").as(JsonString.class).string());

HttpEntity<?> capturedHttpEntity = httpEntityArgumentCaptor.getValue();
HttpHeaders headers = capturedHttpEntity.getHeaders();
assertEquals("foo", headers.get("X-API-KEY").get(0));
}

@Test
void testAddRouteGivenApiHeadersAuthScheme() throws JsonProcessingException {
Map<String, Object> route = new HashMap<>();
route.put("name", "route-under-test");
route.put("auth", Map.of("type", "api-headers", "headers", Map.of("X-API-KEY", "foo")));
route.put("url", "http://stub");

HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route));
assertStatus(org.hisp.dhis.web.HttpStatus.CREATED, postHttpResponse);

HttpResponse getHttpResponse =
GET(
"/routes/{id}",
postHttpResponse.content().get("response.uid").as(JsonString.class).string());
assertStatus(org.hisp.dhis.web.HttpStatus.OK, getHttpResponse);
assertNotEquals(
"foo",
getHttpResponse.content().get("auth.headers.X-API-KEY").as(JsonString.class).string());
assertEquals(
ApiHeadersAuthScheme.API_HEADERS_TYPE,
getHttpResponse.content().get("auth.type").as(JsonString.class).string());
}

@Test
void testGetRouteGivenApiHeadersAuthScheme() throws JsonProcessingException {
Map<String, Object> route = new HashMap<>();
route.put("name", "route-under-test");
route.put("auth", Map.of("type", "api-headers", "headers", Map.of("X-API-KEY", "foo")));
route.put("url", "http://stub");

HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route));

HttpResponse getHttpResponse =
GET(
"/routes/{id}",
postHttpResponse.content().get("response.uid").as(JsonString.class).string());
assertStatus(org.hisp.dhis.web.HttpStatus.OK, getHttpResponse);
assertNotEquals(
"foo",
getHttpResponse.content().get("auth.headers.X-API-KEY").as(JsonString.class).string());
assertEquals(
ApiHeadersAuthScheme.API_HEADERS_TYPE,
getHttpResponse.content().get("auth.type").as(JsonString.class).string());
assertFalse(getHttpResponse.content().get("auth").as(JsonObject.class).has("headers"));
}

@Test
void testAddRouteGivenApiQueryParamsAuthScheme() throws JsonProcessingException {
ApiQueryParamsAuthScheme queryParamsAuthScheme = new ApiQueryParamsAuthScheme();
queryParamsAuthScheme.setQueryParams(Map.of("token", "foo"));

Route route = new Route();
route.setName("route-under-test");
route.setAuth(queryParamsAuthScheme);
route.setUrl("http://stub");

HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route));
assertStatus(org.hisp.dhis.web.HttpStatus.CREATED, postHttpResponse);

HttpResponse getHttpResponse =
GET(
"/routes/{id}",
postHttpResponse.content().get("response.uid").as(JsonString.class).string());
assertStatus(org.hisp.dhis.web.HttpStatus.OK, getHttpResponse);
assertNotEquals(
"foo", getHttpResponse.content().get("auth.headers.token").as(JsonString.class).string());
assertEquals(
ApiQueryParamsAuthScheme.API_QUERY_PARAMS_TYPE,
getHttpResponse.content().get("auth.type").as(JsonString.class).string());
}

@Test
void testGetRouteGivenApiQueryParamsAuthScheme() throws JsonProcessingException {
Map<String, Object> route = new HashMap<>();
route.put("name", "route-under-test");
route.put("auth", Map.of("type", "api-query-params", "queryParams", Map.of("token", "foo")));
route.put("url", "http://stub");

HttpResponse postHttpResponse = POST("/routes", jsonMapper.writeValueAsString(route));

HttpResponse getHttpResponse =
GET(
"/routes/{id}",
postHttpResponse.content().get("response.uid").as(JsonString.class).string());
assertStatus(org.hisp.dhis.web.HttpStatus.OK, getHttpResponse);
assertEquals(
ApiQueryParamsAuthScheme.API_QUERY_PARAMS_TYPE,
getHttpResponse.content().get("auth.type").as(JsonString.class).string());
assertFalse(getHttpResponse.content().get("auth").as(JsonObject.class).has("queryParams"));
}
}