spec/util_stanza_spec.lua
author Kim Alvefur <zash@zash.se>
Sat, 23 Mar 2024 20:48:19 +0100
changeset 13465 c673ff1075bd
parent 12691 5b69ecaf3427
permissions -rw-r--r--
mod_posix: Move everything to util.startup This allows greater control over the order of events. Notably, the internal ordering between daemonization, initialization of libunbound and setup of signal handling is sensitive. libunbound starts a separate thread for processing DNS requests. If this thread is started before signal handling has been set up, it will not inherit the signal handlers and instead behave as it would have before signal handlers were set up, i.e. cause the whole process to immediately exit. libunbound is usually initialized on the first DNS request, usually triggered by an outgoing s2s connection attempt. If daemonization happens before signals have been set up, signals may not be processed at all.


local st = require "util.stanza";
local errors = require "util.error";

describe("util.stanza", function()
	describe("#preserialize()", function()
		it("should work", function()
			local stanza = st.stanza("message", { type = "chat" }):text_tag("body", "Hello");
			local stanza2 = st.preserialize(stanza);
			assert.is_table(stanza2, "Preserialized stanza is a table");
			assert.is_nil(getmetatable(stanza2), "Preserialized stanza has no metatable");
			assert.is_string(stanza2.name, "Preserialized stanza has a name field");
			assert.equal(stanza.name, stanza2.name, "Preserialized stanza has same name as the input stanza");
			assert.same(stanza.attr, stanza2.attr, "Preserialized stanza same attr table as input stanza");
			assert.is_nil(stanza2.tags, "Preserialized stanza has no tag list");
			assert.is_nil(stanza2.last_add, "Preserialized stanza has no last_add marker");
			assert.is_table(stanza2[1], "Preserialized child element preserved");
			assert.equal("body", stanza2[1].name, "Preserialized child element name preserved");
		end);
	end);

	describe("#deserialize()", function()
		it("should work", function()
			local stanza = { name = "message", attr = { type = "chat" }, { name = "body", attr = { }, "Hello" } };
			local stanza2 = st.deserialize(st.preserialize(stanza));

			assert.is_table(stanza2, "Deserialized stanza is a table");
			assert.equal(st.stanza_mt, getmetatable(stanza2), "Deserialized stanza has stanza metatable");
			assert.is_string(stanza2.name, "Deserialized stanza has a name field");
			assert.equal(stanza.name, stanza2.name, "Deserialized stanza has same name as the input table");
			assert.same(stanza.attr, stanza2.attr, "Deserialized stanza same attr table as input table");
			assert.is_table(stanza2.tags, "Deserialized stanza has tag list");
			assert.is_table(stanza2[1], "Deserialized child element preserved");
			assert.equal("body", stanza2[1].name, "Deserialized child element name preserved");
		end);
	end);

	describe("#stanza()", function()
		it("should work", function()
			local s = st.stanza("foo", { xmlns = "myxmlns", a = "attr-a" });
			assert.are.equal(s.name, "foo");
			assert.are.equal(s.attr.xmlns, "myxmlns");
			assert.are.equal(s.attr.a, "attr-a");

			local s1 = st.stanza("s1");
			assert.are.equal(s1.name, "s1");
			assert.are.equal(s1.attr.xmlns, nil);
			assert.are.equal(#s1, 0);
			assert.are.equal(#s1.tags, 0);

			s1:tag("child1");
			assert.are.equal(#s1.tags, 1);
			assert.are.equal(s1.tags[1].name, "child1");

			s1:tag("grandchild1"):up();
			assert.are.equal(#s1.tags, 1);
			assert.are.equal(s1.tags[1].name, "child1");
			assert.are.equal(#s1.tags[1], 1);
			assert.are.equal(s1.tags[1][1].name, "grandchild1");

			s1:up():tag("child2");
			assert.are.equal(#s1.tags, 2, tostring(s1));
			assert.are.equal(s1.tags[1].name, "child1");
			assert.are.equal(s1.tags[2].name, "child2");
			assert.are.equal(#s1.tags[1], 1);
			assert.are.equal(s1.tags[1][1].name, "grandchild1");

			s1:up():text("Hello world");
			assert.are.equal(#s1.tags, 2);
			assert.are.equal(#s1, 3);
			assert.are.equal(s1.tags[1].name, "child1");
			assert.are.equal(s1.tags[2].name, "child2");
			assert.are.equal(#s1.tags[1], 1);
			assert.are.equal(s1.tags[1][1].name, "grandchild1");
		end);
		it("should work with unicode values", function ()
			local s = st.stanza("Объект", { xmlns = "myxmlns", ["Объект"] = "&" });
			assert.are.equal(s.name, "Объект");
			assert.are.equal(s.attr.xmlns, "myxmlns");
			assert.are.equal(s.attr["Объект"], "&");
		end);
		it("should allow :text() with nil and empty strings", function ()
			local s_control = st.stanza("foo");
			assert.same(st.stanza("foo"):text(), s_control);
			assert.same(st.stanza("foo"):text(nil), s_control);
			assert.same(st.stanza("foo"):text(""), s_control);
		end);
		it("validates names", function ()
			assert.has_error_match(function ()
				st.stanza("invalid\0name");
			end, "invalid tag name:")
			assert.has_error_match(function ()
				st.stanza("name", { ["foo\1\2\3bar"] = "baz" });
			end, "invalid attribute name: contains control characters")
			assert.has_error_match(function ()
				st.stanza("name", { ["foo"] = "baz\1\2\3\255moo" });
			end, "invalid attribute value: contains control characters")
		end)
		it("validates types", function ()
			assert.has_error_match(function ()
				st.stanza(1);
			end, "invalid tag name: expected string, got number")
			assert.has_error_match(function ()
				st.stanza("name", "string");
			end, "invalid attributes: expected table, got string")
			assert.has_error_match(function ()
				st.stanza("name",{1});
			end, "invalid attribute name: expected string, got number")
			assert.has_error_match(function ()
				st.stanza("name",{foo=1});
			end, "invalid attribute value: expected string, got number")
		end)
	end);

	describe("#message()", function()
		it("should work", function()
			local m = st.message();
			assert.are.equal(m.name, "message");
		end);
	end);

	describe("#iq()", function()
		it("should create an iq stanza", function()
			local i = st.iq({ type = "get", id = "foo" });
			assert.are.equal("iq", i.name);
			assert.are.equal("foo", i.attr.id);
			assert.are.equal("get", i.attr.type);
		end);

		it("should reject stanzas with no attributes", function ()
			assert.has.error_match(function ()
				st.iq();
			end, "attributes");
		end);


		it("should reject stanzas with no id", function ()
			assert.has.error_match(function ()
				st.iq({ type = "get" });
			end, "id attribute");
		end);

		it("should reject stanzas with no type", function ()
			assert.has.error_match(function ()
				st.iq({ id = "foo" });
			end, "type attribute");

		end);
	end);

	describe("#presence()", function ()
		it("should work", function()
			local p = st.presence();
			assert.are.equal(p.name, "presence");
		end);
	end);

	describe("#reply()", function()
		it("should work for <s>", function()
			-- Test stanza
			local s = st.stanza("s", { to = "touser", from = "fromuser", id = "123" })
				:tag("child1");
			-- Make reply stanza
			local r = st.reply(s);
			assert.are.equal(r.name, s.name);
			assert.are.equal(r.id, s.id);
			assert.are.equal(r.attr.to, s.attr.from);
			assert.are.equal(r.attr.from, s.attr.to);
			assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
		end);

		it("should work for <iq get>", function()
			-- Test stanza
			local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
				:tag("child1");
			-- Make reply stanza
			local r = st.reply(s);
			assert.are.equal(r.name, s.name);
			assert.are.equal(r.id, s.id);
			assert.are.equal(r.attr.to, s.attr.from);
			assert.are.equal(r.attr.from, s.attr.to);
			assert.are.equal(r.attr.type, "result");
			assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
		end);

		it("should work for <iq set>", function()
			-- Test stanza
			local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "set" })
				:tag("child1");
			-- Make reply stanza
			local r = st.reply(s);
			assert.are.equal(r.name, s.name);
			assert.are.equal(r.id, s.id);
			assert.are.equal(r.attr.to, s.attr.from);
			assert.are.equal(r.attr.from, s.attr.to);
			assert.are.equal(r.attr.type, "result");
			assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
		end);

		it("should reject not-stanzas", function ()
			assert.has.error_match(function ()
				st.reply(not "a stanza");
			end, "expected stanza");
		end);

		it("should reject not-stanzas", function ()
			assert.has.error_match(function ()
				st.reply({name="x"});
			end, "expected stanza");
		end);

	end);

	describe("#error_reply()", function()
		it("should work for <s>", function()
			-- Test stanza
			local s = st.stanza("s", { to = "touser", from = "fromuser", id = "123" })
				:tag("child1");
			-- Make reply stanza
			local r = st.error_reply(s, "cancel", "service-unavailable", nil, "host");
			assert.are.equal(r.name, s.name);
			assert.are.equal(r.id, s.id);
			assert.are.equal(r.attr.to, s.attr.from);
			assert.are.equal(r.attr.from, s.attr.to);
			assert.are.equal(#r.tags, 1);
			assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
			assert.are.equal(r.tags[1].attr.by, "host");
		end);

		it("should work for <iq get>", function()
			-- Test stanza
			local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
				:tag("child1");
			-- Make reply stanza
			local r = st.error_reply(s, "cancel", "service-unavailable");
			assert.are.equal(r.name, s.name);
			assert.are.equal(r.id, s.id);
			assert.are.equal(r.attr.to, s.attr.from);
			assert.are.equal(r.attr.from, s.attr.to);
			assert.are.equal(r.attr.type, "error");
			assert.are.equal(#r.tags, 1);
			assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
		end);

		it("should reject not-stanzas", function ()
			assert.has.error_match(function ()
				st.error_reply(not "a stanza", "modify", "bad-request");
			end, "expected stanza");
		end);

		it("should reject stanzas of type error", function ()
			assert.has.error_match(function ()
				st.error_reply(st.message({type="error"}), "cancel", "conflict");
			end, "got stanza of type error");
			assert.has.error_match(function ()
				st.error_reply(st.error_reply(st.message({type="chat"}), "modify", "forbidden"), "cancel", "service-unavailable");
			end, "got stanza of type error");
		end);

		describe("util.error integration", function ()
		it("should accept util.error objects", function ()
			local s = st.message({ to = "touser", from = "fromuser", id = "123", type = "chat" }, "Hello");
			local e = errors.new({ type = "modify", condition = "not-acceptable", text = "Bork bork bork" }, { by = "this.test" });
			local r = st.error_reply(s, e);

			assert.are.equal(r.name, s.name);
			assert.are.equal(r.id, s.id);
			assert.are.equal(r.attr.to, s.attr.from);
			assert.are.equal(r.attr.from, s.attr.to);
			assert.are.equal(r.attr.type, "error");
			assert.are.equal(r.tags[1].name, "error");
			assert.are.equal(r.tags[1].attr.type, e.type);
			assert.are.equal(r.tags[1].tags[1].name, e.condition);
			assert.are.equal(r.tags[1].tags[2]:get_text(), e.text);
			assert.are.equal("this.test", r.tags[1].attr.by);
		end);

		it("should accept util.error objects with an URI", function ()
			local s = st.message({ to = "touser", from = "fromuser", id = "123", type = "chat" }, "Hello");
			local gone = errors.new({ condition = "gone", extra = { uri = "file:///dev/null" } })
			local gonner = st.error_reply(s, gone);
			assert.are.equal("gone", gonner.tags[1].tags[1].name);
			assert.are.equal("file:///dev/null", gonner.tags[1].tags[1][1]);
		end);

		it("should accept util.error objects with application specific error", function ()
			local s = st.message({ to = "touser", from = "fromuser", id = "123", type = "chat" }, "Hello");
			local e = errors.new({ condition = "internal-server-error", text = "Namespaced thing happened",
				extra = {namespace="xmpp:example.test", condition="this-happened"} })
			local r = st.error_reply(s, e);
			assert.are.equal("xmpp:example.test", r.tags[1].tags[3].attr.xmlns);
			assert.are.equal("this-happened", r.tags[1].tags[3].name);

			local e2 = errors.new({ condition = "internal-server-error", text = "Namespaced thing happened",
				extra = {tag=st.stanza("that-happened", { xmlns = "xmpp:example.test", ["another-attribute"] = "here" })} })
			local r2 = st.error_reply(s, e2);
			assert.are.equal("xmpp:example.test", r2.tags[1].tags[3].attr.xmlns);
			assert.are.equal("that-happened", r2.tags[1].tags[3].name);
			assert.are.equal("here", r2.tags[1].tags[3].attr["another-attribute"]);
		end);
		end);
	end);

	describe("#get_error()", function ()
		describe("basics", function ()
			local s = st.message();
			local e = st.error_reply(s, "cancel", "not-acceptable", "UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!")
				:tag("dungeon", { xmlns = "urn:uuid:c9026187-5b05-4e70-b265-c3b6338a7d0f", period="1000000years"});
			local typ, cond, text, extra = e:get_error();
			assert.equal("cancel", typ);
			assert.equal("not-acceptable", cond);
			assert.equal("UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!", text);
			assert.not_nil(extra)
		end)
	end)

	describe("#add_error()", function ()
		describe("basics", function ()
			local s = st.stanza("custom", { xmlns = "urn:example:foo" });
			local e = s:add_error("cancel", "not-acceptable", "UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!")
				:tag("dungeon", { xmlns = "urn:uuid:c9026187-5b05-4e70-b265-c3b6338a7d0f", period="1000000years"});
			assert.equal(s, e);
			local typ, cond, text, extra = e:get_error();
			assert.equal("cancel", typ);
			assert.equal("not-acceptable", cond);
			assert.equal("UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!", text);
			assert.is_nil(extra);
		end)
	end)

	describe("should reject #invalid", function ()
		local invalid_names = {
			["empty string"] = "", ["characters"] = "<>";
		}
		local invalid_data = {
			["number"] = 1234, ["table"] = {};
			["utf8"] = string.char(0xF4, 0x90, 0x80, 0x80);
			["nil"] = "nil"; ["boolean"] = true;
			["control characters"] = "\0\1\2\3";
		};

		for value_type, value in pairs(invalid_names) do
			it(value_type.." in tag names", function ()
				assert.error_matches(function ()
					st.stanza(value);
				end, value_type);
			end);
			it(value_type.." in attribute names", function ()
				assert.error_matches(function ()
					st.stanza("valid", { [value] = "valid" });
				end, value_type);
			end);
		end
		for value_type, value in pairs(invalid_data) do
			if value == "nil" then value = nil; end
			it(value_type.." in tag names", function ()
				assert.error_matches(function ()
					st.stanza(value);
				end, value_type);
			end);
			it(value_type.." in attribute names", function ()
				assert.error_matches(function ()
					st.stanza("valid", { [value] = "valid" });
				end, value_type);
			end);
			if value ~= nil then
				it(value_type.." in attribute values", function ()
					assert.error_matches(function ()
						st.stanza("valid", { valid = value });
					end, value_type);
				end);
				it(value_type.." in text node", function ()
					assert.error_matches(function ()
						st.stanza("valid"):text(value);
					end, value_type);
				end);
			end
		end
	end);

	describe("#is_stanza", function ()
		-- is_stanza(any) -> boolean
		it("identifies stanzas as stanzas", function ()
			assert.truthy(st.is_stanza(st.stanza("x")));
		end);
		it("identifies strings as not stanzas", function ()
			assert.falsy(st.is_stanza(""));
		end);
		it("identifies numbers as not stanzas", function ()
			assert.falsy(st.is_stanza(1));
		end);
		it("identifies tables as not stanzas", function ()
			assert.falsy(st.is_stanza({}));
		end);
	end);

	describe("#remove_children", function ()
		it("should work", function ()
			local s = st.stanza("x", {xmlns="test"})
				:tag("y", {xmlns="test"}):up()
				:tag("z", {xmlns="test2"}):up()
				:tag("x", {xmlns="test2"}):up()

			s:remove_children("x");
			assert.falsy(s:get_child("x"))
			assert.truthy(s:get_child("z","test2"));
			assert.truthy(s:get_child("x","test2"));

			s:remove_children(nil, "test2");
			assert.truthy(s:get_child("y"))
			assert.falsy(s:get_child(nil,"test2"));

			s:remove_children();
			assert.falsy(s.tags[1]);
		end);
	end);

	describe("#maptags", function ()
		it("should work", function ()
			local s = st.stanza("test")
				:tag("one"):up()
				:tag("two"):up()
				:tag("one"):up()
				:tag("three"):up();

			local function one_filter(tag)
				if tag.name == "one" then
					return nil;
				end
				return tag;
			end
			assert.equal(4, #s.tags);
			s:maptags(one_filter);
			assert.equal(2, #s.tags);
		end);

		it("should work with multiple consecutive text nodes", function ()
			local s = st.deserialize({
				"\n";
				{
					"away";
					name = "show";
					attr = {};
				};
				"\n";
				{
					"I am away";
					name = "status";
					attr = {};
				};
				"\n";
				{
					"0";
					name = "priority";
					attr = {};
				};
				"\n";
				{
					name = "c";
					attr = {
						xmlns = "http://jabber.org/protocol/caps";
						node = "http://psi-im.org";
						hash = "sha-1";
					};
				};
				"\n";
				"\n";
				name = "presence";
				attr = {
					to = "user@example.com/jflsjfld";
					from = "room@chat.example.org/nick";
				};
			});

			assert.equal(4, #s.tags);

			s:maptags(function (tag) return tag; end);
			assert.equal(4, #s.tags);

			s:maptags(function (tag)
				if tag.name == "c" then
					return nil;
				end
				return tag;
			end);
			assert.equal(3, #s.tags);
		end);
		it("errors on invalid data - #981", function ()
			local s = st.message({}, "Hello");
			s.tags[1] = st.clone(s.tags[1]);
			assert.has_error_match(function ()
				s:maptags(function () end);
			end, "Invalid stanza");
		end);
	end);

	describe("get_child_with_attr", function ()
		local s = st.message({ type = "chat" })
			:text_tag("body", "Hello world", { ["xml:lang"] = "en" })
			:text_tag("body", "Bonjour le monde", { ["xml:lang"] = "fr" })
			:text_tag("body", "Hallo Welt", { ["xml:lang"] = "de" })

		it("works", function ()
			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "en"):get_text(), "Hello world");
			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "de"):get_text(), "Hallo Welt");
			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "fr"):get_text(), "Bonjour le monde");
			assert.is_nil(s:get_child_with_attr("body", nil, "xml:lang", "FR"));
			assert.is_nil(s:get_child_with_attr("body", nil, "xml:lang", "es"));
		end);

		it("supports normalization", function ()
			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "EN", string.upper):get_text(), "Hello world");
			assert.is_nil(s:get_child_with_attr("body", nil, "xml:lang", "ES", string.upper));
		end);
	end);

	describe("#clone", function ()
		it("works", function ()
			local s = st.message({type="chat"}, "Hello"):reset();
			local c = st.clone(s);
			assert.same(s, c);
		end);

		it("works", function ()
			assert.has_error(function ()
				st.clone("this is not a stanza");
			end);
		end);
	end);

	describe("top_tag", function ()
		local xml_parse = require "util.xml".parse;
		it("works", function ()
			local s = st.message({type="chat"}, "Hello");
			local top_tag = s:top_tag();
			assert.is_string(top_tag);
			assert.not_equal("/>", top_tag:sub(-2, -1));
			assert.equal(">", top_tag:sub(-1, -1));
			local s2 = xml_parse(top_tag.."</message>");
			assert(st.is_stanza(s2));
			assert.equal("message", s2.name);
			assert.equal(0, #s2);
			assert.equal(0, #s2.tags);
			assert.equal("chat", s2.attr.type);
		end);

		it("works with namespaced attributes", function ()
			local s = xml_parse[[<message foo:bar='true' xmlns:foo='my-awesome-ns'/>]];
			local top_tag = s:top_tag();
			assert.is_string(top_tag);
			assert.not_equal("/>", top_tag:sub(-2, -1));
			assert.equal(">", top_tag:sub(-1, -1));
			local s2 = xml_parse(top_tag.."</message>");
			assert(st.is_stanza(s2));
			assert.equal("message", s2.name);
			assert.equal(0, #s2);
			assert.equal(0, #s2.tags);
			assert.equal("true", s2.attr["my-awesome-ns\1bar"]);
		end);
	end);

	describe("indent", function ()
		local s = st.stanza("foo"):text("\n"):tag("bar"):tag("baz"):up():text_tag("cow", "moo");
		assert.equal("<foo>\n\t<bar>\n\t\t<baz/>\n\t\t<cow>moo</cow>\n\t</bar>\n</foo>", tostring(s:indent()));
		assert.equal("<foo>\n  <bar>\n    <baz/>\n    <cow>moo</cow>\n  </bar>\n</foo>", tostring(s:indent(1, "  ")));
		assert.equal("<foo>\n\t\t<bar>\n\t\t\t<baz/>\n\t\t\t<cow>moo</cow>\n\t\t</bar>\n\t</foo>", tostring(s:indent(2, "\t")));
	end);

	describe("find", function()
		it("works", function()
			local s = st.stanza("root", { attr = "value" }):tag("child",
				{ xmlns = "urn:example:not:same"; childattr = "thisvalue" }):text_tag("nested", "text"):reset();
			assert.equal("value", s:find("@attr"), "finds attr")
			assert.equal(s:get_child("child", "urn:example:not:same"), s:find("{urn:example:not:same}child"),
				"equivalent to get_child")
			assert.equal("thisvalue", s:find("{urn:example:not:same}child@childattr"), "finds child attr")
			assert.equal("text", s:find("{urn:example:not:same}child/nested#"), "finds nested text")
			assert.is_nil(s:find("child"), "respects namespaces")
		end);
	end);
end);