Repository: andregasser/bigbone (kotlin)

Issue:
Implement Admin IP Blocks API
Mastodon's Admin IP Blocks API allows an admin to prevent certain IP address ranges from signing up. The goal of this task is to implement the following endpoints of this API in BigBone:

- List all IP blocks
- Get a single IP block
- Block an IP address range from signing up
- Update a domain block
- Delete an IP block

The Admin IP Blocks API as it is documented by Mastodon can be found here:
https://docs.joinmastodon.org/methods/admin/ip_blocks/

Tip: A lot of other endpoints similar to this have already been implemented. In order to get an idea how to implement this, I suggest that you study the code upfront. Start with one of the `*Methods.kt` files.

Please consult the following documents to make sure your contribution meets our projects criteria:

- [Code of Conduct](https://github.com/andregasser/bigbone/blob/master/CODE_OF_CONDUCT.md)
- [Code Contribution Guidelines](https://github.com/andregasser/bigbone/blob/master/CONTRIBUTING.md)

Pull request description:
# Description

This PR implements the [ip blocks API](https://docs.joinmastodon.org/methods/admin/ip_blocks/) so that users with role of admin to both see blocked ip, with related reasons, and block new ip and update reason/severity for past ones and removed ip from blocked list.

This closes #320

# Type of Change
- New feature

# How Has This Been Tested?

Gradle ktlint, gradle check, gradle detect and gradle build

# Mandatory Checklist

- [x] I ran `gradle check` and there were no errors reported
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] All tests pass locally with my changes
- [x] I have added KDoc documentation to all public methods

# Optional Things To Check

I implemented related RX counter part and implemented unit test.
I also updated the API coverage page with corresponding ones developed in this PR.

Patch:
diff --git a/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminIpBlockMethods.kt b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminIpBlockMethods.kt
new file mode 100644
index 000000000..1802551e0
--- /dev/null
+++ b/bigbone-rx/src/main/kotlin/social/bigbone/rx/admin/RxAdminIpBlockMethods.kt
@@ -0,0 +1,97 @@
+package social.bigbone.rx.admin
+
+import io.reactivex.rxjava3.core.Completable
+import io.reactivex.rxjava3.core.Single
+import social.bigbone.MastodonClient
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.admin.AdminIpBlock
+import social.bigbone.api.method.admin.AdminIpBlockMethods
+
+/**
+ * Reactive implementation of [AdminIpBlockMethods].
+ *
+ * Allow management of ip addresses blocked and to be blocked
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/">Mastodon admin/ip_blocks API methods</a>
+ */
+class RxAdminIpBlockMethods(client: MastodonClient) {
+
+    private val adminIpBlockMethods = AdminIpBlockMethods(client)
+
+    /**
+     * Show information about all blocked IP ranges.
+     * @param range optional Range for the pageable return value
+     * @see <a href="https://docs.joinmastodon.org/methods/ip_blocks/#get">Mastodon API documentation: methods/ip_blocks/#get</a>
+     */
+    @JvmOverloads
+    fun getAllIpBlocks(range: Range = Range()): Single<Pageable<AdminIpBlock>> = Single.fromCallable {
+        adminIpBlockMethods.getAllIpBlocks(range = range).execute()
+    }
+
+    /**
+     * Show information about a single IP block.
+     * @param id The ID of the IpBlock in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/ip_blocks/#get">Mastodon API documentation: methods/ip_blocks/#get</a>
+     */
+    fun getBlockedIpRange(id: String): Single<AdminIpBlock> = Single.fromCallable {
+        adminIpBlockMethods.getBlockedIpRange(id = id).execute()
+    }
+
+    /**
+     * Add an IP address range to the list of IP blocks.
+     * @param ipAddress The IP address and prefix to block.
+     * @param severity The policy to apply to this IP range.
+     * @param comment The reason for this IP block.
+     * @param expiresInSeconds The number of seconds in which this IP block will expire.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/#create">Mastodon API documentation: admin/ip_blocks/#create</a>
+     */
+    @JvmOverloads
+    fun blockIpRange(
+        ipAddress: String,
+        severity: AdminIpBlock.Severity,
+        comment: String? = null,
+        expiresInSeconds: Int? = null
+    ): Single<AdminIpBlock> = Single.fromCallable {
+        adminIpBlockMethods.blockIpRange(
+            ipAddress = ipAddress,
+            severity = severity,
+            comment = comment,
+            expiresInSeconds = expiresInSeconds
+        ).execute()
+    }
+
+    /**
+     * Change parameters for an existing IP block.
+     * @param id The ID of the IpBlock in the database.
+     * @param ipAddress The IP address and prefix to block.
+     * @param severity The policy to apply to this IP range.
+     * @param comment The reason for this IP block.
+     * @param expiresInSeconds The number of seconds in which this IP block will expire.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/#update">Mastodon API documentation: admin/ip_blocks/#update</a>
+     */
+    @JvmOverloads
+    fun updateBlockedIpRange(
+        id: String,
+        ipAddress: String,
+        severity: AdminIpBlock.Severity,
+        comment: String? = null,
+        expiresInSeconds: Int? = null
+    ): Single<AdminIpBlock> = Single.fromCallable {
+        adminIpBlockMethods.updateBlockedIpRange(
+            id = id,
+            ipAddress = ipAddress,
+            severity = severity,
+            comment = comment,
+            expiresInSeconds = expiresInSeconds
+        ).execute()
+    }
+
+    /**
+     * Lift a block against an IP range.
+     * @param id The ID of the IpBlock in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/#delete">Mastodon API documentation: admin/ip_blocks/#delete</a>
+     */
+    fun removeIpBlock(id: String): Completable = Completable.fromAction {
+        adminIpBlockMethods.removeIpBlock(id = id)
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
index 5d4127639..48473f8bb 100644
--- a/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/MastodonClient.kt
@@ -66,6 +66,7 @@ import social.bigbone.api.method.TagMethods
 import social.bigbone.api.method.TimelineMethods
 import social.bigbone.api.method.TrendMethods
 import social.bigbone.api.method.admin.AdminDimensionMethods
+import social.bigbone.api.method.admin.AdminIpBlockMethods
 import social.bigbone.api.method.admin.AdminMeasureMethods
 import social.bigbone.api.method.admin.AdminRetentionMethods
 import social.bigbone.extension.emptyRequestBody
@@ -125,6 +126,13 @@ private constructor(
     @get:JvmName("adminRetention")
     val adminRetention: AdminRetentionMethods by lazy { AdminRetentionMethods(this) }
 
+    /**
+     * Access API methods under the "admin/retention" endpoint.
+     */
+    @Suppress("unused") // public API
+    @get:JvmName("adminIpBlock")
+    val adminIpBlock: AdminIpBlockMethods by lazy { AdminIpBlockMethods(this) }
+
     /**
      * Access API methods under the "announcements" endpoint.
      */
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminIpBlock.kt b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminIpBlock.kt
new file mode 100644
index 000000000..e6e8c4bfe
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/entity/admin/AdminIpBlock.kt
@@ -0,0 +1,80 @@
+package social.bigbone.api.entity.admin
+
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import social.bigbone.DateTimeSerializer
+import social.bigbone.PrecisionDateTime
+
+/**
+ * Represents an IP address range that cannot be used to sign up.
+ * @see <a href="https://docs.joinmastodon.org/entities/Admin_IpBlock/">Mastodon API Admin::IpBlock/</a>
+ */
+@Serializable
+data class AdminIpBlock(
+    /**
+     * The ID of the DomainBlock in the database.
+     */
+    @SerialName("id")
+    val id: String = "0",
+
+    /**
+     * The IP address range that is not allowed to federate.
+     */
+    @SerialName("ip")
+    val ip: String = "",
+
+    /**
+     * The associated policy with this IP block.
+     */
+    @SerialName("severity")
+    val severity: Severity? = null,
+
+    /**
+     * The recorded reason for this IP block.
+     */
+    @SerialName("comment")
+    val comment: String = "",
+
+    /**
+     * When the IP block was created.
+     */
+    @SerialName("created_at")
+    @Serializable(with = DateTimeSerializer::class)
+    val createdAt: PrecisionDateTime = PrecisionDateTime.InvalidPrecisionDateTime.Unavailable,
+
+    /**
+     * When the IP block will expire.
+     */
+    @SerialName("expires_at")
+    @Serializable(with = DateTimeSerializer::class)
+    val expiresAt: PrecisionDateTime = PrecisionDateTime.InvalidPrecisionDateTime.Unavailable
+) {
+    /**
+     * Specifies the severity of the blocked ip.
+     */
+    @Serializable
+    enum class Severity {
+
+        /**
+         *  Any signup from this IP range will create a pending account.
+         */
+        @SerialName("sign_up_requires_approval")
+        SIGN_UP_REQUIRES_APPROVAL,
+
+        /**
+         *  Any signup from this IP range will be rejected.
+         */
+        @SerialName("sign_up_block")
+        SIGN_UP_BLOCK,
+
+        /**
+         *  Any activity from this IP range will be rejected entirely.
+         */
+        @SerialName("no_access")
+        NO_ACCESS;
+
+        @OptIn(ExperimentalSerializationApi::class)
+        val apiName: String get() = serializer().descriptor.getElementName(ordinal)
+    }
+}
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminIpBlockMethods.kt b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminIpBlockMethods.kt
new file mode 100644
index 000000000..31b0117a4
--- /dev/null
+++ b/bigbone/src/main/kotlin/social/bigbone/api/method/admin/AdminIpBlockMethods.kt
@@ -0,0 +1,111 @@
+package social.bigbone.api.method.admin
+
+import social.bigbone.MastodonClient
+import social.bigbone.MastodonRequest
+import social.bigbone.Parameters
+import social.bigbone.api.Pageable
+import social.bigbone.api.Range
+import social.bigbone.api.entity.admin.AdminIpBlock
+
+/**
+ * Allow management of ip addresses blocked and to be blocked.
+ * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/">Mastodon admin/ip_blocks API methods</a>
+ */
+class AdminIpBlockMethods(private val client: MastodonClient) {
+
+    private val adminIpBlockEndpoint = "/api/v1/admin/ip_blocks"
+
+    /**
+     * Show information about all blocked IP ranges.
+     * @param range optional Range for the pageable return value
+     * @see <a href="https://docs.joinmastodon.org/methods/ip_blocks/#get">Mastodon API documentation: methods/ip_blocks/#get</a>
+     */
+    @JvmOverloads
+    fun getAllIpBlocks(range: Range = Range()): MastodonRequest<Pageable<AdminIpBlock>> {
+        return client.getPageableMastodonRequest(
+            endpoint = adminIpBlockEndpoint,
+            method = MastodonClient.Method.GET,
+            parameters = range.toParameters()
+        )
+    }
+
+    /**
+     * Show information about a single IP block.
+     * @param id The ID of the IpBlock in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/ip_blocks/#get-one">Mastodon API documentation: methods/ip_blocks/#get-one</a>
+     */
+    fun getBlockedIpRange(id: String): MastodonRequest<AdminIpBlock> {
+        return client.getMastodonRequest(
+            endpoint = "$adminIpBlockEndpoint/$id",
+            method = MastodonClient.Method.GET
+        )
+    }
+
+    /**
+     * Add an IP address range to the list of IP blocked ones.
+     * @param ipAddress The IP address and prefix to block. Defaults to 0.0.0.0/32.
+     * @param severity The policy to apply to this IP range.
+     * @param comment The reason for this IP block.
+     * @param expiresInSeconds The number of seconds in which this IP block will expire.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/#create">Mastodon API documentation: admin/ip_blocks/#create</a>
+     */
+    @JvmOverloads
+    fun blockIpRange(
+        ipAddress: String,
+        severity: AdminIpBlock.Severity,
+        comment: String? = null,
+        expiresInSeconds: Int? = null
+    ): MastodonRequest<AdminIpBlock> {
+        return client.getMastodonRequest(
+            endpoint = adminIpBlockEndpoint,
+            method = MastodonClient.Method.POST,
+            parameters = Parameters().apply {
+                append("ip", ipAddress)
+                append("severity", severity.apiName)
+                comment?.let { append("comment", it) }
+                expiresInSeconds?.let { append("expires_in", it) }
+            }
+        )
+    }
+
+    /**
+     * Change parameters for an existing IP block.
+     * @param id The ID of the IpBlock in the database.
+     * @param ipAddress The IP address and prefix to block.
+     * @param severity The policy to apply to this IP range.
+     * @param comment The reason for this IP block.
+     * @param expiresInSeconds The number of seconds in which this IpBlock will expire.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/#update">Mastodon API documentation: admin/ip_blocks/#update</a>
+     */
+    @JvmOverloads
+    fun updateBlockedIpRange(
+        id: String,
+        ipAddress: String,
+        severity: AdminIpBlock.Severity,
+        comment: String? = null,
+        expiresInSeconds: Int? = null
+    ): MastodonRequest<AdminIpBlock> {
+        return client.getMastodonRequest(
+            endpoint = "$adminIpBlockEndpoint/$id",
+            method = MastodonClient.Method.PUT,
+            parameters = Parameters().apply {
+                append("ip", ipAddress)
+                append("severity", severity.apiName)
+                comment?.let { append("comment", it) }
+                expiresInSeconds?.let { append("expires_in", it) }
+            }
+        )
+    }
+
+    /**
+     * Lift a block against an IP range.
+     * @param id The ID of the IpBlock in the database.
+     * @see <a href="https://docs.joinmastodon.org/methods/admin/ip_blocks/#delete">Mastodon API documentation: admin/ip_blocks/#delete</a>
+     */
+    fun removeIpBlock(id: String) {
+        client.performAction(
+            endpoint = "$adminIpBlockEndpoint/$id",
+            method = MastodonClient.Method.DELETE
+        )
+    }
+}
diff --git a/docs/api-coverage/admin/ip-blocks.md b/docs/api-coverage/admin/ip-blocks.md
index 9f4e04b23..ee6715ffa 100644
--- a/docs/api-coverage/admin/ip-blocks.md
+++ b/docs/api-coverage/admin/ip-blocks.md
@@ -12,8 +12,8 @@ nav_order: 9
 
 | Method                               | Description                               | Status | Comments | 
 |--------------------------------------|-------------------------------------------|--------|----------|
-| `GET /api/v1/admin/ip_blocks`        | List all IP blocks                        | 🔴     |          |
-| `GET /api/v1/admin/ip_blocks/:id`    | Get a single IP block                     | 🔴     |          |
-| `POST /api/v1/admin/ip_blocks`       | Block an IP address range from signing up | 🔴     |          |
-| `PUT /api/v1/admin/ip_blocks/:id`    | Update a domain block                     | 🔴     |          |
-| `DELETE /api/v1/admin/ip_blocks/:id` | Delete an IP block                        | 🔴     |          |
+| `GET /api/v1/admin/ip_blocks`        | List all IP blocks                        | <img src="/assets/green16.png"> | Fully supported |
+| `GET /api/v1/admin/ip_blocks/:id`    | Get a single IP block                     | <img src="/assets/green16.png"> | Fully supported |
+| `POST /api/v1/admin/ip_blocks`       | Block an IP address range from signing up | <img src="/assets/green16.png"> | Fully supported |
+| `PUT /api/v1/admin/ip_blocks/:id`    | Update a domain block                     | <img src="/assets/green16.png"> | Fully supported |
+| `DELETE /api/v1/admin/ip_blocks/:id` | Delete an IP block                        | <img src="/assets/green16.png"> | Fully supported |


Test patch:
diff --git a/bigbone/src/test/assets/admin_ip_blocks_get_all.json b/bigbone/src/test/assets/admin_ip_blocks_get_all.json
new file mode 100644
index 000000000..97ae17693
--- /dev/null
+++ b/bigbone/src/test/assets/admin_ip_blocks_get_all.json
@@ -0,0 +1,18 @@
+[
+  {
+    "id": "1",
+    "ip": "8.8.8.8/32",
+    "severity": "no_access",
+    "comment": "",
+    "created_at": "2022-11-16T07:22:00.501Z",
+    "expires_at": null
+  },
+  {
+    "id": "2",
+    "ip": "18.18.18.18/32",
+    "severity": "no_access",
+    "comment": "",
+    "created_at": "2022-11-16T07:22:10.501Z",
+    "expires_at": null
+  }
+]
\ No newline at end of file
diff --git a/bigbone/src/test/assets/admin_ip_blocks_single.json b/bigbone/src/test/assets/admin_ip_blocks_single.json
new file mode 100644
index 000000000..5a8d9589d
--- /dev/null
+++ b/bigbone/src/test/assets/admin_ip_blocks_single.json
@@ -0,0 +1,8 @@
+{
+  "id": "1",
+  "ip": "8.8.8.8/32",
+  "severity": "no_access",
+  "comment": "",
+  "created_at": "2022-11-16T07:22:00.501Z",
+  "expires_at": null
+}
\ No newline at end of file
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminIpBlockMethodsTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminIpBlockMethodsTest.kt
new file mode 100644
index 000000000..d9e8f3885
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/api/method/admin/AdminIpBlockMethodsTest.kt
@@ -0,0 +1,130 @@
+package social.bigbone.api.method.admin
+
+import io.mockk.slot
+import io.mockk.verify
+import org.amshove.kluent.AnyException
+import org.amshove.kluent.invoking
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldNotThrow
+import org.junit.jupiter.api.Test
+import social.bigbone.MastodonClient
+import social.bigbone.Parameters
+import social.bigbone.api.Range
+import social.bigbone.api.entity.admin.AdminIpBlock
+import social.bigbone.testtool.MockClient
+import java.net.URLEncoder
+
+class AdminIpBlockMethodsTest {
+
+    private val adminIpBlockEndpoint = "/api/v1/admin/ip_blocks"
+
+    @Test
+    fun `Given client returning success, when getting all blocked ip addresses, then return list of ip addresses`() {
+        val client = MockClient.mock("admin_ip_blocks_get_all.json")
+        val adminIpBlockMethods = AdminIpBlockMethods(client)
+        val range = Range("1", "10", "1", 10)
+        val blockedIpAddresses = adminIpBlockMethods.getAllIpBlocks(range).execute()
+        blockedIpAddresses.part.size shouldBeEqualTo 2
+        with(blockedIpAddresses.part[0]) {
+            id shouldBeEqualTo "1"
+            ip shouldBeEqualTo "8.8.8.8/32"
+            severity?.apiName shouldBeEqualTo "no_access"
+        }
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.get(
+                path = adminIpBlockEndpoint,
+                query = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "max_id=${range.maxId}&min_id=${range.minId}&since_id=${range.sinceId}&limit=${range.limit}"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when getting specific blocked ip address, then return expected data for single ip`() {
+        val client = MockClient.mock("admin_ip_blocks_single.json")
+        val adminIpBlockMethods = AdminIpBlockMethods(client)
+        val pathVariable = "1"
+        val blockedIpAddress = adminIpBlockMethods.getBlockedIpRange(pathVariable).execute()
+        with(blockedIpAddress) {
+            id shouldBeEqualTo pathVariable
+            ip shouldBeEqualTo "8.8.8.8/32"
+            severity?.apiName shouldBeEqualTo "no_access"
+        }
+
+        verify {
+            client.get(
+                path = "$adminIpBlockEndpoint/$pathVariable"
+            )
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when posting an ip address to be blocked, then check expected data that has been posted`() {
+        val client = MockClient.mock("admin_ip_blocks_single.json")
+        val adminIpBlockMethods = AdminIpBlockMethods(client)
+        val ipToBeBlocked = "8.8.8.8/32"
+        val severity = AdminIpBlock.Severity.NO_ACCESS
+        val comment = "new_comment"
+        invoking {
+            adminIpBlockMethods.blockIpRange(ipAddress = ipToBeBlocked, severity = severity, comment = comment).execute()
+        } shouldNotThrow AnyException
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.post(
+                path = adminIpBlockEndpoint,
+                body = capture(parametersCapturingSlot),
+                addIdempotencyKey = false
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "ip=${URLEncoder.encode(ipToBeBlocked, "utf-8")}&severity=${severity.apiName}&comment=$comment"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when updating a blocked ip address, then check expected data that has been sent`() {
+        val client = MockClient.mock("admin_ip_blocks_single.json")
+        val adminIpBlockMethods = AdminIpBlockMethods(client)
+        val idOfIpBlocked = "1"
+        val ipToBeBlocked = "8.8.8.8/32"
+        val severity = AdminIpBlock.Severity.SIGN_UP_REQUIRES_APPROVAL
+        val comment = "new_comment"
+        invoking {
+            adminIpBlockMethods.updateBlockedIpRange(id = idOfIpBlocked, ipAddress = ipToBeBlocked, severity = severity, comment = comment).execute()
+        } shouldNotThrow AnyException
+
+        val parametersCapturingSlot = slot<Parameters>()
+        verify {
+            client.put(
+                path = "$adminIpBlockEndpoint/$idOfIpBlocked",
+                body = capture(parametersCapturingSlot)
+            )
+        }
+        with(parametersCapturingSlot.captured) {
+            toQuery() shouldBeEqualTo "ip=${URLEncoder.encode(ipToBeBlocked, "utf-8")}&severity=${severity.apiName}&comment=$comment"
+        }
+    }
+
+    @Test
+    fun `Given client returning success, when removing specific blocked ip address, then check data for single ip`() {
+        val client = MockClient.mock("admin_ip_blocks_single.json")
+        val adminIpBlockMethods = AdminIpBlockMethods(client)
+        val idOfIpBlocked = "1"
+
+        invoking {
+            adminIpBlockMethods.removeIpBlock(id = idOfIpBlocked)
+        } shouldNotThrow AnyException
+
+        verify {
+            client.performAction(
+                endpoint = "$adminIpBlockEndpoint/$idOfIpBlocked",
+                method = MastodonClient.Method.DELETE
+            )
+        }
+    }
+}