diff --git a/package.json b/package.json
index de0179c870..1f979369e3 100644
--- a/package.json
+++ b/package.json
@@ -125,6 +125,7 @@
     "karma-spec-reporter": "^0.0.31",
     "karma-summary-reporter": "^1.3.3",
     "karma-webpack": "^1.7.0",
+    "matrix-mock-request": "^1.2.1",
     "matrix-react-test-utils": "^0.1.1",
     "mocha": "^5.0.5",
     "parallelshell": "^3.0.2",
diff --git a/test/components/structures/GroupView-test.js b/test/components/structures/GroupView-test.js
new file mode 100644
index 0000000000..2df0599f89
--- /dev/null
+++ b/test/components/structures/GroupView-test.js
@@ -0,0 +1,378 @@
+/*
+Copyright 2018 New Vector Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ReactTestUtils from 'react-dom/test-utils';
+import expect from 'expect';
+import Promise from 'bluebird';
+
+import MockHttpBackend from 'matrix-mock-request';
+import MatrixClientPeg from '../../../src/MatrixClientPeg';
+import sdk from 'matrix-react-sdk';
+import Matrix from 'matrix-js-sdk';
+
+import * as TestUtils from 'test-utils';
+
+const GroupView = sdk.getComponent('structures.GroupView');
+const WrappedGroupView = TestUtils.wrapInMatrixClientContext(GroupView);
+
+const Spinner = sdk.getComponent('elements.Spinner');
+
+/**
+ * Call fn before calling componentDidUpdate on a react component instance, inst.
+ * @param {React.Component} inst an instance of a React component.
+ * @returns {Promise} promise that resolves when componentDidUpdate is called on
+ *                    given component instance.
+ */
+function waitForUpdate(inst) {
+    return new Promise((resolve, reject) => {
+        const cdu = inst.componentDidUpdate;
+
+        inst.componentDidUpdate = (prevProps, prevState, snapshot) => {
+            resolve();
+
+            if (cdu) cdu(prevProps, prevState, snapshot);
+
+            inst.componentDidUpdate = cdu;
+        };
+    });
+}
+
+describe.only('GroupView', function() {
+    let root;
+    let rootElement;
+    let httpBackend;
+    let summaryResponse;
+    let summaryResponseWithComplicatedLongDesc;
+    let summaryResponseWithNoLongDesc;
+    let summaryResponseWithBadImg;
+    let groupId;
+    let groupIdEncoded;
+
+    // Summary response fields
+    const user = {
+        is_privileged: true, // can edit the group
+        is_public: true, // appear as a member to non-members
+        is_publicised: true, // display flair
+    };
+    const usersSection = {
+        roles: {},
+        total_user_count_estimate: 0,
+        users: [],
+    };
+    const roomsSection = {
+        categories: {},
+        rooms: [],
+        total_room_count_estimate: 0,
+    };
+
+    beforeEach(function() {
+        TestUtils.beforeEach(this);
+
+        httpBackend = new MockHttpBackend();
+
+        Matrix.request(httpBackend.requestFn);
+
+        MatrixClientPeg.get = () => Matrix.createClient({
+            baseUrl: 'https://my.home.server',
+            userId: '@me:here',
+            accessToken: '123456789',
+        });
+
+        summaryResponse = {
+            profile: {
+                avatar_url: "mxc://someavatarurl",
+                is_openly_joinable: true,
+                is_public: true,
+                long_description: "This is a LONG description.",
+                name: "The name of a community",
+                short_description: "This is a community",
+            },
+            user,
+            users_section: usersSection,
+            rooms_section: roomsSection,
+        };
+        summaryResponseWithNoLongDesc = {
+            profile: {
+                avatar_url: "mxc://someavatarurl",
+                is_openly_joinable: true,
+                is_public: true,
+                long_description: null,
+                name: "The name of a community",
+                short_description: "This is a community",
+            },
+            user,
+            users_section: usersSection,
+            rooms_section: roomsSection,
+        };
+        summaryResponseWithComplicatedLongDesc = {
+            profile: {
+                avatar_url: "mxc://someavatarurl",
+                is_openly_joinable: true,
+                is_public: true,
+                long_description: `
+
This is a more complicated group page
+With paragraphs
+
+    - And lists!+
- With list items.+
+And also images: 
`,
+                name: "The name of a community",
+                short_description: "This is a community",
+            },
+            user,
+            users_section: usersSection,
+            rooms_section: roomsSection,
+        };
+
+        summaryResponseWithBadImg = {
+            profile: {
+                avatar_url: "mxc://someavatarurl",
+                is_openly_joinable: true,
+                is_public: true,
+                long_description: 'Evil image: 
',
+                name: "The name of a community",
+                short_description: "This is a community",
+            },
+            user,
+            users_section: usersSection,
+            rooms_section: roomsSection,
+        };
+
+        groupId = "+" + Math.random().toString(16).slice(2) + ':domain';
+        groupIdEncoded = encodeURIComponent(groupId);
+
+        rootElement = document.createElement('div');
+        root = ReactDOM.render(, rootElement);
+    });
+
+    afterEach(function() {
+        ReactDOM.unmountComponentAtNode(rootElement);
+    });
+
+    it('should show a spinner when first displayed', function() {
+        ReactTestUtils.findRenderedComponentWithType(root, Spinner);
+
+        // If we don't respond here, the rate limiting done to ensure a maximum of
+        // 3 concurrent network requests for GroupStore will block subsequent requests
+        // in other tests.
+        //
+        // This is a good case for doing the rate limiting somewhere other than the module
+        // scope of GroupStore.js
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(200, summaryResponse);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        return httpBackend.flush(undefined, undefined, 0);
+    });
+
+    it('should indicate failure after failed /summary', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView_error');
+        });
+
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(500, {});
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a group avatar, name, id and short description after successful /summary', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView');
+
+            const avatar = ReactTestUtils.findRenderedComponentWithType(root, sdk.getComponent('avatars.GroupAvatar'));
+            const img = ReactTestUtils.findRenderedDOMComponentWithTag(avatar, 'img');
+            const avatarImgElement = ReactDOM.findDOMNode(img);
+            expect(avatarImgElement).toExist();
+            expect(avatarImgElement.src).toInclude(
+                'https://my.home.server/_matrix/media/v1/thumbnail/' +
+                'someavatarurl?width=48&height=48&method=crop',
+            );
+
+            const name = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView_header_name');
+            const nameElement = ReactDOM.findDOMNode(name);
+            expect(nameElement).toExist();
+            expect(nameElement.innerText).toInclude('The name of a community');
+            expect(nameElement.innerText).toInclude(groupId);
+
+            const shortDesc = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView_header_shortDesc');
+            const shortDescElement = ReactDOM.findDOMNode(shortDesc);
+            expect(shortDescElement).toExist();
+            expect(shortDescElement.innerText).toBe('This is a community');
+        });
+
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(200, summaryResponse);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a simple long description after successful /summary', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView');
+
+            const longDesc = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView_groupDesc');
+            const longDescElement = ReactDOM.findDOMNode(longDesc);
+            expect(longDescElement).toExist();
+            expect(longDescElement.innerText).toBe('This is a LONG description.');
+            expect(longDescElement.innerHTML).toBe('This is a LONG description.
');
+        });
+
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(200, summaryResponse);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a placeholder if a long description is not set', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const placeholder = ReactTestUtils
+                .findRenderedDOMComponentWithClass(root, 'mx_GroupView_groupDesc_placeholder');
+            const placeholderElement = ReactDOM.findDOMNode(placeholder);
+            expect(placeholderElement).toExist();
+        });
+
+        httpBackend
+            .when('GET', '/groups/' + groupIdEncoded + '/summary')
+            .respond(200, summaryResponseWithNoLongDesc);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a complicated long description after successful /summary', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const longDesc = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView_groupDesc');
+            const longDescElement = ReactDOM.findDOMNode(longDesc);
+            expect(longDescElement).toExist();
+
+            expect(longDescElement.innerHTML).toInclude('This is a more complicated group page
');
+            expect(longDescElement.innerHTML).toInclude('With paragraphs
');
+            expect(longDescElement.innerHTML).toInclude('');
+            expect(longDescElement.innerHTML).toInclude('- And lists!');
+
+            const imgSrc = "https://my.home.server/_matrix/media/v1/thumbnail/someimageurl?width=800&height=600";
+            expect(longDescElement.innerHTML).toInclude('
 ');
+        });
+
+        httpBackend
+            .when('GET', '/groups/' + groupIdEncoded + '/summary')
+            .respond(200, summaryResponseWithComplicatedLongDesc);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should disallow images with non-mxc URLs', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const longDesc = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView_groupDesc');
+            const longDescElement = ReactDOM.findDOMNode(longDesc);
+            expect(longDescElement).toExist();
+
+            // If this fails, the URL could be in an img `src`, which is what we care about but
+            // there's no harm in keeping this simple and checking the entire HTML string.
+            expect(longDescElement.innerHTML).toExclude('evilimageurl');
+        });
+
+        httpBackend
+            .when('GET', '/groups/' + groupIdEncoded + '/summary')
+            .respond(200, summaryResponseWithBadImg);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a RoomDetailList after a successful /summary & /rooms (no rooms returned)', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const roomDetailList = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_RoomDetailList');
+            const roomDetailListElement = ReactDOM.findDOMNode(roomDetailList);
+            expect(roomDetailListElement).toExist();
+        });
+
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(200, summaryResponse);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a RoomDetailList after a successful /summary & /rooms (with a single room)', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const roomDetailList = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_RoomDetailList');
+            const roomDetailListElement = ReactDOM.findDOMNode(roomDetailList);
+            expect(roomDetailListElement).toExist();
+
+            const roomDetailListRoomName = ReactTestUtils.findRenderedDOMComponentWithClass(
+                root,
+                'mx_RoomDirectory_name',
+            );
+            const roomDetailListRoomNameElement = ReactDOM.findDOMNode(roomDetailListRoomName);
+
+            expect(roomDetailListRoomNameElement).toExist();
+            expect(roomDetailListRoomNameElement.innerText).toEqual('Some room name');
+        });
+
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(200, summaryResponse);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [{
+            avatar_url: "mxc://someroomavatarurl",
+            canonical_alias: "#somealias:domain",
+            guest_can_join: true,
+            is_public: true,
+            name: "Some room name",
+            num_joined_members: 123,
+            room_id: "!someroomid",
+            topic: "some topic",
+            world_readable: true,
+        }] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+});
diff --git a/test/test-utils.js b/test/test-utils.js
index b593761bd4..d2c685b371 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -92,6 +92,7 @@ export function createTestClient() {
                 content: {},
             });
         },
+        mxcUrlToHttp: (mxc) => 'http://this.is.a.url/',
         setAccountData: sinon.stub(),
         sendTyping: sinon.stub().returns(Promise.resolve({})),
         sendTextMessage: () => Promise.resolve({}),
');
+        });
+
+        httpBackend
+            .when('GET', '/groups/' + groupIdEncoded + '/summary')
+            .respond(200, summaryResponseWithComplicatedLongDesc);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should disallow images with non-mxc URLs', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const longDesc = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_GroupView_groupDesc');
+            const longDescElement = ReactDOM.findDOMNode(longDesc);
+            expect(longDescElement).toExist();
+
+            // If this fails, the URL could be in an img `src`, which is what we care about but
+            // there's no harm in keeping this simple and checking the entire HTML string.
+            expect(longDescElement.innerHTML).toExclude('evilimageurl');
+        });
+
+        httpBackend
+            .when('GET', '/groups/' + groupIdEncoded + '/summary')
+            .respond(200, summaryResponseWithBadImg);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a RoomDetailList after a successful /summary & /rooms (no rooms returned)', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const roomDetailList = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_RoomDetailList');
+            const roomDetailListElement = ReactDOM.findDOMNode(roomDetailList);
+            expect(roomDetailListElement).toExist();
+        });
+
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(200, summaryResponse);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+
+    it('should show a RoomDetailList after a successful /summary & /rooms (with a single room)', function() {
+        const groupView = ReactTestUtils.findRenderedComponentWithType(root, GroupView);
+        const prom = waitForUpdate(groupView).then(() => {
+            const roomDetailList = ReactTestUtils.findRenderedDOMComponentWithClass(root, 'mx_RoomDetailList');
+            const roomDetailListElement = ReactDOM.findDOMNode(roomDetailList);
+            expect(roomDetailListElement).toExist();
+
+            const roomDetailListRoomName = ReactTestUtils.findRenderedDOMComponentWithClass(
+                root,
+                'mx_RoomDirectory_name',
+            );
+            const roomDetailListRoomNameElement = ReactDOM.findDOMNode(roomDetailListRoomName);
+
+            expect(roomDetailListRoomNameElement).toExist();
+            expect(roomDetailListRoomNameElement.innerText).toEqual('Some room name');
+        });
+
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/summary').respond(200, summaryResponse);
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/invited_users').respond(200, { chunk: [] });
+        httpBackend.when('GET', '/groups/' + groupIdEncoded + '/rooms').respond(200, { chunk: [{
+            avatar_url: "mxc://someroomavatarurl",
+            canonical_alias: "#somealias:domain",
+            guest_can_join: true,
+            is_public: true,
+            name: "Some room name",
+            num_joined_members: 123,
+            room_id: "!someroomid",
+            topic: "some topic",
+            world_readable: true,
+        }] });
+
+        httpBackend.flush(undefined, undefined, 0);
+        return prom;
+    });
+});
diff --git a/test/test-utils.js b/test/test-utils.js
index b593761bd4..d2c685b371 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -92,6 +92,7 @@ export function createTestClient() {
                 content: {},
             });
         },
+        mxcUrlToHttp: (mxc) => 'http://this.is.a.url/',
         setAccountData: sinon.stub(),
         sendTyping: sinon.stub().returns(Promise.resolve({})),
         sendTextMessage: () => Promise.resolve({}),