Repository: Kinto/kinto

Issue:
List users / delete all records?
I understand there is no [user management](http://kinto.readthedocs.org/en/latest/api/1.x/authentication.html#a-word-about-users), but...
- Is it possible to list all users (user ids)?
- How to delete all records owned by a particular user?

Thanks!



Discussion:
1. Not sure what you mean with "list all users", since we don't have any concept of users.
2. To delete records owned by a particular user, you need to authenticate as this user and issue a DELETE request on the collection.

> Is it possible to list all users (user ids)?

If by list all users you mean, list all user ids that have at list one permission on a resource.

We do not have this method to permissions backends yet: https://github.com/mozilla-services/cliquet/blob/master/cliquet/permission/__init__.py

Can you elaborate on what would be the use case for that? It could be added if necessary.

> How to delete all records owned by a particular user?

As an administrator? As the user? At which level (server, bucket, collection)?

It is possible to get all the objects for a particular user using https://github.com/mozilla-services/cliquet/blob/master/cliquet/permission/__init__.py#L89

Then it is possible to remove all this objects at once.

Also that's not a public API.

Raw Python internals might not be the most convenient way :)

@floomy would you expect something like a shell script to purge, or your need would a priviledged HTTP API to be manipulated from the app?

My reasoning was handling orphaned data (as administrator) e.g. testing records during development or outdated records from since canceled projects.

I'd like to clean up records from abandoned users (without keeping track of every single token). I'm not sure if this could be part of the HTTP API.

> handling orphaned data (as administrator) e.g. testing records during development or outdated records from since canceled projects.

For this you can simply delete the bucket related to the project:

```
DELETE /buckets/abandoned_or_test_project_bucket_id
```

> I'd like to clean up records from abandoned users

How do you detect that a user id has been abandoned?

OK, I will then use multiple buckets (instead of multiple users' default bucket) and delete old "stuff" with the /buckets endpoint.

Thanks for your help!

Actually default bucket is a shortcut to distinct bucket id.
You can get the bucket id of a user by calling the `/` endpoint (while being authenticated) or the `/buckets/default` endpoint and looking at the id:

``` http
GET /v1/buckets/default HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Basic dG9rZW46bXktc2VjcmV0
Connection: keep-alive
Host: kinto.dev.mozaws.net
User-Agent: HTTPie/0.9.2



HTTP/1.1 200 OK
Access-Control-Expose-Headers: Content-Length, Expires, Alert, Retry-After, Last-Modified, ETag, Pragma, Cache-Control, Backoff
Cache-Control: no-cache
Connection: keep-alive
Content-Length: 187
Content-Type: application/json; charset=UTF-8
Date: Thu, 18 Feb 2016 08:02:35 GMT
ETag: "1455782547511"
Last-Modified: Thu, 18 Feb 2016 08:02:27 GMT
Server: nginx

{
    "data": {
        "id": "e777874f-2936-11a1-3269-68a6c1648a92", 
        "last_modified": 1455782547511
    }, 
    "permissions": {
        "write": [
            "basicauth:c635be9375673027e9b2f357a3955a0a46b58aeface61930838b61e946008ab0"
        ]
    }
}
```

``` http
GET /v1/ HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Basic dG9rZW46bXktc2VjcmV0
Connection: keep-alive
Host: kinto.dev.mozaws.net
User-Agent: HTTPie/0.9.2



HTTP/1.1 200 OK
Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff
Connection: keep-alive
Content-Length: 505
Content-Type: application/json; charset=UTF-8
Date: Thu, 18 Feb 2016 08:03:00 GMT
Server: nginx

{
    "capabilities": {}, 
    "cliquet_protocol_version": "2", 
    "http_api_version": "1.3", 
    "project_docs": "https://kinto.readthedocs.org/", 
    "project_name": "kinto", 
    "project_version": "1.11.2", 
    "settings": {
        "attachment.base_url": "https://kinto.dev.mozaws.net/attachments/", 
        "batch_max_requests": 25, 
        "cliquet.batch_max_requests": 25, 
        "readonly": false
    }, 
    "url": "https://kinto.dev.mozaws.net/v1/", 
    "user": {
        "bucket": "e777874f-2936-11a1-3269-68a6c1648a92", 
        "id": "basicauth:c635be9375673027e9b2f357a3955a0a46b58aeface61930838b61e946008ab0"
    }
}
```

You can notice that the bucket_id for this user is: `e777874f-2936-11a1-3269-68a6c1648a92`
Note that a user can add permission on this to share it with other users as if it was normal bucket.

An administrator can look at all the existing buckets by calling:

``` http
GET /v1/buckets HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: Basic dG9rZW46bXktc2VjcmV0
Connection: keep-alive
Host: kinto.dev.mozaws.net
User-Agent: HTTPie/0.9.2



HTTP/1.1 200 OK
Access-Control-Expose-Headers: Content-Length, Expires, Alert, Retry-After, Last-Modified, Total-Records, ETag, Pragma, Cache-Control, Backoff, Next-Page
Cache-Control: no-cache
Connection: keep-alive
Content-Length: 86
Content-Type: application/json; charset=UTF-8
Date: Thu, 18 Feb 2016 08:04:53 GMT
ETag: "1455782547511"
Last-Modified: Thu, 18 Feb 2016 08:02:27 GMT
Server: nginx
Total-Records: 1

{
    "data": [
        {
            "id": "e599a995-92b2-2f26-1027-c1168114592e", 
            "last_modified": 1455782879954
        }, 
        {
            "id": "e777874f-2936-11a1-3269-68a6c1648a92", 
            "last_modified": 1455782547511
        }
    ]
}
```

I am implementing something similar to this right now. One difficulty I had was defining objects that are "owned" by a user. I guess I can say any object for which the user is the only one to have write permission on it is "owned" by that user.

Probably this endpoint should require that the user authenticate, but often you need an endpoint like this after the user is gone, so that probably means this endpoint needs to be accessible by some "superuser". I think I'd require that a PR adding this endpoint should also add a config option for which principals are allowed to access it, defaulting to none. System administrators can turn the feature on by either providing their own user principal, or for instance by allowing system.Everyone and limiting access to the endpoint using nginx or something.


Resolving patch:
diff --git a/kinto/views/admin.py b/kinto/views/admin.py
new file mode 100644
--- /dev/null
+++ b/kinto/views/admin.py
@@ -0,0 +1,193 @@
+"""
+Special views for administration.
+"""
+
+import itertools
+import colander
+import collections
+
+from kinto.authorization import RouteFactory
+from kinto.core import utils as core_utils, resource
+from kinto.core.events import notify_resource_event, ACTIONS
+from kinto.core.resource import viewset
+from kinto.core.storage import Filter
+
+
+def slice_into_batches(iterable, batch_size):
+    # Taken from https://code.activestate.com/recipes/303279-getting-items-in-batches/
+    i = iter(iterable)
+    while True:
+        batchiter = itertools.islice(i, batch_size)
+        try:
+            yield itertools.chain([next(batchiter)], batchiter)
+        except StopIteration:
+            return
+
+
+class Deleted(resource.ResourceSchema):
+    principal = colander.SchemaNode(colander.String())
+
+    class Options:
+        preserve_unknown = False
+
+
+class UserDataFactory(RouteFactory):
+    method_permissions = {"delete": "delete"}
+
+
+class UserDataViewSet(viewset.ShareableViewSet):
+    factory = UserDataFactory
+
+
+def get_parent_uri(object_uri):
+    """Get the parent URI for an object_uri.
+
+    In order to be generic over any kind of resource hierarchy, we do
+    this by string manipulation on the URI instead of trying to parse
+    the URI, identify the parent resource, and generate a new URI.
+
+    """
+    path = object_uri.rsplit("/", 2)
+    # len(path) == 1: no '/', probably a broken URL?
+    # len(path) == 2: one '/', doesn't conform to our URL scheme
+    if len(path) < 3:
+        return ""
+
+    return path[0]
+
+
+def condense_under_parents(request, object_uris):
+    """Simplify object_uris by removing "duplicates".
+
+    Deleting a resource usually cascades to all its descendant
+    resources. Use this out-of-band knowledge to remove any objects
+    which will already be deleted by virtue of deleting their
+    ancestors.
+
+    """
+    # Sort object_uris so we see ancestors before descendants.
+    object_uris = list(object_uris)
+    object_uris.sort()
+
+    ancestor_object_uris = set()
+    for object_uri in object_uris:
+        include = True
+        parent_uri = get_parent_uri(object_uri)
+        while parent_uri:
+            if parent_uri in ancestor_object_uris:
+                # It's being deleted already.
+                include = False
+                break
+            parent_uri = get_parent_uri(parent_uri)
+
+        if include:
+            ancestor_object_uris.add(object_uri)
+
+    return list(ancestor_object_uris)
+
+
+@resource.register(
+    name="user-data",
+    description="Delete the data owned by a user",
+    plural_path="/__user_data__",
+    object_path="/__user_data__/{{principal}}",
+    viewset=UserDataViewSet(),
+    object_methods=("DELETE",),
+)
+class UserData(resource.ShareableResource):
+
+    schema = Deleted
+
+    def delete(self):
+        principal = self.request.matchdict["principal"]
+        storage = self.request.registry.storage
+        permission = self.request.registry.permission
+        object_uris_and_permissions = permission.get_accessible_objects([principal])
+        object_uris = list(object_uris_and_permissions.keys())
+        write_perm_principals = permission.get_objects_permissions(object_uris, ["write"])
+        to_delete = set()
+        for object_uri, principals in zip(object_uris, write_perm_principals):
+            principals = principals["write"]
+            # "Ownership" isn't a real concept in Kinto, so instead we
+            # define ownership as meaning "this user is the only one
+            # who can write to this object".
+            if principals == set([principal]):
+                to_delete.add(object_uri)
+
+        # Any accessible objects that won't be deleted, need to have
+        # the user's permission removed.
+        for object_uri, permissions in object_uris_and_permissions.items():
+            if object_uri in to_delete:
+                continue
+
+            for perm in permissions:
+                permission.remove_principal_from_ace(object_uri, perm, principal)
+
+        to_delete = condense_under_parents(self.request, to_delete)
+
+        # Group by (parent_uri, resource of child) to make fewer
+        # requests to storage backend.
+        # Store the parsed object IDs, since those are what we
+        # actually give to the storage backend.
+        object_ids_by_parent_uri = collections.defaultdict(list)
+        # Store also the object URIs, which we give to the permission backend.
+        objects_by_parent_uri = collections.defaultdict(list)
+        # We have to get the matchdict of the child here anyhow, so
+        # keep that to generate events later.
+        matchdicts_by_parent_uri = {}
+        for object_uri in to_delete:
+            parent_uri = get_parent_uri(object_uri)
+            resource_name, matchdict = core_utils.view_lookup(self.request, object_uri)
+            objects_by_parent_uri[(parent_uri, resource_name)].append(object_uri)
+            object_ids_by_parent_uri[(parent_uri, resource_name)].append(matchdict["id"])
+            # This overwrites previous matchdicts for the parent, but
+            # we'll only use the fields that are relevant to the
+            # parent, which will be the same for each child.
+            matchdicts_by_parent_uri[parent_uri] = matchdict
+
+        for (parent_uri, resource_name), object_ids in object_ids_by_parent_uri.items():
+            # Generate the parent matchdict from an arbitrary child's matchdict.
+            matchdict = {**matchdicts_by_parent_uri[parent_uri]}
+            matchdict.pop("id", None)
+
+            # Deletes are paginated too, so take the page size from settings.
+            batch_size = self.request.registry.settings["storage_max_fetch_size"]
+            for batch in slice_into_batches(object_ids, batch_size):
+                batch = list(batch)
+                filters = [Filter("id", batch, core_utils.COMPARISON.IN)]
+                timestamp = storage.resource_timestamp(resource_name, parent_uri)
+                records, _ = storage.get_all(
+                    resource_name=resource_name, parent_id=parent_uri, filters=filters
+                )
+                tombstones = storage.delete_all(
+                    resource_name=resource_name, parent_id=parent_uri, filters=filters
+                )
+                notify_resource_event(
+                    self.request,
+                    parent_uri,
+                    timestamp,
+                    tombstones,
+                    ACTIONS.DELETE,
+                    old=records,
+                    resource_name=resource_name,
+                    resource_data=matchdict,
+                )
+                # FIXME: need to purge the above tombstones, but no
+                # way to purge just some tombstones for just this
+                # principal
+
+                # Clear permissions from the deleted objects, for
+                # example those of other users.
+                permission.delete_object_permissions(
+                    *objects_by_parent_uri[(parent_uri, resource_name)]
+                )
+
+        # Remove this principal from existing users.
+        permission.remove_principal(principal)
+
+        # Remove this principal from all groups that contain it.
+        associated_principals = permission.get_user_principals(principal)
+        for associated_principal in associated_principals:
+            permission.remove_user_principal(principal, associated_principal)
+
+        return {"data": {"principal": principal}}


Test patch:
diff --git a/tests/test_views_admin.py b/tests/test_views_admin.py
new file mode 100644
--- /dev/null
+++ b/tests/test_views_admin.py
@@ -0,0 +1,276 @@
+from pyramid.config import Configurator
+import unittest
+
+from kinto.core import events
+from kinto.core.storage import exceptions
+from kinto.core.testing import get_user_headers
+from kinto.core import utils
+from kinto.views.admin import get_parent_uri
+
+from .support import BaseWebTest, MINIMALIST_BUCKET, MINIMALIST_COLLECTION, MINIMALIST_RECORD
+
+
+class DeleteUserDataTest(BaseWebTest, unittest.TestCase):
+    """Create some data belonging to a user, then delete the user.
+
+    This user (and all their data) should be deleted. Thus, this user
+    (and their data) is "doomed".
+
+    To make sure we don't delete too much data, create some data owned
+    by a different user which won't be deleted. This user (and their
+    data) will be "safe". We'll use the normal self.headers user (see
+    tests.support.BaseWebTest and kinto.core.get_user_headers) for
+    this.
+
+    We also want to ensure that the "doomed" user is removed correctly
+    from "surviving" permissions and groups, so add the "doomed" user
+    to some stuff belonging to the "safe" user too.
+
+    """
+
+    doomed_bucket_url = "/buckets/user_to_delete"
+    doomed_collection_url = doomed_bucket_url + "/collections/collection_to_delete"
+    doomed_bucket_collection_url = doomed_bucket_url + "/collections/collection"
+    safe_bucket_url = "/buckets/user_safe"
+    safe_bucket_doomed_collection_url = safe_bucket_url + "/collections/collection_to_delete"
+    safe_collection_url = safe_bucket_url + "/collections/collection_safe"
+    safe_collection_doomed_record_url = safe_collection_url + "/records/record_to_delete"
+    safe_record_url = safe_collection_url + "/records/record_safe"
+    safe_group_containing_doomed_user = safe_bucket_url + "/groups/silly-group"
+    shared_bucket_url = "/buckets/both"
+    doomed_user = get_user_headers("doomed", "trustno1")
+    hmac_secret = "secret_hmac_for_userids"
+    user_hash = utils.hmac_digest(hmac_secret, "mat:secret")
+    user_principal = f"basicauth:{user_hash}"
+    doomed_hash = utils.hmac_digest(hmac_secret, "doomed:trustno1")
+    doomed_user_principal = f"basicauth:{doomed_hash}"
+    delete_user_url = f"/__user_data__/{doomed_user_principal}"
+    events = []
+
+    @classmethod
+    def listener(cls, event):
+        cls.events.append(event)
+
+    @classmethod
+    def get_app_settings(cls, extras=None):
+        dct = super().get_app_settings(extras=extras)
+        dct["userid_hmac_secret"] = cls.hmac_secret
+        dct["user-data_delete_principals"] = cls.user_principal
+        dct["bucket_create_principals"] = "system.Authenticated"
+        return dct
+
+    @classmethod
+    def make_app(cls, settings=None, config=None):
+        settings = cls.get_app_settings(settings)
+        config = Configurator(settings=settings)
+        config.add_subscriber(cls.listener, events.ResourceChanged)
+        config.commit()
+        return super().make_app(settings=settings, config=config)
+
+    def setUp(self):
+        del self.events[:]
+        super().setUp()
+        self.app.put_json(self.doomed_bucket_url, MINIMALIST_BUCKET, headers=self.doomed_user)
+        self.app.put_json(
+            self.doomed_collection_url, MINIMALIST_COLLECTION, headers=self.doomed_user
+        )
+        # Temporarily grant write to self.user_principal to allow them
+        # to create a collection
+        self.app.patch_json(
+            self.doomed_bucket_url,
+            {"permissions": {"write": [self.user_principal]}},
+            headers=self.doomed_user,
+        )
+        self.app.put_json(
+            self.doomed_bucket_collection_url, MINIMALIST_COLLECTION, headers=self.headers
+        )
+        self.app.patch_json(
+            self.doomed_bucket_url, {"permissions": {"write": []}}, headers=self.doomed_user
+        )
+        self.app.put_json(
+            self.safe_bucket_url,
+            {**MINIMALIST_BUCKET, "permissions": {"write": [self.doomed_user_principal]}},
+            headers=self.headers,
+        )
+        self.app.put_json(
+            self.safe_bucket_doomed_collection_url, MINIMALIST_COLLECTION, headers=self.doomed_user
+        )
+        self.app.put_json(self.safe_collection_url, MINIMALIST_COLLECTION, headers=self.headers)
+        self.app.patch_json(
+            self.safe_collection_url,
+            {"permissions": {"write": [self.doomed_user_principal]}},
+            headers=self.headers,
+        )
+        self.app.put_json(
+            self.safe_collection_doomed_record_url, MINIMALIST_RECORD, headers=self.doomed_user
+        )
+        self.app.patch_json(
+            self.safe_collection_url, {"permissions": {"write": []}}, headers=self.headers
+        )
+        # This record will automatically have "write" permission for
+        # the safe user.
+        self.app.put_json(
+            self.safe_record_url,
+            {**MINIMALIST_RECORD, "permissions": {"read": [self.doomed_user_principal]}},
+            headers=self.headers,
+        )
+        self.app.put_json(
+            self.shared_bucket_url,
+            {
+                **MINIMALIST_BUCKET,
+                "permissions": {"write": [self.doomed_user_principal, self.user_principal]},
+            },
+            headers=self.doomed_user,
+        )
+        self.app.put_json(
+            self.safe_group_containing_doomed_user,
+            {"data": {"members": [self.doomed_user_principal, self.user_principal]}},
+            headers=self.headers,
+        )
+
+        # Actually do the delete. Each `test_` method checks some
+        # consequence of what got deleted.
+        self.app.delete(self.delete_user_url, headers=self.headers)
+
+    def test_doomed_bucket_was_deleted(self):
+        with self.assertRaises(exceptions.ObjectNotFoundError):
+            self.storage.get(resource_name="bucket", parent_id="", object_id="user_to_delete")
+
+    def test_doomed_bucket_collection_was_deleted(self):
+        # Everything under this bucket should be gone
+        with self.assertRaises(exceptions.ObjectNotFoundError):
+            self.storage.get(
+                resource_name="collection",
+                parent_id=self.doomed_bucket_url,
+                object_id="collection",
+            )
+
+    def test_doomed_bucket_doomed_collection_was_deleted(self):
+        with self.assertRaises(exceptions.ObjectNotFoundError):
+            self.storage.get(
+                resource_name="collection",
+                parent_id=self.doomed_bucket_url,
+                object_id="collection_to_delete",
+            )
+
+    def test_safe_bucket_was_not_deleted(self):
+        self.storage.get(resource_name="bucket", parent_id="", object_id="user_safe")
+
+    def test_safe_bucket_doomed_collection_was_deleted(self):
+        with self.assertRaises(exceptions.ObjectNotFoundError):
+            self.storage.get(
+                resource_name="collection",
+                parent_id=self.safe_bucket_url,
+                object_id="collection_to_delete",
+            )
+
+    def test_safe_collection_was_not_deleted(self):
+        self.storage.get(
+            resource_name="collection", parent_id=self.safe_bucket_url, object_id="collection_safe"
+        )
+
+    def test_safe_collection_safe_record_was_not_deleted(self):
+        self.storage.get(
+            resource_name="record", parent_id=self.safe_collection_url, object_id="record_safe"
+        )
+
+    def test_safe_collection_doomed_record_was_deleted(self):
+        with self.assertRaises(exceptions.ObjectNotFoundError):
+            self.storage.get(
+                resource_name="record",
+                parent_id=self.safe_collection_url,
+                object_id="record_to_delete",
+            )
+
+    def test_safe_group_not_deleted(self):
+        principals = self.permission.get_user_principals(self.user_principal)
+        self.assertEqual(principals, {"/buckets/user_safe/groups/silly-group"})
+
+    def test_safe_group_doomed_user_was_removed(self):
+        principals = self.permission.get_user_principals(self.doomed_user_principal)
+        self.assertEqual(principals, set())
+
+    def test_doomed_collection_no_longer_has_any_perms(self):
+        permissions = self.permission.get_object_permissions(self.doomed_collection_url)
+        self.assertEqual(permissions, {})
+
+    def test_doomed_bucket_collection_no_longer_has_any_perms(self):
+        permissions = self.permission.get_object_permissions(self.doomed_bucket_collection_url)
+        self.assertEqual(permissions, {})
+
+    def test_safe_bucket_no_longer_has_doomed_user_perm(self):
+        permissions = self.permission.get_object_permissions(self.safe_bucket_url)
+        self.assertEqual(permissions, {"write": {self.user_principal}})
+
+    def test_safe_record_no_longer_has_doomed_user_perm(self):
+        permissions = self.permission.get_object_permissions(self.safe_record_url)
+        self.assertEqual(permissions, {"write": {self.user_principal}})
+
+    def test_shared_bucket_was_not_deleted(self):
+        self.storage.get(resource_name="bucket", parent_id="", object_id="both")
+
+    def test_shared_bucket_permissions_are_correct(self):
+        permissions = self.permission.get_object_permissions(self.shared_bucket_url)
+        self.assertEqual(permissions, {"write": {self.user_principal}})
+
+    def test_double_delete_is_ok(self):
+        self.app.delete(self.delete_user_url, headers=self.headers)
+
+    def test_cannot_delete_user_without_permission(self):
+        # Only the user with self.headers can access this URL.
+        self.app.delete(self.delete_user_url, headers=self.doomed_user, status=403)
+
+    def test_event_emitted_for_bucket_deletion(self):
+        delete_events = [
+            e for e in self.events if e.payload["action"] == events.ACTIONS.DELETE.value
+        ]
+        bucket_events = [e for e in delete_events if e.payload["resource_name"] == "bucket"]
+        # There should only be one such event, because all buckets
+        # share a parent
+        self.assertEqual(len(bucket_events), 1)
+        # There should only be one such bucket
+        self.assertEqual(len(bucket_events[0].impacted_objects), 1)
+        event = bucket_events[0].impacted_objects[0]
+        self.assertEqual(event["old"]["id"], "user_to_delete")
+
+    def test_event_not_emitted_for_coalesced_collection_deletion(self):
+        # Verify that collection deletion for
+        # /buckets/user_to_delete/collections/collection_to_delete
+        # was folded into bucket deletion for
+        # /buckets/user_to_delete
+        delete_events = [
+            e for e in self.events if e.payload["action"] == events.ACTIONS.DELETE.value
+        ]
+        collection_events = [
+            e for e in delete_events if e.payload["resource_name"] == "collection"
+        ]
+        # There should only be one such event, for /buckets/user_safe
+        self.assertEqual(len(collection_events), 1)
+        self.assertEqual(collection_events[0].payload["bucket_id"], "user_safe")
+        # Nothing for user_to_delete, so it must have been coalesced
+
+    def test_event_emitted_for_record_deletion(self):
+        delete_events = [
+            e for e in self.events if e.payload["action"] == events.ACTIONS.DELETE.value
+        ]
+        record_events = [e for e in delete_events if e.payload["resource_name"] == "record"]
+        # Just one, for safe_collection_doomed_record_url
+        self.assertEqual(len(record_events), 1)
+        event = record_events[0]
+        self.assertEqual(event.payload["bucket_id"], "user_safe")
+        self.assertEqual(event.payload["collection_id"], "collection_safe")
+        # Just the doomed record, not the safe one
+        self.assertEqual(len(event.impacted_objects), 1)
+        self.assertEqual(event.impacted_objects[0]["old"]["id"], "record_to_delete")
+
+
+class GetParentUriTest(unittest.TestCase):
+    def test_parent_uri_behaves_sensibly_for_unknown_resources(self):
+        unknown_url = "/cities/prague/monuments/castle/foods/mushrooms"
+        self.assertEqual(get_parent_uri(unknown_url), "/cities/prague/monuments/castle")
+
+    def test_parent_uri_accepts_pathological_urls(self):
+        # This shouldn't ever actually happen, and if it does happen
+        # it's probably a sign of a larger incompatibility of this
+        # method with the URL scheme
+        self.assertEqual(get_parent_uri("/prague"), "")