diff --git a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt index 62fbc8d1a41a409b09321c636cc2279fd175ecc9..8b4a690adb4ee5451f60cf4650accd4338880f4e 100644 --- a/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt +++ b/mailbox-android/src/main/java/org/briarproject/mailbox/android/MailboxService.kt @@ -23,7 +23,7 @@ import kotlin.system.exitProcess class MailboxService : Service() { companion object { - private val LOG = getLogger(MailboxService::class.java.name) + private val LOG = getLogger(MailboxService::class.java) fun startService(context: Context) { val startIntent = Intent(context, MailboxService::class.java) diff --git a/mailbox-cli/build.gradle b/mailbox-cli/build.gradle index 6d07db5931dc9c7ae6c2c8e1525ed9f828173c59..1aade2c8a2c6f24eb08a41a34c3de81783f95559 100644 --- a/mailbox-cli/build.gradle +++ b/mailbox-cli/build.gradle @@ -35,7 +35,7 @@ dependencies { } application { - mainClassName = 'org.briarproject.mailbox.cli.MainKt' + mainClass = 'org.briarproject.mailbox.cli.MainKt' } test { diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/contacts/ContactsManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/contacts/ContactsManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..240a6b5c69107632327c30a7833815c98d569407 --- /dev/null +++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/contacts/ContactsManager.kt @@ -0,0 +1,99 @@ +package org.briarproject.mailbox.core.contacts + +import com.fasterxml.jackson.core.JacksonException +import io.ktor.application.ApplicationCall +import io.ktor.auth.principal +import io.ktor.features.BadRequestException +import io.ktor.features.UnsupportedMediaTypeException +import io.ktor.http.HttpStatusCode.Companion.Conflict +import io.ktor.http.HttpStatusCode.Companion.Created +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.request.receive +import io.ktor.response.respond +import org.briarproject.mailbox.core.db.Database +import org.briarproject.mailbox.core.server.AuthManager +import org.briarproject.mailbox.core.system.RandomIdManager +import org.briarproject.mailbox.core.util.LogUtils.logException +import org.slf4j.LoggerFactory.getLogger +import javax.inject.Inject + +class ContactsManager @Inject constructor( + private val db: Database, + private val authManager: AuthManager, + private val randomIdManager: RandomIdManager, +) { + + companion object { + private val LOG = getLogger(ContactsManager::class.java) + } + + /** + * Used by owner to list contacts. + */ + suspend fun listContacts(call: ApplicationCall) { + authManager.assertIsOwner(call.principal()) + + val contacts = db.transactionWithResult(true) { txn -> + db.getContacts(txn) + } + val contactIds = contacts.map { contact -> contact.contactId } + call.respond(ContactsResponse(contactIds)) + } + + /** + * Used by owner to add new contacts. + */ + suspend fun postContact(call: ApplicationCall) { + authManager.assertIsOwner(call.principal()) + val c: Contact = try { + call.receive() + } catch (e: JacksonException) { + logException(LOG, e) + throw BadRequestException("Unable to deserialise Contact: ${e.message}", e) + } catch (e: UnsupportedMediaTypeException) { + logException(LOG, e) + throw BadRequestException("Unable to deserialise Contact: ${e.message}", e) + } + + randomIdManager.assertIsRandomId(c.token) + randomIdManager.assertIsRandomId(c.inboxId) + randomIdManager.assertIsRandomId(c.outboxId) + + val status = db.transactionWithResult(false) { txn -> + if (db.getContact(txn, c.contactId) != null) { + Conflict + } else { + db.addContact(txn, c) + Created + } + } + call.response.status(status) + } + + /** + * Used by owner to add remove contacts. + */ + fun deleteContact(call: ApplicationCall, paramContactId: String) { + authManager.assertIsOwner(call.principal()) + + val contactId = try { + Integer.parseInt(paramContactId) + } catch (e: NumberFormatException) { + throw BadRequestException("Invalid value for parameter contactId") + } + + val status = db.transactionWithResult(false) { txn -> + if (db.getContact(txn, contactId) == null) { + NotFound + } else { + db.removeContact(txn, contactId) + OK + } + } + call.response.status(status) + } + +} + +data class ContactsResponse(val contacts: List<Int>) diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt index 7fbb0c5c8dc23f5a8925bbf7f1c9d5f65d411b1b..0d899a75faf449b156edba5c7f6427851a5826ab 100644 --- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt +++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/Database.kt @@ -17,6 +17,9 @@ interface Database : TransactionManager { @Throws(DbException::class) fun close() + @Throws(DbException::class) + fun clearDatabase(txn: Transaction) + @Throws(DbException::class) fun getSettings(txn: Transaction, namespace: String): Settings diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt index c85ecb099fcb7700029688f28c1b6490cef5fa6f..31c5a8a32ceb0f058db42cb03158b74b0efc6af7 100644 --- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt +++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/db/JdbcDatabase.kt @@ -351,6 +351,25 @@ abstract class JdbcDatabase(private val dbTypes: DatabaseTypes, private val cloc } } + @Throws(DbException::class) + override fun clearDatabase(txn: Transaction) { + val connection: Connection = txn.unbox() + execute(connection, "DELETE FROM settings") + execute(connection, "DELETE FROM contacts") + } + + private fun execute(connection: Connection, sql: String) { + var ps: PreparedStatement? = null + try { + ps = connection.prepareStatement(sql) + ps.executeUpdate() + ps.close() + } catch (e: SQLException) { + tryToClose(ps, LOG) + throw DbException(e) + } + } + @Throws(DbException::class) override fun getSettings(txn: Transaction, namespace: String): Settings { val connection: Connection = txn.unbox() diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt index 526ff542f8d8d094d5b264ccdcc9659b175efa0c..80ef74aba57f4588afe0a0ac48e7307c3f3f4606 100644 --- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt +++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/Routing.kt @@ -4,8 +4,12 @@ import io.ktor.application.Application import io.ktor.application.ApplicationCall import io.ktor.application.call import io.ktor.auth.authenticate +import io.ktor.features.BadRequestException +import io.ktor.features.MissingRequestParameterException import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.HttpStatusCode.Companion.BadRequest +import io.ktor.http.HttpStatusCode.Companion.Unauthorized import io.ktor.response.respond import io.ktor.response.respondText import io.ktor.routing.delete @@ -15,6 +19,7 @@ import io.ktor.routing.put import io.ktor.routing.route import io.ktor.routing.routing import io.ktor.util.getOrFail +import org.briarproject.mailbox.core.contacts.ContactsManager import org.briarproject.mailbox.core.files.FileManager import org.briarproject.mailbox.core.system.InvalidIdException @@ -36,29 +41,29 @@ internal fun Application.configureBasicApi() = routing { } } -internal fun Application.configureContactApi() = routing { - authenticate { - route("$V/contacts") { - put("/{contactId}") { - call.respond( - HttpStatusCode.OK, - "get: Not yet implemented. " + - "contactId: ${call.parameters["contactId"]}" - ) - } - delete("/{contactId}") { - call.respond( - HttpStatusCode.OK, - "delete: Not yet implemented. " + - "contactId: ${call.parameters["contactId"]}" - ) - } - get { - call.respond(HttpStatusCode.OK, "get: Not yet implemented") +internal fun Application.configureContactApi(contactsManager: ContactsManager) = + routing { + authenticate { + route("$V/contacts") { + post { + call.handle { + contactsManager.postContact(call) + } + } + delete("/{contactId}") { + call.handle { + val contactId = call.parameters.getOrFail("contactId") + contactsManager.deleteContact(call, contactId) + } + } + get { + call.handle { + contactsManager.listContacts(call) + } + } } } } -} internal fun Application.configureFilesApi(fileManager: FileManager) = routing { @@ -105,8 +110,12 @@ private suspend fun ApplicationCall.handle(block: suspend () -> Unit) { try { block() } catch (e: AuthException) { - respond(HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized.description) + respond(Unauthorized, Unauthorized.description) } catch (e: InvalidIdException) { - respond(HttpStatusCode.BadRequest, "Malformed ID: ${e.id}") + respond(BadRequest, "Malformed ID: ${e.id}") + } catch (e: MissingRequestParameterException) { + respond(BadRequest, "Missing parameter: ${e.parameterName}") + } catch (e: BadRequestException) { + respond(BadRequest, "Bad request: ${e.message}") } } diff --git a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt index 60cd89b501261cbb3135999727565ba09ae18bc5..82140aa955e54402e868e9e476c2cb82406ec1fa 100644 --- a/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt +++ b/mailbox-core/src/main/java/org/briarproject/mailbox/core/server/WebServerManager.kt @@ -7,6 +7,7 @@ import io.ktor.features.ContentNegotiation import io.ktor.jackson.jackson import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty +import org.briarproject.mailbox.core.contacts.ContactsManager import org.briarproject.mailbox.core.files.FileManager import org.briarproject.mailbox.core.lifecycle.Service import org.briarproject.mailbox.core.server.WebServerManager.Companion.PORT @@ -24,6 +25,7 @@ interface WebServerManager : Service { @Singleton internal class WebServerManagerImpl @Inject constructor( private val authManager: AuthManager, + private val contactsManager: ContactsManager, private val fileManager: FileManager, ) : WebServerManager { @@ -47,7 +49,7 @@ internal class WebServerManagerImpl @Inject constructor( jackson() } configureBasicApi() - configureContactApi() + configureContactApi(contactsManager) configureFilesApi(fileManager) } } diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt index 9ec2c1b1be157711e7f86ff4316e9fb913692c17..6849f8c32a4b343f9474689da3b5d64183639a30 100644 --- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt +++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/TestUtils.kt @@ -1,5 +1,9 @@ package org.briarproject.mailbox.core +import com.fasterxml.jackson.databind.ObjectMapper +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.readText +import io.ktor.http.HttpStatusCode import io.mockk.every import io.mockk.mockk import org.briarproject.mailbox.core.contacts.Contact @@ -10,6 +14,7 @@ import org.briarproject.mailbox.core.system.toHex import org.briarproject.mailbox.core.util.IoUtils import java.io.File import kotlin.random.Random +import kotlin.test.assertEquals object TestUtils { @@ -40,4 +45,19 @@ object TestUtils { block(txn) } + /** + * Asserts that response is OK and contains the expected JSON. The expected JSON can be + * specified in arbitrary formatting as it will be prettified before comparing it to the + * response, which will be prettified, too. + */ + suspend fun assertJson(expectedJson: String, response: HttpResponse) { + assertEquals(HttpStatusCode.OK, response.status) + val mapper = ObjectMapper() + val expectedValue: Any = mapper.readValue(expectedJson, Any::class.java) + val expected = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(expectedValue) + val actualValue: Any = mapper.readValue(response.readText(), Any::class.java) + val actual = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(actualValue) + assertEquals(expected, actual) + } + } diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerIntegrationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..767df7596aea2d357c3eab15a6749308166c686e --- /dev/null +++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerIntegrationTest.kt @@ -0,0 +1,250 @@ +package org.briarproject.mailbox.core.contacts + +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.readText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode.Companion.BadRequest +import io.ktor.http.HttpStatusCode.Companion.Conflict +import io.ktor.http.HttpStatusCode.Companion.Created +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import io.ktor.http.contentType +import kotlinx.coroutines.runBlocking +import org.briarproject.mailbox.core.TestUtils.assertJson +import org.briarproject.mailbox.core.TestUtils.getNewRandomContact +import org.briarproject.mailbox.core.db.Database +import org.briarproject.mailbox.core.server.IntegrationTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +class ContactsManagerIntegrationTest : IntegrationTest() { + + val db: Database + get() = testComponent.getDatabase() + + @BeforeEach + fun initDb() { + addOwnerToken() + } + + @AfterEach + fun clearDb() { + db.transaction(false) { txn -> + db.clearDatabase(txn) + } + } + + @Test + fun `get contacts is initially empty`(): Unit = runBlocking { + val response: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson("""{ "contacts": [ ] }""", response) + } + + @Test + fun `get contacts returns correct ids`(): Unit = runBlocking { + addContact(contact1) + addContact(contact2) + val response: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson("""{ "contacts": [ ${contact1.contactId}, ${contact2.contactId} ] }""", response) + } + + @Test + fun `get contacts rejects unauthorized for contacts`(): Unit = runBlocking { + addContact(contact1) + addContact(contact2) + val response: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(contact1.token) + } + assertEquals(Unauthorized, response.status) + } + + @Test + fun `get contacts rejects unauthorized without token`(): Unit = runBlocking { + val response: HttpResponse = httpClient.get("$baseUrl/contacts") + assertEquals(Unauthorized, response.status) + } + + @Test + fun `get contacts rejects unauthorized for random token`(): Unit = runBlocking { + addContact(contact1) + addContact(contact2) + val response: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(token) + } + assertEquals(Unauthorized, response.status) + } + + @Test + fun `get contacts rejects unauthorized for invalid token (too short)`(): Unit = runBlocking { + addContact(contact1) + addContact(contact2) + val response: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken("abc0123") + } + assertEquals(Unauthorized, response.status) + } + + @Test + fun `get contacts rejects unauthorized for invalid token (illegal characters)`(): Unit = + runBlocking { + addContact(contact1) + addContact(contact2) + val response: HttpResponse = httpClient.get("$baseUrl/contacts") { + // letters outside a-f and dot not allowed, only [a-f0-9]{64} is valid + authenticateWithToken("foo.bar") + } + assertEquals(Unauthorized, response.status) + } + + @Test + fun `owner can add contacts`(): Unit = runBlocking { + val c1 = getNewRandomContact(1).also { addContact(it) } + val c2 = getNewRandomContact(2).also { addContact(it) } + val c3 = getNewRandomContact(3) + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + contentType(ContentType.Application.Json) + body = c3 + } + assertEquals(Created, response1.status) + + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson("""{ "contacts": [ 1, 2, 3 ] }""", response2) + + db.transaction(true) { txn -> + assertEquals(c1, db.getContact(txn, 1)) + assertEquals(c2, db.getContact(txn, 2)) + assertEquals(c3, db.getContact(txn, 3)) + } + } + + @Test + fun `contact cannot add contacts`(): Unit = runBlocking { + addContact(contact1) + addContact(contact2) + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(contact1.token) + contentType(ContentType.Application.Json) + body = getNewRandomContact(3) + } + assertEquals(Unauthorized, response1.status) + + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson( + """{ "contacts": [ ${contact1.contactId}, ${contact2.contactId} ] }""", + response2 + ) + } + + @Test + fun `owner can remove contacts`(): Unit = runBlocking { + addContact(getNewRandomContact(1)) + addContact(getNewRandomContact(2)) + + val response1: HttpResponse = httpClient.delete("$baseUrl/contacts/1") { + authenticateWithToken(ownerToken) + } + assertEquals(OK, response1.status) + + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson("""{ "contacts": [ 2 ] }""", response2) + } + + @Test + fun `contact cannot remove contacts`(): Unit = runBlocking { + addContact(contact1) + addContact(contact2) + + val response1: HttpResponse = httpClient.delete("$baseUrl/contacts/1") { + authenticateWithToken(contact2.token) + } + assertEquals(Unauthorized, response1.status) + + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson( + """{ "contacts": [ ${contact1.contactId}, ${contact2.contactId} ] }""", + response2 + ) + } + + @Test + fun `adding contact with existing contactId is rejected`(): Unit = runBlocking { + addContact(getNewRandomContact(1)) + addContact(getNewRandomContact(2)) + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + contentType(ContentType.Application.Json) + body = getNewRandomContact(2) + } + assertEquals(Conflict, response1.status) + + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson("""{ "contacts": [ 1, 2 ] }""", response2) + } + + @Test + fun `removing non-existent contacts fails gracefully`(): Unit = runBlocking { + addContact(getNewRandomContact(1)) + addContact(getNewRandomContact(2)) + + val response1: HttpResponse = httpClient.delete("$baseUrl/contacts/3") { + authenticateWithToken(ownerToken) + } + assertEquals(NotFound, response1.status) + + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + assertJson("""{ "contacts": [ 1, 2 ] }""", response2) + } + + /* + * Tests about malformed input + */ + + @Test + fun `contact removal with missing contactId is rejected`(): Unit = runBlocking { + addContact(getNewRandomContact(1)) + addContact(getNewRandomContact(2)) + + val response: HttpResponse = httpClient.delete("$baseUrl/contacts/") { + authenticateWithToken(ownerToken) + } + assertEquals(NotFound, response.status) + } + + @Test + fun `contact removal with non-integer contactId is rejected`(): Unit = runBlocking { + addContact(getNewRandomContact(1)) + addContact(getNewRandomContact(2)) + + val response: HttpResponse = httpClient.delete("$baseUrl/contacts/foo") { + authenticateWithToken(ownerToken) + } + assertEquals(BadRequest, response.status) + assertEquals("Bad request: Invalid value for parameter contactId", response.readText()) + } +} diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerMalformedInputIntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerMalformedInputIntegrationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e538c35e3a993db7f96d14ffe087f0725d612d80 --- /dev/null +++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/contacts/ContactsManagerMalformedInputIntegrationTest.kt @@ -0,0 +1,229 @@ +package org.briarproject.mailbox.core.contacts + +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode.Companion.BadRequest +import io.ktor.http.HttpStatusCode.Companion.Created +import io.ktor.http.contentType +import kotlinx.coroutines.runBlocking +import org.briarproject.mailbox.core.TestUtils +import org.briarproject.mailbox.core.TestUtils.getNewRandomContact +import org.briarproject.mailbox.core.server.IntegrationTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.random.Random +import kotlin.test.assertEquals + +class ContactsManagerMalformedInputIntegrationTest : IntegrationTest(false) { + + @BeforeEach + fun initDb() { + addOwnerToken() + } + + @AfterEach + fun clearDb() { + val db = testComponent.getDatabase() + db.transaction(false) { txn -> + db.clearDatabase(txn) + } + } + + /** + * This test is the same as the one from [ContactsManagerIntegrationTest], just that it supplies + * raw JSON as a body. Unlike all other tests in this class, this one should be able to create + * a contact. Just making sure, it is possible to specify raw JSON properly here, while all + * other tests supply some kind of broken JSON. + */ + @Test + fun `owner can add contacts`(): Unit = runBlocking { + val c1 = getNewRandomContact(1).also { addContact(it) } + val c2 = getNewRandomContact(2).also { addContact(it) } + val c3 = getNewRandomContact(3) + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + contentType(ContentType.Application.Json) + body = """{ + "contactId": ${c3.contactId}, + "token": "${c3.token}", + "inboxId": "${c3.inboxId}", + "outboxId": "${c3.outboxId}" + }""".trimMargin() + } + assertEquals(Created, response1.status) + + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + TestUtils.assertJson("""{ "contacts": [ 1, 2, 3 ] }""", response2) + + val db = testComponent.getDatabase() + db.transaction(true) { txn -> + assertEquals(c1, db.getContact(txn, 1)) + assertEquals(c2, db.getContact(txn, 2)) + assertEquals(c3, db.getContact(txn, 3)) + } + } + + /* + * Tests with header "Content-Type: application/json" set + */ + + @Test + fun `empty string is rejected`(): Unit = runBlocking { + `invalid body with json content type is rejected`("") + } + + @Test + fun `empty object is rejected`(): Unit = runBlocking { + `invalid body with json content type is rejected`("{}") + } + + @Test + fun `invalid JSON is rejected`(): Unit = runBlocking { + `invalid body with json content type is rejected`("foo") + } + + @Test + fun `invalid contactId in body is rejected`(): Unit = runBlocking { + `invalid body with json content type is rejected`( + """{ + "contactId": foo, + "token": "${TestUtils.getNewRandomId()}", + "inboxId": "${TestUtils.getNewRandomId()}", + "outboxId": "${TestUtils.getNewRandomId()}" + }""".trimMargin() + ) + } + + @Test + fun `invalid token in body is rejected`(): Unit = runBlocking { + `invalid body with json content type is rejected`( + """{ + "contactId": 3, + "token": "foo", + "inboxId": "${TestUtils.getNewRandomId()}", + "outboxId": "${TestUtils.getNewRandomId()}" + }""".trimMargin() + ) + } + + @Test + fun `invalid inboxId in body is rejected`(): Unit = runBlocking { + `invalid body with json content type is rejected`( + """{ + "contactId": 3, + "token": "${TestUtils.getNewRandomId()}", + "inboxId": "123", + "outboxId": "${TestUtils.getNewRandomId()}" + }""".trimMargin() + ) + } + + @Test + fun `invalid outboxId in body is rejected`(): Unit = runBlocking { + `invalid body with json content type is rejected`( + """{ + "contactId": 3, + "token": "${TestUtils.getNewRandomId()}", + "inboxId": "${TestUtils.getNewRandomId()}", + "outboxId": ${"0".repeat(63) + "A"} + }""".trimMargin() + ) + } + + private fun `invalid body with json content type is rejected`(json: Any): Unit = runBlocking { + val c1 = getNewRandomContact(1).also { addContact(it) } + val c2 = getNewRandomContact(2).also { addContact(it) } + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + contentType(ContentType.Application.Json) + body = json + } + assertEquals(BadRequest, response1.status) + + assertContacts(c1, c2) + } + + /* + * Tests without header "Content-Type: application/json" set + */ + + @Test + fun `empty body is rejected`(): Unit = runBlocking { + val c1 = getNewRandomContact(1).also { addContact(it) } + val c2 = getNewRandomContact(2).also { addContact(it) } + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + // It looks like here, when the JSON feature is not installed, we are not allowed to + // set the content type explicitly, and actually, if we do, we get an exception when + // not passing a body along with the request here + } + assertEquals(BadRequest, response1.status) + + assertContacts(c1, c2) + } + + /* + * Tests with other content type headers + */ + + @Test + fun `plaintext content is rejected`(): Unit = runBlocking { + val c1 = getNewRandomContact(1).also { addContact(it) } + val c2 = getNewRandomContact(2).also { addContact(it) } + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + // cannot do this here: + // + // contentType(ContentType.Text.Plain) + // + // but the content type will automatically be set to "text/plain; charset=UTF-8" + // in this case + body = "foo" + } + assertEquals(BadRequest, response1.status) + + assertContacts(c1, c2) + } + + @Test + fun `PDF content is rejected`(): Unit = runBlocking { + val c1 = getNewRandomContact(1).also { addContact(it) } + val c2 = getNewRandomContact(2).also { addContact(it) } + + val response1: HttpResponse = httpClient.post("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + contentType(ContentType.Application.Pdf) + body = Random.nextBytes(100) + } + assertEquals(BadRequest, response1.status) + + assertContacts(c1, c2) + } + + /* + * Utility methods + */ + + private suspend fun assertContacts(c1: Contact, c2: Contact) { + val response2: HttpResponse = httpClient.get("$baseUrl/contacts") { + authenticateWithToken(ownerToken) + } + TestUtils.assertJson("""{ "contacts": [ 1, 2 ] }""", response2) + + val db = testComponent.getDatabase() + db.transaction(true) { txn -> + assertEquals(c1, db.getContact(txn, 1)) + assertEquals(c2, db.getContact(txn, 2)) + } + } + +} diff --git a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt index 1b2e339914e5008210bc2a507d9271fe74bcf92b..d963bd1161b04bc19120967702551c9beb75076b 100644 --- a/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt +++ b/mailbox-core/src/test/java/org/briarproject/mailbox/core/server/IntegrationTest.kt @@ -23,14 +23,16 @@ import org.junit.jupiter.api.io.TempDir import java.io.File @TestInstance(Lifecycle.PER_CLASS) -abstract class IntegrationTest { +abstract class IntegrationTest(private val installJsonFeature: Boolean = true) { protected lateinit var testComponent: TestComponent private val lifecycleManager by lazy { testComponent.getLifecycleManager() } protected val httpClient = HttpClient(CIO) { expectSuccess = false // prevents exceptions on non-success responses - install(JsonFeature) { - serializer = JacksonSerializer() + if (installJsonFeature) { + install(JsonFeature) { + serializer = JacksonSerializer() + } } } protected val baseUrl = "http://127.0.0.1:$PORT" @@ -47,11 +49,6 @@ abstract class IntegrationTest { testComponent.injectCoreEagerSingletons() lifecycleManager.startServices() lifecycleManager.waitForStartup() - initDb() - } - - open fun initDb() { - // sub-classes can initialize the DB here as needed } @AfterAll