Commit bea073a5 authored by JC Brand's avatar JC Brand

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.
parent ade6266d
......@@ -400,24 +400,129 @@
describe("A Groupchat", function () {
describe("upon being entered", function () {
it("will fetch the member list if muc_fetch_members is true",
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';
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();
// 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;
await test_utils.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo');
view = _converse.chatboxviews.get('orchard@montague.lit');
expect(view.model.occupants.fetchMembers.calls.count()).toBe(1);
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",
......@@ -2935,7 +3040,7 @@
await test_utils.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
const view = _converse.chatboxviews.get('lounge@montague.lit');
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);
textarea.value = '/clear';
view.onKeyDown(enter);
......@@ -4208,33 +4313,23 @@
spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough();
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';
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.
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(
`<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"/>`+
`</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
const view = _converse.chatboxviews.get(muc_jid);
const features_stanza = $iq({
from: 'coven@chat.shakespeare.lit',
'id': last_sent_IQ.getAttribute('id'),
'id': stanza.getAttribute('id'),
'to': 'romeo@montague.lit/desktop',
'type': 'result'
})
......@@ -4261,49 +4356,30 @@
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);
// 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 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(
`<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>`);
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(
`<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>`);
/* 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({
'from': 'coven@chat.shakespeare.lit',
'id': member_iq.getAttribute('id'),
......@@ -4343,11 +4419,8 @@
});
_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,
`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
`<query xmlns="http://jabber.org/protocol/muc#admin">`+
......@@ -4407,8 +4480,7 @@
expect(delta.length).toBe(0);
// 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'}];
delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
expect(delta.length).toBe(1);
......@@ -4438,6 +4510,10 @@
old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}];
delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
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();
}));
});
......
......@@ -617,8 +617,8 @@ converse.plugins.add('converse-muc-views', {
},
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') {
this.showChatEvent(__("%1$s is no longer an admin of this groupchat", occupant.get('nick')))
......
......@@ -770,15 +770,8 @@ converse.plugins.add('converse-muc', {
if (this.features.get('membersonly')) {
// When inviting to a members-only groupchat, we first add
// 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 = {
'xmlns': 'jabber:x:conference',
......@@ -847,24 +840,6 @@ converse.plugins.add('converse-muc', {
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
* 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
* @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
* @method _converse.ChatRoom#updateMemberLists
* @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 }
* A promise which is resolved once the list has been
* updated or once it's been established there's no need
* 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) {
return this.occupants.fetchMembers();
}
......@@ -1938,11 +1909,14 @@ converse.plugins.add('converse-muc', {
},
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_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => m !== undefined);
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_jids.includes(m.get('jid'));
});
......@@ -1956,12 +1930,9 @@ converse.plugins.add('converse-muc', {
}
});
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) {
occupant.save(attrs);
} else {
......
......@@ -158,6 +158,14 @@ u.isHeadlineMessage = function (_converse, message) {
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) {
if (!_.isElement(stanza)) {
return false;
......
......@@ -72,12 +72,24 @@ u.computeAffiliationsDelta = function computeAffiliationsDelta (exclude_existing
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) {
/* 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) => {
/**
* @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 = {
'affiliation': item.getAttribute('affiliation'),
}
......
......@@ -260,18 +260,7 @@
_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({
to: _converse.connection.jid,
from: `${muc_jid}/${nick}`,
......@@ -284,7 +273,20 @@
}).up()
.c('status').attrs({code:'110'});
_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);
await u.waitUntil(() => (view.model.get('connection_status') === converse.ROOMSTATUS.ENTERED));
if (_converse.muc_fetch_members) {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment