-- Unit tests for mod_muc_message_moderation.lua -- Run with busted from resources/prosody-plugins/: -- busted spec/lua/ -- -- Stubs every Prosody dependency so no Prosody installation is needed. The -- util.stanza stub is a small but faithful implementation of the builder API the -- module actually uses, so the assertions below inspect real stanza structure. -- --------------------------------------------------------------------------- -- Minimal util.stanza implementation -- --------------------------------------------------------------------------- local stanza_mt = {} stanza_mt.__index = stanza_mt local st = {} local function new_stanza(name, attrs) return setmetatable({ name = name, attr = attrs or {}, _children = {}, last_add = {} }, stanza_mt) end -- current insertion point: innermost open tag, or the stanza itself function stanza_mt:_cursor() return self.last_add[#self.last_add] or self end function stanza_mt:tag(name, attrs) local child = new_stanza(name, attrs); table.insert(self:_cursor()._children, child); table.insert(self.last_add, child); return self; end function stanza_mt:text(t) table.insert(self:_cursor()._children, t); return self; end function stanza_mt:up() table.remove(self.last_add); return self; end function stanza_mt:add_child(child) table.insert(self:_cursor()._children, child); return self; end local function ns_matches(child, ns) return ns == nil or child.attr.xmlns == ns; end function stanza_mt:get_child(name, ns) for _, child in ipairs(self._children) do if type(child) == 'table' and child.name == name and ns_matches(child, ns) then return child; end end return nil; end function stanza_mt:get_text() local out = {}; for _, child in ipairs(self._children) do if type(child) == 'string' then table.insert(out, child); end end return table.concat(out); end function stanza_mt:get_child_text(name, ns) local child = self:get_child(name, ns); return child and child:get_text() or nil; end function stanza_mt:remove_children(name, ns) local kept = {}; for _, child in ipairs(self._children) do local drop = type(child) == 'table' and child.name == name and ns_matches(child, ns); if not drop then table.insert(kept, child); end end self._children = kept; return self; end -- counts direct children with a given name, for assertions function stanza_mt:count_children(name) local n = 0; for _, child in ipairs(self._children) do if type(child) == 'table' and child.name == name then n = n + 1; end end return n; end function st.stanza(name, attrs) return new_stanza(name, attrs); end function st.message(attrs) return new_stanza('message', attrs); end function st.clone(s) local copy = new_stanza(s.name, {}); for k, v in pairs(s.attr) do copy.attr[k] = v; end for _, child in ipairs(s._children) do if type(child) == 'string' then table.insert(copy._children, child); else table.insert(copy._children, st.clone(child)); end end return copy; end function st.error_reply(_stanza, error_type, condition, text) return { name = 'error_reply', error_type = error_type, condition = condition, text = text }; end -- --------------------------------------------------------------------------- -- Package preloads and global stubs, registered before dofile() -- --------------------------------------------------------------------------- local FIXED_STAMP = '2026-08-31T12:00:00Z'; package.preload['util.stanza'] = function() return st end package.preload['util.id'] = function() return { medium = function() return 'generated-id' end } end package.preload['util.datetime'] = function() return { datetime = function() return FIXED_STAMP end } end local hooks = {}; local logs = {}; local fired = {}; local MAIN_DOMAIN = 'main.example.com'; local LOCAL_DOMAIN = 'v16.example.com'; local main_domain_option = MAIN_DOMAIN; local mock_util = { ends_with = function(str, ending) return ending == '' or str:sub(-#ending) == ending; end }; _G.module = { host = 'conference.example.com', log = function(_, level, ...) table.insert(logs, { level = level, args = { ... } }); end, hook = function(_, name, fn, priority) if not hooks[name] then hooks[name] = {}; end table.insert(hooks[name], { fn = fn, priority = priority }); end, fire_event = function(_, name, event) table.insert(fired, { name = name, event = event }); end, require = function(_, name) if name == 'util' then return mock_util end return nil; end, -- loaded in visitor node role first, so handle_main_groupchat has a -- main_domain to compare against; reloaded in main role further down get_option_string = function(_, key) if key == 'main_domain' then return main_domain_option end if key == 'muc_mapper_domain_base' then return LOCAL_DOMAIN end return nil; end }; package.preload['util.jid'] = function() return { host = function(j) if not j then return nil end local without_resource = j:match('^([^/]+)') or j; return without_resource:match('@(.+)$'); end }; end -- --------------------------------------------------------------------------- -- Load the module under test -- --------------------------------------------------------------------------- local ok, err = pcall(dofile, 'mod_muc_message_moderation.lua'); if not ok then describe('mod_muc_message_moderation', function() it('skipped — failed to load', function() pending(tostring(err):match('([^\n]+)') or tostring(err)); end) end) return end local parse_moderation_request = assert(_G.parse_moderation_request); local parse_correction = assert(_G.parse_correction); local find_history_entry = assert(_G.find_history_entry); local tombstone_entry = assert(_G.tombstone_entry); local handle_groupchat = assert(_G.handle_groupchat); local skip_history = assert(_G.skip_history); local add_room_info = assert(_G.add_room_info); local handle_main_groupchat = assert(_G.handle_main_groupchat); local parse_retraction = assert(_G.parse_retraction); local FASTEN_NS = 'urn:xmpp:fasten:0'; local MODERATE_NS = 'urn:xmpp:message-moderate:1'; local RETRACT_NS = 'urn:xmpp:message-retract:1'; local CORRECT_NS = 'urn:xmpp:message-correct:0'; local HINTS_NS = 'urn:xmpp:hints'; local ROOM_JID = 'room@conference.example.com'; -- Actors carry both identities on purpose. 'jid' is the sender's real jid, which -- is what stanza.attr.from still holds while these hooks run; 'occupant.nick' is -- the in-room identity the room stamps on history entries. The module must work -- off the occupant and never off stanza.attr.from. local MODERATOR = { jid = 'moderator@example.com/device1', occupant = { nick = ROOM_JID .. '/moderator-nick', role = 'moderator' } }; local AUTHOR = { jid = 'author@example.com/device2', occupant = { nick = ROOM_JID .. '/author-nick', role = 'participant' } }; local OTHER = { jid = 'other@example.com/device3', occupant = { nick = ROOM_JID .. '/other-nick', role = 'participant' } }; -- --------------------------------------------------------------------------- -- Fixture builders -- --------------------------------------------------------------------------- -- A moderation request as lib-jitsi-meet sends it. payload_by sets 'by' on the -- request, which the module is expected to ignore in favour of the sender. local function moderation_request(actor, target_id, reason, payload_by) local request = st.message({ from = actor.jid, to = ROOM_JID, type = 'groupchat' }) :tag('apply-to', { xmlns = FASTEN_NS, id = target_id }) :tag('moderated', { xmlns = MODERATE_NS, by = payload_by }) :tag('retract', { xmlns = RETRACT_NS }):up(); if reason then request:tag('reason'):text(reason):up(); end return request; end local function correction_request(actor, target_id, body) return st.message({ from = actor.jid, to = ROOM_JID, type = 'groupchat' }) :tag('body'):text(body):up() :tag('replace', { xmlns = CORRECT_NS, id = target_id }):up(); end -- A retraction exactly as sendMessageRetraction builds it, fallback body and -- 'store' hint included. local function retraction_request(actor, target_id) return st.message({ from = actor.jid, to = ROOM_JID, type = 'groupchat' }) :tag('retract', { xmlns = RETRACT_NS, id = target_id }):up() :tag('fallback', { xmlns = 'urn:xmpp:fallback:0', ['for'] = RETRACT_NS }):up() :tag('body'):text('I retracted a previous message, but it is unsupported by your client.'):up() :tag('store', { xmlns = HINTS_NS }):up(); end local function history_message(actor, message_id, body) local msg = st.message({ from = actor.occupant.nick, to = '', type = 'groupchat', id = message_id }); msg:add_child(st.stanza('body'):text(body)); return { stanza = msg, timestamp = 1000 }; end -- occupants are only needed by the visitor node path, which routes per occupant -- instead of broadcasting local function make_room(history, occupants) return { jid = ROOM_JID, _history = history, broadcasts = {}, routed = {}, broadcast_message = function(self, stanza) table.insert(self.broadcasts, stanza); end, each_occupant = function(self) local i = 0; return function() i = i + 1; if (occupants or {})[i] then return i, occupants[i]; end end; end, route_to_occupant = function(self, occupant, stanza) table.insert(self.routed, { occupant = occupant, stanza = stanza }); end }; end local function make_origin() local sent = {}; return { sent = sent, send = function(stanza) table.insert(sent, stanza); end }; end -- --------------------------------------------------------------------------- -- Tests -- --------------------------------------------------------------------------- describe('mod_muc_message_moderation', function() describe('registration', function() it('hooks muc-occupant-groupchat', function() assert.is_table(hooks['muc-occupant-groupchat']); end) it('hooks muc-add-history', function() assert.is_table(hooks['muc-add-history']); end) it('hooks muc-disco#info', function() assert.is_table(hooks['muc-disco#info']); end) it('hooks muc-config-form', function() assert.is_table(hooks['muc-config-form']); end) it('registers only the visitor node hook, above mod_fmuc', function() assert.equal(1, #hooks['muc-occupant-groupchat']); assert.equal(60, hooks['muc-occupant-groupchat'][1].priority); end) it('hooks nothing beyond the groupchat handler on a visitor node', function() -- the operations arrive as stanzas, so there is no decoded json event -- to listen for on either host any more assert.is_nil(hooks['jitsi-endpoint-message-received']); end) end) -- ----------------------------------------------------------------------- describe('capability advertisement', function() local function field_named(form, name) for _, field in ipairs(form) do if field.name == name then return field; end end return nil; end it('adds the room info field to a disco#info form', function() local event = { form = {} }; add_room_info(event); local field = field_named(event.form, 'muc#roominfo_messageModerationEnabled'); assert.is_table(field); assert.equal('boolean', field.type); assert.equal(1, field.value); end) it('adds the room info field to the room config form', function() local event = { form = {} }; hooks['muc-config-form'][1].fn(event); assert.is_table(field_named(event.form, 'muc#roominfo_messageModerationEnabled')); end) it('appends to a form that already has fields', function() local event = { form = { { name = 'muc#roominfo_visitorsEnabled', value = 1 } } }; add_room_info(event); assert.equal(2, #event.form); assert.is_table(field_named(event.form, 'muc#roominfo_visitorsEnabled')); assert.is_table(field_named(event.form, 'muc#roominfo_messageModerationEnabled')); end) end) -- ----------------------------------------------------------------------- describe('parse_moderation_request', function() it('returns the target id and reason', function() local target, reason = parse_moderation_request(moderation_request(MODERATOR, 'msg-1', 'spam')); assert.equal('msg-1', target); assert.equal('spam', reason); end) it('returns the target id with no reason', function() local target, reason = parse_moderation_request(moderation_request(MODERATOR, 'msg-1', nil)); assert.equal('msg-1', target); assert.is_nil(reason); end) it('ignores a plain body message', function() local msg = st.message({ from = AUTHOR.jid, type = 'groupchat' }):tag('body'):text('hi'):up(); assert.is_nil(parse_moderation_request(msg)); end) it('ignores apply-to without an id', function() local msg = st.message({ from = MODERATOR.jid, type = 'groupchat' }) :tag('apply-to', { xmlns = FASTEN_NS }) :tag('moderated', { xmlns = MODERATE_NS }) :tag('retract', { xmlns = RETRACT_NS }):up(); assert.is_nil(parse_moderation_request(msg)); end) it('ignores apply-to without a moderated child', function() local msg = st.message({ from = MODERATOR.jid, type = 'groupchat' }) :tag('apply-to', { xmlns = FASTEN_NS, id = 'msg-1' }):up(); assert.is_nil(parse_moderation_request(msg)); end) it('ignores moderated without a retract child', function() local msg = st.message({ from = MODERATOR.jid, type = 'groupchat' }) :tag('apply-to', { xmlns = FASTEN_NS, id = 'msg-1' }) :tag('moderated', { xmlns = MODERATE_NS }):up(); assert.is_nil(parse_moderation_request(msg)); end) end) -- ----------------------------------------------------------------------- describe('parse_correction', function() it('returns the target id and the new body', function() local target, body = parse_correction(correction_request(AUTHOR, 'msg-1', 'corrected')); assert.equal('msg-1', target); assert.equal('corrected', body); end) it('ignores a message with no replace element', function() local msg = st.message({ from = AUTHOR.jid, type = 'groupchat' }):tag('body'):text('hi'):up(); assert.is_nil(parse_correction(msg)); end) it('ignores a replace element with no body', function() local msg = st.message({ from = AUTHOR.jid, type = 'groupchat' }) :tag('replace', { xmlns = CORRECT_NS, id = 'msg-1' }):up(); assert.is_nil(parse_correction(msg)); end) it('ignores a replace element with no id', function() local msg = st.message({ from = AUTHOR.jid, type = 'groupchat' }) :tag('body'):text('hi'):up() :tag('replace', { xmlns = CORRECT_NS }):up(); assert.is_nil(parse_correction(msg)); end) end) -- ----------------------------------------------------------------------- describe('find_history_entry', function() it('finds an entry by message id', function() local wanted = history_message(AUTHOR, 'msg-2', 'two'); local room = make_room({ history_message(AUTHOR, 'msg-1', 'one'), wanted }); local entry, index = find_history_entry(room, 'msg-2'); assert.equal(wanted, entry); assert.equal(2, index); end) it('returns nil for an unknown id', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'one') }); assert.is_nil(find_history_entry(room, 'nope')); end) it('returns nil when the room has no history', function() assert.is_nil(find_history_entry(make_room(nil), 'msg-1')); end) it('returns nil for a nil message id', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'one') }); assert.is_nil(find_history_entry(room, nil)); end) end) -- ----------------------------------------------------------------------- describe('moderation role checks', function() it('rejects a request from a participant without the moderator role', function() for i = #fired, 1, -1 do fired[i] = nil end local entry = history_message(AUTHOR, 'msg-1', 'a message'); local room = make_room({ entry }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = moderation_request(OTHER, 'msg-1', 'no reason given'), occupant = OTHER.occupant }); assert.is_true(handled); assert.equal(0, #room.broadcasts); assert.equal(0, #fired); assert.equal(1, #origin.sent); assert.equal('auth', origin.sent[1].error_type); assert.equal('forbidden', origin.sent[1].condition); -- history is left alone, the body survives assert.equal('a message', entry.stanza:get_child_text('body')); assert.is_nil(entry.stanza:get_child('moderated', MODERATE_NS)); end) it('rejects a request with no occupant on the event', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'hi') }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = moderation_request(OTHER, 'msg-1', nil), occupant = nil }); assert.is_true(handled); assert.equal('forbidden', origin.sent[1].condition); end) it('still moderates a message that has aged out of history', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'hi') }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = moderation_request(MODERATOR, 'aged-out', 'off topic'), occupant = MODERATOR.occupant }); assert.is_true(handled); assert.equal(0, #origin.sent); assert.equal(1, #room.broadcasts); assert.equal('aged-out', room.broadcasts[1]:get_child('apply-to', FASTEN_NS).attr.id); end) it('still checks the role for a message that has aged out of history', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'hi') }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = moderation_request(OTHER, 'aged-out', nil), occupant = OTHER.occupant }); assert.is_true(handled); assert.equal(0, #room.broadcasts); assert.equal('forbidden', origin.sent[1].condition); end) it('moderates when the room has no history at all', function() local room = make_room(nil); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = moderation_request(MODERATOR, 'msg-1', nil), occupant = MODERATOR.occupant }); assert.is_true(handled); assert.equal(0, #origin.sent); assert.equal(1, #room.broadcasts); end) it('ignores non-groupchat stanzas', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'hi') }); local request = moderation_request(MODERATOR, 'msg-1', nil); request.attr.type = 'chat'; assert.is_nil(handle_groupchat({ origin = make_origin(), room = room, stanza = request, occupant = MODERATOR.occupant })); end) end) -- ----------------------------------------------------------------------- describe('moderation by a moderator', function() local entry, room, origin; before_each(function() entry = history_message(AUTHOR, 'msg-1', 'a message'); room = make_room({ entry }); origin = make_origin(); for i = #fired, 1, -1 do fired[i] = nil end handle_groupchat({ origin = origin, room = room, stanza = moderation_request(MODERATOR, 'msg-1', 'off topic'), occupant = MODERATOR.occupant }); end) it('sends no error', function() assert.equal(0, #origin.sent); end) it('strips the body from the history entry', function() assert.is_nil(entry.stanza:get_child('body')); end) it('keeps the original id and author on the tombstone', function() assert.equal('msg-1', entry.stanza.attr.id); assert.equal(AUTHOR.occupant.nick, entry.stanza.attr.from); end) it('marks the history entry as moderated by the sending occupant', function() local moderated = entry.stanza:get_child('moderated', MODERATE_NS); assert.is_table(moderated); assert.equal(MODERATOR.occupant.nick, moderated.attr.by); end) it('stamps the retraction in the tombstone', function() local moderated = entry.stanza:get_child('moderated', MODERATE_NS); local retracted = moderated:get_child('retracted', RETRACT_NS); assert.is_table(retracted); assert.equal(FIXED_STAMP, retracted.attr.stamp); end) it('records the reason in the tombstone', function() local moderated = entry.stanza:get_child('moderated', MODERATE_NS); assert.equal('off topic', moderated:get_child_text('reason')); end) it('broadcasts the moderation from the room jid', function() assert.equal(1, #room.broadcasts); assert.equal(ROOM_JID, room.broadcasts[1].attr.from); assert.equal('groupchat', room.broadcasts[1].attr.type); end) it('broadcasts an apply-to referencing the moderated message', function() local apply_to = room.broadcasts[1]:get_child('apply-to', FASTEN_NS); assert.is_table(apply_to); assert.equal('msg-1', apply_to.attr.id); assert.is_table(apply_to:get_child('moderated', MODERATE_NS)); end) it('marks the broadcast no-store so it never enters history', function() assert.is_table(room.broadcasts[1]:get_child('no-store', HINTS_NS)); end) it('announces the moderation for the visitor nodes', function() assert.equal(1, #fired); assert.equal('jitsi-message-moderated', fired[1].name); assert.equal(room, fired[1].event.room); end) it('announces the same stanza that was broadcast', function() assert.equal(room.broadcasts[1], fired[1].event.stanza); end) end) -- ----------------------------------------------------------------------- describe('moderation attribution', function() it('takes by from the occupant, not from the payload', function() local entry = history_message(AUTHOR, 'msg-1', 'hi'); local room = make_room({ entry }); handle_groupchat({ origin = make_origin(), room = room, stanza = moderation_request(MODERATOR, 'msg-1', nil, ROOM_JID .. '/someone-else'), occupant = MODERATOR.occupant }); local moderated = room.broadcasts[1]:get_child('apply-to', FASTEN_NS) :get_child('moderated', MODERATE_NS); assert.equal(MODERATOR.occupant.nick, moderated.attr.by); assert.equal(MODERATOR.occupant.nick, entry.stanza:get_child('moderated', MODERATE_NS).attr.by); end) it('never uses the sender real jid as by', function() local entry = history_message(AUTHOR, 'msg-1', 'hi'); local room = make_room({ entry }); handle_groupchat({ origin = make_origin(), room = room, stanza = moderation_request(MODERATOR, 'msg-1', nil), occupant = MODERATOR.occupant }); local moderated = entry.stanza:get_child('moderated', MODERATE_NS); assert.are_not.equal(MODERATOR.jid, moderated.attr.by); end) it('does not nest a second marker when moderated twice', function() local entry = history_message(AUTHOR, 'msg-1', 'hi'); local room = make_room({ entry }); for _ = 1, 2 do handle_groupchat({ origin = make_origin(), room = room, stanza = moderation_request(MODERATOR, 'msg-1', 'again'), occupant = MODERATOR.occupant }); end assert.equal(1, entry.stanza:count_children('moderated')); end) end) -- ----------------------------------------------------------------------- describe('correction author checks', function() it('rejects a correction from someone who is not the author', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = correction_request(OTHER, 'msg-1', 'rewritten by someone else'), occupant = OTHER.occupant }); assert.is_true(handled); assert.equal('auth', origin.sent[1].error_type); assert.equal('forbidden', origin.sent[1].condition); assert.equal('original', entry.stanza:get_child_text('body')); end) it('rejects a correction from a moderator who is not the author', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = correction_request(MODERATOR, 'msg-1', 'rewritten by a moderator'), occupant = MODERATOR.occupant }); assert.is_true(handled); assert.equal('forbidden', origin.sent[1].condition); assert.equal('original', entry.stanza:get_child_text('body')); end) it('rejects a correction with no occupant on the event', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = correction_request(AUTHOR, 'msg-1', 'whatever'), occupant = nil }); assert.is_true(handled); assert.equal('forbidden', origin.sent[1].condition); assert.equal('original', entry.stanza:get_child_text('body')); end) it('relays a correction of a message that has aged out of history', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); local origin = make_origin(); local request = correction_request(AUTHOR, 'aged-out', 'corrected'); local handled = handle_groupchat({ origin = origin, room = room, stanza = request, occupant = AUTHOR.occupant }); assert.is_nil(handled); assert.equal(0, #origin.sent); assert.equal('original', entry.stanza:get_child_text('body')); -- archived like any other correction, there is just no entry here to -- have checked it against assert.is_nil(request:get_child('no-store', HINTS_NS)); end) it('relays a correction when the room has no history at all', function() local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = make_room(nil), stanza = correction_request(AUTHOR, 'msg-1', 'corrected'), occupant = AUTHOR.occupant }); assert.is_nil(handled); assert.equal(0, #origin.sent); end) it('rejects a correction of a moderated message', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); tombstone_entry(entry, MODERATOR.occupant.nick, 'spam', FIXED_STAMP); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = correction_request(AUTHOR, 'msg-1', 'putting it back'), occupant = AUTHOR.occupant }); assert.is_true(handled); assert.equal('not-allowed', origin.sent[1].condition); assert.is_nil(entry.stanza:get_child('body')); end) end) -- ----------------------------------------------------------------------- describe('correction by the author', function() local entry, room, origin, request, handled; before_each(function() entry = history_message(AUTHOR, 'msg-1', 'original'); room = make_room({ entry }); origin = make_origin(); request = correction_request(AUTHOR, 'msg-1', 'corrected text'); handled = handle_groupchat({ origin = origin, room = room, stanza = request, occupant = AUTHOR.occupant }); end) it('falls through so the room relays and archives it', function() assert.is_nil(handled); assert.equal(0, #origin.sent); end) it('relays the correction untouched', function() assert.equal('corrected text', request:get_child_text('body')); assert.equal('msg-1', request:get_child('replace', CORRECT_NS).attr.id); end) it('does not keep it out of history', function() -- the room archives it and replays it after the message it corrects assert.is_nil(request:get_child('no-store', HINTS_NS)); end) it('leaves the history entry for the original alone', function() assert.equal('original', entry.stanza:get_child_text('body')); assert.equal(1, entry.stanza:count_children('body')); assert.equal('msg-1', entry.stanza.attr.id); assert.equal(AUTHOR.occupant.nick, entry.stanza.attr.from); end) it('honours a second correction the same way', function() local second = correction_request(AUTHOR, 'msg-1', 'final text'); local second_handled = handle_groupchat({ origin = make_origin(), room = room, stanza = second, occupant = AUTHOR.occupant }); assert.is_nil(second_handled); assert.equal('final text', second:get_child_text('body')); end) end) -- ----------------------------------------------------------------------- describe('parse_retraction', function() it('returns the target id', function() assert.equal('msg-1', parse_retraction(retraction_request(AUTHOR, 'msg-1'))); end) it('ignores a plain body message', function() local msg = st.message({ from = AUTHOR.jid, type = 'groupchat' }):tag('body'):text('hi'):up(); assert.is_nil(parse_retraction(msg)); end) it('ignores a retract with no id', function() local msg = st.message({ from = AUTHOR.jid, type = 'groupchat' }) :tag('retract', { xmlns = RETRACT_NS }):up(); assert.is_nil(parse_retraction(msg)); end) it('does not match the nested retract of a moderation request', function() assert.is_nil(parse_retraction(moderation_request(MODERATOR, 'msg-1', 'spam'))); end) end) -- ----------------------------------------------------------------------- describe('retraction', function() it('removes the history entry for the author', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); local origin = make_origin(); local request = retraction_request(AUTHOR, 'msg-1'); local handled = handle_groupchat({ origin = origin, room = room, stanza = request, occupant = AUTHOR.occupant }); assert.is_nil(handled); assert.equal(0, #origin.sent); assert.equal(0, #room._history); end) it('removes only the targeted entry', function() local keep_before = history_message(AUTHOR, 'msg-1', 'one'); local target = history_message(AUTHOR, 'msg-2', 'two'); local keep_after = history_message(OTHER, 'msg-3', 'three'); local room = make_room({ keep_before, target, keep_after }); handle_groupchat({ origin = make_origin(), room = room, stanza = retraction_request(AUTHOR, 'msg-2'), occupant = AUTHOR.occupant }); assert.equal(2, #room._history); assert.equal(keep_before, room._history[1]); assert.equal(keep_after, room._history[2]); end) it('replaces the store hint so the retraction is not archived', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'original') }); local request = retraction_request(AUTHOR, 'msg-1'); handle_groupchat({ origin = make_origin(), room = room, stanza = request, occupant = AUTHOR.occupant }); assert.is_nil(request:get_child('store', HINTS_NS)); assert.is_table(request:get_child('no-store', HINTS_NS)); end) it('rejects a retraction from someone who is not the author', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = retraction_request(OTHER, 'msg-1'), occupant = OTHER.occupant }); assert.is_true(handled); assert.equal('auth', origin.sent[1].error_type); assert.equal('forbidden', origin.sent[1].condition); assert.equal(1, #room._history); assert.equal('original', entry.stanza:get_child_text('body')); end) it('rejects a retraction from a moderator who is not the author', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'original') }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = retraction_request(MODERATOR, 'msg-1'), occupant = MODERATOR.occupant }); assert.is_true(handled); assert.equal('forbidden', origin.sent[1].condition); assert.equal(1, #room._history); end) it('rejects a retraction with no occupant on the event', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'original') }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = retraction_request(AUTHOR, 'msg-1'), occupant = nil }); assert.is_true(handled); assert.equal('forbidden', origin.sent[1].condition); assert.equal(1, #room._history); end) it('relays a retraction of a message that has aged out of history', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }); local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = room, stanza = retraction_request(AUTHOR, 'aged-out'), occupant = AUTHOR.occupant }); assert.is_nil(handled); assert.equal(0, #origin.sent); assert.equal(1, #room._history); end) it('relays a retraction when the room has no history at all', function() local origin = make_origin(); local handled = handle_groupchat({ origin = origin, room = make_room(nil), stanza = retraction_request(AUTHOR, 'msg-1'), occupant = AUTHOR.occupant }); assert.is_nil(handled); assert.equal(0, #origin.sent); end) end) -- ----------------------------------------------------------------------- describe('visitor node history', function() local MAIN_ROOM_JID = 'room@conference.' .. MAIN_DOMAIN; -- a moderation as mod_visitors forwards it: sent by the main room, so no -- local occupant, with 'by' already stamped on the main prosody local function forwarded_moderation(target_id, reason, by) local stanza = st.message({ from = MAIN_ROOM_JID, to = ROOM_JID, type = 'groupchat' }) :tag('apply-to', { xmlns = FASTEN_NS, id = target_id }) :tag('moderated', { xmlns = MODERATE_NS, by = by }) :tag('retract', { xmlns = RETRACT_NS }):up(); if reason then stanza:tag('reason'):text(reason):up(); end return stanza; end local function forwarded_retraction(target_id, from) return st.message({ from = from or ('author@' .. MAIN_DOMAIN .. '/device'), to = ROOM_JID, type = 'groupchat' }) :tag('retract', { xmlns = RETRACT_NS, id = target_id }):up(); end it('tombstones the local entry for a moderation from main', function() local entry = history_message(AUTHOR, 'msg-1', 'a message'); local room = make_room({ entry }); local handled = handle_main_groupchat({ room = room, stanza = forwarded_moderation('msg-1', 'off topic', MODERATOR.occupant.nick), occupant = nil }); -- handled here, this room announces its own stanza instead assert.is_true(handled); assert.is_nil(entry.stanza:get_child('body')); local moderated = entry.stanza:get_child('moderated', MODERATE_NS); assert.is_table(moderated); assert.equal('off topic', moderated:get_child_text('reason')); end) it('keeps the attribution stamped by the main prosody', function() local entry = history_message(AUTHOR, 'msg-1', 'a message'); local room = make_room({ entry }); handle_main_groupchat({ room = room, stanza = forwarded_moderation('msg-1', nil, MODERATOR.occupant.nick), occupant = nil }); assert.equal(MODERATOR.occupant.nick, entry.stanza:get_child('moderated', MODERATE_NS).attr.by); end) it('removes the local entry for a retraction from main', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'one'), history_message(AUTHOR, 'msg-2', 'two') }); local handled = handle_main_groupchat({ room = room, stanza = forwarded_retraction('msg-1'), occupant = nil }); assert.is_nil(handled); assert.equal(1, #room._history); assert.equal('msg-2', room._history[1].stanza.attr.id); end) it('ignores a stanza from a local occupant', function() local entry = history_message(AUTHOR, 'msg-1', 'a message'); local room = make_room({ entry }); handle_main_groupchat({ room = room, stanza = forwarded_moderation('msg-1', nil, OTHER.occupant.nick), occupant = OTHER.occupant }); assert.equal('a message', entry.stanza:get_child_text('body')); end) it('ignores a stanza that did not come from the main domain', function() local entry = history_message(AUTHOR, 'msg-1', 'a message'); local room = make_room({ entry }); local stanza = forwarded_moderation('msg-1', nil, OTHER.occupant.nick); stanza.attr.from = 'room@conference.elsewhere.example.com'; handle_main_groupchat({ room = room, stanza = stanza, occupant = nil }); assert.equal('a message', entry.stanza:get_child_text('body')); end) it('ignores a non-groupchat stanza', function() local entry = history_message(AUTHOR, 'msg-1', 'a message'); local room = make_room({ entry }); local stanza = forwarded_moderation('msg-1', nil, MODERATOR.occupant.nick); stanza.attr.type = 'chat'; handle_main_groupchat({ room = room, stanza = stanza, occupant = nil }); assert.equal('a message', entry.stanza:get_child_text('body')); end) it('ignores an ordinary message from main', function() local entry = history_message(AUTHOR, 'msg-1', 'a message'); local room = make_room({ entry }); local stanza = st.message({ from = MAIN_ROOM_JID, to = ROOM_JID, type = 'groupchat' }) :tag('body'):text('hello'):up(); assert.is_nil(handle_main_groupchat({ room = room, stanza = stanza, occupant = nil })); assert.equal('a message', entry.stanza:get_child_text('body')); assert.equal(1, #room._history); end) local VISITOR = { bare_jid = 'visitor@' .. LOCAL_DOMAIN }; local MAIN_PARTICIPANT = { bare_jid = 'someone@' .. MAIN_DOMAIN }; local FOCUS = { bare_jid = 'focus@auth.elsewhere.example.com' }; -- an occupant of this node, i.e. a visitor, whose request has not been -- past the main prosody at the point this node routes it local function local_occupant(nick) return { bare_jid = 'visitor@' .. LOCAL_DOMAIN, nick = nick }; end -- an occupant that belongs to the main prosody local function main_occupant(nick) return { bare_jid = 'someone@' .. MAIN_DOMAIN, nick = nick }; end it('routes the moderation to the occupants of this node', function() local room = make_room( { history_message(AUTHOR, 'msg-1', 'a message') }, { VISITOR, MAIN_PARTICIPANT, FOCUS }); handle_main_groupchat({ room = room, stanza = forwarded_moderation('msg-1', 'off topic', MODERATOR.occupant.nick), occupant = nil }); assert.equal(1, #room.routed); assert.equal(VISITOR, room.routed[1].occupant); local stanza = room.routed[1].stanza; -- built locally, so it does not depend on the from the hop produced assert.equal(ROOM_JID, stanza.attr.from); local moderated = stanza:get_child('apply-to', FASTEN_NS) :get_child('moderated', MODERATE_NS); assert.equal('msg-1', stanza:get_child('apply-to', FASTEN_NS).attr.id); assert.equal('off topic', moderated:get_child_text('reason')); assert.is_table(stanza:get_child('no-store', HINTS_NS)); end) it('does not route to occupants belonging to another node', function() local room = make_room( { history_message(AUTHOR, 'msg-1', 'a message') }, { MAIN_PARTICIPANT, FOCUS }); handle_main_groupchat({ room = room, stanza = forwarded_moderation('msg-1', nil, MODERATOR.occupant.nick), occupant = nil }); -- the main prosody has already told its own, and this node has no -- route to them assert.equal(0, #room.routed); end) it('never broadcasts, which would reach the remote occupants', function() local room = make_room( { history_message(AUTHOR, 'msg-1', 'a message') }, { VISITOR, MAIN_PARTICIPANT }); handle_main_groupchat({ room = room, stanza = forwarded_moderation('msg-1', nil, MODERATOR.occupant.nick), occupant = nil }); assert.equal(0, #room.broadcasts); end) it('removes the entry for a retraction forwarded from main', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'one'), history_message(AUTHOR, 'msg-2', 'two') }, { VISITOR }); handle_main_groupchat({ room = room, stanza = forwarded_retraction('msg-1'), occupant = main_occupant(ROOM_JID .. '/someone') }); assert.equal(1, #room._history); assert.equal('msg-2', room._history[1].stanza.attr.id); end) it('leaves a correction to be replayed rather than rewriting history', function() local entry = history_message(AUTHOR, 'msg-1', 'original'); local room = make_room({ entry }, { VISITOR }); local handled = handle_main_groupchat({ room = room, stanza = correction_request(AUTHOR, 'msg-1', 'corrected'), occupant = main_occupant(ROOM_JID .. '/someone') }); -- the correction is archived and replayed by the room, so a joining -- client applies it after the message it corrects assert.is_nil(handled); assert.equal('original', entry.stanza:get_child_text('body')); end) it('refuses a visitor retraction of a message they did not author', function() local room = make_room({ history_message(AUTHOR, 'msg-1', 'original') }, { VISITOR }); handle_main_groupchat({ room = room, stanza = forwarded_retraction('msg-1'), occupant = local_occupant(OTHER.occupant.nick) }); assert.equal(1, #room._history); end) it('announces it even when the message is not in the local history', function() local room = make_room( { history_message(AUTHOR, 'msg-1', 'a message') }, { VISITOR }); local handled = handle_main_groupchat({ room = room, stanza = forwarded_moderation('aged-out', nil, MODERATOR.occupant.nick), occupant = nil }); assert.is_true(handled); assert.equal(1, #room.routed); end) end) -- ----------------------------------------------------------------------- describe('skip_history', function() it('skips a moderation broadcast', function() local msg = st.message({ from = ROOM_JID, type = 'groupchat' }) :tag('apply-to', { xmlns = FASTEN_NS, id = 'msg-1' }):up(); assert.is_true(skip_history({ stanza = msg })); end) it('stores a correction', function() -- it carries the new body and is replayed after the message it corrects assert.is_nil(skip_history({ stanza = correction_request(AUTHOR, 'msg-1', 'hi') })); end) it('skips a retraction', function() assert.is_true(skip_history({ stanza = retraction_request(AUTHOR, 'msg-1') })); end) it('stores an ordinary message', function() local msg = st.message({ from = AUTHOR.jid, type = 'groupchat' }):tag('body'):text('hi'):up(); assert.is_nil(skip_history({ stanza = msg })); end) end) -- ----------------------------------------------------------------------- -- Declared last: it reloads the module in the main prosody role, which -- replaces the globals the blocks above exercise. describe('registration on the main prosody', function() setup(function() hooks = {}; main_domain_option = nil; dofile('mod_muc_message_moderation.lua'); end) it('registers the checking handler', function() assert.equal(1, #hooks['muc-occupant-groupchat']); assert.equal(10, hooks['muc-occupant-groupchat'][1].priority); end) it('does not register the visitor node handler', function() for _, h in ipairs(hooks['muc-occupant-groupchat']) do assert.are_not.equal(60, h.priority); end end) it('advertises the room info field either way', function() assert.is_table(hooks['muc-disco#info']); assert.is_table(hooks['muc-config-form']); end) end) end)