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