integration tests initial setup
This commit is contained in:
parent
af0d5adcdc
commit
a553093046
|
@ -0,0 +1,30 @@
|
|||
name: Integration Tests
|
||||
|
||||
on: [workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
cache: 'maven'
|
||||
- uses: aws-actions/configure-aws-credentials@v2
|
||||
name: Configure AWS credentials from Test account
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Fetch integration utils library
|
||||
run: |
|
||||
mkdir -p integration-tests/.libs
|
||||
mkdir -p integration-tests/src/main/resources
|
||||
wget -O integration-tests/.libs/software.amazon.awssdk-sso.jar https://repo1.maven.org/maven2/software/amazon/awssdk/sso/2.19.8/sso-2.19.8.jar
|
||||
aws s3 cp "s3://${{ vars.INTEGRATION_TESTS_BUCKET }}/config-latest.yml" integration-tests/src/main/resources/config.yml
|
||||
- name: Run and verify integration tests
|
||||
run: ./mvnw clean compile test-compile failsafe:integration-test failsafe:verify
|
|
@ -0,0 +1,2 @@
|
|||
.libs
|
||||
src/main/resources/config.yml
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>TextSecureServer</artifactId>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<version>JGITVER</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<artifactId>integration-tests</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.whispersystems.textsecure</groupId>
|
||||
<artifactId>service</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>dynamodb</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.0.0-M7</version>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<configuration>
|
||||
<additionalClasspathElements>
|
||||
<additionalClasspathElement>${project.basedir}/.libs/software.amazon.awssdk-sso.jar</additionalClasspathElement>
|
||||
</additionalClasspathElements>
|
||||
<includes>
|
||||
<include>**/*.java</include>
|
||||
</includes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import java.io.IOException;
|
||||
import java.util.Base64;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
|
||||
public final class Codecs {
|
||||
|
||||
private Codecs() {
|
||||
// utility class
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface CheckedFunction<T, R> {
|
||||
R apply(T t) throws Exception;
|
||||
}
|
||||
|
||||
public static class Base64BasedSerializer<T> extends JsonSerializer<T> {
|
||||
|
||||
private final CheckedFunction<T, byte[]> mapper;
|
||||
|
||||
public Base64BasedSerializer(final CheckedFunction<T, byte[]> mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(final T value, final JsonGenerator gen, final SerializerProvider serializers) throws IOException {
|
||||
try {
|
||||
gen.writeString(Base64.getEncoder().withoutPadding().encodeToString(mapper.apply(value)));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class Base64BasedDeserializer<T> extends JsonDeserializer<T> {
|
||||
|
||||
private final CheckedFunction<byte[], T> mapper;
|
||||
|
||||
public Base64BasedDeserializer(final CheckedFunction<byte[], T> mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T deserialize(final JsonParser p, final DeserializationContext ctxt) throws IOException {
|
||||
try {
|
||||
return mapper.apply(Base64.getDecoder().decode(p.getValueAsString()));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class ByteArraySerializer extends Base64BasedSerializer<byte[]> {
|
||||
public ByteArraySerializer() {
|
||||
super(bytes -> bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ByteArrayDeserializer extends Base64BasedDeserializer<byte[]> {
|
||||
public ByteArrayDeserializer() {
|
||||
super(bytes -> bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ECPublicKeySerializer extends Base64BasedSerializer<ECPublicKey> {
|
||||
public ECPublicKeySerializer() {
|
||||
super(ECPublicKey::serialize);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ECPublicKeyDeserializer extends Base64BasedDeserializer<ECPublicKey> {
|
||||
public ECPublicKeyDeserializer() {
|
||||
super(bytes -> Curve.decodePoint(bytes, 0));
|
||||
}
|
||||
}
|
||||
|
||||
public static class IdentityKeySerializer extends Base64BasedSerializer<IdentityKey> {
|
||||
public IdentityKeySerializer() {
|
||||
super(IdentityKey::serialize);
|
||||
}
|
||||
}
|
||||
|
||||
public static class IdentityKeyDeserializer extends Base64BasedDeserializer<IdentityKey> {
|
||||
public IdentityKeyDeserializer() {
|
||||
super(bytes -> new IdentityKey(bytes, 0));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.signal.integration.config.Config;
|
||||
import org.whispersystems.textsecuregcm.registration.VerificationSession;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;
|
||||
import org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessionManager;
|
||||
import org.whispersystems.textsecuregcm.storage.VerificationSessions;
|
||||
import org.whispersystems.textsecuregcm.util.DynamoDbFromConfig;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
|
||||
public class IntegrationTools {
|
||||
|
||||
private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;
|
||||
|
||||
private final VerificationSessionManager verificationSessionManager;
|
||||
|
||||
|
||||
public static IntegrationTools create(final Config config) {
|
||||
final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();
|
||||
|
||||
final DynamoDbAsyncClient dynamoDbAsyncClient = DynamoDbFromConfig.asyncClient(
|
||||
config.dynamoDbClientConfiguration(),
|
||||
credentialsProvider);
|
||||
|
||||
final DynamoDbClient dynamoDbClient = DynamoDbFromConfig.client(
|
||||
config.dynamoDbClientConfiguration(),
|
||||
credentialsProvider);
|
||||
|
||||
final RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(
|
||||
config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbClient, dynamoDbAsyncClient);
|
||||
|
||||
final VerificationSessions verificationSessions = new VerificationSessions(
|
||||
dynamoDbAsyncClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());
|
||||
|
||||
return new IntegrationTools(
|
||||
new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords),
|
||||
new VerificationSessionManager(verificationSessions)
|
||||
);
|
||||
}
|
||||
|
||||
private IntegrationTools(
|
||||
final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,
|
||||
final VerificationSessionManager verificationSessionManager) {
|
||||
this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;
|
||||
this.verificationSessionManager = verificationSessionManager;
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> populateRecoveryPassword(final String e164, final byte[] password) {
|
||||
return registrationRecoveryPasswordsManager.storeForCurrentNumber(e164, password);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<String>> peekVerificationSessionPushChallenge(final String sessionId) {
|
||||
return verificationSessionManager.findForId(sessionId)
|
||||
.thenApply(maybeSession -> maybeSession.map(VerificationSession::pushChallenge));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.google.common.io.Resources;
|
||||
import com.google.common.net.HttpHeaders;
|
||||
import java.io.IOException;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import org.apache.commons.lang3.RandomUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.signal.integration.config.Config;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.RegistrationRequest;
|
||||
import org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
import org.whispersystems.textsecuregcm.util.HeaderUtils;
|
||||
import org.whispersystems.textsecuregcm.util.SystemMapper;
|
||||
|
||||
public final class Operations {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
|
||||
|
||||
private static final Config CONFIG = loadConfigFromClasspath("config.yml");
|
||||
|
||||
private static final IntegrationTools INTEGRATION_TOOLS = IntegrationTools.create(CONFIG);
|
||||
|
||||
private static final String USER_AGENT = "integration-test";
|
||||
|
||||
private static final FaultTolerantHttpClient CLIENT = buildClient();
|
||||
|
||||
|
||||
private Operations() {
|
||||
// utility class
|
||||
}
|
||||
|
||||
public static TestUser newRegisteredUser(final String number) {
|
||||
final byte[] registrationPassword = RandomUtils.nextBytes(32);
|
||||
final String accountPassword = Base64.getEncoder().encodeToString(RandomUtils.nextBytes(32));
|
||||
|
||||
final TestUser user = TestUser.create(number, accountPassword, registrationPassword);
|
||||
final AccountAttributes accountAttributes = user.accountAttributes();
|
||||
|
||||
INTEGRATION_TOOLS.populateRecoveryPassword(number, registrationPassword).join();
|
||||
|
||||
// register account
|
||||
final RegistrationRequest registrationRequest = new RegistrationRequest(
|
||||
null, registrationPassword, accountAttributes, true);
|
||||
|
||||
final AccountIdentityResponse registrationResponse = apiPost("/v1/registration", registrationRequest)
|
||||
.authorized(number, accountPassword)
|
||||
.executeExpectSuccess(AccountIdentityResponse.class);
|
||||
|
||||
user.setAciUuid(registrationResponse.uuid());
|
||||
user.setPniUuid(registrationResponse.pni());
|
||||
|
||||
// upload pre-key
|
||||
final TestUser.PreKeySetPublicView preKeySetPublicView = user.preKeys(Device.MASTER_ID, false);
|
||||
apiPut("/v2/keys", preKeySetPublicView)
|
||||
.authorized(user, Device.MASTER_ID)
|
||||
.executeExpectSuccess();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public static void deleteUser(final TestUser user) {
|
||||
apiDelete("/v1/accounts/me").authorized(user).executeExpectSuccess();
|
||||
}
|
||||
|
||||
public static String peekVerificationSessionPushChallenge(final String sessionId) {
|
||||
return INTEGRATION_TOOLS.peekVerificationSessionPushChallenge(sessionId).join()
|
||||
.orElseThrow(() -> new RuntimeException("push challenge not found for the verification session"));
|
||||
}
|
||||
|
||||
public static <T> T sendEmptyRequestAuthenticated(
|
||||
final String endpoint,
|
||||
final String method,
|
||||
final String username,
|
||||
final String password,
|
||||
final Class<T> outputType) {
|
||||
try {
|
||||
final HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(serverUri(endpoint, Collections.emptyList()))
|
||||
.method(method, HttpRequest.BodyPublishers.noBody())
|
||||
.header(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(username, password))
|
||||
.header(HttpHeaders.CONTENT_TYPE, "application/json")
|
||||
.build();
|
||||
return CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
|
||||
.whenComplete((response, error) -> {
|
||||
if (error != null) {
|
||||
logger.error("request error", error);
|
||||
error.printStackTrace();
|
||||
} else {
|
||||
logger.info("response: {}", response.statusCode());
|
||||
System.out.println("response: " + response.statusCode() + ", " + response.body());
|
||||
}
|
||||
})
|
||||
.thenApply(response -> {
|
||||
try {
|
||||
return outputType.equals(Void.class)
|
||||
? null
|
||||
: SystemMapper.jsonMapper().readValue(response.body(), outputType);
|
||||
} catch (final IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.get();
|
||||
} catch (final Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static RequestBuilder apiGet(final String endpoint) {
|
||||
return new RequestBuilder(HttpRequest.newBuilder().GET(), endpoint);
|
||||
}
|
||||
|
||||
public static RequestBuilder apiDelete(final String endpoint) {
|
||||
return new RequestBuilder(HttpRequest.newBuilder().DELETE(), endpoint);
|
||||
}
|
||||
|
||||
public static <R> RequestBuilder apiPost(final String endpoint, final R input) {
|
||||
return RequestBuilder.withJsonBody(endpoint, "POST", input);
|
||||
}
|
||||
|
||||
public static <R> RequestBuilder apiPut(final String endpoint, final R input) {
|
||||
return RequestBuilder.withJsonBody(endpoint, "PUT", input);
|
||||
}
|
||||
|
||||
public static <R> RequestBuilder apiPatch(final String endpoint, final R input) {
|
||||
return RequestBuilder.withJsonBody(endpoint, "PATCH", input);
|
||||
}
|
||||
|
||||
private static URI serverUri(final String endpoint, final List<String> queryParams) {
|
||||
final String query = queryParams.isEmpty()
|
||||
? StringUtils.EMPTY
|
||||
: "?" + String.join("&", queryParams);
|
||||
return URI.create("https://" + CONFIG.domain() + endpoint + query);
|
||||
}
|
||||
|
||||
public static class RequestBuilder {
|
||||
|
||||
private final HttpRequest.Builder builder;
|
||||
|
||||
private final String endpoint;
|
||||
|
||||
private final List<String> queryParams = new ArrayList<>();
|
||||
|
||||
|
||||
private RequestBuilder(final HttpRequest.Builder builder, final String endpoint) {
|
||||
this.builder = builder;
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
private static <R> RequestBuilder withJsonBody(final String endpoint, final String method, final R input) {
|
||||
try {
|
||||
final byte[] body = SystemMapper.jsonMapper().writeValueAsBytes(input);
|
||||
return new RequestBuilder(HttpRequest.newBuilder()
|
||||
.header(HttpHeaders.CONTENT_TYPE, "application/json")
|
||||
.method(method, HttpRequest.BodyPublishers.ofByteArray(body)), endpoint);
|
||||
} catch (final JsonProcessingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public RequestBuilder authorized(final TestUser user) {
|
||||
return authorized(user, Device.MASTER_ID);
|
||||
}
|
||||
|
||||
public RequestBuilder authorized(final TestUser user, final long deviceId) {
|
||||
final String username = "%s.%d".formatted(user.aciUuid().toString(), deviceId);
|
||||
return authorized(username, user.accountPassword());
|
||||
}
|
||||
|
||||
public RequestBuilder authorized(final String username, final String password) {
|
||||
builder.header(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(username, password));
|
||||
return this;
|
||||
}
|
||||
|
||||
public RequestBuilder queryParam(final String key, final String value) {
|
||||
queryParams.add("%s=%s".formatted(key, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public RequestBuilder header(final String name, final String value) {
|
||||
builder.header(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Pair<Integer, Void> execute() {
|
||||
return execute(Void.class);
|
||||
}
|
||||
|
||||
public Pair<Integer, Void> executeExpectSuccess() {
|
||||
final Pair<Integer, Void> execute = execute();
|
||||
Validate.isTrue(
|
||||
execute.getLeft() >= 200 && execute.getLeft() < 300,
|
||||
"Unexpected response code: %d",
|
||||
execute.getLeft());
|
||||
return execute;
|
||||
}
|
||||
|
||||
public <T> T executeExpectSuccess(final Class<T> expectedType) {
|
||||
final Pair<Integer, T> execute = execute(expectedType);
|
||||
return requireNonNull(execute.getRight());
|
||||
}
|
||||
|
||||
public <T> Pair<Integer, T> execute(final Class<T> expectedType) {
|
||||
builder.uri(serverUri(endpoint, queryParams))
|
||||
.header(HttpHeaders.USER_AGENT, USER_AGENT);
|
||||
return CLIENT.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
|
||||
.whenComplete((response, error) -> {
|
||||
if (error != null) {
|
||||
logger.error("request error", error);
|
||||
error.printStackTrace();
|
||||
}
|
||||
})
|
||||
.thenApply(response -> {
|
||||
try {
|
||||
final T result = expectedType.equals(Void.class)
|
||||
? null
|
||||
: SystemMapper.jsonMapper().readValue(response.body(), expectedType);
|
||||
return Pair.of(response.statusCode(), result);
|
||||
} catch (final IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})
|
||||
.join();
|
||||
}
|
||||
}
|
||||
|
||||
private static FaultTolerantHttpClient buildClient() {
|
||||
try {
|
||||
return FaultTolerantHttpClient.newBuilder()
|
||||
.withName("integration-test")
|
||||
.withExecutor(Executors.newFixedThreadPool(16))
|
||||
.withCircuitBreaker(new CircuitBreakerConfiguration())
|
||||
.withTrustedServerCertificates(CONFIG.rootCert())
|
||||
.build();
|
||||
} catch (final CertificateException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Config loadConfigFromClasspath(final String filename) {
|
||||
try {
|
||||
final URL configFileUrl = Resources.getResource(filename);
|
||||
return SystemMapper.yamlMapper().readValue(Resources.toByteArray(configFileUrl), Config.class);
|
||||
} catch (final IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
|
||||
public class TestDevice {
|
||||
|
||||
private final long deviceId;
|
||||
|
||||
private final Map<Integer, Pair<IdentityKeyPair, SignedPreKeyRecord>> signedPreKeys = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
public static TestDevice create(
|
||||
final long deviceId,
|
||||
final IdentityKeyPair aciIdentityKeyPair,
|
||||
final IdentityKeyPair pniIdentityKeyPair) {
|
||||
final TestDevice device = new TestDevice(deviceId);
|
||||
device.addSignedPreKey(aciIdentityKeyPair);
|
||||
device.addSignedPreKey(pniIdentityKeyPair);
|
||||
return device;
|
||||
}
|
||||
|
||||
public TestDevice(final long deviceId) {
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
public long deviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public SignedPreKeyRecord latestSignedPreKey(final IdentityKeyPair identity) {
|
||||
final int id = signedPreKeys.entrySet()
|
||||
.stream()
|
||||
.filter(p -> p.getValue().getLeft().equals(identity))
|
||||
.mapToInt(Map.Entry::getKey)
|
||||
.max()
|
||||
.orElseThrow();
|
||||
return signedPreKeys.get(id).getRight();
|
||||
}
|
||||
|
||||
public SignedPreKeyRecord addSignedPreKey(final IdentityKeyPair identity) {
|
||||
try {
|
||||
final int nextId = signedPreKeys.keySet().stream().mapToInt(k -> k + 1).max().orElse(0);
|
||||
final ECKeyPair keyPair = Curve.generateKeyPair();
|
||||
final byte[] signature = Curve.calculateSignature(identity.getPrivateKey(), keyPair.getPublicKey().serialize());
|
||||
final SignedPreKeyRecord signedPreKeyRecord = new SignedPreKeyRecord(nextId, System.currentTimeMillis(), keyPair, signature);
|
||||
signedPreKeys.put(nextId, Pair.of(identity, signedPreKeyRecord));
|
||||
return signedPreKeyRecord;
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.apache.commons.lang3.RandomUtils;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.protocol.util.KeyHelper;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountAttributes;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class TestUser {
|
||||
|
||||
private final int registrationId;
|
||||
|
||||
private final IdentityKeyPair aciIdentityKey;
|
||||
|
||||
private final Map<Long, TestDevice> devices = new ConcurrentHashMap<>();
|
||||
|
||||
private final byte[] unidentifiedAccessKey;
|
||||
|
||||
private String phoneNumber;
|
||||
|
||||
private IdentityKeyPair pniIdentityKey;
|
||||
|
||||
private String accountPassword;
|
||||
|
||||
private byte[] registrationPassword;
|
||||
|
||||
private UUID aciUuid;
|
||||
|
||||
private UUID pniUuid;
|
||||
|
||||
|
||||
public static TestUser create(final String phoneNumber, final String accountPassword, final byte[] registrationPassword) {
|
||||
// ACI identity key pair
|
||||
final IdentityKeyPair aciIdentityKey = IdentityKeyPair.generate();
|
||||
// PNI identity key pair
|
||||
final IdentityKeyPair pniIdentityKey = IdentityKeyPair.generate();
|
||||
// registration id
|
||||
final int registrationId = KeyHelper.generateRegistrationId(false);
|
||||
// uak
|
||||
final byte[] unidentifiedAccessKey = RandomUtils.nextBytes(16);
|
||||
|
||||
return new TestUser(
|
||||
registrationId,
|
||||
aciIdentityKey,
|
||||
phoneNumber,
|
||||
pniIdentityKey,
|
||||
unidentifiedAccessKey,
|
||||
accountPassword,
|
||||
registrationPassword);
|
||||
}
|
||||
|
||||
public TestUser(
|
||||
final int registrationId,
|
||||
final IdentityKeyPair aciIdentityKey,
|
||||
final String phoneNumber,
|
||||
final IdentityKeyPair pniIdentityKey,
|
||||
final byte[] unidentifiedAccessKey,
|
||||
final String accountPassword,
|
||||
final byte[] registrationPassword) {
|
||||
this.registrationId = registrationId;
|
||||
this.aciIdentityKey = aciIdentityKey;
|
||||
this.phoneNumber = phoneNumber;
|
||||
this.pniIdentityKey = pniIdentityKey;
|
||||
this.unidentifiedAccessKey = unidentifiedAccessKey;
|
||||
this.accountPassword = accountPassword;
|
||||
this.registrationPassword = registrationPassword;
|
||||
devices.put(Device.MASTER_ID, TestDevice.create(Device.MASTER_ID, aciIdentityKey, pniIdentityKey));
|
||||
}
|
||||
|
||||
public int registrationId() {
|
||||
return registrationId;
|
||||
}
|
||||
|
||||
public IdentityKeyPair aciIdentityKey() {
|
||||
return aciIdentityKey;
|
||||
}
|
||||
|
||||
public String phoneNumber() {
|
||||
return phoneNumber;
|
||||
}
|
||||
|
||||
public IdentityKeyPair pniIdentityKey() {
|
||||
return pniIdentityKey;
|
||||
}
|
||||
|
||||
public String accountPassword() {
|
||||
return accountPassword;
|
||||
}
|
||||
|
||||
public byte[] registrationPassword() {
|
||||
return registrationPassword;
|
||||
}
|
||||
|
||||
public UUID aciUuid() {
|
||||
return aciUuid;
|
||||
}
|
||||
|
||||
public UUID pniUuid() {
|
||||
return pniUuid;
|
||||
}
|
||||
|
||||
public AccountAttributes accountAttributes() {
|
||||
return new AccountAttributes(true, registrationId, "", "", true, new Device.DeviceCapabilities())
|
||||
.withUnidentifiedAccessKey(unidentifiedAccessKey)
|
||||
.withRecoveryPassword(registrationPassword);
|
||||
}
|
||||
|
||||
public void setAciUuid(final UUID aciUuid) {
|
||||
this.aciUuid = aciUuid;
|
||||
}
|
||||
|
||||
public void setPniUuid(final UUID pniUuid) {
|
||||
this.pniUuid = pniUuid;
|
||||
}
|
||||
|
||||
public void setPhoneNumber(final String phoneNumber) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public void setPniIdentityKey(final IdentityKeyPair pniIdentityKey) {
|
||||
this.pniIdentityKey = pniIdentityKey;
|
||||
}
|
||||
|
||||
public void setAccountPassword(final String accountPassword) {
|
||||
this.accountPassword = accountPassword;
|
||||
}
|
||||
|
||||
public void setRegistrationPassword(final byte[] registrationPassword) {
|
||||
this.registrationPassword = registrationPassword;
|
||||
}
|
||||
|
||||
public PreKeySetPublicView preKeys(final long deviceId, final boolean pni) {
|
||||
final IdentityKeyPair identity = pni
|
||||
? pniIdentityKey
|
||||
: aciIdentityKey;
|
||||
final TestDevice device = requireNonNull(devices.get(deviceId));
|
||||
final SignedPreKeyRecord signedPreKeyRecord = device.latestSignedPreKey(identity);
|
||||
return new PreKeySetPublicView(
|
||||
Collections.emptyList(),
|
||||
identity.getPublicKey(),
|
||||
new SignedPreKeyPublicView(
|
||||
signedPreKeyRecord.getId(),
|
||||
signedPreKeyRecord.getKeyPair().getPublicKey(),
|
||||
signedPreKeyRecord.getSignature()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public record SignedPreKeyPublicView(
|
||||
int keyId,
|
||||
@JsonSerialize(using = Codecs.ECPublicKeySerializer.class)
|
||||
@JsonDeserialize(using = Codecs.ECPublicKeyDeserializer.class)
|
||||
ECPublicKey publicKey,
|
||||
@JsonSerialize(using = Codecs.ByteArraySerializer.class)
|
||||
@JsonDeserialize(using = Codecs.ByteArrayDeserializer.class)
|
||||
byte[] signature) {
|
||||
}
|
||||
|
||||
public record PreKeySetPublicView(
|
||||
List<String> preKeys,
|
||||
@JsonSerialize(using = Codecs.IdentityKeySerializer.class)
|
||||
@JsonDeserialize(using = Codecs.IdentityKeyDeserializer.class)
|
||||
IdentityKey identityKey,
|
||||
SignedPreKeyPublicView signedPreKey) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration.config;
|
||||
|
||||
import org.whispersystems.textsecuregcm.configuration.DynamoDbClientConfiguration;
|
||||
|
||||
public record Config(String domain,
|
||||
String rootCert,
|
||||
DynamoDbClientConfiguration dynamoDbClientConfiguration,
|
||||
DynamoDbTables dynamoDbTables) {
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration.config;
|
||||
|
||||
public record DynamoDbTables(String registrationRecovery,
|
||||
String verificationSessions) {
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.integration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.IncomingMessage;
|
||||
import org.whispersystems.textsecuregcm.entities.IncomingMessageList;
|
||||
import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;
|
||||
import org.whispersystems.textsecuregcm.entities.SendMessageResponse;
|
||||
import org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;
|
||||
import org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;
|
||||
import org.whispersystems.textsecuregcm.storage.Device;
|
||||
|
||||
public class IntegrationTest {
|
||||
|
||||
@Test
|
||||
public void testCreateAccount() throws Exception {
|
||||
final TestUser user = Operations.newRegisteredUser("+19995550101");
|
||||
try {
|
||||
final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet("/v1/accounts/whoami")
|
||||
.authorized(user)
|
||||
.execute(AccountIdentityResponse.class);
|
||||
assertEquals(200, execute.getLeft());
|
||||
} finally {
|
||||
Operations.deleteUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRegistration() throws Exception {
|
||||
final UpdateVerificationSessionRequest originalRequest = new UpdateVerificationSessionRequest(
|
||||
"test", UpdateVerificationSessionRequest.PushTokenType.FCM, null, null, null, null);
|
||||
final CreateVerificationSessionRequest input = new CreateVerificationSessionRequest("+19995550102", originalRequest);
|
||||
|
||||
final VerificationSessionResponse verificationSessionResponse = Operations
|
||||
.apiPost("/v1/verification/session", input)
|
||||
.executeExpectSuccess(VerificationSessionResponse.class);
|
||||
System.out.println("session created: " + verificationSessionResponse);
|
||||
|
||||
final String sessionId = verificationSessionResponse.id();
|
||||
final String pushChallenge = Operations.peekVerificationSessionPushChallenge(sessionId);
|
||||
|
||||
// supply push challenge
|
||||
final UpdateVerificationSessionRequest updatedRequest = new UpdateVerificationSessionRequest(
|
||||
"test", UpdateVerificationSessionRequest.PushTokenType.FCM, pushChallenge, null, null, null);
|
||||
final VerificationSessionResponse pushChallengeSupplied = Operations
|
||||
.apiPatch("/v1/verification/session/%s".formatted(sessionId), updatedRequest)
|
||||
.executeExpectSuccess(VerificationSessionResponse.class);
|
||||
System.out.println("push challenge supplied: " + pushChallengeSupplied);
|
||||
|
||||
Assertions.assertTrue(pushChallengeSupplied.allowedToRequestCode());
|
||||
|
||||
// request code
|
||||
final VerificationCodeRequest verificationCodeRequest = new VerificationCodeRequest(
|
||||
VerificationCodeRequest.Transport.SMS, "android-ng");
|
||||
|
||||
final VerificationSessionResponse codeRequested = Operations
|
||||
.apiPost("/v1/verification/session/%s/code".formatted(sessionId), verificationCodeRequest)
|
||||
.executeExpectSuccess(VerificationSessionResponse.class);
|
||||
System.out.println("code requested: " + codeRequested);
|
||||
|
||||
// verify code
|
||||
final SubmitVerificationCodeRequest submitVerificationCodeRequest = new SubmitVerificationCodeRequest("265402");
|
||||
final VerificationSessionResponse codeVerified = Operations
|
||||
.apiPut("/v1/verification/session/%s/code".formatted(sessionId), submitVerificationCodeRequest)
|
||||
.executeExpectSuccess(VerificationSessionResponse.class);
|
||||
System.out.println("sms code supplied: " + codeVerified);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessageUnsealed() throws Exception {
|
||||
final TestUser userA = Operations.newRegisteredUser("+19995550102");
|
||||
final TestUser userB = Operations.newRegisteredUser("+19995550103");
|
||||
|
||||
try {
|
||||
final byte[] expectedContent = "Hello, World!".getBytes(StandardCharsets.UTF_8);
|
||||
final String contentBase64 = Base64.getEncoder().encodeToString(expectedContent);
|
||||
final IncomingMessage message = new IncomingMessage(1, Device.MASTER_ID, userB.registrationId(), contentBase64);
|
||||
final IncomingMessageList messages = new IncomingMessageList(List.of(message), false, true, System.currentTimeMillis());
|
||||
|
||||
System.out.println("Sending message");
|
||||
final Pair<Integer, SendMessageResponse> sendMessage = Operations
|
||||
.apiPut("/v1/messages/%s".formatted(userB.aciUuid().toString()), messages)
|
||||
.authorized(userA)
|
||||
.execute(SendMessageResponse.class);
|
||||
System.out.println("Message sent: " + sendMessage);
|
||||
|
||||
System.out.println("Receive message");
|
||||
final Pair<Integer, OutgoingMessageEntityList> receiveMessages = Operations.apiGet("/v1/messages")
|
||||
.authorized(userB)
|
||||
.execute(OutgoingMessageEntityList.class);
|
||||
System.out.println("Message received: " + receiveMessages);
|
||||
|
||||
final byte[] actualContent = receiveMessages.getRight().messages().get(0).content();
|
||||
assertArrayEquals(expectedContent, actualContent);
|
||||
} finally {
|
||||
Operations.deleteUser(userA);
|
||||
Operations.deleteUser(userB);
|
||||
}
|
||||
}
|
||||
}
|
1
pom.xml
1
pom.xml
|
@ -32,6 +32,7 @@
|
|||
<modules>
|
||||
<module>api-doc</module>
|
||||
<module>event-logger</module>
|
||||
<module>integration-tests</module>
|
||||
<module>redis-dispatch</module>
|
||||
<module>service</module>
|
||||
<module>websocket-resources</module>
|
||||
|
|
|
@ -7,6 +7,7 @@ package org.whispersystems.textsecuregcm.entities;
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import org.whispersystems.textsecuregcm.util.E164;
|
||||
|
@ -24,6 +25,15 @@ public final class CreateVerificationSessionRequest {
|
|||
@JsonUnwrapped
|
||||
private UpdateVerificationSessionRequest updateVerificationSessionRequest;
|
||||
|
||||
public CreateVerificationSessionRequest() {
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public CreateVerificationSessionRequest(final String number, final UpdateVerificationSessionRequest updateVerificationSessionRequest) {
|
||||
this.number = number;
|
||||
this.updateVerificationSessionRequest = updateVerificationSessionRequest;
|
||||
}
|
||||
|
||||
public String getNumber() {
|
||||
return number;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue