diff --git a/build.gradle b/build.gradle index 281d028..5f62c05 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/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 59960cf..973a1d6 100644 --- a/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt +++ b/src/main/kotlin/tech/relaycorp/poweb/PoWebClient.kt @@ -1,20 +1,25 @@ 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 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.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 @@ -27,11 +32,14 @@ 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 import java.net.ConnectException import java.net.SocketException +import java.security.MessageDigest +import java.security.PublicKey /** * PoWeb client. @@ -46,9 +54,10 @@ import java.net.SocketException 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) } @@ -62,6 +71,42 @@ 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 { + val keyDigest = getSHA256DigestHex(nodePublicKey.encoded) + val response = post("/pre-registrations", TextContent(keyDigest, ContentType.Text.Plain)) + + 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. * @@ -70,30 +115,18 @@ public class PoWebClient internal constructor( @Throws( ServerConnectionException::class, ServerBindingException::class, - RefusedParcelException::class, + RejectedParcelException::class, ClientBindingException::class ) public suspend fun deliverParcel(parcelSerialized: ByteArray) { + val body = ByteArrayContent(parcelSerialized, PARCEL_CONTENT_TYPE) 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)") - } + post("/parcels", body) + } catch (exc: ClientBindingException) { + throw if (exc.statusCode == 403) + RejectedParcelException("The server rejected the parcel") + else + exc } } @@ -166,6 +199,49 @@ public class PoWebClient internal constructor( } } + @Throws( + ServerConnectionException::class, + ServerBindingException::class, + ClientBindingException::class + ) + internal suspend fun post(path: String, requestBody: OutgoingContent): HttpResponse { + val url = "$baseURL$path" + 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) + } + + 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})") + } + } + + 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, @@ -189,6 +265,12 @@ 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") + 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. @@ -208,6 +290,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/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 631499d..1bb295f 100644 --- a/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt +++ b/src/test/kotlin/tech/relaycorp/poweb/ParcelDeliveryTest.kt @@ -1,21 +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.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 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 @@ -27,7 +23,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 +36,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 +49,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 +62,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,99 +75,32 @@ 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) } - - 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 = makeClient { respondError(HttpStatusCode.Forbidden) } + 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`() { - val client = makeClient { respondError(HttpStatusCode.BadRequest) } + 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 = makeClient { 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) - } - } - - 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/PoWebClientTest.kt b/src/test/kotlin/tech/relaycorp/poweb/PoWebClientTest.kt index 3b140c9..098dbad 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 { @@ -66,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 @@ -107,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) + } } } @@ -129,8 +155,144 @@ 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/RegistrationTest.kt b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt new file mode 100644 index 0000000..34935c2 --- /dev/null +++ b/src/test/kotlin/tech/relaycorp/poweb/RegistrationTest.kt @@ -0,0 +1,247 @@ +package tech.relaycorp.poweb + +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.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +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 { + @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 method should be POST`() = runBlockingTest { + var method: HttpMethod? = null + val client = makeTestClient { request: HttpRequestData -> + method = request.method + respond(byteArrayOf(), headers = responseHeaders) + } + + client.use { client.preRegisterNode(publicKey) } + + 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(byteArrayOf(), headers = responseHeaders) + } + + 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 + respond(byteArrayOf(), headers = responseHeaders) + } + + 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() + respond(byteArrayOf(), headers = responseHeaders) + } + + client.use { client.preRegisterNode(publicKey) } + + assertEquals( + getSHA256DigestHex(publicKey.encoded), + requestBody!!.toString(Charset.defaultCharset()) + ) + } + + @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.preRegisterNode(publicKey) } + } + } + + assertEquals( + "The server returned an invalid Content-Type ($invalidContentType)", + exception.message + ) + } + + @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) + } + + 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) } + } + } + + 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 + ) + } + } + } +} 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..4a1aba2 --- /dev/null +++ b/src/test/kotlin/tech/relaycorp/poweb/Utils.kt @@ -0,0 +1,21 @@ +package tech.relaycorp.poweb + +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 { + val ktorEngine = MockEngine.create { + addHandler(handler) + } + return PoWebClient("127.0.0.1", 1234, false, ktorEngine) +} + +internal fun getSHA256DigestHex(plaintext: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(plaintext).joinToString("") { "%02x".format(it) } +}