瀏覽代碼

Fix erroneous "is no longer an admin/owner" messages in MUCs

Don't remove affiliation for occupants if we weren't allowed to
fetch that particular affiliation list.

Previously, if fetching the list failed, we would return an empty array,
which would imply that the affiliation list is empty and that all
users with the given affiliation should have their affiliations removed.

Instead we now return `null` and properly account for that when setting
affiliations on existing contacts based on the returned member lists.
JC Brand 6 年之前
父節點
當前提交
bea073a5a2
共有 6 個文件被更改,包括 209 次插入140 次删除
  1. 134 58
      spec/muc.js
  2. 2 2
      src/converse-muc-views.js
  3. 35 64
      src/headless/converse-muc.js
  4. 8 0
      src/headless/utils/core.js
  5. 16 4
      src/headless/utils/muc.js
  6. 14 12
      tests/utils.js

+ 134 - 58
spec/muc.js

@@ -400,24 +400,129 @@
 
 
         describe("A Groupchat", function () {
         describe("A Groupchat", function () {
 
 
-
             describe("upon being entered", function () {
             describe("upon being entered", function () {
+
                 it("will fetch the member list if muc_fetch_members is true",
                 it("will fetch the member list if muc_fetch_members is true",
                     mock.initConverse(
                     mock.initConverse(
                         null, ['rosterGroupsFetched'], {'muc_fetch_members': true},
                         null, ['rosterGroupsFetched'], {'muc_fetch_members': true},
                         async function (done, _converse) {
                         async function (done, _converse) {
 
 
+                    const sent_IQs = _converse.connection.IQ_stanzas;
+                    const muc_jid = 'lounge@montague.lit';
                     spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
                     spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
-                    await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-                    let view = _converse.chatboxviews.get('lounge@montague.lit');
+                    await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+                    let view = _converse.chatboxviews.get(muc_jid);
                     expect(view.model.occupants.fetchMembers).toHaveBeenCalled();
                     expect(view.model.occupants.fetchMembers).toHaveBeenCalled();
 
 
+                    // Check in reverse order that we requested all three lists
+                    const owner_iq = sent_IQs.pop();
+                    expect(Strophe.serialize(owner_iq)).toBe(
+                        `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                            `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
+                        `</iq>`);
+
+                    const admin_iq = sent_IQs.pop();
+                    expect(Strophe.serialize(admin_iq)).toBe(
+                        `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                            `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+                        `</iq>`);
+
+                    const member_iq = sent_IQs.pop();
+                    expect(Strophe.serialize(member_iq)).toBe(
+                        `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                            `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
+                        `</iq>`);
+
                     _converse.muc_fetch_members = false;
                     _converse.muc_fetch_members = false;
                     await test_utils.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo');
                     await test_utils.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo');
                     view = _converse.chatboxviews.get('orchard@montague.lit');
                     view = _converse.chatboxviews.get('orchard@montague.lit');
                     expect(view.model.occupants.fetchMembers.calls.count()).toBe(1);
                     expect(view.model.occupants.fetchMembers.calls.count()).toBe(1);
                     done();
                     done();
                 }));
                 }));
+
+                describe("when fetching the member lists", function () {
+
+                    it("gracefully handles being forbidden from fetching the lists for certain affiliations",
+                        mock.initConverse(
+                            null, ['rosterGroupsFetched'], {'muc_fetch_members': true},
+                            async function (done, _converse) {
+
+                        const sent_IQs = _converse.connection.IQ_stanzas;
+                        const muc_jid = 'lounge@montague.lit';
+                        const features = [
+                            'http://jabber.org/protocol/muc',
+                            'jabber:iq:register',
+                            'muc_hidden',
+                            'muc_membersonly',
+                            'muc_passwordprotected',
+                            Strophe.NS.MAM,
+                            Strophe.NS.SID
+                        ];
+                        const nick = 'romeo';
+                        const room = Strophe.getNodeFromJid(muc_jid);
+                        const server = Strophe.getDomainFromJid(muc_jid);
+                        await _converse.api.rooms.open(muc_jid);
+                        await test_utils.getRoomFeatures(_converse, room, server, features);
+                        await test_utils.waitForReservedNick(_converse, muc_jid, nick);
+                        test_utils.receiveOwnMUCPresence(_converse, muc_jid, nick);
+                        const view = _converse.chatboxviews.get(muc_jid);
+                        await u.waitUntil(() => (view.model.get('connection_status') === converse.ROOMSTATUS.ENTERED));
+
+                        // Check in reverse order that we requested all three lists
+                        const owner_iq = sent_IQs.pop();
+                        expect(Strophe.serialize(owner_iq)).toBe(
+                            `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                                `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
+                            `</iq>`);
+                        const admin_iq = sent_IQs.pop();
+                        expect(Strophe.serialize(admin_iq)).toBe(
+                            `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                                `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+                            `</iq>`);
+                        const member_iq = sent_IQs.pop();
+                        expect(Strophe.serialize(member_iq)).toBe(
+                            `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
+                                `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
+                            `</iq>`);
+
+                        // It might be that the user is not allowed to fetch certain lists.
+                        let err_stanza = u.toStanza(
+                            `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${admin_iq.getAttribute('id')}">
+                                <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+                            </iq>`);
+                        _converse.connection._dataRecv(test_utils.createRequest(err_stanza));
+
+                        err_stanza = u.toStanza(
+                            `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${owner_iq.getAttribute('id')}">
+                                <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+                            </iq>`);
+                        _converse.connection._dataRecv(test_utils.createRequest(err_stanza));
+
+                        // Now the service sends the member lists to the user
+                        const member_list_stanza = $iq({
+                                'from': muc_jid,
+                                'id': member_iq.getAttribute('id'),
+                                'to': 'romeo@montague.lit/orchard',
+                                'type': 'result'
+                            }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
+                                .c('item', {
+                                    'affiliation': 'member',
+                                    'jid': 'hag66@shakespeare.lit',
+                                    'nick': 'thirdwitch',
+                                    'role': 'participant'
+                                });
+                        _converse.connection._dataRecv(test_utils.createRequest(member_list_stanza));
+
+                        await u.waitUntil(() => view.model.occupants.length > 1);
+                        expect(view.model.occupants.length).toBe(2);
+                        // The existing owner occupant should not have their
+                        // affiliation removed due to the owner list
+                        // not being returned (forbidden err).
+                        expect(view.model.occupants.findWhere({'jid': _converse.bare_jid}).get('affiliation')).toBe('owner');
+                        expect(view.model.occupants.findWhere({'jid': 'hag66@shakespeare.lit'}).get('affiliation')).toBe('member');
+                        done();
+                    }));
+                });
             });
             });
 
 
             it("clears cached messages when it gets closed and clear_messages_on_reconnection is true",
             it("clears cached messages when it gets closed and clear_messages_on_reconnection is true",
@@ -2935,7 +3040,7 @@
                 await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
                 await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
                 const view = _converse.chatboxviews.get('lounge@montague.lit');
                 const view = _converse.chatboxviews.get('lounge@montague.lit');
                 var textarea = view.el.querySelector('.chat-textarea');
                 var textarea = view.el.querySelector('.chat-textarea');
-                const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+                const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
                 spyOn(window, 'confirm').and.callFake(() => true);
                 spyOn(window, 'confirm').and.callFake(() => true);
                 textarea.value = '/clear';
                 textarea.value = '/clear';
                 view.onKeyDown(enter);
                 view.onKeyDown(enter);
@@ -4208,33 +4313,23 @@
 
 
                 spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
                 spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
                 const sendIQ = _converse.connection.sendIQ;
                 const sendIQ = _converse.connection.sendIQ;
-                const IQ_stanzas = _converse.connection.IQ_stanzas;
+                const sent_IQs = _converse.connection.IQ_stanzas;
                 const muc_jid = 'coven@chat.shakespeare.lit';
                 const muc_jid = 'coven@chat.shakespeare.lit';
 
 
                 await _converse.api.rooms.open(muc_jid, {'nick': 'romeo'});
                 await _converse.api.rooms.open(muc_jid, {'nick': 'romeo'});
-                let stanza = await u.waitUntil(() => _.filter(
-                    IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
-                    )).pop());
+
                 // Check that the groupchat queried for the feautures.
                 // Check that the groupchat queried for the feautures.
+                let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop());
                 expect(Strophe.serialize(stanza)).toBe(
                 expect(Strophe.serialize(stanza)).toBe(
                     `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
                     `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+
                         `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
                         `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
                     `</iq>`);
                     `</iq>`);
 
 
-                const sent_IQs = _converse.connection.IQ_stanzas;
-                const last_sent_IQ = sent_IQs.pop();
-                expect(Strophe.serialize(last_sent_IQ)).toBe(
-                    `<iq from="romeo@montague.lit/orchard" id="${last_sent_IQ.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
-                    `</iq>`);
-
-                const view = _converse.chatboxviews.get(muc_jid);
                 // State that the chat is members-only via the features IQ
                 // State that the chat is members-only via the features IQ
+                const view = _converse.chatboxviews.get(muc_jid);
                 const features_stanza = $iq({
                 const features_stanza = $iq({
                         from: 'coven@chat.shakespeare.lit',
                         from: 'coven@chat.shakespeare.lit',
-                        'id': last_sent_IQ.getAttribute('id'),
+                        'id': stanza.getAttribute('id'),
                         'to': 'romeo@montague.lit/desktop',
                         'to': 'romeo@montague.lit/desktop',
                         'type': 'result'
                         'type': 'result'
                     })
                     })
@@ -4261,49 +4356,30 @@
                         sent_stanza = stanza;
                         sent_stanza = stanza;
                     }
                     }
                 });
                 });
-                var name = mock.cur_names[0];
-                const invitee_jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                var reason = "Please join this groupchat";
+                const invitee_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                const reason = "Please join this groupchat";
                 view.model.directInvite(invitee_jid, reason);
                 view.model.directInvite(invitee_jid, reason);
 
 
                 // Check in reverse order that we requested all three lists
                 // Check in reverse order that we requested all three lists
-                // (member, owner and admin).
-                const admin_iq = sent_IQs.pop();
                 const owner_iq = sent_IQs.pop();
                 const owner_iq = sent_IQs.pop();
-                const member_iq = sent_IQs.pop();
-                expect(Strophe.serialize(admin_iq)).toBe(
-                    `<iq id="${admin_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="admin"/>`+
-                        `</query>`+
-                    `</iq>`);
                 expect(Strophe.serialize(owner_iq)).toBe(
                 expect(Strophe.serialize(owner_iq)).toBe(
                     `<iq id="${owner_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
                     `<iq id="${owner_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="owner"/>`+
-                        `</query>`+
+                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+
                     `</iq>`);
                     `</iq>`);
+
+                const admin_iq = sent_IQs.pop();
+                expect(Strophe.serialize(admin_iq)).toBe(
+                    `<iq id="${admin_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
+                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+
+                    `</iq>`);
+
+                const member_iq = sent_IQs.pop();
                 expect(Strophe.serialize(member_iq)).toBe(
                 expect(Strophe.serialize(member_iq)).toBe(
                     `<iq id="${member_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
                     `<iq id="${member_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+
-                        `<query xmlns="http://jabber.org/protocol/muc#admin">`+
-                            `<item affiliation="member"/>`+
-                        `</query>`+
+                        `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+
                     `</iq>`);
                     `</iq>`);
 
 
-                /* Now the service sends the member list to the user
-                 *
-                 *  <iq from='coven@chat.shakespeare.lit'
-                 *      id='member3'
-                 *      to='crone1@shakespeare.lit/desktop'
-                 *      type='result'>
-                 *  <query xmlns='http://jabber.org/protocol/muc#admin'>
-                 *      <item affiliation='member'
-                 *          jid='hag66@shakespeare.lit'
-                 *          nick='thirdwitch'
-                 *          role='participant'/>
-                 *  </query>
-                 *  </iq>
-                 */
+                // Now the service sends the member lists to the user
                 const member_list_stanza = $iq({
                 const member_list_stanza = $iq({
                         'from': 'coven@chat.shakespeare.lit',
                         'from': 'coven@chat.shakespeare.lit',
                         'id': member_iq.getAttribute('id'),
                         'id': member_iq.getAttribute('id'),
@@ -4343,11 +4419,8 @@
                         });
                         });
                 _converse.connection._dataRecv(test_utils.createRequest(owner_list_stanza));
                 _converse.connection._dataRecv(test_utils.createRequest(owner_list_stanza));
 
 
-                stanza = await u.waitUntil(() => _.filter(
-                    IQ_stanzas,
-                    iq => iq.querySelector(
-                        `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/muc#admin"]`
-                    )).pop());
+                // Converse puts the user on the member list
+                stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/muc#admin"]`)).pop());
                 expect(stanza.outerHTML,
                 expect(stanza.outerHTML,
                     `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
                     `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
                         `<query xmlns="http://jabber.org/protocol/muc#admin">`+
                         `<query xmlns="http://jabber.org/protocol/muc#admin">`+
@@ -4407,8 +4480,7 @@
                 expect(delta.length).toBe(0);
                 expect(delta.length).toBe(0);
 
 
                 // With exclude_existing set to false, any changed affiliations
                 // With exclude_existing set to false, any changed affiliations
-                // will be included in the delta (i.e. existing affiliations
-                // are included in the comparison).
+                // will be included in the delta (i.e. existing affiliations are included in the comparison).
                 old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
                 old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
                 delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
                 delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
                 expect(delta.length).toBe(1);
                 expect(delta.length).toBe(1);
@@ -4438,6 +4510,10 @@
                 old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
                 old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
                 delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
                 delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
                 expect(delta.length).toBe(0);
                 expect(delta.length).toBe(0);
+
+                old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'admin'}];
+                delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
+                expect(delta.length).toBe(0);
                 done();
                 done();
             }));
             }));
         });
         });

+ 2 - 2
src/converse-muc-views.js

@@ -617,8 +617,8 @@ converse.plugins.add('converse-muc-views', {
             },
             },
 
 
             informOfOccupantsAffiliationChange (occupant) {
             informOfOccupantsAffiliationChange (occupant) {
-                const previous_affiliation = occupant._previousAttributes.affiliation,
-                      current_affiliation = occupant.get('affiliation');
+                const previous_affiliation = occupant._previousAttributes.affiliation;
+                const current_affiliation = occupant.get('affiliation');
 
 
                 if (previous_affiliation === 'admin') {
                 if (previous_affiliation === 'admin') {
                     this.showChatEvent(__("%1$s is no longer an admin of this groupchat", occupant.get('nick')))
                     this.showChatEvent(__("%1$s is no longer an admin of this groupchat", occupant.get('nick')))

+ 35 - 64
src/headless/converse-muc.js

@@ -770,15 +770,8 @@ converse.plugins.add('converse-muc', {
                 if (this.features.get('membersonly')) {
                 if (this.features.get('membersonly')) {
                     // When inviting to a members-only groupchat, we first add
                     // When inviting to a members-only groupchat, we first add
                     // the person to the member list by giving them an
                     // the person to the member list by giving them an
-                    // affiliation of 'member' (if they're not affiliated
-                    // already), otherwise they won't be able to join.
-                    const map = {}; map[recipient] = 'member';
-                    const deltaFunc = _.partial(u.computeAffiliationsDelta, true, false);
-                    this.updateMemberLists(
-                        [{'jid': recipient, 'affiliation': 'member', 'reason': reason}],
-                        ['member', 'owner', 'admin'],
-                        deltaFunc
-                    );
+                    // affiliation of 'member' otherwise they won't be able to join.
+                    this.updateMemberLists([{'jid': recipient, 'affiliation': 'member', 'reason': reason}]);
                 }
                 }
                 const attrs = {
                 const attrs = {
                     'xmlns': 'jabber:x:conference',
                     'xmlns': 'jabber:x:conference',
@@ -847,24 +840,6 @@ converse.plugins.add('converse-muc', {
                 this.features.save(attrs);
                 this.features.save(attrs);
             },
             },
 
 
-            /* Send an IQ stanza to the server, asking it for the
-             * member-list of this groupchat.
-             * See: https://xmpp.org/extensions/xep-0045.html#modifymember
-             * @private
-             * @method _converse.ChatRoom#requestMemberList
-             * @param { string } affiliation - The specific member list to
-             *      fetch. 'admin', 'owner' or 'member'.
-             * @returns:
-             *  A promise which resolves once the list has been retrieved.
-             */
-            requestMemberList (affiliation) {
-                affiliation = affiliation || 'member';
-                const iq = $iq({to: this.get('jid'), type: "get"})
-                    .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
-                        .c("item", {'affiliation': affiliation});
-                return _converse.api.sendIQ(iq);
-            },
-
             /**
             /**
              * Send IQ stanzas to the server to set an affiliation for
              * Send IQ stanzas to the server to set an affiliation for
              * the provided JIDs.
              * the provided JIDs.
@@ -1122,25 +1097,27 @@ converse.plugins.add('converse-muc', {
             },
             },
 
 
             /**
             /**
-             * Returns a map of JIDs that have the affiliations
-             * as provided.
+             * Sends an IQ stanza to the server, asking it for the relevant affiliation list .
+             * Returns an array of {@link MemberListItem} objects, representing occupants
+             * that have the given affiliation.
+             * See: https://xmpp.org/extensions/xep-0045.html#modifymember
              * @private
              * @private
-             * @method _converse.ChatRoom#getJidsWithAffiliations
-             * @param { string|array } affiliation - An array of affiliations or
-             *      a string if only one affiliation.
+             * @method _converse.ChatRoom#getAffiliationList
+             * @param { ("admin"|"owner"|"member") } affiliation
+             * @returns { Promise<MemberListItem[]> }
              */
              */
-            async getJidsWithAffiliations (affiliations) {
-                if (_.isString(affiliations)) {
-                    affiliations = [affiliations];
-                }
-                const result = await Promise.all(affiliations.map(a =>
-                    this.requestMemberList(a)
-                        .then(iq => u.parseMemberListIQ(iq))
-                        .catch(iq => {
-                            _converse.log(iq, Strophe.LogLevel.ERROR);
-                        })
-                ));
-                return [].concat.apply([], result).filter(p => p);
+            async getAffiliationList (affiliation) {
+                const iq = $iq({to: this.get('jid'), type: "get"})
+                    .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
+                        .c("item", {'affiliation': affiliation});
+                const result = await _converse.api.sendIQ(iq, null, false);
+                if (result.getAttribute('type') === 'error') {
+                    const err_msg = `Not allowed to fetch ${affiliation} list for MUC ${this.get('jid')}`;
+                    _converse.log(err_msg, Strophe.LogLevel.WARN);
+                    _converse.log(result, Strophe.LogLevel.WARN);
+                    return null;
+                }
+                return u.parseMemberListIQ(result).filter(p => p);
             },
             },
 
 
             /**
             /**
@@ -1151,23 +1128,17 @@ converse.plugins.add('converse-muc', {
              * @private
              * @private
              * @method _converse.ChatRoom#updateMemberLists
              * @method _converse.ChatRoom#updateMemberLists
              * @param { object } members - Map of member jids and affiliations.
              * @param { object } members - Map of member jids and affiliations.
-             * @param { string|array } affiliation - An array of affiliations or
-             *      a string if only one affiliation.
-             * @param { function } deltaFunc - The function to compute the delta
-             *      between old and new member lists.
              * @returns { Promise }
              * @returns { Promise }
              *  A promise which is resolved once the list has been
              *  A promise which is resolved once the list has been
              *  updated or once it's been established there's no need
              *  updated or once it's been established there's no need
              *  to update the list.
              *  to update the list.
              */
              */
-            async updateMemberLists (members, affiliations, deltaFunc) {
-                try {
-                    const old_members = await this.getJidsWithAffiliations(affiliations);
-                    await this.setAffiliations(deltaFunc(members, old_members));
-                } catch (e) {
-                    _converse.log(e, Strophe.LogLevel.ERROR);
-                    return;
-                }
+            async updateMemberLists (members) {
+                const all_affiliations = ['member', 'admin', 'owner'];
+                const aff_lists = await Promise.all(all_affiliations.map(a => this.getAffiliationList(a)));
+                const known_affiliations = all_affiliations.filter(a => aff_lists[all_affiliations.indexOf(a)] !== null);
+                const old_members = aff_lists.reduce((acc, val) => (val !== null ? [...val, ...acc] : acc), []);
+                await this.setAffiliations(u.computeAffiliationsDelta(true, false, members, old_members));
                 if (_converse.muc_fetch_members) {
                 if (_converse.muc_fetch_members) {
                     return this.occupants.fetchMembers();
                     return this.occupants.fetchMembers();
                 }
                 }
@@ -1938,11 +1909,14 @@ converse.plugins.add('converse-muc', {
             },
             },
 
 
             async fetchMembers () {
             async fetchMembers () {
-                const new_members = await this.chatroom.getJidsWithAffiliations(['member', 'owner', 'admin']);
+                const all_affiliations = ['member', 'admin', 'owner'];
+                const aff_lists = await Promise.all(all_affiliations.map(a => this.chatroom.getAffiliationList(a)));
+                const new_members = aff_lists.reduce((acc, val) => (val !== null ? [...val, ...acc] : acc), []);
+                const known_affiliations = all_affiliations.filter(a => aff_lists[all_affiliations.indexOf(a)] !== null);
                 const new_jids = new_members.map(m => m.jid).filter(m => m !== undefined);
                 const new_jids = new_members.map(m => m.jid).filter(m => m !== undefined);
                 const new_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => m !== undefined);
                 const new_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => m !== undefined);
                 const removed_members = this.filter(m => {
                 const removed_members = this.filter(m => {
-                        return ['admin', 'member', 'owner'].includes(m.get('affiliation')) &&
+                        return known_affiliations.includes(m.get('affiliation')) &&
                             !new_nicks.includes(m.get('nick')) &&
                             !new_nicks.includes(m.get('nick')) &&
                             !new_jids.includes(m.get('jid'));
                             !new_jids.includes(m.get('jid'));
                     });
                     });
@@ -1956,12 +1930,9 @@ converse.plugins.add('converse-muc', {
                     }
                     }
                 });
                 });
                 new_members.forEach(attrs => {
                 new_members.forEach(attrs => {
-                    let occupant;
-                    if (attrs.jid) {
-                        occupant = this.findOccupant({'jid': attrs.jid});
-                    } else {
-                        occupant = this.findOccupant({'nick': attrs.nick});
-                    }
+                    const occupant = attrs.jid ?
+                        this.findOccupant({'jid': attrs.jid}) :
+                        this.findOccupant({'nick': attrs.nick});
                     if (occupant) {
                     if (occupant) {
                         occupant.save(attrs);
                         occupant.save(attrs);
                     } else {
                     } else {

+ 8 - 0
src/headless/utils/core.js

@@ -158,6 +158,14 @@ u.isHeadlineMessage = function (_converse, message) {
     return false;
     return false;
 };
 };
 
 
+
+u.isForbiddenError = function (stanza) {
+    if (!_.isElement(stanza)) {
+        return false;
+    }
+    return sizzle(`error[type="auth"] forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length > 0;
+}
+
 u.isServiceUnavailableError = function (stanza) {
 u.isServiceUnavailableError = function (stanza) {
     if (!_.isElement(stanza)) {
     if (!_.isElement(stanza)) {
         return false;
         return false;

+ 16 - 4
src/headless/utils/muc.js

@@ -72,12 +72,24 @@ u.computeAffiliationsDelta = function computeAffiliationsDelta (exclude_existing
     return delta;
     return delta;
 };
 };
 
 
+/**
+ * Given an IQ stanza with a member list, create an array of objects containing
+ * known member data (e.g. jid, nick, role, affiliation).
+ * @private
+ * @method u#parseMemberListIQ
+ * @returns { MemberListItem[] }
+ */
 u.parseMemberListIQ = function parseMemberListIQ (iq) {
 u.parseMemberListIQ = function parseMemberListIQ (iq) {
-    /* Given an IQ stanza with a member list, create an array of member objects.
-    */
-    return _.map(
-        sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq),
+    return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(
         (item) => {
         (item) => {
+            /**
+             * @typedef {Object} MemberListItem
+             * Either the JID or the nickname (or both) will be available.
+             * @property {string} affiliation
+             * @property {string} [role]
+             * @property {string} [jid]
+             * @property {string} [nick]
+             */
             const data = {
             const data = {
                 'affiliation': item.getAttribute('affiliation'),
                 'affiliation': item.getAttribute('affiliation'),
             }
             }

+ 14 - 12
tests/utils.js

@@ -260,18 +260,7 @@
         _converse.connection._dataRecv(utils.createRequest(owner_list_stanza));
         _converse.connection._dataRecv(utils.createRequest(owner_list_stanza));
     };
     };
 
 
-
-    utils.openAndEnterChatRoom = async function (_converse, muc_jid, nick, features=[], members=[]) {
-        muc_jid = muc_jid.toLowerCase();
-        const room = Strophe.getNodeFromJid(muc_jid);
-        const server = Strophe.getDomainFromJid(muc_jid);
-        await _converse.api.rooms.open(muc_jid);
-        await utils.getRoomFeatures(_converse, room, server, features);
-        await utils.waitForReservedNick(_converse, muc_jid, nick);
-
-        // The user has just entered the room (because join was called)
-        // and receives their own presence from the server.
-        // See example 24: https://xmpp.org/extensions/xep-0045.html#enter-pres
+    utils.receiveOwnMUCPresence = function (_converse, muc_jid, nick) {
         const presence = $pres({
         const presence = $pres({
                 to: _converse.connection.jid,
                 to: _converse.connection.jid,
                 from: `${muc_jid}/${nick}`,
                 from: `${muc_jid}/${nick}`,
@@ -284,7 +273,20 @@
             }).up()
             }).up()
             .c('status').attrs({code:'110'});
             .c('status').attrs({code:'110'});
         _converse.connection._dataRecv(utils.createRequest(presence));
         _converse.connection._dataRecv(utils.createRequest(presence));
+    }
+
 
 
+    utils.openAndEnterChatRoom = async function (_converse, muc_jid, nick, features=[], members=[]) {
+        muc_jid = muc_jid.toLowerCase();
+        const room = Strophe.getNodeFromJid(muc_jid);
+        const server = Strophe.getDomainFromJid(muc_jid);
+        await _converse.api.rooms.open(muc_jid);
+        await utils.getRoomFeatures(_converse, room, server, features);
+        await utils.waitForReservedNick(_converse, muc_jid, nick);
+        // The user has just entered the room (because join was called)
+        // and receives their own presence from the server.
+        // See example 24: https://xmpp.org/extensions/xep-0045.html#enter-pres
+        utils.receiveOwnMUCPresence(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => (view.model.get('connection_status') === converse.ROOMSTATUS.ENTERED));
         await u.waitUntil(() => (view.model.get('connection_status') === converse.ROOMSTATUS.ENTERED));
         if (_converse.muc_fetch_members) {
         if (_converse.muc_fetch_members) {