Skip to content

Commit 8e52bb7

Browse files
authored
Merge pull request #118 from IABTechLab/ian-UID2-6154-optout-e2e-update-sqs
update e2e tests for optout sqs
2 parents 6a61174 + 5e534dd commit 8e52bb7

8 files changed

Lines changed: 278 additions & 27 deletions

File tree

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ ENV E2E_PHONE_SUPPORT ""
2222

2323
ENV UID2_CORE_E2E_OPERATOR_API_KEY ""
2424
ENV UID2_CORE_E2E_OPTOUT_API_KEY ""
25+
ENV UID2_CORE_E2E_OPTOUT_INTERNAL_API_KEY ""
2526
ENV UID2_CORE_E2E_CORE_URL ""
2627
ENV UID2_CORE_E2E_OPTOUT_URL ""
28+
ENV UID2_CORE_E2E_LOCALSTACK_URL ""
2729

2830
ENV UID2_OPERATOR_E2E_CLIENT_SITE_ID ""
2931
ENV UID2_OPERATOR_E2E_CLIENT_API_KEY ""

docker-compose.yml

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,27 @@ version: "3.8"
33
services:
44
localstack:
55
container_name: localstack
6-
image: localstack/localstack:1.3.0
6+
image: localstack/localstack:4.0.3
77
ports:
88
- "127.0.0.1:5001:5001"
99
volumes:
1010
- "./docker/uid2-core/src/init-aws.sh:/etc/localstack/init/ready.d/init-aws-core.sh"
1111
- "./docker/uid2-core/src/s3/core:/s3/core"
12-
- "./docker/uid2-core/src/kms/seed.yaml:/init/seed.yaml"
1312
- "./docker/uid2-optout/src/init-aws.sh:/etc/localstack/init/ready.d/init-aws-optout.sh"
1413
- "./docker/uid2-optout/src/s3/optout:/s3/optout"
1514
environment:
16-
- EDGE_PORT=5001
17-
- KMS_PROVIDER=local-kms
15+
- GATEWAY_LISTEN=0.0.0.0:5001
16+
- LOCALSTACK_HOST=localstack:5001
17+
- SERVICES=s3,sqs,kms
18+
- DEFAULT_REGION=us-east-1
1819
healthcheck:
1920
test: awslocal s3api wait bucket-exists --bucket test-core-bucket
2021
&& awslocal s3api wait bucket-exists --bucket test-optout-bucket
22+
&& awslocal sqs get-queue-url --queue-name optout-queue
23+
&& awslocal kms describe-key --key-id ff275b92-0def-4dfc-b0f6-87c96b26c6c7
2124
interval: 5s
22-
timeout: 5s
23-
retries: 3
25+
timeout: 10s
26+
retries: 6
2427
networks:
2528
- e2e_default
2629

@@ -49,17 +52,20 @@ services:
4952
image: ghcr.io/iabtechlab/uid2-optout:latest
5053
ports:
5154
- "127.0.0.1:8081:8081"
55+
- "127.0.0.1:8082:8082"
5256
- "127.0.0.1:5090:5005"
5357
volumes:
5458
- ./docker/uid2-optout/conf/default-config.json:/app/conf/default-config.json
5559
- ./docker/uid2-optout/conf/local-e2e-docker-config.json:/app/conf/local-config.json
5660
- ./docker/uid2-optout/mount/:/opt/uid2/optout/
5761
depends_on:
62+
localstack:
63+
condition: service_healthy
5864
core:
5965
condition: service_healthy
6066
healthcheck:
6167
test: wget --tries=1 --spider http://localhost:8081/ops/healthcheck || exit 1
62-
interval: 5s
68+
retries: 12
6369
networks:
6470
- e2e_default
6571

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.uid2</groupId>
88
<artifactId>uid2-e2e</artifactId>
9-
<version>4.1.0</version>
9+
<version>4.1.15-alpha-89-SNAPSHOT</version>
1010

1111
<properties>
1212
<maven.compiler.source>21</maven.compiler.source>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package app.component;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.uid2.shared.util.Mapper;
6+
import common.Const;
7+
import common.EnvUtil;
8+
import common.HttpClient;
9+
10+
/**
11+
* Component for interacting with the UID2 Optout service.
12+
*/
13+
public class Optout extends App {
14+
private static final ObjectMapper OBJECT_MAPPER = Mapper.getInstance();
15+
16+
// The SQS delta producer runs on port 8082 (8081 + 1)
17+
private static final int DELTA_PRODUCER_PORT_OFFSET = 1;
18+
19+
private String optoutInternalApiKey;
20+
21+
public Optout(String host, Integer port, String name) {
22+
super(host, port, name);
23+
this.optoutInternalApiKey = EnvUtil.getEnv(Const.Config.Core.OPTOUT_INTERNAL_API_KEY, false);
24+
}
25+
26+
public Optout(String host, String name) {
27+
super(host, null, name);
28+
this.optoutInternalApiKey = EnvUtil.getEnv(Const.Config.Core.OPTOUT_INTERNAL_API_KEY, false);
29+
}
30+
31+
private String getOptoutInternalApiKey() {
32+
if (optoutInternalApiKey == null || optoutInternalApiKey.isEmpty()) {
33+
throw new IllegalStateException("Missing environment variable: " + Const.Config.Core.OPTOUT_INTERNAL_API_KEY);
34+
}
35+
return optoutInternalApiKey;
36+
}
37+
38+
/**
39+
* Triggers delta production on the optout service.
40+
* This reads from the SQS queue and produces delta files.
41+
* The endpoint is on port 8082 (optout port + 1).
42+
*
43+
* @return JsonNode with response, or null if job already running (409)
44+
*/
45+
public JsonNode triggerDeltaProduce() throws Exception {
46+
String deltaProduceUrl = getDeltaProducerBaseUrl() + "/optout/deltaproduce";
47+
try {
48+
String response = HttpClient.post(deltaProduceUrl, "", getOptoutInternalApiKey());
49+
return OBJECT_MAPPER.readTree(response);
50+
} catch (HttpClient.HttpException e) {
51+
if (e.getCode() == 409) {
52+
// Job already running - this is fine, we'll just wait for it
53+
return null;
54+
}
55+
throw e;
56+
}
57+
}
58+
59+
/**
60+
* Gets the status of the current delta production job.
61+
*/
62+
public JsonNode getDeltaProduceStatus() throws Exception {
63+
String statusUrl = getDeltaProducerBaseUrl() + "/optout/deltaproduce/status";
64+
String response = HttpClient.get(statusUrl, getOptoutInternalApiKey());
65+
return OBJECT_MAPPER.readTree(response);
66+
}
67+
68+
/**
69+
* Triggers delta production and waits for it to complete.
70+
* If a job is already running, waits for that job instead.
71+
* @param maxWaitSeconds Maximum time to wait for completion
72+
* @return true if delta production completed successfully
73+
*/
74+
public boolean triggerDeltaProduceAndWait(int maxWaitSeconds) throws Exception {
75+
// Try to trigger - will return null if job already running (409)
76+
triggerDeltaProduce();
77+
78+
long startTime = System.currentTimeMillis();
79+
long maxWaitMs = maxWaitSeconds * 1000L;
80+
81+
while (System.currentTimeMillis() - startTime < maxWaitMs) {
82+
Thread.sleep(2000); // Poll every 2 seconds
83+
84+
JsonNode status = getDeltaProduceStatus();
85+
String state = status.path("state").asText();
86+
87+
if ("completed".equalsIgnoreCase(state) || "failed".equalsIgnoreCase(state)) {
88+
return "completed".equalsIgnoreCase(state);
89+
}
90+
91+
// If idle (no job), try to trigger again
92+
if ("idle".equalsIgnoreCase(state) || "none".equalsIgnoreCase(state) || state.isEmpty()) {
93+
triggerDeltaProduce();
94+
}
95+
}
96+
97+
return false; // Timed out
98+
}
99+
100+
private String getDeltaProducerBaseUrl() {
101+
// Delta producer runs on optout port + 1
102+
if (getPort() != null) {
103+
return "http://" + getHost() + ":" + (getPort() + DELTA_PRODUCER_PORT_OFFSET);
104+
}
105+
// If port not specified, assume default optout port (8081) + 1
106+
return "http://" + getHost() + ":8082";
107+
}
108+
}

src/test/java/common/Const.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public static final class Config {
1313
public static final class Core {
1414
public static final String OPERATOR_API_KEY = "UID2_CORE_E2E_OPERATOR_API_KEY";
1515
public static final String OPTOUT_API_KEY = "UID2_CORE_E2E_OPTOUT_API_KEY";
16+
public static final String OPTOUT_INTERNAL_API_KEY = "UID2_CORE_E2E_OPTOUT_INTERNAL_API_KEY";
1617
public static final String CORE_URL = "UID2_CORE_E2E_CORE_URL";
1718
public static final String OPTOUT_URL = "UID2_CORE_E2E_OPTOUT_URL";
1819
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package common;
2+
3+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
4+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
5+
import software.amazon.awssdk.regions.Region;
6+
import software.amazon.awssdk.services.kms.KmsClient;
7+
import software.amazon.awssdk.services.kms.model.GetPublicKeyRequest;
8+
import software.amazon.awssdk.services.kms.model.GetPublicKeyResponse;
9+
10+
import java.net.URI;
11+
import java.security.KeyFactory;
12+
import java.security.PublicKey;
13+
import java.security.spec.X509EncodedKeySpec;
14+
import java.util.Base64;
15+
16+
/**
17+
* Helper class for interacting with KMS (or LocalStack KMS) in e2e tests.
18+
*
19+
* This allows tests to dynamically fetch public keys from KMS rather than
20+
* relying on hardcoded keys, which is necessary when using LocalStack since
21+
* it generates its own RSA key material.
22+
*/
23+
public final class KmsHelper {
24+
25+
private static final String LOCALSTACK_ENDPOINT = "http://localstack:5001";
26+
private static final String KMS_KEY_ID = "ff275b92-0def-4dfc-b0f6-87c96b26c6c7";
27+
private static final Region REGION = Region.US_EAST_1;
28+
29+
private KmsHelper() {
30+
}
31+
32+
/**
33+
* Fetches the public key from LocalStack KMS for the configured key ID.
34+
*
35+
* @return The public key as a base64-encoded string
36+
* @throws Exception if the key cannot be fetched or parsed
37+
*/
38+
public static String getPublicKeyFromLocalstack() throws Exception {
39+
try (KmsClient kmsClient = createLocalstackKmsClient()) {
40+
GetPublicKeyRequest request = GetPublicKeyRequest.builder()
41+
.keyId(KMS_KEY_ID)
42+
.build();
43+
44+
GetPublicKeyResponse response = kmsClient.getPublicKey(request);
45+
byte[] publicKeyBytes = response.publicKey().asByteArray();
46+
47+
// Return as base64-encoded string (format expected by JwtService)
48+
return Base64.getEncoder().encodeToString(publicKeyBytes);
49+
}
50+
}
51+
52+
/**
53+
* Fetches the public key from LocalStack KMS and returns it as a Java PublicKey object.
54+
*
55+
* @return The PublicKey object
56+
* @throws Exception if the key cannot be fetched or parsed
57+
*/
58+
public static PublicKey getPublicKeyObjectFromLocalstack() throws Exception {
59+
try (KmsClient kmsClient = createLocalstackKmsClient()) {
60+
GetPublicKeyRequest request = GetPublicKeyRequest.builder()
61+
.keyId(KMS_KEY_ID)
62+
.build();
63+
64+
GetPublicKeyResponse response = kmsClient.getPublicKey(request);
65+
66+
byte[] publicKeyBytes = response.publicKey().asByteArray();
67+
68+
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
69+
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
70+
return keyFactory.generatePublic(keySpec);
71+
}
72+
}
73+
74+
private static KmsClient createLocalstackKmsClient() {
75+
return KmsClient.builder()
76+
.endpointOverride(URI.create(LOCALSTACK_ENDPOINT))
77+
.region(REGION)
78+
.credentialsProvider(StaticCredentialsProvider.create(
79+
AwsBasicCredentials.create("test", "test")))
80+
.build();
81+
}
82+
}

src/test/java/suite/core/CoreTest.java

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
package suite.core;
22

33
import common.HttpClient;
4+
import common.KmsHelper;
45
import app.component.Core;
56
import com.fasterxml.jackson.databind.JsonNode;
7+
import com.uid2.shared.Const;
68
import com.uid2.shared.attest.JwtService;
7-
import com.uid2.shared.attest.JwtValidationResponse;
89
import io.vertx.core.json.JsonObject;
910
import org.junit.jupiter.api.condition.EnabledIf;
1011
import org.junit.jupiter.params.ParameterizedTest;
@@ -29,6 +30,13 @@ public void testAttest_EmptyAttestationRequest(Core core) {
2930
assertEquals("Unsuccessful POST request - URL: " + coreUrl + "/attest - Code: 400 Bad Request - Response body: {\"status\":\"no attestation_request attached\"}", exception.getMessage());
3031
}
3132

33+
/**
34+
* Tests valid attestation request with JWT signing.
35+
*
36+
* Since LocalStack generates its own RSA key material,
37+
* we dynamically fetch the public key from LocalStack's
38+
* KMS using GetPublicKey API to validate JWT signatures.
39+
*/
3240
@ParameterizedTest(name = "/attest - {0}")
3341
@MethodSource({
3442
"suite.core.TestData#baseArgs"
@@ -38,7 +46,7 @@ public void testAttest_ValidAttestationRequest(Core core) throws Exception {
3846

3947
JsonNode response = core.attest(validTrustedAttestationRequest);
4048

41-
assertAll("",
49+
assertAll("Attestation response should be successful",
4250
() -> assertNotNull(response.get("status")),
4351
() -> assertEquals("success", response.get("status").asText()));
4452

@@ -48,29 +56,45 @@ public void testAttest_ValidAttestationRequest(Core core) throws Exception {
4856
() -> assertNotNull(body.get("attestation_token")),
4957
() -> assertNotNull(body.get("expiresAt")));
5058

51-
JwtService jwtService = new JwtService(getConfig());
52-
assertNotNull(body.get("attestation_jwt_optout"));
53-
JwtValidationResponse validationResponseOptOut = jwtService.validateJwt(body.get("attestation_jwt_optout").asText(), Core.OPTOUT_URL, Core.CORE_URL);
54-
assertAll("testAttest_ValidAttestationRequest valid OptOut JWT. Local OptOut URL: '" + Core.OPTOUT_URL + "', Core URL: '" + Core.CORE_URL + "'",
55-
() -> assertNotNull(validationResponseOptOut),
56-
() -> assertTrue(validationResponseOptOut.getIsValid()));
59+
// Verify JWTs are generated - LocalStack 4.x supports KMS Sign
60+
JsonNode jwtOptoutNode = body.get("attestation_jwt_optout");
61+
JsonNode jwtCoreNode = body.get("attestation_jwt_core");
62+
63+
assertAll("JWTs should be generated by KMS Sign",
64+
() -> assertNotNull(jwtOptoutNode, "attestation_jwt_optout should not be null"),
65+
() -> assertFalse(jwtOptoutNode.isNull(), "attestation_jwt_optout should not be JSON null"),
66+
() -> assertFalse(jwtOptoutNode.asText().isEmpty(), "attestation_jwt_optout should not be empty"),
67+
() -> assertNotNull(jwtCoreNode, "attestation_jwt_core should not be null"),
68+
() -> assertFalse(jwtCoreNode.isNull(), "attestation_jwt_core should not be JSON null"),
69+
() -> assertFalse(jwtCoreNode.asText().isEmpty(), "attestation_jwt_core should not be empty"));
5770

58-
assertNotNull(body.get("attestation_jwt_core"));
59-
JwtValidationResponse validationResponseCore = jwtService.validateJwt(body.get("attestation_jwt_core").asText(), Core.CORE_URL, Core.CORE_URL);
60-
assertAll("testAttest_ValidAttestationRequest valid Core JWT. Local Core URL: '" + Core.CORE_URL + "'",
61-
() -> assertNotNull(validationResponseCore),
62-
() -> assertTrue(validationResponseCore.getIsValid()));
71+
// Verify JWT format (header.payload.signature)
72+
String jwtOptout = jwtOptoutNode.asText();
73+
String jwtCore = jwtCoreNode.asText();
74+
assertAll("JWTs should have valid format",
75+
() -> assertEquals(3, jwtOptout.split("\\.").length, "OptOut JWT should have 3 parts"),
76+
() -> assertEquals(3, jwtCore.split("\\.").length, "Core JWT should have 3 parts"));
77+
78+
// Fetch the public key dynamically from LocalStack KMS and validate JWT signatures
79+
String publicKeyBase64 = KmsHelper.getPublicKeyFromLocalstack();
80+
JsonObject config = new JsonObject()
81+
.put(Const.Config.AwsKmsJwtSigningPublicKeysProp, publicKeyBase64);
82+
JwtService jwtService = new JwtService(config);
83+
84+
// Validate optout JWT signature
85+
var optoutValidation = jwtService.validateJwt(jwtOptout, Core.OPTOUT_URL, Core.CORE_URL);
86+
assertTrue(optoutValidation.getIsValid(), "OptOut JWT signature should be valid");
87+
88+
// Validate core JWT signature
89+
var coreValidation = jwtService.validateJwt(jwtCore, Core.CORE_URL, Core.CORE_URL);
90+
assertTrue(coreValidation.getIsValid(), "Core JWT signature should be valid");
6391

6492
String optoutUrl = body.get("optout_url").asText();
6593
assertAll("testAttest_ValidAttestationRequest OptOut URL not null",
6694
() -> assertNotNull(optoutUrl),
6795
() -> assertEquals(Core.OPTOUT_URL, optoutUrl));
6896
}
6997

70-
private static JsonObject getConfig() {
71-
return new JsonObject("{ \"aws_kms_jwt_signing_public_keys\": \"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmvwB41qI5Fe41PDbXqcX5uOvSvfKh8l9QV0O3M+NsB4lKqQEP0t1hfoiXTpOgKz1ArYxHsQ2LeXifX4uwEbYJFlpVM+tyQkTWQjBOw6fsLYK2Xk4X2ylNXUUf7x3SDiOVxyvTh3OZW9kqrDBN9JxSoraNLyfw0hhW0SHpfs699SehgbQ7QWep/gVlKRLIz0XAXaZNw24s79ORcQlrCE6YD0PgQmpI/dK5xMML82n6y3qcTlywlGaU7OGIMdD+CTXA3BcOkgXeqZTXNaX1u6jCTa1lvAczun6avp5VZ4TFiuPo+y4rJ3GU+14cyT5NckEcaTKSvd86UdwK5Id9tl3bQIDAQAB\"}");
72-
}
73-
7498
@ParameterizedTest(name = "/operator/config - {0}")
7599
@MethodSource({
76100
"suite.core.TestData#baseArgs"

0 commit comments

Comments
 (0)