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

Networking connectivity integration test using local proxy #866

Merged
merged 49 commits into from
Jan 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
d4e5af6
Barebones test and proxy interface for issue #865
jaley Dec 28, 2022
9a44fd5
Clone of PublisherIntegrationTest using an injected AblyRealtime for …
jaley Dec 28, 2022
12683e3
Hacky log handlers for testing
jaley Dec 28, 2022
4290df2
NetworkingConenctivityTest running through Paul's proxy
jaley Dec 29, 2022
8fc094f
Integrate latest proxy code
jaley Dec 30, 2022
c84f87b
Happy path test passing through proxy
jaley Dec 30, 2022
e115778
Cleanup and refactor some boilerplate
jaley Dec 30, 2022
f2cde37
Adding setup and teardown to reset proxy and other state between tests
jaley Dec 30, 2022
ea3a4a3
WIP: reconnection test failing as ably-java unable to reconnect to re…
jaley Dec 30, 2022
24036e2
Fix proxy reconnection test
jaley Jan 3, 2023
960a9bc
Factor out some boiler plate and cleanup each TrackableStateReceiver …
jaley Jan 3, 2023
365a9cd
Introduce Fault and Proxy abstractions
jaley Jan 5, 2023
ae74a9e
Drive test assertions from fault implementations, and clean things up…
jaley Jan 5, 2023
41624d2
Add test for fault after Trackable is Online
jaley Jan 5, 2023
d959287
Exercise add() and remove() during connectivity tests
jaley Jan 5, 2023
76869bb
Add TCP connection hang fault
jaley Jan 5, 2023
1dc25a0
Oops, accidental check-in of libs folder
jaley Jan 6, 2023
7acfbea
Resolve merge conflicts
jaley Jan 6, 2023
799e1a7
Bump ably-java dependency to pull in hostname verification fix
jaley Jan 6, 2023
ab102a7
Remove unused test dependencies
jaley Jan 6, 2023
ccbd852
Run ./gradlew ktlintFormat
QuintinWillison Jan 9, 2023
0c003db
Fix lint issues in AblyProxy implementation.
QuintinWillison Jan 9, 2023
6859a54
Fix lint issues.
QuintinWillison Jan 10, 2023
dcedccb
Inject Ably API key from test runner project, where BuildConfig will …
QuintinWillison Jan 10, 2023
704e1dd
Ensure that ABLY_API_KEY and MAPBOX_ACCESS_TOKEN BuildConfig properti…
QuintinWillison Jan 10, 2023
388d6b9
Merge branch 'main' into 865-networking-connectivity-system-tests
QuintinWillison Jan 10, 2023
564ad41
Add some SubProject evaluation lifecycle logging.
QuintinWillison Jan 10, 2023
b13d5e8
Move release build unit test coverage check to the emulation workflow…
QuintinWillison Jan 10, 2023
7ab7d3e
Stop injecting secrets into debug BuildConfig that aren't present for…
QuintinWillison Jan 10, 2023
3e5fe1e
Revert "Move release build unit test coverage check to the emulation …
QuintinWillison Jan 10, 2023
9abb49e
Provide escape path for CI check runs (pure unit tests) so that secre…
QuintinWillison Jan 10, 2023
b2c634c
Remove impotent artifact upload.
QuintinWillison Jan 10, 2023
5450f91
Log key events around secrets configuration for sub projects.
QuintinWillison Jan 10, 2023
e1af08a
Merge branch 'main' into 865-networking-connectivity-system-tests
QuintinWillison Jan 10, 2023
3bded6b
Stop trying to be clever by using initWith to DRY things up.
QuintinWillison Jan 10, 2023
d2e73a3
Use empty-string secrets when secrets are not injected by the workflow.
QuintinWillison Jan 10, 2023
bab0c83
Improve the information provided in lifecycle logs for Android runtim…
QuintinWillison Jan 11, 2023
98e35c3
Provide escape slightly hacky (but obvious at least!) escape hatch to…
QuintinWillison Jan 11, 2023
7469b35
Revert unnecessary build configuration changes.
QuintinWillison Jan 11, 2023
24ba199
Revert change to the LocationSourceAbly public API, adding a new Loca…
QuintinWillison Jan 11, 2023
f064353
Fix Ably API key injection.
QuintinWillison Jan 11, 2023
1e96b3a
Merge branch 'main' into 865-networking-connectivity-system-tests
QuintinWillison Jan 11, 2023
02a5d1e
Disable known-to-fail proxy-integration tests at runtime, but keep th…
QuintinWillison Jan 12, 2023
591e51c
Merge branch 'main' into 865-networking-connectivity-system-tests
QuintinWillison Jan 12, 2023
8f1a328
Add contributing guidance around the runtimeSecrets Gradle property.
QuintinWillison Jan 12, 2023
951ec1a
Merge branch 'main' into 865-networking-connectivity-system-tests
QuintinWillison Jan 12, 2023
cf6c2ff
Move notes to notes column.
QuintinWillison Jan 13, 2023
26c04e5
Merge branch 'main' into 865-networking-connectivity-system-tests
QuintinWillison Jan 13, 2023
d093815
Add links to workflows.
QuintinWillison Jan 13, 2023
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
9 changes: 1 addition & 8 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
distribution: 'zulu'
java-version: ${{ matrix.java-version }}

- run: ./gradlew check testDebugUnitTestCoverage testReleaseUnitTestCoverage --profile
- run: ./gradlew check testDebugUnitTestCoverage testReleaseUnitTestCoverage --profile -PruntimeSecrets=USE_DUMMY_EMPTY_STRING_VALUES
env:
ORG_GRADLE_PROJECT_MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }}

Expand Down Expand Up @@ -64,13 +64,6 @@ jobs:
name: java-version-${{ matrix.java-version }}-publishing-sdk-build-reports
path: publishing-sdk/build/reports

- uses: actions/upload-artifact@v3
name: Build Reports for subscribing-example-app
KacperKluka marked this conversation as resolved.
Show resolved Hide resolved
if: always()
with:
name: java-version-${{ matrix.java-version }}-subscribing-example-app-build-reports
path: subscribing-example-app/build/reports

- uses: actions/upload-artifact@v3
name: Build Reports for subscribing-java-testing
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

- name: Build Documentation
run: |
./gradlew dokkaHtmlMultiModule
./gradlew dokkaHtmlMultiModule -PruntimeSecrets=USE_DUMMY_EMPTY_STRING_VALUES
ls -al build/dokka/htmlMultiModule
env:
ORG_GRADLE_PROJECT_MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }}
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/emulate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ jobs:
#boot. See https://developer.android.com/studio/run/emulator-commandline#common for more
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
# ${{ condition && 'ifTrue' || 'ifFalse' }} is a workaround for a ternary operator https://github.com/actions/runner/issues/409#issuecomment-752775072
script: ${{ matrix.excludeModules && './gradlew connectedCheck -x :publishing-java-testing:connectedCheck -x :subscribing-java-testing:connectedCheck -x :publishing-example-app:connectedCheck -x :subscribing-example-app:connectedCheck' || './gradlew connectedCheck' }}
script: |
${{
matrix.excludeModules
&& './gradlew connectedCheck -x :publishing-java-testing:connectedCheck -x :subscribing-java-testing:connectedCheck -x :publishing-example-app:connectedCheck -x :subscribing-example-app:connectedCheck -PruntimeSecrets=FOR_ALL_PROJECTS_BECAUSE_WE_ARE_RUNNING_INTEGRATION_TESTS'
|| './gradlew connectedCheck -PruntimeSecrets=FOR_ALL_PROJECTS_BECAUSE_WE_ARE_RUNNING_INTEGRATION_TESTS'
}}
env:
ORG_GRADLE_PROJECT_MAPBOX_DOWNLOADS_TOKEN: ${{ secrets.MAPBOX_DOWNLOADS_TOKEN }}
ORG_GRADLE_PROJECT_MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-github-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ jobs:
ORG_GRADLE_PROJECT_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_RING_FILE_BASE64 }}
run: |
echo "Publishing version ${{ github.event.inputs.version }} to GitHub Packages..."
./gradlew publish -PpublishTarget=GitHubPackages
./gradlew publish -PpublishTarget=GitHubPackages -PruntimeSecrets=USE_DUMMY_EMPTY_STRING_VALUES
2 changes: 1 addition & 1 deletion .github/workflows/publish-maven-central.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ jobs:
ORG_GRADLE_PROJECT_SIGNING_KEY_BASE64: ${{ secrets.MAVEN_SIGNING_KEY_RING_FILE_BASE64 }}
run: |
echo "Publishing version ${{ github.event.inputs.version }} to MavenCentral..."
./gradlew -PpublishTarget=MavenCentral publishToSonatype closeAndReleaseSonatypeStagingRepository
./gradlew -PpublishTarget=MavenCentral -PruntimeSecrets=USE_DUMMY_EMPTY_STRING_VALUES publishToSonatype closeAndReleaseSonatypeStagingRepository
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ The following secrets need configuring in a similar manner to that described abo
- `MAPBOX_ACCESS_TOKEN`
- `GOOGLE_MAPS_API_KEY`

### Runtime Secrets and Connected Checks

The Gradle build scripts react to values assigned to the `runtimeSecrets` property.
This is a property unique to the projects in this repository, altering the build configuration depending on the downstream needs of the build, in respect of `BuildConfig` availability and values of `ABLY_API_KEY` and `MAPBOX_ACCESS_TOKEN` (both supplied via Gradle properties).

| `runtimeSecrets` value | Build Configuration | Notes |
| ---------------------- | ------------------- | ----- |
| `FOR_ALL_PROJECTS_BECAUSE_WE_ARE_RUNNING_INTEGRATION_TESTS` | Production secrets injected into all projects, for both `release` and `debug` build types. | Allows integration tests (connected checks, the `androidTest` source set in each project) to have access to these secrets. Used by the [emulate](.github/workflows/emulate.yml) workflow. |
| `USE_DUMMY_EMPTY_STRING_VALUES` | Dummy secrets injected only into app projects. This allows the projects to build without production secrets needing to be supplied via Gradle properties. | This means that any app or live-service integration test builds that attempt to use these secret values at runtime will fail. Used by the [check](.github/workflows/check.yml), [docs](.github/workflows/docs.yml) and publishing workflows. |
| _either undefined or any other value_ | Production secrets injected only into app projects. | Ensures that they are not accidentally exposed to any of the SDK projects. Used, implicitly, by the [assemble](.github/workflows/assemble.yml) workflow. |

It is a little bit hacky and there might be another way to do this in a more Gradle or Android idiomatic manner, however it suits the needs of our project build for the time being and does not change or otherwise alter the SDK products we publish.

For local development purposes, most developers will find that the most helpful general purpose configuration is to put the following line into their `~/.gradle/gradle.properties` file:

runtimeSecrets: FOR_ALL_PROJECTS_BECAUSE_WE_ARE_RUNNING_INTEGRATION_TESTS

### Debugging Gradle Task Dependencies

There isn't an out-of-the-box command provided by Gradle to provide a readable breakdown of which tasks in the build are configured to rely upon which other tasks. The `--dry-run` switch helps a bit, but it provides a flat view which doesn't provide the full picture.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package com.ably.tracking.test.android.common

import io.ably.lib.types.ClientOptions
import io.ably.lib.util.Log
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.util.UUID
import javax.net.ssl.SSLSocketFactory

private const val AGENT_HEADER_NAME = "ably-asset-tracking-android-publisher-tests"

private const val PROXY_HOST = "localhost"
private const val PROXY_PORT = 13579
private const val REALTIME_HOST = "realtime.ably.io"
private const val REALTIME_PORT = 443

/**
* A local proxy that can be used to intercept Realtime traffic for testing
*/
interface RealtimeProxy {
/**
* Start the proxy listening for connections
*/
fun start()

/**
* Stop the proxy and close any active connetions
*/
fun stop()

/**
* Ably ClientOptions that have been configured to direct traffic
* through this proxy service
*/
val clientOptions: ClientOptions
}

/**
* A TCP Proxy, which can run locally and intercept traffic to Ably realtime.
*
* This proxy is only capable of simulating faults at the transport layer, such
* as connections being interrupted or packets being dropped entirely.
*/
class Layer4Proxy(
val listenHost: String = PROXY_HOST,
val listenPort: Int = PROXY_PORT,
private val targetAddress: String = REALTIME_HOST,
private val targetPort: Int = REALTIME_PORT,
private val apiKey: String,
) : RealtimeProxy {

private val loggingTag = "Layer4Proxy"

private var server: ServerSocket? = null
private val sslSocketFactory = SSLSocketFactory.getDefault()
private val connections: MutableList<Layer4ProxyConnection> = mutableListOf()

/**
* Flag mutated by fault implementations to hang the TCP connection
*/
var isForwarding = true

/**
* Block current thread and wait for a new incoming client connection on the server socket.
* Returns a connection object when a client has connected.
*/
private fun accept(): Layer4ProxyConnection {
val clientSock = server?.accept()
testLogD("$loggingTag: accepted connection")

val serverSock = sslSocketFactory.createSocket(targetAddress, targetPort)
val conn = Layer4ProxyConnection(serverSock, clientSock!!, targetAddress, parentProxy = this)
connections.add(conn)
return conn
}

/**
* Pre-configured client options to configure AblyRealtime to send traffic locally through
* this proxy. Note that TLS is disabled, so that the proxy can act as a man in the middle.
*/
override val clientOptions = ClientOptions().apply {
this.clientId = "AatTestProxy_${UUID.randomUUID()}"
this.agents = mapOf(AGENT_HEADER_NAME to BuildConfig.VERSION_NAME)
this.idempotentRestPublishing = true
this.autoConnect = false
this.key = apiKey
this.logHandler = Log.LogHandler { _, _, msg, tr ->
testLogD("${msg!!} - $tr")
}
this.realtimeHost = listenHost
this.port = listenPort
this.tls = false
}

/**
* Close open connections and stop listening for new local connections
*/
override fun stop() {
server?.close()
server = null

connections.forEach {
it.stop()
}
connections.clear()
}

/**
* Begin a background thread listening for local Realtime connections
*/
override fun start() {
server = ServerSocket(listenPort)
Thread {
while (true) {
testLogD("$loggingTag: proxy trying to accept")
try {
val conn = this.accept()
testLogD("$loggingTag: proxy starting to run")
conn.run()
} catch (e: Exception) {
testLogD("$loggingTag: proxy shutting down: " + e.message)
break
}
}
}.start()
}
}

/**
* A TCP Proxy connection between a local client and the remote Ably service.
*/
internal class Layer4ProxyConnection(
private val server: Socket,
private val client: Socket,
private val targetHost: String,
private val parentProxy: Layer4Proxy
) {

private val loggingTag = "Layer4ProxyConnection"

/**
* Starts two threads, one forwarding traffic in each direction between
* the local client and the Ably Realtime service.
*/
fun run() {
Thread { proxy(server, client, true) }.start()
Thread { proxy(client, server) }.start()
}

/**
* Close socket connections, causing proxy threads to exit.
*/
fun stop() {
try {
server.close()
} catch (e: Exception) {
testLogD("$loggingTag: stop() server: $e")
}

try {
client.close()
} catch (e: Exception) {
testLogD("$loggingTag: stop() client: $e")
}
}

/**
* Copies traffic between source and destination sockets, rewriting the
* HTTP host header if requested to remove the proxy host details.
*/
private fun proxy(dstSock: Socket, srcSock: Socket, rewriteHost: Boolean = false) {
try {
val dst = dstSock.getOutputStream()
val src = srcSock.getInputStream()
val buff = ByteArray(4096)
var bytesRead: Int

// deal with the initial HTTP upgrade packet
bytesRead = src.read(buff)
if (bytesRead < 0) {
return
}

// HTTP is plaintext so we can just read it
val msg = String(buff, 0, bytesRead)
testLogD("$loggingTag: ${String(buff.copyOfRange(0, bytesRead))}")
if (rewriteHost) {
val newMsg = msg.replace(
oldValue = "${parentProxy.listenHost}:${parentProxy.listenPort}",
newValue = targetHost
)
val newBuff = newMsg.toByteArray()
dst.write(newBuff, 0, newBuff.size)
} else {
dst.write(buff, 0, bytesRead)
}

while (-1 != src.read(buff).also { bytesRead = it }) {
if (parentProxy.isForwarding) {
dst.write(buff, 0, bytesRead)
}
}
} catch (ignored: SocketException) {
} catch (e: Exception) {
testLogD("$loggingTag: $e")
} finally {
try {
srcSock.close()
} catch (ignored: Exception) {
}
}
}
}
Loading