From 045fdfdf27280c83076b841ec097d505049b5a96 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Fri, 9 Jan 2026 03:49:32 +1300 Subject: [PATCH] General code coverage --- src/d2m/actions/create-room.js | 3 +- src/m2d/converters/emoji.test.js | 12 ++ src/matrix/mreq.js | 30 ++-- src/matrix/utils.js | 5 +- src/matrix/utils.test.js | 218 +++++++++++++++++++++++- src/web/routes/download-discord.js | 4 +- src/web/routes/download-discord.test.js | 65 +++++-- src/web/routes/guild.test.js | 7 + src/web/routes/info.js | 3 +- src/web/routes/link.test.js | 10 +- src/web/routes/password.test.js | 16 ++ test/test.js | 1 + 12 files changed, 331 insertions(+), 43 deletions(-) create mode 100644 src/web/routes/password.test.js diff --git a/src/d2m/actions/create-room.js b/src/d2m/actions/create-room.js index 5c2b76c..11a03e5 100644 --- a/src/d2m/actions/create-room.js +++ b/src/d2m/actions/create-room.js @@ -127,8 +127,9 @@ async function channelToKState(channel, guild, di) { const everyoneCanMentionEveryone = dUtils.hasAllPermissions(everyonePermissions, ["MentionEveryone"]) const spacePowerDetails = await mUtils.getEffectivePower(guildSpaceID, [], di.api) + spacePowerDetails.powerLevels.users ??= {} const spaceCreatorsAndFounders = spacePowerDetails.allCreators - .concat(Object.entries(spacePowerDetails.powerLevels.users ?? {}).filter(([, power]) => power >= spacePowerDetails.tombstone).map(([mxid]) => mxid)) + .concat(Object.entries(spacePowerDetails.powerLevels.users).filter(([, power]) => power >= spacePowerDetails.tombstone).map(([mxid]) => mxid)) const globalAdmins = select("member_power", ["mxid", "power_level"], {room_id: "*"}).all() const globalAdminPower = globalAdmins.reduce((a, c) => (a[c.mxid] = c.power_level, a), {}) diff --git a/src/m2d/converters/emoji.test.js b/src/m2d/converters/emoji.test.js index ad9846b..fafb163 100644 --- a/src/m2d/converters/emoji.test.js +++ b/src/m2d/converters/emoji.test.js @@ -50,3 +50,15 @@ test("emoji: spy needs u+fe0f in the middle", async t => { test("emoji: couple needs u+fe0f in the middle", async t => { t.equal(await encodeEmoji("👩‍❤‍👩", null), "%F0%9F%91%A9%E2%80%8D%E2%9D%A4%EF%B8%8F%E2%80%8D%F0%9F%91%A9") }) + +test("emoji: exact known emojis are returned", async t => { + t.equal(await encodeEmoji("mxc://cadence.moe/qWmbXeRspZRLPcjseyLmeyXC", "hippo"), "hippo%3A230201364309868544") +}) + +test("emoji: inexact emojis are guessed by name", async t => { + t.equal(await encodeEmoji("mxc://example.invalid/a", "hippo"), "hippo%3A230201364309868544") +}) + +test("emoji: unknown custom emoji returns null", async t => { + t.equal(await encodeEmoji("mxc://example.invalid/a", "silly"), null) +}) diff --git a/src/matrix/mreq.js b/src/matrix/mreq.js index 888aa54..9085add 100644 --- a/src/matrix/mreq.js +++ b/src/matrix/mreq.js @@ -18,21 +18,6 @@ class MatrixServerError extends Error { } } -/** - * @param {Response} res - * @param {object} opts - */ -async function makeMatrixServerError(res, opts = {}) { - delete opts.headers?.["Authorization"] - if (res.headers.get("content-type") === "application/json") { - return new MatrixServerError(await res.json(), opts) - } else if (res.headers.get("content-type")?.startsWith("text/")) { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts) - } else { - return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts) - } -} - /** * @param {undefined | string | object | streamWeb.ReadableStream | stream.Readable} body * @returns {Promise} @@ -52,6 +37,21 @@ async function _convertBody(body) { /* c8 ignore start */ +/** + * @param {Response} res + * @param {object} opts + */ +async function makeMatrixServerError(res, opts = {}) { + delete opts.headers?.["Authorization"] + if (res.headers.get("content-type") === "application/json") { + return new MatrixServerError(await res.json(), opts) + } else if (res.headers.get("content-type")?.startsWith("text/")) { + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, message: await res.text()}, opts) + } else { + return new MatrixServerError({errcode: "CX_SERVER_ERROR", error: `Server returned HTTP status ${res.status}`, content_type: res.headers.get("content-type")}, opts) + } +} + /** * @param {string} method * @param {string} url diff --git a/src/matrix/utils.js b/src/matrix/utils.js index 3860f6e..f299d95 100644 --- a/src/matrix/utils.js +++ b/src/matrix/utils.js @@ -138,14 +138,17 @@ async function getViaServers(roomID, api) { candidates.push(reg.ooye.server_name) // Candidate 1: Highest joined non-sim non-bot power level user in the room // https://github.com/matrix-org/matrix-react-sdk/blob/552c65db98b59406fb49562e537a2721c8505517/src/utils/permalinks/Permalinks.ts#L172 + /* c8 ignore next */ const call = "getEffectivePower" in api ? api.getEffectivePower(roomID, [bot], api) : getEffectivePower(roomID, [bot], api) const {allCreators, powerLevels} = await call - const sorted = allCreators.concat(Object.entries(powerLevels.users ?? {}).sort((a, b) => b[1] - a[1]).map(([mxid]) => mxid)) // Highest... + powerLevels.users ??= {} + const sorted = allCreators.concat(Object.entries(powerLevels.users).sort((a, b) => b[1] - a[1]).map(([mxid]) => mxid)) // Highest... for (const mxid of sorted) { if (!(mxid in joined)) continue // joined... if (userRegex.some(r => mxid.match(r))) continue // non-sim non-bot... const match = mxid.match(/:(.*)/) assert(match) + /* c8 ignore next - should be already covered by the userRegex test, but let's be explicit */ if (candidates.includes(match[1])) continue // from a different server candidates.push(match[1]) break diff --git a/src/matrix/utils.test.js b/src/matrix/utils.test.js index 0ecd41e..842c513 100644 --- a/src/matrix/utils.test.js +++ b/src/matrix/utils.test.js @@ -1,7 +1,8 @@ // @ts-check +const {select} = require("../passthrough") const {test} = require("supertape") -const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion} = require("./utils") +const {eventSenderIsFromDiscord, getEventIDHash, MatrixStringBuilder, getViaServers, roomHasAtLeastVersion, removeCreatorsFromPowerLevels, setUserPower} = require("./utils") const util = require("util") /** @param {string[]} mxids */ @@ -201,4 +202,219 @@ test("getViaServers: only considers power levels of currently joined members", a t.deepEqual(result, ["cadence.moe", "tractor.invalid", "thecollective.invalid", "selfhosted.invalid"]) }) +test("roomHasAtLeastVersion: v9 < v11", t => { + t.equal(roomHasAtLeastVersion("9", 11), false) +}) + +test("roomHasAtLeastVersion: v12 >= v11", t => { + t.equal(roomHasAtLeastVersion("12", 11), true) +}) + +test("roomHasAtLeastVersion: v12 >= v12", t => { + t.equal(roomHasAtLeastVersion("12", 12), true) +}) + +test("roomHasAtLeastVersion: custom versions never match", t => { + t.equal(roomHasAtLeastVersion("moe.cadence.silly", 11), false) +}) + +test("removeCreatorsFromPowerLevels: removes the creator from a v12 room", t => { + t.deepEqual(removeCreatorsFromPowerLevels({ + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!example", + event_id: "$create", + origin_server_ts: 0, + content: { + room_version: "12" + } + }, { + users: { + "@_ooye_bot:cadence.moe": 100 + } + }), { + users: { + } + }) +}) + +test("removeCreatorsFromPowerLevels: removes all creators from a v12 room", t => { + t.deepEqual(removeCreatorsFromPowerLevels({ + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!example", + event_id: "$create", + origin_server_ts: 0, + content: { + additional_creators: ["@cadence:cadence.moe"], + room_version: "12" + } + }, { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }), { + users: { + } + }) +}) + +test("removeCreatorsFromPowerLevels: doesn't touch a v11 room", t => { + t.deepEqual(removeCreatorsFromPowerLevels({ + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!example", + event_id: "$create", + origin_server_ts: 0, + content: { + additional_creators: ["@cadence:cadence.moe"], + room_version: "11" + } + }, { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }), { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }) +}) + +test("set user power: no-op", async t => { + let called = 0 + await setUserPower("!room", "@cadence:cadence.moe", 0, { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return {} + }, + async getStateEventOuter(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!room", + origin_server_ts: 0, + event_id: "$create", + content: { + room_version: "11" + } + } + }, + /* c8 ignore next 4 */ + async sendState() { + called++ + throw new Error("should not try to send state") + } + }) + t.equal(called, 2) +}) + +test("set user power: bridge bot must promote unprivileged users", async t => { + let called = 0 + await setUserPower("!room", "@cadence:cadence.moe", 100, { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: {"@_ooye_bot:cadence.moe": 100} + } + }, + async getStateEventOuter(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!room", + origin_server_ts: 0, + event_id: "$create", + content: { + room_version: "11" + } + } + }, + async sendState(roomID, type, key, content, mxid) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + t.deepEqual(content, { + users: { + "@_ooye_bot:cadence.moe": 100, + "@cadence:cadence.moe": 100 + } + }) + t.equal(mxid, undefined) + return "$sent" + } + }) + t.equal(called, 3) +}) + +test("set user power: privileged users must demote themselves", async t => { + let called = 0 + await setUserPower("!room", "@cadence:cadence.moe", 0, { + async getStateEvent(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + return { + users: { + "@cadence:cadence.moe": 100, + "@_ooye_bot:cadence.moe": 100 + } + } + }, + async getStateEventOuter(roomID, type, key) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.create") + t.equal(key, "") + return { + type: "m.room.create", + state_key: "", + sender: "@_ooye_bot:cadence.moe", + room_id: "!room", + origin_server_ts: 0, + event_id: "$create", + content: { + room_version: "11" + } + } + }, + async sendState(roomID, type, key, content, mxid) { + called++ + t.equal(roomID, "!room") + t.equal(type, "m.room.power_levels") + t.equal(key, "") + t.deepEqual(content, { + users: {"@_ooye_bot:cadence.moe": 100} + }) + t.equal(mxid, "@cadence:cadence.moe") + return "$sent" + } + }) + t.equal(called, 3) +}) + module.exports.mockGetEffectivePower = mockGetEffectivePower diff --git a/src/web/routes/download-discord.js b/src/web/routes/download-discord.js index bbf33b0..7c8c8e7 100644 --- a/src/web/routes/download-discord.js +++ b/src/web/routes/download-discord.js @@ -31,14 +31,13 @@ function getSnow(event) { /** @type {Map>} */ const cache = new Map() -/** @param {string} url */ +/** @param {string | undefined} url */ function timeUntilExpiry(url) { const params = new URL(url).searchParams const ex = params.get("ex") assert(ex) // refreshed urls from the discord api always include this parameter const time = parseInt(ex, 16)*1000 - Date.now() if (time > 0) return time - return false } function defineMediaProxyHandler(domain) { @@ -71,6 +70,7 @@ function defineMediaProxyHandler(domain) { refreshed = await promise const time = timeUntilExpiry(refreshed) assert(time) // the just-refreshed URL will always be in the future + /* c8 ignore next 3 */ setTimeout(() => { cache.delete(url) }, time).unref() diff --git a/src/web/routes/download-discord.test.js b/src/web/routes/download-discord.test.js index b0b0077..a801c2c 100644 --- a/src/web/routes/download-discord.test.js +++ b/src/web/routes/download-discord.test.js @@ -5,20 +5,6 @@ const {test} = require("supertape") const {router} = require("../../../test/web") const {MatrixServerError} = require("../../matrix/mreq") -const snow = { - channel: { - async refreshAttachmentURLs(attachments) { - if (typeof attachments === "string") attachments = [attachments] - return { - refreshed_urls: attachments.map(a => ({ - original: a, - refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` - })) - } - } - } -} - test("web download discord: access denied if not a known attachment", async t => { const [error] = await tryToCatch(() => router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { @@ -27,7 +13,19 @@ test("web download discord: access denied if not a known attachment", async t => attachment_id: "2", file_name: "image.png" }, - snow + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + if (typeof attachments === "string") attachments = [attachments] + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } }) ) t.ok(error) @@ -42,8 +40,43 @@ test("web download discord: works if a known attachment", async t => { file_name: "image.png" }, event, - snow + snow: { + channel: { + async refreshAttachmentURLs(attachments) { + if (typeof attachments === "string") attachments = [attachments] + return { + refreshed_urls: attachments.map(a => ({ + original: a, + refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}` + })) + } + } + } + } }) t.equal(event.node.res.statusCode, 302) t.match(event.node.res.getHeader("location"), /https:\/\/cdn.discordapp.com\/attachments\/655216173696286746\/1314358913482621010\/image\.png\?ex=/) }) + +test("web download discord: uses cache", async t => { + let notCalled = true + const event = {} + await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", { + params: { + channel_id: "655216173696286746", + attachment_id: "1314358913482621010", + file_name: "image.png" + }, + event, + snow: { + channel: { + // @ts-ignore + async refreshAttachmentURLs(attachments) { + notCalled = false + throw new Error("tried to refresh when it should be in cache") + } + } + } + }) + t.ok(notCalled) +}) diff --git a/src/web/routes/guild.test.js b/src/web/routes/guild.test.js index bb77c12..45869b3 100644 --- a/src/web/routes/guild.test.js +++ b/src/web/routes/guild.test.js @@ -314,6 +314,13 @@ test("api invite: can invite to a moderated guild", async t => { guest_can_join: false, num_joined_members: 2, } + yield { + room_id: spaceID, + children_state: [], + guest_can_join: false, + num_joined_members: 2, + room_type: "m.space" + } }, async sendState(roomID, type, key, content) { called++ diff --git a/src/web/routes/info.js b/src/web/routes/info.js index 0c3e3b1..e83bf89 100644 --- a/src/web/routes/info.js +++ b/src/web/routes/info.js @@ -68,8 +68,7 @@ as.router.get("/api/message", defineEventHandler(async event => { } } if (!matrix_author.displayname) matrix_author.displayname = mxid - if (matrix_author.avatar_url) matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) - else matrix_author.avatar_url = null + matrix_author.avatar_url = mUtils.getPublicUrlForMxc(matrix_author.avatar_url) || null matrix_author["mxid"] = mxid } diff --git a/src/web/routes/link.test.js b/src/web/routes/link.test.js index 291ea8e..440bdfc 100644 --- a/src/web/routes/link.test.js +++ b/src/web/routes/link.test.js @@ -148,7 +148,7 @@ test("web link space: check that inviting user has PL 50", async t => { t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(type, "m.room.power_levels") t.equal(key, "") - return {users: {"@_ooye_bot:cadence.moe": 100}} + return {users: {"@_ooye_bot:cadence.moe": 100}, events: {"m.room.tombstone": 150}} }, async getStateEventOuter(roomID, type, key) { called++ @@ -163,7 +163,7 @@ test("web link space: check that inviting user has PL 50", async t => { event_id: "$create", origin_server_ts: 0, content: { - room_version: "11" + room_version: "12" } } } @@ -194,7 +194,7 @@ test("web link space: successfully adds entry to database and loads page", async t.equal(roomID, "!zTMspHVUBhFLLSdmnS:cadence.moe") t.equal(type, "m.room.power_levels") t.equal(key, "") - return {users: {"@_ooye_bot:cadence.moe": 100, "@cadence:cadence.moe": 50}} + return {users: {"@cadence:cadence.moe": 50}} }, async getStateEventOuter(roomID, type, key) { called++ @@ -204,12 +204,12 @@ test("web link space: successfully adds entry to database and loads page", async return { type: "m.room.create", state_key: "", - sender: "@creator:cadence.moe", + sender: "@_ooye_bot:cadence.moe", room_id: "!zTMspHVUBhFLLSdmnS:cadence.moe", event_id: "$create", origin_server_ts: 0, content: { - room_version: "11" + room_version: "12" } } } diff --git a/src/web/routes/password.test.js b/src/web/routes/password.test.js new file mode 100644 index 0000000..aa60bd3 --- /dev/null +++ b/src/web/routes/password.test.js @@ -0,0 +1,16 @@ +// @ts-check + +const tryToCatch = require("try-to-catch") +const {test} = require("supertape") +const {router} = require("../../../test/web") + +test("web password: stores password", async t => { + const event = {} + await router.test("post", "/api/password", { + body: { + password: "password123" + }, + event + }) + t.equal(event.node.res.statusCode, 302) +}) diff --git a/test/test.js b/test/test.js index 6e2c97b..6470aae 100644 --- a/test/test.js +++ b/test/test.js @@ -140,6 +140,7 @@ file._actuallyUploadDiscordFileToMxc = function(url, res) { throw new Error(`Not require("../src/web/routes/info.test") require("../src/web/routes/link.test") require("../src/web/routes/log-in-with-matrix.test") + require("../src/web/routes/password.test") require("../src/discord/utils.test") require("../src/matrix/kstate.test") require("../src/matrix/api.test")