More code coverage

This commit is contained in:
Cadence Ember
2026-01-10 02:28:18 +13:00
parent 513e67189e
commit 505c41a35e
23 changed files with 735 additions and 102 deletions
+2
View File
@@ -83,3 +83,5 @@ function defineMediaProxyHandler(domain) {
as.router.get(`/download/discordcdn/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("cdn.discordapp.com"))
as.router.get(`/download/discordmedia/:channel_id/:attachment_id/:file_name`, defineMediaProxyHandler("media.discordapp.net"))
module.exports._cache = cache
+31 -16
View File
@@ -1,9 +1,10 @@
// @ts-check
const assert = require("assert").strict
const tryToCatch = require("try-to-catch")
const {test} = require("supertape")
const {router} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
const {_cache} = require("./download-discord")
test("web download discord: access denied if not a known attachment", async t => {
const [error] = await tryToCatch(() =>
@@ -12,19 +13,6 @@ test("web download discord: access denied if not a known attachment", async t =>
channel_id: "1",
attachment_id: "2",
file_name: "image.png"
},
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)}`
}))
}
}
}
}
})
)
@@ -43,7 +31,7 @@ test("web download discord: works if a known attachment", async t => {
snow: {
channel: {
async refreshAttachmentURLs(attachments) {
if (typeof attachments === "string") attachments = [attachments]
assert(Array.isArray(attachments))
return {
refreshed_urls: attachments.map(a => ({
original: a,
@@ -70,7 +58,7 @@ test("web download discord: uses cache", async t => {
event,
snow: {
channel: {
// @ts-ignore
/* c8 ignore next 4 */
async refreshAttachmentURLs(attachments) {
notCalled = false
throw new Error("tried to refresh when it should be in cache")
@@ -80,3 +68,30 @@ test("web download discord: uses cache", async t => {
})
t.ok(notCalled)
})
test("web download discord: refreshes when cache has expired", async t => {
_cache.set(`https://cdn.discordapp.com/attachments/655216173696286746/1314358913482621010/image.png`, Promise.resolve(`https://cdn.discordapp.com/blah?ex=${Math.floor(new Date("2026-01-01").getTime() / 1000 + 3600).toString(16)}`))
let called = 0
await router.test("get", "/download/discordcdn/:channel_id/:attachment_id/:file_name", {
params: {
channel_id: "655216173696286746",
attachment_id: "1314358913482621010",
file_name: "image.png"
},
snow: {
channel: {
async refreshAttachmentURLs(attachments) {
called++
assert(Array.isArray(attachments))
return {
refreshed_urls: attachments.map(a => ({
original: a,
refreshed: a + `?ex=${Math.floor(Date.now() / 1000 + 3600).toString(16)}`
}))
}
}
}
}
})
t.equal(called, 1)
})
+40 -13
View File
@@ -73,23 +73,47 @@ function filterTo(xs, fn) {
return filtered
}
/**
* @param {{type: number, parent_id?: string, position?: number}} channel
* @param {Map<string, {type: number, parent_id?: string, position?: number}>} channels
*/
function getPosition(channel, channels) {
let position = 0
// Categories always appear below un-categorised channels. Their contents can be ordered.
// So categories, and things in them, will have their position multiplied by a big number. The category's big number. The regular position small number sorts within the category.
// Categories are size 2000.
let foundCategory = channel
while (foundCategory.parent_id) {
foundCategory = channels.get(foundCategory.parent_id)
}
if (foundCategory.type === DiscordTypes.ChannelType.GuildCategory) position = (foundCategory.position + 1) * 2000
// Categories always appear above what they contain.
if (channel.type === DiscordTypes.ChannelType.GuildCategory) position -= 0.5
// Within a category, voice channels are always sorted to the bottom. The text/voice split is size 1000 each.
if ([DiscordTypes.ChannelType.GuildVoice, DiscordTypes.ChannelType.GuildStageVoice].includes(channel.type)) position += 1000
// Channels are manually ordered within the text/voice split.
if (typeof channel.position === "number") position += channel.position
// Threads appear below their channel.
if ([DiscordTypes.ChannelType.PublicThread, DiscordTypes.ChannelType.PrivateThread, DiscordTypes.ChannelType.AnnouncementThread].includes(channel.type)) {
position += 0.5
let parent = channels.get(channel.parent_id)
if (parent && parent["position"]) position += parent["position"]
}
return position
}
/**
* @param {DiscordTypes.APIGuild} guild
* @param {Ty.R.Hierarchy[]} rooms
* @param {string[]} roles
*/
function getChannelRoomsLinks(guild, rooms, roles) {
function getPosition(channel) {
let position = 0
let looking = channel
while (looking.parent_id) {
looking = discord.channels.get(looking.parent_id)
position = looking.position * 1000
}
if (channel.position) position += channel.position
return position
}
let channelIDs = discord.guildChannelMap.get(guild.id)
assert(channelIDs)
@@ -97,7 +121,7 @@ function getChannelRoomsLinks(guild, rooms, roles) {
let linkedChannelsWithDetails = linkedChannels.map(c => ({channel: discord.channels.get(c.channel_id), ...c}))
let removedUncachedChannels = filterTo(linkedChannelsWithDetails, c => c.channel)
let linkedChannelIDs = linkedChannelsWithDetails.map(c => c.channel_id)
linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel) - getPosition(b.channel))
linkedChannelsWithDetails.sort((a, b) => getPosition(a.channel, discord.channels) - getPosition(b.channel, discord.channels))
let unlinkedChannelIDs = channelIDs.filter(c => !linkedChannelIDs.includes(c))
/** @type {DiscordTypes.APIGuildChannel[]} */ // @ts-ignore
@@ -107,7 +131,7 @@ function getChannelRoomsLinks(guild, rooms, roles) {
const permissions = dUtils.getPermissions(roles, guild.roles, botID, c["permission_overwrites"])
return dUtils.hasPermission(permissions, DiscordTypes.PermissionFlagsBits.ViewChannel)
})
unlinkedChannels.sort((a, b) => getPosition(a) - getPosition(b))
unlinkedChannels.sort((a, b) => getPosition(a, discord.channels) - getPosition(b, discord.channels))
let linkedRoomIDs = linkedChannels.map(c => c.room_id)
let unlinkedRooms = [...rooms]
@@ -239,3 +263,6 @@ as.router.post("/api/invite", defineEventHandler(async event => {
return sendRedirect(event, "/ok?msg=User has been invited.", 302)
}
}))
module.exports._getPosition = getPosition
module.exports._filterTo = filterTo
+56 -17
View File
@@ -1,8 +1,10 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch")
const {router, test} = require("../../../test/web")
const {MatrixServerError} = require("../../matrix/mreq")
const {_getPosition, _filterTo} = require("./guild")
let nonce
@@ -101,12 +103,6 @@ test("web guild: can view bridged guild when logged in with discord", async t =>
managedGuilds: ["112760669178241024"]
},
api: {
async getStateEvent(roomID, type, key) {
return {}
},
async getMembers(roomID, membership) {
return {chunk: []}
},
async getFullHierarchy(roomID) {
return []
}
@@ -121,12 +117,6 @@ test("web guild: can view bridged guild when logged in with matrix", async t =>
mxid: "@cadence:cadence.moe"
},
api: {
async getStateEvent(roomID, type, key) {
return {}
},
async getMembers(roomID, membership) {
return {chunk: []}
},
async getFullHierarchy(roomID) {
return []
}
@@ -191,12 +181,12 @@ test("api invite: can invite with valid nonce", async t => {
async getStateEvent(roomID, type, key) {
called++
if (type === "m.room.member" && key === "@cadence:cadence.moe") {
return {membership: "leave"}
throw new Error("event not found")
} else if (type === "m.room.power_levels" && key === "") {
return {}
} else {
t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
}
/* c8 ignore next */
t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
},
async getStateEventOuter(roomID, type, key) {
called++
@@ -284,9 +274,9 @@ test("api invite: can invite to a moderated guild", async t => {
return {membership: "leave"}
} else if (type === "m.room.power_levels" && key === "") {
return {}
} else {
t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
}
/* c8 ignore next */
t.fail(`unexpected getStateEvent call. roomID: ${roomID}, type: ${type}, key: ${key}`)
},
async getStateEventOuter(roomID, type, key) {
called++
@@ -362,3 +352,52 @@ test("api invite: does not reinvite joined users", async t => {
t.notOk(error)
t.equal(called, 1)
})
test("position sorting: sorts like discord does", t => {
const channelsList = [{
type: DiscordTypes.ChannelType.GuildText,
id: "first",
position: 0
}, {
type: DiscordTypes.ChannelType.PublicThread,
id: "thread",
parent_id: "first",
}, {
type: DiscordTypes.ChannelType.GuildText,
id: "second",
position: 1
}, {
type: DiscordTypes.ChannelType.GuildVoice,
id: "voice",
position: 0
}, {
type: DiscordTypes.ChannelType.GuildCategory,
id: "category",
position: 0
}, {
type: DiscordTypes.ChannelType.GuildText,
id: "category-first",
parent_id: "category",
position: 0
}, {
type: DiscordTypes.ChannelType.GuildText,
id: "category-second",
parent_id: "category",
position: 1
}, {
type: DiscordTypes.ChannelType.PublicThread,
id: "category-second-thread",
parent_id: "category-second",
}].reverse()
const channels = new Map(channelsList.map(c => [c.id, c]))
const sortedChannelIDs = [...channels.values()].sort((a, b) => _getPosition(a, channels) - _getPosition(b, channels)).map(c => c.id)
t.deepEqual(sortedChannelIDs, ["first", "thread", "second", "voice", "category", "category-first", "category-second", "category-second-thread"])
})
test("filterTo: works", t => {
const fruit = ["apple", "banana", "apricot"]
const rest = _filterTo(fruit, f => f[0] === "b")
t.deepEqual(fruit, ["banana"])
t.deepEqual(rest, ["apple", "apricot"])
})
+3 -5
View File
@@ -70,16 +70,14 @@ as.router.post("/api/link-space", defineEventHandler(async event => {
// Check space ID
if (!session.data.mxid) throw createError({status: 403, message: "Forbidden", data: "Can't link with your Matrix space if you aren't logged in to Matrix"})
const spaceID = parsedBody.space_id
const inviteType = select("invite", "type", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
if (inviteType !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"})
const inviteRow = select("invite", ["mxid", "type"], {mxid: session.data.mxid, room_id: spaceID}).get()
if (!inviteRow || inviteRow.type !== "m.space") throw createError({status: 403, message: "Forbidden", data: "You personally must invite OOYE to that space on Matrix"})
// Check they are not already bridged
const existing = select("guild_space", "guild_id", {}, "WHERE guild_id = ? OR space_id = ?").get(guildID, spaceID)
if (existing) throw createError({status: 400, message: "Bad Request", data: `Guild ID ${guildID} or space ID ${spaceID} are already bridged and cannot be reused`})
const inviteSender = select("invite", "mxid", {mxid: session.data.mxid, room_id: spaceID}).pluck().get()
const inviteSenderServer = inviteSender?.match(/:(.*)/)?.[1]
const via = [inviteSenderServer || ""]
const via = [inviteRow.mxid.match(/:(.*)/)[1]]
// Check space exists and bridge is joined
try {
+3 -2
View File
@@ -39,7 +39,8 @@ test("log in with matrix: sends message to log in", async t => {
let called = 0
await router.test("post", "/api/log-in-with-matrix", {
body: {
mxid: "@cadence:cadence.moe"
mxid: "@cadence:cadence.moe",
next: "https://bridge.cadence.moe/guild?guild_id=123"
},
api: {
async usePrivateChat(mxid) {
@@ -51,7 +52,7 @@ test("log in with matrix: sends message to log in", async t => {
called++
t.equal(roomID, "!created:cadence.moe")
t.equal(type, "m.room.message")
token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)/)[1]
token = content.body.match(/log-in-with-matrix\?token=([a-f0-9-]+)&next=/)[1]
t.ok(token, "log in token not issued")
return ""
}
+28 -16
View File
@@ -2,12 +2,12 @@
const {z} = require("zod")
const {randomUUID} = require("crypto")
const {defineEventHandler, getValidatedQuery, sendRedirect, createError} = require("h3")
const {defineEventHandler, getValidatedQuery, sendRedirect, createError, H3Event} = require("h3")
const {SnowTransfer, tokenless} = require("snowtransfer")
const DiscordTypes = require("discord-api-types/v10")
const getRelativePath = require("get-relative-path")
const {discord, as, db, sync} = require("../../passthrough")
const {as, db, sync} = require("../../passthrough")
const {id, permissions} = require("../../../addbot")
/** @type {import("../auth")} */
const auth = sync.require("../auth")
@@ -33,6 +33,24 @@ const schema = {
})
}
/**
* @param {H3Event} event
* @returns {(string) => {user: {getGuilds: () => Promise<DiscordTypes.RESTGetAPICurrentUserGuildsResult>}}}
*/
function getClient(event) {
/* c8 ignore next */
return event.context.getClient || (accessToken => new SnowTransfer(`Bearer ${accessToken}`))
}
/**
* @param {H3Event} event
* @returns {typeof tokenless.getOauth2Token}
*/
function getOauth2Token(event) {
/* c8 ignore next */
return event.context.getOauth2Token || tokenless.getOauth2Token
}
as.router.get("/oauth", defineEventHandler(async event => {
const session = await auth.useSession(event)
let scope = "guilds"
@@ -61,21 +79,15 @@ as.router.get("/oauth", defineEventHandler(async event => {
if (!savedState) throw createError({status: 400, message: "Missing state", data: "Missing saved state parameter. Please try again, and make sure you have cookies enabled."})
if (savedState != parsedQuery.data.state) return tryAgain()
const oauthResult = await tokenless.getOauth2Token(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
const parsedToken = schema.token.safeParse(oauthResult)
if (!parsedToken.success) {
throw createError({status: 502, message: "Invalid token response", data: `Discord completed OAuth, but returned this instead of an OAuth access token: ${JSON.stringify(oauthResult)}`})
}
const oauthResult = await getOauth2Token(event)(id, redirect_uri, reg.ooye.discord_client_secret, parsedQuery.data.code)
const parsedToken = schema.token.parse(oauthResult)
const userID = Buffer.from(parsedToken.data.access_token.split(".")[0], "base64").toString()
const client = new SnowTransfer(`Bearer ${parsedToken.data.access_token}`)
try {
const guilds = await client.user.getGuilds()
var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
await session.update({managedGuilds, userID, state: undefined})
} catch (e) {
throw createError({status: 502, message: "API call failed", data: e.message})
}
const userID = Buffer.from(parsedToken.access_token.split(".")[0], "base64").toString()
const client = getClient(event)(parsedToken.access_token)
const guilds = await client.user.getGuilds()
var managedGuilds = guilds.filter(g => BigInt(g.permissions) & DiscordTypes.PermissionFlagsBits.ManageGuild).map(g => g.id)
await session.update({managedGuilds, userID, state: undefined})
// Set auto-create for the guild
// @ts-ignore
+121
View File
@@ -0,0 +1,121 @@
// @ts-check
const DiscordTypes = require("discord-api-types/v10")
const tryToCatch = require("try-to-catch")
const assert = require("assert/strict")
const {router, test} = require("../../../test/web")
test("web oauth: redirects to Discord on first visit (add easy)", async t => {
let event = {}
await router.test("get", "/oauth?action=add", {
event,
sessionData: {
password: "password123"
}
})
t.equal(event.node.res.statusCode, 302)
t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/)
})
test("web oauth: redirects to Discord on first visit (add self service)", async t => {
let event = {}
await router.test("get", "/oauth?action=add-self-service", {
event,
sessionData: {
password: "password123"
}
})
t.equal(event.node.res.statusCode, 302)
t.match(event.node.res.getHeader("location"), /^https:\/\/discord.com\/oauth2\/authorize\?client_id=684280192553844747&scope=bot\+guilds&permissions=2251801424568320&response_type=code&redirect_uri=https:\/\/bridge\.example\.org\/oauth&state=/)
})
test("web oauth: advises user about cookies if state is missing", async t => {
let event = {}
const [e] = await tryToCatch(() => router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", {
event
}))
t.equal(e.message, "Missing state")
})
test("web oauth: redirects to Discord again if state doesn't match", async t => {
let event = {}
await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", {
event,
sessionData: {
state: "438aa253-1311-4483-9aa2-c251e29e72c9",
password: "password123"
}
})
t.equal(event.node.res.statusCode, 302)
t.match(event.node.res.getHeader("location"), /^https:\/\/discord\.com\/oauth2\/authorize/)
})
test("web oauth: uses returned state, logs in", async t => {
let event = {}
await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE", {
event,
sessionData: {
state: "693551d5-47c5-49e2-a433-3600abe3c15c",
selfService: false,
password: "password123"
},
getOauth2Token() {
return {
token_type: "Bearer",
access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG",
expires_in: 604800,
refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue",
scope: "bot+guilds"
}
},
getClient(accessToken) {
return {
user: {
async getGuilds() {
return [{
id: "9",
permissions: DiscordTypes.PermissionFlagsBits.ManageGuild
}]
}
}
}
}
})
t.equal(event.node.res.statusCode, 302)
t.equal(event.node.res.getHeader("location"), "./")
})
test("web oauth: uses returned state, adds managed guild", async t => {
let event = {}
await router.test("get", "/oauth?state=693551d5-47c5-49e2-a433-3600abe3c15c&code=DISCORD_CODE&guild_id=9", {
event,
sessionData: {
state: "693551d5-47c5-49e2-a433-3600abe3c15c",
selfService: false,
password: "password123"
},
getOauth2Token() {
return {
token_type: "Bearer",
access_token: "6qrZcUqja7812RVdnEKjpzOL4CvHBFG",
expires_in: 604800,
refresh_token: "D43f5y0ahjqew82jZ4NViEr2YafMKhue",
scope: "bot+guilds"
}
},
getClient(accessToken) {
return {
user: {
async getGuilds() {
return [{
id: "9",
permissions: DiscordTypes.PermissionFlagsBits.ManageGuild
}]
}
}
}
}
})
t.equal(event.node.res.statusCode, 302)
t.equal(event.node.res.getHeader("location"), "guild?guild_id=9")
})