Files
upstream-jitsi-meet/tests/prosody/lua/mod_muc_message_moderation_spec.lua
upstream-mirror 1d2bfcac0c
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s
snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
2026-09-15 15:56:32 +05:00

1417 lines
50 KiB
Lua

-- 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)