From 6b37b3d1756e7a695cc98a28b31f03b8986948cd Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 7 Sep 2020 16:52:15 +0100 Subject: [PATCH 01/15] Add junit-platform.properties --- src/test/resources/junit-platform.properties | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/test/resources/junit-platform.properties diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..d265fd8 --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.testinstance.lifecycle.default = per_class From 467bd76c9fc8ac1eafeca329b3d6715542c85b7c Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 7 Sep 2020 16:55:38 +0100 Subject: [PATCH 02/15] Implement pre-reg skeleton --- .../tech/relaycorp/poweb/PoWebClient.kt | 15 ++++++++ .../tech/relaycorp/poweb/RegistrationTest.kt | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt index 59960cf..ad74def 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -32,6 +32,7 @@ import java.io.Closeable import java.io.EOFException import java.net.ConnectException import java.net.SocketException +import java.security.PublicKey /** * PoWeb client. @@ -62,6 +63,20 @@ public class PoWebClient internal constructor( */ override fun close(): Unit = ktorClient.close() + /** + * Request a Private Node Registration Authorization (PNRA). + * + * @param nodePublicKey The public key of the private node requesting authorization + */ + @Throws( + ServerConnectionException::class, + ServerBindingException::class, + ClientBindingException::class + ) + public suspend fun preRegisterNode(nodePublicKey: PublicKey): ByteArray { + TODO() + } + /** * Deliver a parcel. * diff --git a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt new file mode 100644 index 0000000..f9246cc --- /dev/null +++ b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt @@ -0,0 +1,35 @@ +package tech.relaycorp.poweb + +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class RegistrationTest { + @Nested + inner class PreRegistration { + @Test + @Disabled + fun `Request should be POSTed to the appropriate endpoint`() { + } + + @Test + @Disabled + fun `Request body should be SHA-256 digest of the node public key`() { + } + + @Test + @Disabled + fun `An invalid response content type should be refused`() { + } + + @Test + @Disabled + fun `20X response status other than 200 should throw an error`() { + } + + @Test + @Disabled + fun `Authorization should be output serialized if status is 200`() { + } + } +} \ No newline at end of file From 73044af3eb40229c4cd25db20f8153d7e26cf917 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Mon, 7 Sep 2020 17:27:20 +0100 Subject: [PATCH 03/15] fix formatting --- src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt index f9246cc..96f5d14 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt @@ -32,4 +32,4 @@ class RegistrationTest { fun `Authorization should be output serialized if status is 200`() { } } -} \ No newline at end of file +} From 5aff01c10aac0a90b84a8a422db3ddf5c9794c01 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 11:26:59 +0100 Subject: [PATCH 04/15] Undo reuse of JUnit test classes --- src/test/resources/junit-platform.properties | 1 - 1 file changed, 1 deletion(-) delete mode 100644 src/test/resources/junit-platform.properties diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties deleted file mode 100644 index d265fd8..0000000 --- a/src/test/resources/junit-platform.properties +++ /dev/null @@ -1 +0,0 @@ -junit.jupiter.testinstance.lifecycle.default = per_class From 448ee23359be8f665ea0f8fe560edb81a44f867e Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 11:47:42 +0100 Subject: [PATCH 05/15] Factor out shared util --- .../relaycorp/poweb/ParcelDeliveryTest.kt | 32 ++++++------------- src/test/kotlin/tech/relaycorp/poweb/Utils.kt | 17 ++++++++++ 2 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 src/test/kotlin/tech/relaycorp/poweb/Utils.kt diff --git a/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt b/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt index fdaf7eb..6d5c18b 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt @@ -1,11 +1,9 @@ package tech.relaycorp.poweb -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.MockRequestHandler import io.ktor.client.engine.mock.respond import io.ktor.client.engine.mock.respondError import io.ktor.client.engine.mock.respondOk +import io.ktor.client.request.HttpRequestData import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.content.OutgoingContent @@ -27,7 +25,7 @@ class ParcelDeliveryTest { @Test fun `Request should be made with HTTP POST`() = runBlockingTest { var method: HttpMethod? = null - val client = makeClient { request -> + val client = makeTestClient { request: HttpRequestData -> method = request.method respondOk() } @@ -40,7 +38,7 @@ class ParcelDeliveryTest { @Test fun `Endpoint should be the one for parcels`() = runBlockingTest { var endpointURL: String? = null - val client = makeClient { request -> + val client = makeTestClient { request: HttpRequestData -> endpointURL = request.url.toString() respondOk() } @@ -53,7 +51,7 @@ class ParcelDeliveryTest { @Test fun `Request content type should be the appropriate value`() = runBlockingTest { var contentType: String? = null - val client = makeClient { request -> + val client = makeTestClient { request: HttpRequestData -> contentType = request.body.contentType.toString() respondOk() } @@ -66,7 +64,7 @@ class ParcelDeliveryTest { @Test fun `Request body should be the parcel serialized`() = runBlockingTest { var requestBody: ByteArray? = null - val client = makeClient { request -> + val client = makeTestClient { request: HttpRequestData -> assertTrue(request.body is OutgoingContent.ByteArrayContent) requestBody = (request.body as OutgoingContent.ByteArrayContent).bytes() respondOk() @@ -79,14 +77,14 @@ class ParcelDeliveryTest { @Test fun `HTTP 20X should be regarded a successful delivery`() = runBlockingTest { - val client = makeClient { respond("", HttpStatusCode.Accepted) } + val client = makeTestClient { respond("", HttpStatusCode.Accepted) } client.use { client.deliverParcel(parcelSerialized) } } @Test fun `HTTP 30X responses should be regarded protocol violations by the server`() { - val client = makeClient { respond("", HttpStatusCode.Found) } + val client = makeTestClient { respond("", HttpStatusCode.Found) } client.use { val exception = assertThrows { @@ -102,7 +100,7 @@ class ParcelDeliveryTest { @Test fun `HTTP 403 should throw a RefusedParcelException`() { - val client = makeClient { respondError(HttpStatusCode.Forbidden) } + val client = makeTestClient { respondError(HttpStatusCode.Forbidden) } client.use { val exception = assertThrows { @@ -118,7 +116,7 @@ class ParcelDeliveryTest { @Test fun `Other 40X responses should be regarded protocol violations by the client`() { - val client = makeClient { respondError(HttpStatusCode.BadRequest) } + val client = makeTestClient { respondError(HttpStatusCode.BadRequest) } client.use { val exception = assertThrows { @@ -135,7 +133,7 @@ class ParcelDeliveryTest { @Test fun `HTTP 50X responses should throw a ServerConnectionException`() { - val client = makeClient { respondError(HttpStatusCode.BadGateway) } + val client = makeTestClient { respondError(HttpStatusCode.BadGateway) } client.use { val exception = assertThrows { @@ -164,14 +162,4 @@ class ParcelDeliveryTest { assertTrue(exception.cause is ConnectException) } } - - private fun makeClient(handler: MockRequestHandler): PoWebClient { - val poWebClient = PoWebClient.initLocal() - poWebClient.ktorClient = HttpClient(MockEngine) { - engine { - addHandler(handler) - } - } - return poWebClient - } } diff --git a/src/test/kotlin/tech/relaycorp/poweb/Utils.kt b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt new file mode 100644 index 0000000..e6cafe8 --- /dev/null +++ b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt @@ -0,0 +1,17 @@ +package tech.relaycorp.poweb + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandler +import io.ktor.util.KtorExperimentalAPI + +@KtorExperimentalAPI +internal fun makeTestClient(handler: MockRequestHandler): PoWebClient { + val poWebClient = PoWebClient.initLocal() + poWebClient.ktorClient = HttpClient(MockEngine) { + engine { + addHandler(handler) + } + } + return poWebClient +} From 4bb61c23139397ddcd05c1738bebc690ceef4e7e Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 14:41:57 +0100 Subject: [PATCH 06/15] Refactor --- .../tech/relaycorp/poweb/PoWebClient.kt | 60 ++++--- .../tech/relaycorp/poweb/PoWebException.kt | 5 +- .../relaycorp/poweb/ParcelDeliveryTest.kt | 67 +------- .../tech/relaycorp/poweb/PoWebClientTest.kt | 153 +++++++++++++++++- src/test/kotlin/tech/relaycorp/poweb/Utils.kt | 2 + 5 files changed, 199 insertions(+), 88 deletions(-) diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt index ad74def..d7c4059 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -14,6 +14,7 @@ import io.ktor.http.cio.websocket.Frame import io.ktor.http.cio.websocket.close import io.ktor.http.cio.websocket.readBytes import io.ktor.http.content.ByteArrayContent +import io.ktor.http.content.OutgoingContent import io.ktor.util.KtorExperimentalAPI import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.flow.Flow @@ -85,29 +86,18 @@ public class PoWebClient internal constructor( @Throws( ServerConnectionException::class, ServerBindingException::class, - RefusedParcelException::class, + RejectedParcelException::class, ClientBindingException::class ) public suspend fun deliverParcel(parcelSerialized: ByteArray) { - try { - ktorClient.post("$baseURL/parcels") { - body = ByteArrayContent(parcelSerialized, PARCEL_CONTENT_TYPE) - } - } catch (exc: SocketException) { - // Java on macOS throws a SocketException but all other platforms throw a - // ConnectException (a subclass of SocketException) - throw ServerConnectionException("Failed to connect to $baseURL", exc) - } catch (exc: ResponseException) { - val status = exc.response!!.status - when (status.value) { - 403 -> throw RefusedParcelException("Parcel was refused by the server ($status)") - in 400..499 -> throw ClientBindingException( - "The server reports that the client violated binding ($status)" - ) - in 500..599 -> throw ServerConnectionException( - "The server was unable to fulfil the request ($status)" - ) - else -> throw ServerBindingException("Received unexpected status ($status)") + val body = ByteArrayContent(parcelSerialized, PARCEL_CONTENT_TYPE) + return try { + post("/parcels", body) + } catch (exc: ClientBindingException) { + if (exc.statusCode == 403) { + throw RejectedParcelException("The server rejected the parcel") + } else { + throw exc } } } @@ -181,6 +171,36 @@ public class PoWebClient internal constructor( } } + @Throws( + ServerConnectionException::class, + ServerBindingException::class, + ClientBindingException::class + ) + internal suspend inline fun post(path: String, requestBody: OutgoingContent): T { + val url = "$baseURL$path" + try { + return ktorClient.post(url) { + body = requestBody + } + } catch (exc: SocketException) { + // Java on macOS throws a SocketException but all other platforms throw a + // ConnectException (a subclass of SocketException) + throw ServerConnectionException("Failed to connect to $url", exc) + } catch (exc: ResponseException) { + val status = exc.response!!.status + when (status.value) { + in 400..499 -> throw ClientBindingException( + "The server reports that the client violated binding ($status)", + status.value + ) + in 500..599 -> throw ServerConnectionException( + "The server was unable to fulfil the request ($status)" + ) + else -> throw ServerBindingException("Received unexpected status ($status)") + } + } + } + internal suspend fun wsConnect( path: String, headers: List>? = null, diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebException.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebException.kt index d4d62c8..b5db62f 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebException.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebException.kt @@ -30,7 +30,7 @@ public class ServerBindingException(message: String, cause: Throwable? = null) : /** * The server refused to accept a parcel. */ -public class RefusedParcelException(message: String) : PoWebException(message) +public class RejectedParcelException(message: String) : PoWebException(message) /** * Base class for exceptions (supposedly) caused by the client. @@ -43,7 +43,8 @@ public abstract class ClientException internal constructor(message: String) : * * Retrying later is unlikely to make a difference. */ -public class ClientBindingException(message: String) : ClientException(message) +public class ClientBindingException(message: String, public val statusCode: Int) : + ClientException(message) /** * The client made a mistake while specifying the nonce signer(s). diff --git a/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt b/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt index 854dd90..1bb295f 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt @@ -9,11 +9,9 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.content.OutgoingContent import io.ktor.util.KtorExperimentalAPI import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runBlockingTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -import java.net.SocketException import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -82,84 +80,27 @@ class ParcelDeliveryTest { client.use { client.deliverParcel(parcelSerialized) } } - @Test - fun `HTTP 30X responses should be regarded protocol violations by the server`() { - val client = makeTestClient { respond("", HttpStatusCode.Found) } - - client.use { - val exception = assertThrows { - runBlockingTest { client.deliverParcel(parcelSerialized) } - } - - assertEquals( - "Received unexpected status (${HttpStatusCode.Found})", - exception.message - ) - } - } - @Test fun `HTTP 403 should throw a RefusedParcelException`() { val client = makeTestClient { respondError(HttpStatusCode.Forbidden) } client.use { - val exception = assertThrows { + val exception = assertThrows { runBlockingTest { client.deliverParcel(parcelSerialized) } } - assertEquals( - "Parcel was refused by the server (${HttpStatusCode.Forbidden})", - exception.message - ) + assertEquals("The server rejected the parcel", exception.message) } } @Test - fun `Other 40X responses should be regarded protocol violations by the client`() { + fun `Other client exceptions should be propagated`() { val client = makeTestClient { respondError(HttpStatusCode.BadRequest) } client.use { - val exception = assertThrows { + assertThrows { runBlockingTest { client.deliverParcel(parcelSerialized) } } - - assertEquals( - "The server reports that the client violated binding " + - "(${HttpStatusCode.BadRequest})", - exception.message - ) - } - } - - @Test - fun `HTTP 50X responses should throw a ServerConnectionException`() { - val client = makeTestClient { respondError(HttpStatusCode.BadGateway) } - - client.use { - val exception = assertThrows { - runBlockingTest { client.deliverParcel(parcelSerialized) } - } - - assertEquals( - "The server was unable to fulfil the request (${HttpStatusCode.BadGateway})", - exception.message - ) - } - } - - @Test - fun `TCP connection issues should throw a ServerConnectionException`() { - val nonRouteableIPAddress = "192.0.2.1" - // Use a real client to try to open an actual network connection - val client = PoWebClient.initRemote(nonRouteableIPAddress) - - client.use { - val exception = assertThrows { - runBlocking { client.deliverParcel(parcelSerialized) } - } - - assertEquals("Failed to connect to ${client.baseURL}", exception.message) - assertTrue(exception.cause is SocketException) } } } diff --git a/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt b/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt index 3b140c9..9af38ac 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt @@ -2,10 +2,18 @@ package tech.relaycorp.poweb import com.nhaarman.mockitokotlin2.spy import com.nhaarman.mockitokotlin2.verify +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.engine.mock.respondOk import io.ktor.client.engine.okhttp.OkHttpEngine import io.ktor.client.features.websocket.DefaultClientWebSocketSession import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode import io.ktor.http.URLProtocol +import io.ktor.http.content.ByteArrayContent +import io.ktor.http.content.OutgoingContent import io.ktor.util.InternalAPI import io.ktor.util.KtorExperimentalAPI import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -20,14 +28,16 @@ import tech.relaycorp.poweb.websocket.ServerShutdownAction import tech.relaycorp.poweb.websocket.WebSocketTestCase import java.io.EOFException import java.net.ConnectException +import java.net.SocketException import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +@ExperimentalCoroutinesApi @KtorExperimentalAPI +@Suppress("RedundantInnerClassModifier") class PoWebClientTest { @Nested - @Suppress("RedundantInnerClassModifier") inner class Constructor { @Nested inner class InitLocal { @@ -129,8 +139,145 @@ class PoWebClientTest { } @Nested - @Suppress("RedundantInnerClassModifier") - @ExperimentalCoroutinesApi + inner class Post { + private val path = "/foo" + private val body = ByteArrayContent("bar".toByteArray(), ContentType.Text.Plain) + + @Nested + inner class Request { + @Test + fun `Request should be made with HTTP POST`() = runBlockingTest { + var method: HttpMethod? = null + val client = makeTestClient { request: HttpRequestData -> + method = request.method + respondOk() + } + + client.use { client.post(path, body) } + + assertEquals(HttpMethod.Post, method) + } + + @Test + fun `Specified path should be honored`() = runBlockingTest { + var endpointURL: String? = null + val client = makeTestClient { request: HttpRequestData -> + endpointURL = request.url.toString() + respondOk() + } + + client.use { client.post(path, body) } + + assertEquals("${client.baseURL}$path", endpointURL) + } + + @Test + fun `Specified Content-Type should be honored`() = runBlockingTest { + var contentType: String? = null + val client = makeTestClient { request: HttpRequestData -> + contentType = request.body.contentType.toString() + respondOk() + } + + client.use { client.post(path, body) } + + assertEquals(body.contentType!!.toString(), contentType) + } + + @Test + fun `Request body should be the parcel serialized`() = runBlockingTest { + var requestBody: ByteArray? = null + val client = makeTestClient { request: HttpRequestData -> + assertTrue(request.body is OutgoingContent.ByteArrayContent) + requestBody = (request.body as OutgoingContent.ByteArrayContent).bytes() + respondOk() + } + + client.use { client.post(path, body) } + + assertEquals(body.bytes().asList(), requestBody?.asList()) + } + } + + @Nested + inner class Response { + + @Test + fun `HTTP 20X should be regarded a successful delivery`() = runBlockingTest { + val client = makeTestClient { respond("", HttpStatusCode.Accepted) } + + client.use { client.post(path, body) } + } + + @Test + fun `HTTP 30X responses should be regarded protocol violations by the server`() { + val client = makeTestClient { respond("", HttpStatusCode.Found) } + + client.use { + val exception = assertThrows { + runBlockingTest { client.post(path, body) } + } + + assertEquals( + "Received unexpected status (${HttpStatusCode.Found})", + exception.message + ) + } + } + + @Test + fun `Other 40X responses should be regarded protocol violations by the client`() { + val client = makeTestClient { respondError(HttpStatusCode.BadRequest) } + + client.use { + val exception = assertThrows { + runBlockingTest { client.post(path, body) } + } + + assertEquals( + "The server reports that the client violated binding " + + "(${HttpStatusCode.BadRequest})", + exception.message + ) + assertEquals(HttpStatusCode.BadRequest.value, exception.statusCode) + } + } + + @Test + fun `HTTP 50X responses should throw a ServerConnectionException`() { + val client = makeTestClient { respondError(HttpStatusCode.BadGateway) } + + client.use { + val exception = assertThrows { + runBlockingTest { client.post(path, body) } + } + + assertEquals( + "The server was unable to fulfil the request " + + "(${HttpStatusCode.BadGateway})", + exception.message + ) + } + } + } + + @Test + fun `TCP connection issues should throw a ServerConnectionException`() { + // Use a real client to try to open an actual network connection + val client = PoWebClient.initRemote(NON_ROUTABLE_IP_ADDRESS) + + client.use { + val exception = assertThrows { + runBlocking { client.post(path, body) } + } + + assertEquals("Failed to connect to ${client.baseURL}$path", exception.message) + assertTrue(exception.cause is SocketException) + } + } + } + + @Nested inner class WebSocketConnection : WebSocketTestCase(false) { private val hostName = "127.0.0.1" private val port = 13276 diff --git a/src/test/kotlin/tech/relaycorp/poweb/Utils.kt b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt index e6cafe8..aabd7a9 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/Utils.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt @@ -15,3 +15,5 @@ internal fun makeTestClient(handler: MockRequestHandler): PoWebClient { } return poWebClient } + +const val NON_ROUTABLE_IP_ADDRESS = "192.0.2.1" From 15b47dea03941820eaa1b1a7a151199d2e1043f6 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 14:52:08 +0100 Subject: [PATCH 07/15] WIP --- .../tech/relaycorp/poweb/PoWebClient.kt | 10 ++- .../tech/relaycorp/poweb/RegistrationTest.kt | 70 +++++++++++++++++-- src/test/kotlin/tech/relaycorp/poweb/Utils.kt | 8 ++- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt index d7c4059..38878e5 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -15,6 +15,7 @@ import io.ktor.http.cio.websocket.close import io.ktor.http.cio.websocket.readBytes import io.ktor.http.content.ByteArrayContent import io.ktor.http.content.OutgoingContent +import io.ktor.http.content.TextContent import io.ktor.util.KtorExperimentalAPI import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.flow.Flow @@ -33,6 +34,7 @@ import java.io.Closeable import java.io.EOFException import java.net.ConnectException import java.net.SocketException +import java.security.MessageDigest import java.security.PublicKey /** @@ -75,7 +77,8 @@ public class PoWebClient internal constructor( ClientBindingException::class ) public suspend fun preRegisterNode(nodePublicKey: PublicKey): ByteArray { - TODO() + val keyDigest = getSHA256DigestHex(nodePublicKey.encoded) + return post("/pre-registrations", TextContent(keyDigest, ContentType.Text.Plain)) } /** @@ -243,6 +246,11 @@ public class PoWebClient internal constructor( */ public fun initRemote(hostName: String, port: Int = DEFAULT_REMOTE_PORT): PoWebClient = PoWebClient(hostName, port, true) + + private fun getSHA256DigestHex(plaintext: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(plaintext).joinToString("") { "%02x".format(it) } + } } } diff --git a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt index 96f5d14..b309728 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt @@ -1,20 +1,82 @@ package tech.relaycorp.poweb +import io.ktor.client.engine.mock.respondOk +import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.content.OutgoingContent +import io.ktor.util.KtorExperimentalAPI +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import tech.relaycorp.relaynet.wrappers.generateRSAKeyPair +import java.nio.charset.Charset +import kotlin.test.assertEquals +import kotlin.test.assertTrue +@ExperimentalCoroutinesApi +@KtorExperimentalAPI class RegistrationTest { + private val publicKey = generateRSAKeyPair().public + @Nested inner class PreRegistration { @Test - @Disabled - fun `Request should be POSTed to the appropriate endpoint`() { + fun `Request should be made with HTTP POST`() = runBlockingTest { + var method: HttpMethod? = null + val client = makeTestClient { request: HttpRequestData -> + method = request.method + respondOk() + } + + client.use { client.preRegisterNode(publicKey) } + + assertEquals(HttpMethod.Post, method) } @Test - @Disabled - fun `Request body should be SHA-256 digest of the node public key`() { + fun `Request should be made to the appropriate endpoint`() = runBlockingTest { + var endpointURL: String? = null + val client = makeTestClient { request: HttpRequestData -> + endpointURL = request.url.toString() + respondOk() + } + + client.use { client.preRegisterNode(publicKey) } + + assertEquals("${client.baseURL}/pre-registrations", endpointURL) + } + + @Test + fun `Request Content-Type should be plain text`() = runBlockingTest { + var contentType: ContentType? = null + val client = makeTestClient { request: HttpRequestData -> + contentType = request.body.contentType + respondOk() + } + + client.use { client.preRegisterNode(publicKey) } + + assertEquals(ContentType.Text.Plain, contentType) + } + + @Test + fun `Request body should be SHA-256 digest of the node public key`() = runBlockingTest { + var requestBody: ByteArray? = null + val client = makeTestClient { request: HttpRequestData -> + assertTrue(request.body is OutgoingContent.ByteArrayContent) + requestBody = (request.body as OutgoingContent.ByteArrayContent).bytes() + respondOk() + } + + client.use { client.preRegisterNode(publicKey) } + + assertEquals( + getSHA256DigestHex(publicKey.encoded), + requestBody!!.toString(Charset.defaultCharset()) + ) } @Test diff --git a/src/test/kotlin/tech/relaycorp/poweb/Utils.kt b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt index aabd7a9..dffcfdc 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/Utils.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt @@ -4,6 +4,9 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.MockRequestHandler import io.ktor.util.KtorExperimentalAPI +import java.security.MessageDigest + +internal const val NON_ROUTABLE_IP_ADDRESS = "192.0.2.1" @KtorExperimentalAPI internal fun makeTestClient(handler: MockRequestHandler): PoWebClient { @@ -16,4 +19,7 @@ internal fun makeTestClient(handler: MockRequestHandler): PoWebClient { return poWebClient } -const val NON_ROUTABLE_IP_ADDRESS = "192.0.2.1" +internal fun getSHA256DigestHex(plaintext: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(plaintext).joinToString("") { "%02x".format(it) } +} From d9623686934651f6e2eb107590930e44e7a40ad1 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 14:54:04 +0100 Subject: [PATCH 08/15] refactor --- src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt index 38878e5..1ef5b54 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -97,11 +97,10 @@ public class PoWebClient internal constructor( return try { post("/parcels", body) } catch (exc: ClientBindingException) { - if (exc.statusCode == 403) { - throw RejectedParcelException("The server rejected the parcel") - } else { - throw exc - } + throw if (exc.statusCode == 403) + RejectedParcelException("The server rejected the parcel") + else + exc } } From 7f3c3e8cea01030847324d26a0bdd5c1fa63ef92 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 16:51:27 +0100 Subject: [PATCH 09/15] Workaround for JaCoCO bug with inline functions: https://github.com/jacoco/jacoco/issues/654 --- jacoco.gradle | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/jacoco.gradle b/jacoco.gradle index 84a1d7c..f7900f0 100644 --- a/jacoco.gradle +++ b/jacoco.gradle @@ -29,20 +29,24 @@ jacocoTestCoverageVerification { limit { counter = "CLASS" value = "MISSEDCOUNT" - maximum = "0".toBigDecimal() + // Workaround for https://github.com/jacoco/jacoco/issues/654 + maximum = "3".toBigDecimal() } limit { counter = "METHOD" value = "MISSEDCOUNT" - maximum = "0".toBigDecimal() + // Workaround for https://github.com/jacoco/jacoco/issues/654 + maximum = "4".toBigDecimal() } limit { counter = "BRANCH" value = "MISSEDCOUNT" - // Workaround for https://github.com/jacoco/jacoco/issues/1036 - maximum = "1".toBigDecimal() + // Workaround for + // - https://github.com/jacoco/jacoco/issues/1036 + // - https://github.com/jacoco/jacoco/issues/654 + maximum = "9".toBigDecimal() } } } From 5b84627ecc8bd4e8f9bdc06804a1036429817d33 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 16:53:59 +0100 Subject: [PATCH 10/15] tweak coverage threshold --- jacoco.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jacoco.gradle b/jacoco.gradle index f7900f0..4fd3e72 100644 --- a/jacoco.gradle +++ b/jacoco.gradle @@ -46,7 +46,7 @@ jacocoTestCoverageVerification { // Workaround for // - https://github.com/jacoco/jacoco/issues/1036 // - https://github.com/jacoco/jacoco/issues/654 - maximum = "9".toBigDecimal() + maximum = "8".toBigDecimal() } } } From ddd46233e8656a70c868ac810744c56523564994 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 16:59:58 +0100 Subject: [PATCH 11/15] Revert "tweak coverage threshold" This reverts commit 5b84627e --- jacoco.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jacoco.gradle b/jacoco.gradle index 4fd3e72..f7900f0 100644 --- a/jacoco.gradle +++ b/jacoco.gradle @@ -46,7 +46,7 @@ jacocoTestCoverageVerification { // Workaround for // - https://github.com/jacoco/jacoco/issues/1036 // - https://github.com/jacoco/jacoco/issues/654 - maximum = "8".toBigDecimal() + maximum = "9".toBigDecimal() } } } From aa077357ec1c60adf49d6c180a79365c49a3860b Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 17:00:06 +0100 Subject: [PATCH 12/15] Revert "Workaround for JaCoCO bug with inline functions: https://github.com/jacoco/jacoco/issues/654" This reverts commit 7f3c3e8c --- jacoco.gradle | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/jacoco.gradle b/jacoco.gradle index f7900f0..84a1d7c 100644 --- a/jacoco.gradle +++ b/jacoco.gradle @@ -29,24 +29,20 @@ jacocoTestCoverageVerification { limit { counter = "CLASS" value = "MISSEDCOUNT" - // Workaround for https://github.com/jacoco/jacoco/issues/654 - maximum = "3".toBigDecimal() + maximum = "0".toBigDecimal() } limit { counter = "METHOD" value = "MISSEDCOUNT" - // Workaround for https://github.com/jacoco/jacoco/issues/654 - maximum = "4".toBigDecimal() + maximum = "0".toBigDecimal() } limit { counter = "BRANCH" value = "MISSEDCOUNT" - // Workaround for - // - https://github.com/jacoco/jacoco/issues/1036 - // - https://github.com/jacoco/jacoco/issues/654 - maximum = "9".toBigDecimal() + // Workaround for https://github.com/jacoco/jacoco/issues/1036 + maximum = "1".toBigDecimal() } } } From 4126e58406a6789854ce471f2d683242e7b8ebfe Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 19:21:27 +0100 Subject: [PATCH 13/15] Workaround for https://github.com/jacoco/jacoco/issues/654 --- jacoco.gradle | 8 ++-- .../tech/relaycorp/poweb/PoWebClient.kt | 45 ++++++++++--------- .../tech/relaycorp/poweb/PoWebClientTest.kt | 25 ++++++++--- src/test/kotlin/tech/relaycorp/poweb/Utils.kt | 10 ++--- 4 files changed, 53 insertions(+), 35 deletions(-) diff --git a/jacoco.gradle b/jacoco.gradle index 84a1d7c..47b8ffb 100644 --- a/jacoco.gradle +++ b/jacoco.gradle @@ -29,20 +29,22 @@ jacocoTestCoverageVerification { limit { counter = "CLASS" value = "MISSEDCOUNT" - maximum = "0".toBigDecimal() + // Workaround for https://github.com/jacoco/jacoco/issues/654 + maximum = "1".toBigDecimal() } limit { counter = "METHOD" value = "MISSEDCOUNT" - maximum = "0".toBigDecimal() + // Workaround for https://github.com/jacoco/jacoco/issues/654 + maximum = "1".toBigDecimal() } limit { counter = "BRANCH" value = "MISSEDCOUNT" // Workaround for https://github.com/jacoco/jacoco/issues/1036 - maximum = "1".toBigDecimal() + maximum = "2".toBigDecimal() } } } diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt index 1ef5b54..e74b9b1 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -1,13 +1,14 @@ package tech.relaycorp.poweb import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.features.ResponseException import io.ktor.client.features.websocket.DefaultClientWebSocketSession import io.ktor.client.features.websocket.WebSockets import io.ktor.client.features.websocket.webSocket import io.ktor.client.request.header import io.ktor.client.request.post +import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.cio.websocket.CloseReason import io.ktor.http.cio.websocket.Frame @@ -50,9 +51,10 @@ import java.security.PublicKey public class PoWebClient internal constructor( internal val hostName: String, internal val port: Int, - internal val useTls: Boolean + internal val useTls: Boolean, + ktorEngine: HttpClientEngine = OkHttp.create {} ) : Closeable { - internal var ktorClient = HttpClient(OkHttp) { + internal var ktorClient = HttpClient(ktorEngine) { install(WebSockets) } @@ -78,7 +80,8 @@ public class PoWebClient internal constructor( ) public suspend fun preRegisterNode(nodePublicKey: PublicKey): ByteArray { val keyDigest = getSHA256DigestHex(nodePublicKey.encoded) - return post("/pre-registrations", TextContent(keyDigest, ContentType.Text.Plain)) + post("/pre-registrations", TextContent(keyDigest, ContentType.Text.Plain)) + return "ignore!".toByteArray() } /** @@ -94,7 +97,7 @@ public class PoWebClient internal constructor( ) public suspend fun deliverParcel(parcelSerialized: ByteArray) { val body = ByteArrayContent(parcelSerialized, PARCEL_CONTENT_TYPE) - return try { + try { post("/parcels", body) } catch (exc: ClientBindingException) { throw if (exc.statusCode == 403) @@ -178,28 +181,30 @@ public class PoWebClient internal constructor( ServerBindingException::class, ClientBindingException::class ) - internal suspend inline fun post(path: String, requestBody: OutgoingContent): T { + internal suspend fun post(path: String, requestBody: OutgoingContent): HttpResponse { val url = "$baseURL$path" - try { - return ktorClient.post(url) { + val response: HttpResponse = try { + ktorClient.post(url) { body = requestBody } } catch (exc: SocketException) { // Java on macOS throws a SocketException but all other platforms throw a // ConnectException (a subclass of SocketException) throw ServerConnectionException("Failed to connect to $url", exc) - } catch (exc: ResponseException) { - val status = exc.response!!.status - when (status.value) { - in 400..499 -> throw ClientBindingException( - "The server reports that the client violated binding ($status)", - status.value - ) - in 500..599 -> throw ServerConnectionException( - "The server was unable to fulfil the request ($status)" - ) - else -> throw ServerBindingException("Received unexpected status ($status)") - } + } + + if (response.status.value in 200..299) { + return response + } + throw when (response.status.value) { + in 400..499 -> ClientBindingException( + "The server reports that the client violated binding (${response.status})", + response.status.value + ) + in 500..599 -> ServerConnectionException( + "The server was unable to fulfil the request (${response.status})" + ) + else -> ServerBindingException("Received unexpected status (${response.status})") } } diff --git a/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt b/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt index 9af38ac..098dbad 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt @@ -76,6 +76,14 @@ class PoWebClientTest { assertEquals("http://127.0.0.1:276/v1", client.baseURL) } + + @InternalAPI + @Test + fun `OkHTTP should be the client engine`() { + val client = PoWebClient.initLocal() + + assertTrue(client.ktorClient.engine is OkHttpEngine) + } } @Nested @@ -117,6 +125,14 @@ class PoWebClientTest { assertEquals("https://$hostName:443/v1", client.baseURL) } + + @InternalAPI + @Test + fun `OkHTTP should be the client engine`() { + val client = PoWebClient.initRemote(hostName) + + assertTrue(client.ktorClient.engine is OkHttpEngine) + } } } @@ -153,7 +169,7 @@ class PoWebClientTest { respondOk() } - client.use { client.post(path, body) } + client.use { client.post(path, body) } assertEquals(HttpMethod.Post, method) } @@ -166,7 +182,7 @@ class PoWebClientTest { respondOk() } - client.use { client.post(path, body) } + client.use { client.post(path, body) } assertEquals("${client.baseURL}$path", endpointURL) } @@ -179,7 +195,7 @@ class PoWebClientTest { respondOk() } - client.use { client.post(path, body) } + client.use { client.post(path, body) } assertEquals(body.contentType!!.toString(), contentType) } @@ -193,7 +209,7 @@ class PoWebClientTest { respondOk() } - client.use { client.post(path, body) } + client.use { client.post(path, body) } assertEquals(body.bytes().asList(), requestBody?.asList()) } @@ -201,7 +217,6 @@ class PoWebClientTest { @Nested inner class Response { - @Test fun `HTTP 20X should be regarded a successful delivery`() = runBlockingTest { val client = makeTestClient { respond("", HttpStatusCode.Accepted) } diff --git a/src/test/kotlin/tech/relaycorp/poweb/Utils.kt b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt index dffcfdc..4a1aba2 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/Utils.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt @@ -1,6 +1,5 @@ package tech.relaycorp.poweb -import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.MockRequestHandler import io.ktor.util.KtorExperimentalAPI @@ -10,13 +9,10 @@ internal const val NON_ROUTABLE_IP_ADDRESS = "192.0.2.1" @KtorExperimentalAPI internal fun makeTestClient(handler: MockRequestHandler): PoWebClient { - val poWebClient = PoWebClient.initLocal() - poWebClient.ktorClient = HttpClient(MockEngine) { - engine { - addHandler(handler) - } + val ktorEngine = MockEngine.create { + addHandler(handler) } - return poWebClient + return PoWebClient("127.0.0.1", 1234, false, ktorEngine) } internal fun getSHA256DigestHex(plaintext: ByteArray): String { From c6b993adcf72e13f13eef1b59ef13f0cb0f92562 Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Tue, 8 Sep 2020 19:40:18 +0100 Subject: [PATCH 14/15] Complete pre-registration --- .../tech/relaycorp/poweb/PoWebClient.kt | 16 +++++- .../tech/relaycorp/poweb/RegistrationTest.kt | 56 ++++++++++++++----- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt index e74b9b1..78aa024 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -17,7 +17,9 @@ import io.ktor.http.cio.websocket.readBytes import io.ktor.http.content.ByteArrayContent import io.ktor.http.content.OutgoingContent import io.ktor.http.content.TextContent +import io.ktor.http.contentType import io.ktor.util.KtorExperimentalAPI +import io.ktor.util.toByteArray import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector @@ -80,8 +82,16 @@ public class PoWebClient internal constructor( ) public suspend fun preRegisterNode(nodePublicKey: PublicKey): ByteArray { val keyDigest = getSHA256DigestHex(nodePublicKey.encoded) - post("/pre-registrations", TextContent(keyDigest, ContentType.Text.Plain)) - return "ignore!".toByteArray() + val response = post("/pre-registrations", TextContent(keyDigest, ContentType.Text.Plain)) + + val contentType = response.contentType() + if (contentType != PNRA_CONTENT_TYPE) { + throw ServerBindingException( + "The server returned an invalid Content-Type ($contentType)" + ) + } + + return response.content.toByteArray() } /** @@ -231,6 +241,8 @@ public class PoWebClient internal constructor( private const val DEFAULT_REMOTE_PORT = 443 private val PARCEL_CONTENT_TYPE = ContentType("application", "vnd.relaynet.parcel") + internal val PNRA_CONTENT_TYPE = + ContentType("application", "vnd.relaynet.node-registration.authorization") /** * Connect to a private gateway from a private endpoint. diff --git a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt index b309728..776f7ac 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt @@ -1,16 +1,17 @@ package tech.relaycorp.poweb -import io.ktor.client.engine.mock.respondOk +import io.ktor.client.engine.mock.respond import io.ktor.client.request.HttpRequestData import io.ktor.http.ContentType import io.ktor.http.HttpMethod import io.ktor.http.content.OutgoingContent +import io.ktor.http.headersOf import io.ktor.util.KtorExperimentalAPI import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runBlockingTest -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import tech.relaycorp.relaynet.wrappers.generateRSAKeyPair import java.nio.charset.Charset import kotlin.test.assertEquals @@ -23,12 +24,15 @@ class RegistrationTest { @Nested inner class PreRegistration { + private val responseHeaders = + headersOf("Content-Type", PoWebClient.PNRA_CONTENT_TYPE.toString()) + @Test fun `Request should be made with HTTP POST`() = runBlockingTest { var method: HttpMethod? = null val client = makeTestClient { request: HttpRequestData -> method = request.method - respondOk() + respond(byteArrayOf(), headers = responseHeaders) } client.use { client.preRegisterNode(publicKey) } @@ -41,7 +45,7 @@ class RegistrationTest { var endpointURL: String? = null val client = makeTestClient { request: HttpRequestData -> endpointURL = request.url.toString() - respondOk() + respond(byteArrayOf(), headers = responseHeaders) } client.use { client.preRegisterNode(publicKey) } @@ -54,7 +58,7 @@ class RegistrationTest { var contentType: ContentType? = null val client = makeTestClient { request: HttpRequestData -> contentType = request.body.contentType - respondOk() + respond(byteArrayOf(), headers = responseHeaders) } client.use { client.preRegisterNode(publicKey) } @@ -68,7 +72,7 @@ class RegistrationTest { val client = makeTestClient { request: HttpRequestData -> assertTrue(request.body is OutgoingContent.ByteArrayContent) requestBody = (request.body as OutgoingContent.ByteArrayContent).bytes() - respondOk() + respond(byteArrayOf(), headers = responseHeaders) } client.use { client.preRegisterNode(publicKey) } @@ -80,18 +84,42 @@ class RegistrationTest { } @Test - @Disabled - fun `An invalid response content type should be refused`() { - } + fun `An invalid response Content-Type should be refused`() { + val invalidContentType = ContentType.Application.Json + val client = makeTestClient { + respond( + "{}", + headers = headersOf("Content-Type", invalidContentType.toString()) + ) + } - @Test - @Disabled - fun `20X response status other than 200 should throw an error`() { + val exception = assertThrows { + runBlockingTest { + client.use { client.preRegisterNode(publicKey) } + } + } + + assertEquals( + "The server returned an invalid Content-Type ($invalidContentType)", + exception.message + ) } @Test - @Disabled - fun `Authorization should be output serialized if status is 200`() { + fun `Authorization should be output serialized if request succeeds`() { + runBlockingTest { + val authorizationSerialized = "This is the PNRA".toByteArray() + val client = makeTestClient { + respond(authorizationSerialized, headers = responseHeaders) + } + + client.use { + assertEquals( + authorizationSerialized.asList(), + it.preRegisterNode(publicKey).asList() + ) + } + } } } } From 539e4a64cdd5f898bf84fb0deee81da2637ca4cd Mon Sep 17 00:00:00 2001 From: Gus Narea Date: Wed, 9 Sep 2020 10:20:19 +0100 Subject: [PATCH 15/15] Complete registration --- build.gradle | 1 + .../tech/relaycorp/poweb/PoWebClient.kt | 40 ++++- .../tech/relaycorp/poweb/RegistrationTest.kt | 150 ++++++++++++++++-- 3 files changed, 171 insertions(+), 20 deletions(-) diff --git a/build.gradle b/build.gradle index 35dccb3..d277ad6 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") api('tech.relaycorp:relaynet:1.36.3') + implementation('tech.relaycorp:relaynet-testing:1.0.0') // Handshake nonce signatures implementation("org.bouncycastle:bcpkix-jdk15on:1.66") diff --git a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt index 78aa024..973a1d6 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -32,6 +32,7 @@ import tech.relaycorp.relaynet.bindings.pdc.ParcelCollection import tech.relaycorp.relaynet.bindings.pdc.StreamingMode import tech.relaycorp.relaynet.messages.InvalidMessageException import tech.relaycorp.relaynet.messages.control.ParcelDelivery +import tech.relaycorp.relaynet.messages.control.PrivateNodeRegistration import tech.relaycorp.relaynet.wrappers.x509.Certificate import java.io.Closeable import java.io.EOFException @@ -84,16 +85,28 @@ public class PoWebClient internal constructor( val keyDigest = getSHA256DigestHex(nodePublicKey.encoded) val response = post("/pre-registrations", TextContent(keyDigest, ContentType.Text.Plain)) - val contentType = response.contentType() - if (contentType != PNRA_CONTENT_TYPE) { - throw ServerBindingException( - "The server returned an invalid Content-Type ($contentType)" - ) - } + requireContentType(PNRA_CONTENT_TYPE, response.contentType()) return response.content.toByteArray() } + /** + * Register a private node. + * + * @param pnrrSerialized The Private Node Registration Request + */ + public suspend fun registerNode(pnrrSerialized: ByteArray): PrivateNodeRegistration { + val response = post("/nodes", ByteArrayContent(pnrrSerialized, PNRR_CONTENT_TYPE)) + + requireContentType(PNR_CONTENT_TYPE, response.contentType()) + + return try { + PrivateNodeRegistration.deserialize(response.content.toByteArray()) + } catch (exc: InvalidMessageException) { + throw ServerBindingException("The server returned a malformed registration", exc) + } + } + /** * Deliver a parcel. * @@ -218,6 +231,17 @@ public class PoWebClient internal constructor( } } + private fun requireContentType( + requiredContentType: ContentType, + actualContentType: ContentType? + ) { + if (actualContentType != requiredContentType) { + throw ServerBindingException( + "The server returned an invalid Content-Type ($actualContentType)" + ) + } + } + internal suspend fun wsConnect( path: String, headers: List>? = null, @@ -243,6 +267,10 @@ public class PoWebClient internal constructor( private val PARCEL_CONTENT_TYPE = ContentType("application", "vnd.relaynet.parcel") internal val PNRA_CONTENT_TYPE = ContentType("application", "vnd.relaynet.node-registration.authorization") + internal val PNRR_CONTENT_TYPE = + ContentType("application", "vnd.relaynet.node-registration.request") + internal val PNR_CONTENT_TYPE = + ContentType("application", "vnd.relaynet.node-registration.registration") /** * Connect to a private gateway from a private endpoint. diff --git a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt index 776f7ac..34935c2 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt @@ -12,23 +12,26 @@ import kotlinx.coroutines.test.runBlockingTest import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -import tech.relaycorp.relaynet.wrappers.generateRSAKeyPair +import tech.relaycorp.relaynet.messages.InvalidMessageException +import tech.relaycorp.relaynet.messages.control.PrivateNodeRegistration +import tech.relaycorp.relaynet.testing.CertificationPath +import tech.relaycorp.relaynet.testing.KeyPairSet import java.nio.charset.Charset import kotlin.test.assertEquals import kotlin.test.assertTrue +@Suppress("RedundantInnerClassModifier") @ExperimentalCoroutinesApi @KtorExperimentalAPI class RegistrationTest { - private val publicKey = generateRSAKeyPair().public - @Nested inner class PreRegistration { + private val publicKey = KeyPairSet.PRIVATE_GW.public private val responseHeaders = headersOf("Content-Type", PoWebClient.PNRA_CONTENT_TYPE.toString()) @Test - fun `Request should be made with HTTP POST`() = runBlockingTest { + fun `Request method should be POST`() = runBlockingTest { var method: HttpMethod? = null val client = makeTestClient { request: HttpRequestData -> method = request.method @@ -106,20 +109,139 @@ class RegistrationTest { } @Test - fun `Authorization should be output serialized if request succeeds`() { - runBlockingTest { - val authorizationSerialized = "This is the PNRA".toByteArray() - val client = makeTestClient { - respond(authorizationSerialized, headers = responseHeaders) + fun `Authorization should be output serialized if request succeeds`() = runBlockingTest { + val authorizationSerialized = "This is the PNRA".toByteArray() + val client = makeTestClient { + respond(authorizationSerialized, headers = responseHeaders) + } + + client.use { + assertEquals( + authorizationSerialized.asList(), + it.preRegisterNode(publicKey).asList() + ) + } + } + } + + @Nested + inner class Registration { + private val pnrrSerialized = "The PNRR".toByteArray() + private val responseHeaders = + headersOf("Content-Type", PoWebClient.PNR_CONTENT_TYPE.toString()) + + private val registration = + PrivateNodeRegistration(CertificationPath.PRIVATE_GW, CertificationPath.PUBLIC_GW) + private val registrationSerialized = registration.serialize() + + @Test + fun `Request method should be POST`() = runBlockingTest { + var method: HttpMethod? = null + val client = makeTestClient { request: HttpRequestData -> + method = request.method + respond(registrationSerialized, headers = responseHeaders) + } + + client.use { client.registerNode(pnrrSerialized) } + + assertEquals(HttpMethod.Post, method) + } + + @Test + fun `Request should be made to the appropriate endpoint`() = runBlockingTest { + var endpointURL: String? = null + val client = makeTestClient { request: HttpRequestData -> + endpointURL = request.url.toString() + respond(registrationSerialized, headers = responseHeaders) + } + + client.use { client.registerNode(pnrrSerialized) } + + assertEquals("${client.baseURL}/nodes", endpointURL) + } + + @Test + fun `Request Content-Type should be a PNRR`() = runBlockingTest { + var contentType: ContentType? = null + val client = makeTestClient { request: HttpRequestData -> + contentType = request.body.contentType + respond(registrationSerialized, headers = responseHeaders) + } + + client.use { client.registerNode(pnrrSerialized) } + + assertEquals(PoWebClient.PNRR_CONTENT_TYPE, contentType) + } + + @Test + fun `Request body should be the PNRR serialized`() = runBlockingTest { + var requestBody: ByteArray? = null + val client = makeTestClient { request: HttpRequestData -> + assertTrue(request.body is OutgoingContent.ByteArrayContent) + requestBody = (request.body as OutgoingContent.ByteArrayContent).bytes() + respond(registrationSerialized, headers = responseHeaders) + } + + client.use { client.registerNode(pnrrSerialized) } + + assertEquals(pnrrSerialized.asList(), requestBody!!.asList()) + } + + @Test + fun `An invalid response Content-Type should be refused`() { + val invalidContentType = ContentType.Application.Json + val client = makeTestClient { + respond( + "{}", + headers = headersOf("Content-Type", invalidContentType.toString()) + ) + } + + val exception = assertThrows { + runBlockingTest { + client.use { client.registerNode(pnrrSerialized) } } + } - client.use { - assertEquals( - authorizationSerialized.asList(), - it.preRegisterNode(publicKey).asList() - ) + assertEquals( + "The server returned an invalid Content-Type ($invalidContentType)", + exception.message + ) + } + + @Test + fun `An invalid registration should be refused`() { + val client = makeTestClient { + respond("{}", headers = responseHeaders) + } + + val exception = assertThrows { + runBlockingTest { + client.use { client.registerNode(pnrrSerialized) } } } + + assertEquals("The server returned a malformed registration", exception.message) + assertTrue(exception.cause is InvalidMessageException) + } + + @Test + fun `Registration should be output if request succeeds`() = runBlockingTest { + val client = makeTestClient { + respond(registrationSerialized, headers = responseHeaders) + } + + client.use { + val finalRegistration = it.registerNode(pnrrSerialized) + assertEquals( + registration.privateNodeCertificate, + finalRegistration.privateNodeCertificate + ) + assertEquals( + registration.gatewayCertificate, + finalRegistration.gatewayCertificate + ) + } } } }