Repository: andregasser/bigbone (kotlin)

Issue:
Allow comparison between scopes
Mastodon documentation states that a requested scope may be a subset of a previously allowed scope. For example, an application might be created with the scope `read`, but a specific access token later requested by the app for a specific user might be restricted to `read:accounts`.

It would be useful to be able to compare different of our `Scope` objects (e.g. find out if requesting a `read:accounts` access token will even be successful given what scope set our application was created with in the first place). To be able to do that, I'd like to add the following functions to our `Scope` class:

- `contains(scopeName: Scope.Name)`, returning true if the scope contains the individual scopeName
- `isSubsetOf(otherScope: Scope)`, returning true if otherScope has at least all of our permissions
- `Scope.fromString(scopeString: String)`, allowing the creation of a Scope object from the string representation returned by Mastodon

Pull request description:
# Description

Allow comparison between different scopes, both created by the library as well as returned by Mastodon, including:
- contains(scopeName) to check for individual scopes
- isSubsetOf(scope) to compare two scopes
- Scope.fromString(scopeString) to create Scope from string (for example returned by Mastodon API)
- Scope.scopeStringIsValid(scopeString) to check the validity of a potential scope string

Adds tests for the Scope class.

Closes #532.

# Type of Change

<!-- Keep the one that applies, remove the rest - including this comment) -->

- New feature

# How Has This Been Tested?

<!-- Please describe the tests that you ran to verify your changes. -->

# 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

Patch:
diff --git a/bigbone/src/main/kotlin/social/bigbone/api/Scope.kt b/bigbone/src/main/kotlin/social/bigbone/api/Scope.kt
index abc727746..4925c5d2d 100644
--- a/bigbone/src/main/kotlin/social/bigbone/api/Scope.kt
+++ b/bigbone/src/main/kotlin/social/bigbone/api/Scope.kt
@@ -18,6 +18,35 @@ class Scope(private vararg val scopes: Name) {
         fun name(): String
     }
 
+    /**
+     * Check if this Scope contains a specific, individual scope.
+     * @param scopeName the name of the individual scope to check for
+     * @return true if the scope is contained, either directly or by its parent scope; false if not
+     */
+    fun contains(scopeName: Name): Boolean {
+        if (scopes.contains(scopeName)) {
+            return true
+        }
+        if (!scopeName.name().contains(':')) {
+            return false
+        }
+
+        /*
+        If the scope name indicates a child scope (i.e. contains a colon),
+        check if we contain the parent scope (currently, checking one level up is sufficient).
+         */
+        val superScopeName = scopeName.name().substringBeforeLast(':')
+        return scopes.any { it.name() == superScopeName }
+    }
+
+    /**
+     * Check if this Scope is a subset of another Scope. A Scope is a subset of another, if the other
+     * Scope contains at least all the permissions of the original Scope, and potentially more.
+     * @param otherScope another Scope to compare to
+     * @return true if this Scope is a subset of the other; false if not
+     */
+    fun isSubsetOf(otherScope: Scope): Boolean = scopes.all { scopeName -> otherScope.contains(scopeName) }
+
     /**
      * Scopes granting access to read data are grouped here. Use [READ.ALL] to request full read access, or preferably
      * individual child scopes to limit access to a minimum.
@@ -205,5 +234,97 @@ class Scope(private vararg val scopes: Name) {
         }
     }
 
-    override fun toString(): String = scopes.distinct().joinToString(separator = " ", transform = { it.name() })
+    override fun toString(): String = scopes.distinct().joinToString(separator = SCOPE_DELIMITER, transform = { it.name() })
+
+    companion object {
+        /**
+         * The character used as delimiter between individual scopes in a scope string.
+         */
+        private const val SCOPE_DELIMITER = " "
+
+        /**
+         * A map of all individual scopes, with their string representations as keys.
+         */
+        internal val scopesByName = mapOf(
+            Pair(READ.ALL.name(), READ.ALL),
+            Pair(READ.ACCOUNTS.name(), READ.ACCOUNTS),
+            Pair(READ.BLOCKS.name(), READ.BLOCKS),
+            Pair(READ.BOOKMARKS.name(), READ.BOOKMARKS),
+            Pair(READ.FAVOURITES.name(), READ.FAVOURITES),
+            Pair(READ.FILTERS.name(), READ.FILTERS),
+            Pair(READ.FOLLOWS.name(), READ.FOLLOWS),
+            Pair(READ.LISTS.name(), READ.LISTS),
+            Pair(READ.MUTES.name(), READ.MUTES),
+            Pair(READ.NOTIFICATIONS.name(), READ.NOTIFICATIONS),
+            Pair(READ.SEARCH.name(), READ.SEARCH),
+            Pair(READ.STATUSES.name(), READ.STATUSES),
+
+            Pair(WRITE.ALL.name(), WRITE.ALL),
+            Pair(WRITE.ACCOUNTS.name(), WRITE.ACCOUNTS),
+            Pair(WRITE.BLOCKS.name(), WRITE.BLOCKS),
+            Pair(WRITE.BOOKMARKS.name(), WRITE.BOOKMARKS),
+            Pair(WRITE.CONVERSATIONS.name(), WRITE.CONVERSATIONS),
+            Pair(WRITE.FAVOURITES.name(), WRITE.FAVOURITES),
+            Pair(WRITE.FILTERS.name(), WRITE.FILTERS),
+            Pair(WRITE.FOLLOWS.name(), WRITE.FOLLOWS),
+            Pair(WRITE.LISTS.name(), WRITE.LISTS),
+            Pair(WRITE.MEDIA.name(), WRITE.MEDIA),
+            Pair(WRITE.MUTES.name(), WRITE.MUTES),
+            Pair(WRITE.NOTIFICATIONS.name(), WRITE.NOTIFICATIONS),
+            Pair(WRITE.REPORTS.name(), WRITE.REPORTS),
+            Pair(WRITE.STATUSES.name(), WRITE.STATUSES),
+
+            Pair(PUSH.ALL.name(), PUSH.ALL),
+
+            Pair(ADMIN.READ.ALL.name(), ADMIN.READ.ALL),
+            Pair(ADMIN.READ.ACCOUNTS.name(), ADMIN.READ.ACCOUNTS),
+            Pair(ADMIN.READ.REPORTS.name(), ADMIN.READ.REPORTS),
+            Pair(ADMIN.READ.DOMAIN_ALLOWS.name(), ADMIN.READ.DOMAIN_ALLOWS),
+            Pair(ADMIN.READ.DOMAIN_BLOCKS.name(), ADMIN.READ.DOMAIN_BLOCKS),
+            Pair(ADMIN.READ.IP_BLOCKS.name(), ADMIN.READ.IP_BLOCKS),
+            Pair(ADMIN.READ.EMAIL_DOMAIN_BLOCKS.name(), ADMIN.READ.EMAIL_DOMAIN_BLOCKS),
+            Pair(ADMIN.READ.CANONICAL_EMAIL_BLOCKS.name(), ADMIN.READ.CANONICAL_EMAIL_BLOCKS),
+
+            Pair(ADMIN.WRITE.ALL.name(), ADMIN.WRITE.ALL),
+            Pair(ADMIN.WRITE.ACCOUNTS.name(), ADMIN.WRITE.ACCOUNTS),
+            Pair(ADMIN.WRITE.REPORTS.name(), ADMIN.WRITE.REPORTS),
+            Pair(ADMIN.WRITE.DOMAIN_ALLOWS.name(), ADMIN.WRITE.DOMAIN_ALLOWS),
+            Pair(ADMIN.WRITE.DOMAIN_BLOCKS.name(), ADMIN.WRITE.DOMAIN_BLOCKS),
+            Pair(ADMIN.WRITE.IP_BLOCKS.name(), ADMIN.WRITE.IP_BLOCKS),
+            Pair(ADMIN.WRITE.EMAIL_DOMAIN_BLOCKS.name(), ADMIN.WRITE.EMAIL_DOMAIN_BLOCKS),
+            Pair(ADMIN.WRITE.CANONICAL_EMAIL_BLOCKS.name(), ADMIN.WRITE.CANONICAL_EMAIL_BLOCKS),
+        )
+
+        /**
+         * Check if a string is a valid representation of a scope.
+         * @param scopeString the string to check
+         * @return true if all substrings of the space-delimited string are valid scopes; false if not
+         */
+        fun scopeStringIsValid(scopeString: String): Boolean {
+            return scopeString
+                .split(SCOPE_DELIMITER)
+                .none { scopeName -> scopesByName[scopeName] == null }
+        }
+
+        /**
+         * Create a [Scope] from its string representation.
+         * @param scopeString string representation of a Scope
+         * @param failOnUnknownValues if true (default), will fail when encountering unknown values;
+         *  otherwise, will ignore them and return a potentially smaller Scope
+         * @return the Scope for the given string.
+         * @throws IllegalArgumentException if the scope string contains unknown values (see [scopeStringIsValid]);
+         *  to force parsing an invalid string, call with `failOnUnknownValues = false`
+         */
+        @JvmOverloads
+        fun fromString(scopeString: String, failOnUnknownValues: Boolean = true): Scope {
+            if (failOnUnknownValues && !scopeStringIsValid(scopeString)) {
+                throw IllegalArgumentException("Scope string contains unknown values")
+            }
+
+            val knownScopes: List<Name> = scopeString
+                .split(SCOPE_DELIMITER)
+                .mapNotNull { scopeName -> scopesByName[scopeName] }
+            return Scope(*knownScopes.toTypedArray())
+        }
+    }
 }


Test patch:
diff --git a/bigbone/src/test/kotlin/social/bigbone/api/ScopeTest.kt b/bigbone/src/test/kotlin/social/bigbone/api/ScopeTest.kt
new file mode 100644
index 000000000..9e81339d6
--- /dev/null
+++ b/bigbone/src/test/kotlin/social/bigbone/api/ScopeTest.kt
@@ -0,0 +1,126 @@
+package social.bigbone.api
+
+import org.amshove.kluent.invoking
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldThrow
+import org.junit.jupiter.api.Test
+
+class ScopeTest {
+
+    @Test
+    fun buildScope() {
+        // when creating a scope
+        val scope = Scope(Scope.READ.ALL, Scope.WRITE.ACCOUNTS, Scope.PUSH.ALL, Scope.ADMIN.READ.ACCOUNTS)
+
+        // after splitting the resulting space-delimited scope string
+        val scopeNames = scope.toString().split(" ")
+
+        // the list of individual scope strings should contain those corresponding to the original scopes
+        scopeNames.contains(Scope.READ.ALL.name()) shouldBeEqualTo true
+        scopeNames.contains(Scope.WRITE.ACCOUNTS.name()) shouldBeEqualTo true
+        scopeNames.contains(Scope.PUSH.ALL.name()) shouldBeEqualTo true
+        scopeNames.contains(Scope.ADMIN.READ.ACCOUNTS.name()) shouldBeEqualTo true
+    }
+
+    @Test
+    fun hasScopes() {
+        // given a scope
+        val scope = Scope(Scope.READ.ALL, Scope.WRITE.MEDIA)
+
+        // checking for those scopes or individual child scopes returns true
+        scope.contains(Scope.READ.ALL) shouldBeEqualTo true
+        scope.contains(Scope.WRITE.MEDIA) shouldBeEqualTo true
+        scope.contains(Scope.READ.ACCOUNTS) shouldBeEqualTo true
+        scope.contains(Scope.READ.FAVOURITES) shouldBeEqualTo true
+
+        // and checking for other scopes returns false
+        scope.contains(Scope.WRITE.ALL) shouldBeEqualTo false
+        scope.contains(Scope.ADMIN.WRITE.IP_BLOCKS) shouldBeEqualTo false
+    }
+
+    @Test
+    fun subScopes() {
+        // given various scopes
+        val scopeSuper = Scope(Scope.READ.ALL, Scope.WRITE.ALL, Scope.ADMIN.WRITE.REPORTS)
+        val scopeSub1 = Scope(Scope.READ.ALL)
+        val scopeSub2 = Scope(Scope.READ.ACCOUNTS, Scope.WRITE.ALL)
+        val scopeSub3 = Scope(Scope.READ.BOOKMARKS, Scope.WRITE.BOOKMARKS, Scope.ADMIN.WRITE.REPORTS)
+        val scopeIncompatible = Scope(Scope.READ.ALL, Scope.ADMIN.READ.ALL)
+
+        // checking actual sub scopes returns true
+        scopeSub1.isSubsetOf(scopeSuper) shouldBeEqualTo true
+        scopeSub2.isSubsetOf(scopeSuper) shouldBeEqualTo true
+        scopeSub3.isSubsetOf(scopeSuper) shouldBeEqualTo true
+
+        // and comparing a scope to itself returns true
+        scopeSuper.isSubsetOf(scopeSuper) shouldBeEqualTo true
+
+        // while checking the other way around returns false
+        scopeSuper.isSubsetOf(scopeSub1) shouldBeEqualTo false
+        scopeSuper.isSubsetOf(scopeSub2) shouldBeEqualTo false
+        scopeSuper.isSubsetOf(scopeSub3) shouldBeEqualTo false
+
+        // and checking incompatible scopes returns false
+        scopeIncompatible.isSubsetOf(scopeSuper) shouldBeEqualTo false
+    }
+
+    @Test
+    fun scopeMapEntriesMatching() {
+        // check if all pairs in our map of all scopes match
+        for (pair in Scope.scopesByName) {
+            pair.key shouldBeEqualTo pair.value.name()
+        }
+    }
+
+    @Test
+    fun scopesFromString() {
+        // given various scopes
+        val scope1 = Scope(Scope.READ.ALL, Scope.WRITE.ALL, Scope.ADMIN.WRITE.REPORTS)
+        val scope2 = Scope(Scope.READ.ALL)
+        val scope3 = Scope(Scope.READ.ACCOUNTS, Scope.WRITE.ALL)
+        val scope4 = Scope(Scope.READ.BOOKMARKS, Scope.WRITE.BOOKMARKS, Scope.ADMIN.WRITE.REPORTS)
+        val scope5 = Scope(Scope.READ.ALL, Scope.ADMIN.READ.ALL)
+
+        // when creating new scopes from the original scope's string representation
+        val newScope1 = Scope.fromString(scope1.toString())
+        val newScope2 = Scope.fromString(scope2.toString())
+        val newScope3 = Scope.fromString(scope3.toString())
+        val newScope4 = Scope.fromString(scope4.toString())
+        val newScope5 = Scope.fromString(scope5.toString())
+
+        // then these new scopes match the original scopes in terms of their permission
+        scope1.isSubsetOf(newScope1) shouldBeEqualTo true
+        newScope1.isSubsetOf(scope1) shouldBeEqualTo true
+
+        scope2.isSubsetOf(newScope2) shouldBeEqualTo true
+        newScope2.isSubsetOf(scope2) shouldBeEqualTo true
+
+        scope3.isSubsetOf(newScope3) shouldBeEqualTo true
+        newScope3.isSubsetOf(scope3) shouldBeEqualTo true
+
+        scope4.isSubsetOf(newScope4) shouldBeEqualTo true
+        newScope4.isSubsetOf(scope4) shouldBeEqualTo true
+
+        scope5.isSubsetOf(newScope5) shouldBeEqualTo true
+        newScope5.isSubsetOf(scope5) shouldBeEqualTo true
+    }
+
+    @Test
+    fun handlingUnsupportedValuesInScopeString() {
+        // given a scope string containing known and unknown permissions
+        val scope = Scope(Scope.READ.ALL, Scope.WRITE.ALL, Scope.ADMIN.WRITE.REPORTS)
+        val scopeString = "$scope admin:read:tolstoy"
+
+        // this string should be considered invalid
+        Scope.scopeStringIsValid(scopeString) shouldBeEqualTo false
+
+        // and trying to create a Scope should throw an exception
+        invoking { Scope.fromString(scopeString) } shouldThrow IllegalArgumentException::class
+
+        // but creating a Scope ignoring unknown values should succeed and result in
+        // the set of known permissions
+        val newScope = Scope.fromString(scopeString, failOnUnknownValues = false)
+        scope.isSubsetOf(newScope) shouldBeEqualTo true
+        newScope.isSubsetOf(scope) shouldBeEqualTo true
+    }
+}