Test invite interaction & code coverage

This commit is contained in:
Cadence Ember 2024-09-30 00:51:55 +13:00
parent 65170c1282
commit bad8c5b8c2
15 changed files with 407 additions and 161 deletions

View file

@ -58,3 +58,13 @@ test("orm: from: join direction works", t => {
const hasNoOwnerInner = from("sim").join("sim_proxy", "user_id", "inner").select("user_id", "proxy_owner_id").where({sim_name: "crunch_god"}).get()
t.deepEqual(hasNoOwnerInner, undefined)
})
test("orm: select unsafe works (to select complex column names that can't be type verified)", t => {
const results = from("member_cache")
.join("member_power", "mxid")
.join("channel_room", "room_id") // only include rooms that are bridged
.and("where member_power.room_id = '*' and member_cache.power_level != member_power.power_level")
.selectUnsafe("mxid", "member_cache.room_id", "member_power.power_level")
.all()
t.equal(results[0].power_level, 100)
})

View file

@ -1,115 +0,0 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const Ty = require("../../types")
const {discord, sync, db, select, from, as} = require("../../passthrough")
const assert = require("assert/strict")
/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {Map<string, Promise<{name: string, value: string}[]>>} spaceID -> list of rooms */
const cache = new Map()
/** @type {Map<string, string>} roomID -> spaceID */
const reverseCache = new Map()
// Manage clearing the cache
sync.addTemporaryListener(as, "type:m.room.name", /** @param {Ty.Event.StateOuter<Ty.Event.M_Room_Name>} event */ async event => {
if (event.state_key !== "") return
const roomID = event.room_id
const spaceID = reverseCache.get(roomID)
if (!spaceID) return
const childRooms = await cache.get(spaceID)
if (!childRooms) return
if (event.content.name) {
const found = childRooms.find(r => r.value === roomID)
if (!found) return
found.name = event.content.name
} else {
cache.set(spaceID, Promise.resolve(childRooms.filter(r => r.value !== roomID)))
reverseCache.delete(roomID)
}
})
// Manage adding to the cache
async function getCachedHierarchy(spaceID) {
return cache.get(spaceID) || (() => {
const entry = (async () => {
const result = await api.getFullHierarchy(spaceID)
/** @type {{name: string, value: string}[]} */
const childRooms = []
for (const room of result) {
if (room.name && !room.name.match(/^\[[⛓️🔊]\]/) && room.room_type !== "m.space") {
childRooms.push({name: room.name, value: room.room_id})
reverseCache.set(room.room_id, spaceID)
}
}
return childRooms
})()
cache.set(spaceID, entry)
return entry
})()
}
/** @param {DiscordTypes.APIApplicationCommandAutocompleteGuildInteraction} interaction */
async function interactAutocomplete({id, token, data, guild_id}) {
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
if (!spaceID) {
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
data: {
choices: [
{
name: `Error: This server needs to be bridged somewhere first...`,
value: "baby"
}
]
}
})
}
let rooms = await getCachedHierarchy(spaceID)
// @ts-ignore
rooms = rooms.filter(r => r.name.includes(data.options[0].value))
await discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ApplicationCommandAutocompleteResult,
data: {
choices: rooms
}
})
}
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction} interaction */
async function interactSubmit({id, token, data, guild_id}) {
const spaceID = select("guild_space", "space_id", {guild_id}).pluck().get()
if (!spaceID) {
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Error: This server needs to be bridged somewhere first...",
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
return discord.snow.interaction.createInteractionResponse(id, token, {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Valid input. This would do something but it isn't implemented yet.",
flags: DiscordTypes.MessageFlags.Ephemeral
}
})
}
/** @param {DiscordTypes.APIGuildInteraction} interaction */
async function interact(interaction) {
if (interaction.type === DiscordTypes.InteractionType.ApplicationCommandAutocomplete) {
return interactAutocomplete(interaction)
} else if (interaction.type === DiscordTypes.InteractionType.ApplicationCommand) {
// @ts-ignore
return interactSubmit(interaction)
}
}
module.exports.interact = interact

View file

@ -14,13 +14,14 @@ const api = sync.require("../../matrix/api")
/**
* @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction
* @param {{api: typeof api}} di
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
*/
async function _interact({data, channel, guild_id}) {
async function _interact({data, channel, guild_id}, {api}) {
// Get named MXID
/** @type {DiscordTypes.APIApplicationCommandInteractionDataStringOption[] | undefined} */ // @ts-ignore
const options = data.options
const input = options?.[0].value || ""
const input = options?.[0]?.value || ""
const mxid = input.match(/@([^:]+):([a-z0-9:-]+\.[a-z0-9.:-]+)/)?.[0]
if (!mxid) return {
type: DiscordTypes.InteractionResponseType.ChannelMessageWithSource,
@ -110,9 +111,10 @@ async function _interact({data, channel, guild_id}) {
/**
* @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction
* @param {{api: typeof api}} di
* @returns {Promise<DiscordTypes.APIInteractionResponse>}
*/
async function _interactButton({channel, message}) {
async function _interactButton({channel, message}, {api}) {
const mxid = message.content.match(/`(@(?:[^:]+):(?:[a-z0-9:-]+\.[a-z0-9.:-]+))`/)?.[1]
assert(mxid)
const roomID = select("channel_room", "room_id", {channel_id: channel.id}).pluck().get()
@ -127,14 +129,16 @@ async function _interactButton({channel, message}) {
}
}
/* c8 ignore start */
/** @param {DiscordTypes.APIChatInputApplicationCommandGuildInteraction & {channel: DiscordTypes.APIGuildTextChannel}} interaction */
async function interact(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction))
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interact(interaction, {api}))
}
/** @param {DiscordTypes.APIMessageComponentGuildInteraction} interaction */
async function interactButton(interaction) {
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction))
await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, await _interactButton(interaction, {api}))
}
module.exports.interact = interact

View file

@ -0,0 +1,228 @@
const {test} = require("supertape")
const DiscordTypes = require("discord-api-types/v10")
const {db, discord} = require("../../passthrough")
const {MatrixServerError} = require("../../matrix/mreq")
const {_interact, _interactButton} = require("./invite")
test("invite: checks for missing matrix ID", async t => {
const msg = await _interact({
data: {
options: []
},
channel: discord.channels.get("0"),
guild_id: "112760669178241024"
}, {})
t.equal(msg.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
})
test("invite: checks for invalid matrix ID", async t => {
const msg = await _interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence"
}]
},
channel: discord.channels.get("0"),
guild_id: "112760669178241024"
}, {})
t.equal(msg.data.content, "You have to say the Matrix ID of the person you want to invite. Matrix IDs look like this: `@username:example.org`")
})
test("invite: checks if channel exists or is autocreatable", async t => {
db.prepare("UPDATE guild_active SET autocreate = 0").run()
const msg = await _interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence:cadence.moe"
}]
},
channel: discord.channels.get("498323546729086986"),
guild_id: "112760669178241024"
}, {})
t.equal(msg.data.content, "This channel isn't bridged, so you can't invite Matrix users yet. Try turning on automatic room-creation or link a Matrix room in the website.")
db.prepare("UPDATE guild_active SET autocreate = 1").run()
})
test("invite: checks if user is already invited to space", async t => {
let called = 0
const msg = await _interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence:cadence.moe"
}]
},
channel: discord.channels.get("112760669178241024"),
guild_id: "112760669178241024"
}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe")
return {
displayname: "cadence",
membership: "invite"
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` already has an invite, which they haven't accepted yet.")
t.equal(called, 1)
})
test("invite: invites if user is not in space", async t => {
let called = 0
const msg = await _interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence:cadence.moe"
}]
},
channel: discord.channels.get("112760669178241024"),
guild_id: "112760669178241024"
}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe")
throw new MatrixServerError("State event doesn't exist or something")
},
inviteToRoom: async (roomID, mxid) => {
called++
t.equal(roomID, "!jjWAGMeQdNrVZSSfvz:cadence.moe") // space ID
t.equal(mxid, "@cadence:cadence.moe")
}
}
})
t.equal(msg.data.content, "You invited `@cadence:cadence.moe` to the server.")
t.equal(called, 2)
})
test("invite: prompts to invite to room (if never joined)", async t => {
let called = 0
const msg = await _interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence:cadence.moe"
}]
},
channel: discord.channels.get("112760669178241024"),
guild_id: "112760669178241024"
}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe")
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID
return {
displayname: "cadence",
membership: "join"
}
} else {
throw new MatrixServerError("State event doesn't exist or something")
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
t.equal(called, 2)
})
test("invite: prompts to invite to room (if left)", async t => {
let called = 0
const msg = await _interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence:cadence.moe"
}]
},
channel: discord.channels.get("112760669178241024"),
guild_id: "112760669178241024"
}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe")
if (roomID === "!jjWAGMeQdNrVZSSfvz:cadence.moe") { // space ID
return {
displayname: "cadence",
membership: "join"
}
} else {
return {
displayname: "cadence",
membership: "leave"
}
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?")
t.equal(called, 2)
})
test("invite button: invites to room when button clicked", async t => {
let called = 0
const msg = await _interactButton({
channel: discord.channels.get("112760669178241024"),
message: {
content: "`@cadence:cadence.moe` is already in this server. Would you like to additionally invite them to this specific channel?"
}
}, {
api: {
inviteToRoom: async (roomID, mxid) => {
called++
t.equal(roomID, "!kLRqKKUQXcibIMtOpl:cadence.moe") // room ID
t.equal(mxid, "@cadence:cadence.moe")
}
}
})
t.equal(msg.data.content, "You invited `@cadence:cadence.moe` to the channel.")
t.equal(called, 1)
})
test("invite: no-op if in room and space", async t => {
let called = 0
const msg = await _interact({
data: {
options: [{
name: "user",
type: DiscordTypes.ApplicationCommandOptionType.String,
value: "@cadence:cadence.moe"
}]
},
channel: discord.channels.get("112760669178241024"),
guild_id: "112760669178241024"
}, {
api: {
getStateEvent: async (roomID, type, stateKey) => {
called++
t.equal(type, "m.room.member")
t.equal(stateKey, "@cadence:cadence.moe")
return {
displayname: "cadence",
membership: "join"
}
}
}
})
t.equal(msg.data.content, "`@cadence:cadence.moe` is already in this server and this channel.")
t.equal(called, 2)
})

View file

@ -7,7 +7,6 @@ const {id} = require("../../addbot")
const matrixInfo = sync.require("./interactions/matrix-info.js")
const invite = sync.require("./interactions/invite.js")
const permissions = sync.require("./interactions/permissions.js")
const bridge = sync.require("./interactions/bridge.js")
const reactions = sync.require("./interactions/reactions.js")
const privacy = sync.require("./interactions/privacy.js")
@ -39,20 +38,6 @@ discord.snow.interaction.bulkOverwriteApplicationCommands(id, [{
name: "user"
}
]
}, {
name: "bridge",
contexts: [DiscordTypes.InteractionContextType.Guild],
type: DiscordTypes.ApplicationCommandType.ChatInput,
description: "Start bridging this channel to a Matrix room",
default_member_permissions: String(DiscordTypes.PermissionFlagsBits.ManageChannels),
options: [
{
type: DiscordTypes.ApplicationCommandOptionType.String,
description: "Destination room to bridge to",
name: "room",
autocomplete: true
}
]
}, {
name: "privacy",
contexts: [DiscordTypes.InteractionContextType.Guild],
@ -94,8 +79,6 @@ async function dispatchInteraction(interaction) {
await permissions.interact(interaction)
} else if (interactionId === "permissions_edit") {
await permissions.interactEdit(interaction)
} else if (interactionId === "bridge") {
await bridge.interact(interaction)
} else if (interactionId === "Reactions") {
await reactions.interact(interaction)
} else if (interactionId === "privacy") {

View file

@ -113,7 +113,7 @@ function isWebhookMessage(message) {
* @param {Pick<DiscordTypes.APIMessage, "flags">} message
*/
function isEphemeralMessage(message) {
return message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral)
return Boolean(message.flags && (message.flags & DiscordTypes.MessageFlags.Ephemeral))
}
/** @param {string} snowflake */

View file

@ -84,6 +84,67 @@ test("getPermissions: channel overwrite to allow role works", t => {
t.equal((permissions & want), want)
})
test("getPermissions: channel overwrite to allow user works", t => {
const guildRoles = [
{
version: 1695412489043,
unicode_emoji: null,
tags: {},
position: 0,
permissions: "559623605571137",
name: "@everyone",
mentionable: false,
managed: false,
id: "1154868424724463687",
icon: null,
hoist: false,
flags: 0,
color: 0
},
{
version: 1695412604262,
unicode_emoji: null,
tags: { bot_id: "466378653216014359" },
position: 1,
permissions: "536995904",
name: "PluralKit",
mentionable: false,
managed: true,
id: "1154868908336099444",
icon: null,
hoist: false,
flags: 0,
color: 0
},
{
version: 1698778936921,
unicode_emoji: null,
tags: {},
position: 1,
permissions: "536870912",
name: "web hookers",
mentionable: false,
managed: false,
id: "1168988246680801360",
icon: null,
hoist: false,
flags: 0,
color: 0
}
]
const userRoles = []
const userID = "353373325575323648"
const overwrites = [
{ type: 0, id: "1154868908336099444", deny: "0", allow: "1024" },
{ type: 0, id: "1154868424724463687", deny: "1024", allow: "0" },
{ type: 0, id: "1168988246680801360", deny: "0", allow: "1024" },
{ type: 1, id: "353373325575323648", deny: "0", allow: "1024" }
]
const permissions = utils.getPermissions(userRoles, guildRoles, userID, overwrites)
const want = BigInt(1 << 10 | 1 << 16)
t.equal((permissions & want), want)
})
test("hasSomePermissions: detects the permission", t => {
const userPermissions = DiscordTypes.PermissionFlagsBits.MentionEveryone | DiscordTypes.PermissionFlagsBits.BanMembers
const canRemoveMembers = utils.hasSomePermissions(userPermissions, ["KickMembers", "BanMembers"])
@ -107,3 +168,15 @@ test("hasAllPermissions: doesn't detect not the permissions", t => {
const canRemoveMembers = utils.hasAllPermissions(userPermissions, ["KickMembers", "BanMembers"])
t.equal(canRemoveMembers, false)
})
test("isEphemeralMessage: detects ephemeral message", t => {
t.equal(utils.isEphemeralMessage(data.special_message.ephemeral_message), true)
})
test("isEphemeralMessage: doesn't detect normal message", t => {
t.equal(utils.isEphemeralMessage(data.message.simple_plaintext), false)
})
test("getPublicUrlForCdn: no-op on non-discord URL", t => {
t.equal(utils.getPublicUrlForCdn("https://cadence.moe"), "https://cadence.moe")
})

View file

@ -1,12 +0,0 @@
// @ts-check
const {test} = require("supertape")
const power = require("./power")
test("power: get affected rooms", t => {
t.deepEqual(power._getAffectedRooms(), [{
mxid: "@test_auto_invite:example.org",
power_level: 100,
room_id: "!kLRqKKUQXcibIMtOpl:cadence.moe",
}])
})

View file

@ -9,7 +9,7 @@ const registrationFilePath = path.join(process.cwd(), "registration.yaml")
/** @param {import("../types").AppServiceRegistrationConfig} reg */
function checkRegistration(reg) {
reg["ooye"].invite = (reg.ooye.invite || []).filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
reg["ooye"].invite = reg.ooye.invite.filter(mxid => mxid.endsWith(`:${reg.ooye.server_name}`)) // one day I will understand why typescript disagrees with dot notation on this line
assert(reg.ooye?.max_file_size)
assert(reg.ooye?.namespace_prefix)
assert(reg.ooye?.server_name)
@ -19,6 +19,7 @@ function checkRegistration(reg) {
assert.match(reg.url, /^https?:/, "url must start with http:// or https://")
}
/* c8 ignore next 4 */
/** @param {import("../types").AppServiceRegistrationConfig} reg */
function writeRegistration(reg) {
fs.writeFileSync(registrationFilePath, JSON.stringify(reg, null, 2))
@ -52,6 +53,7 @@ function getTemplateRegistration(serverName) {
socket: 6693,
ooye: {
namespace_prefix,
server_name: serverName,
max_file_size: 5000000,
content_length_workaround: false,
include_user_id_in_mxid: false,
@ -66,6 +68,8 @@ function readRegistration() {
try {
const content = fs.readFileSync(registrationFilePath, "utf8")
result = JSON.parse(content)
result.ooye.invite ||= []
/* c8 ignore next */
} catch (e) {}
return result
}

View file

@ -1,5 +1,8 @@
// @ts-check
const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
const {reg} = require("./read-registration")
const {reg, checkRegistration, getTemplateRegistration} = require("./read-registration")
test("reg: has necessary parameters", t => {
const propertiesToCheck = ["sender_localpart", "id", "as_token", "ooye"]
@ -8,3 +11,19 @@ test("reg: has necessary parameters", t => {
propertiesToCheck
)
})
test("check: passes on sample", t => {
checkRegistration(reg)
t.pass("all assertions passed")
})
test("check: fails on template as template is missing some required values that are gathered during setup", t => {
let err
try {
// @ts-ignore
checkRegistration(getTemplateRegistration("cadence.moe"))
} catch (e) {
err = e
}
t.ok(err, "one of the assertions failed as expected")
})

7
src/types.d.ts vendored
View file

@ -55,9 +55,10 @@ export type InitialAppServiceRegistrationConfig = {
socket?: string | number,
ooye: {
namespace_prefix: string
max_file_size: number,
content_length_workaround: boolean,
invite: string[],
server_name: string
max_file_size: number
content_length_workaround: boolean
invite: string[]
include_user_id_in_mxid: boolean
}
}