Merge pull request 'Restructure rooms codebase and write tests' (#21) from structure-handlers into main

Reviewed-on: https://git.verdigado.com/NB-Public/rocketchat2matrix/pulls/21
This commit is contained in:
Henrik HerHde Huettemann 2023-06-16 20:04:40 +02:00
commit 4c5d4ce72e
7 changed files with 349 additions and 362 deletions

332
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,7 @@
}, },
"version": "0.1.0", "version": "0.1.0",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.2", "@jest/globals": "^29.5.0",
"@types/n-readlines": "^1.0.3", "@types/n-readlines": "^1.0.3",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^5.59.11", "@typescript-eslint/eslint-plugin": "^5.59.11",

View File

@ -1,10 +1,26 @@
import { expect, jest, test } from '@jest/globals'
import axios from 'axios'
import { IdMapping } from '../entity/IdMapping'
import * as storage from '../helpers/storage'
import { import {
MatrixRoomPresets, MatrixRoomPresets,
MatrixRoomVisibility, MatrixRoomVisibility,
RcRoomTypes, RcRoomTypes,
acceptInvitation,
getCreator,
getFilteredMembers,
inviteMember,
mapRoom, mapRoom,
parseMemberships,
registerRoom,
} from './rooms' } from './rooms'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
jest.mock('../helpers/storage')
const mockedStorage = storage as jest.Mocked<typeof storage>
const roomCreator = { const roomCreator = {
_id: 'roomcreatorid', _id: 'roomcreatorid',
name: 'RoomCreator', name: 'RoomCreator',
@ -13,35 +29,51 @@ const roomCreator = {
__rooms: [], __rooms: [],
} }
test('mapping direct chats', () => { const rcDirectChat = {
expect(
mapRoom({
_id: 'aliceidbobid', _id: 'aliceidbobid',
t: RcRoomTypes.direct, t: RcRoomTypes.direct,
usernames: ['Alice', 'Bob'], usernames: ['Alice', 'Bob'],
uids: ['aliceid', 'bobid'], uids: ['aliceid', 'bobid'],
}) }
).toEqual({
is_direct: true,
preset: MatrixRoomPresets.trusted,
creation_content: {
'm.federate': false,
},
_creatorId: 'aliceid',
})
})
test('mapping public rooms', () => { const rcPublicRoom = {
expect(
mapRoom({
_id: 'randomRoomId', _id: 'randomRoomId',
fname: 'public', fname: 'public',
description: 'Public chat room', description: 'Public chat room',
name: 'public', name: 'public',
t: RcRoomTypes.chat, t: RcRoomTypes.chat,
u: roomCreator, u: roomCreator,
}
const rcPrivateRoom = {
_id: 'privateRoomId',
name: 'private',
fname: 'private',
description: 'Private chat room',
t: RcRoomTypes.private,
u: roomCreator,
}
const sessionOption = {
headers: { Authorization: 'Bearer secretAuthToken' },
testingOption: 'there might be other options',
}
const room_id = '!randomId:my.matrix.host'
test('mapping direct chats', () => {
expect(mapRoom(rcDirectChat)).toEqual({
is_direct: true,
preset: MatrixRoomPresets.trusted,
creation_content: {
'm.federate': false,
},
}) })
).toEqual({ expect(getCreator(rcDirectChat)).toBe('aliceid')
})
test('mapping public rooms', () => {
expect(mapRoom(rcPublicRoom)).toEqual({
preset: MatrixRoomPresets.public, preset: MatrixRoomPresets.public,
room_alias_name: 'public', room_alias_name: 'public',
name: 'public', name: 'public',
@ -50,21 +82,12 @@ test('mapping public rooms', () => {
'm.federate': false, 'm.federate': false,
}, },
visibility: MatrixRoomVisibility.public, visibility: MatrixRoomVisibility.public,
_creatorId: roomCreator._id,
}) })
expect(getCreator(rcPublicRoom)).toBe(roomCreator._id)
}) })
test('mapping private rooms', () => { test('mapping private rooms', () => {
expect( expect(mapRoom(rcPrivateRoom)).toEqual({
mapRoom({
_id: 'privateRoomId',
name: 'private',
fname: 'private',
description: 'Private chat room',
t: RcRoomTypes.private,
u: roomCreator,
})
).toEqual({
preset: MatrixRoomPresets.private, preset: MatrixRoomPresets.private,
room_alias_name: 'private', room_alias_name: 'private',
name: 'private', name: 'private',
@ -72,8 +95,8 @@ test('mapping private rooms', () => {
creation_content: { creation_content: {
'm.federate': false, 'm.federate': false,
}, },
_creatorId: roomCreator._id,
}) })
expect(getCreator(rcPrivateRoom)).toBe(roomCreator._id)
}) })
test('mapping live chats', () => { test('mapping live chats', () => {
@ -81,3 +104,108 @@ test('mapping live chats', () => {
mapRoom({ _id: 'liveChatId', t: RcRoomTypes.live }) mapRoom({ _id: 'liveChatId', t: RcRoomTypes.live })
).toThrowError('Room type l is unknown') ).toThrowError('Room type l is unknown')
}) })
test('getting creator', () => {
expect(getCreator(rcDirectChat)).toBe('aliceid')
expect(getCreator(rcPublicRoom)).toBe(roomCreator._id)
expect(getCreator(rcPrivateRoom)).toBe(roomCreator._id)
})
test('creating memberships for direct chats', async () => {
await expect(parseMemberships(rcDirectChat)).resolves.toBe(undefined)
expect(mockedStorage.createMembership).toHaveBeenCalledWith(
rcDirectChat._id,
rcDirectChat.uids[0]
)
expect(mockedStorage.createMembership).toHaveBeenCalledWith(
rcDirectChat._id,
rcDirectChat.uids[1]
)
expect(mockedStorage.createMembership).toHaveBeenCalledTimes(2)
mockedStorage.createMembership.mockClear()
await expect(
parseMemberships({ ...rcDirectChat, _id: 'hoihoi', uids: ['hoi', 'hoi'] })
).resolves.toBe(undefined)
expect(mockedStorage.createMembership).toHaveBeenCalledWith('hoihoi', 'hoi')
expect(mockedStorage.createMembership).toHaveBeenCalledTimes(1)
})
test('registering room', async () => {
mockedAxios.post.mockResolvedValue({
data: { room_id },
})
expect(await registerRoom(rcPublicRoom, sessionOption)).toBe(room_id)
expect(mockedAxios.post).toHaveBeenCalledWith(
'/_matrix/client/v3/createRoom',
rcPublicRoom,
sessionOption
)
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
mockedAxios.post.mockClear()
})
test('inviting member', async () => {
await expect(
inviteMember('inviteme', room_id, sessionOption)
).resolves.not.toThrow()
expect(mockedAxios.post).toHaveBeenCalledWith(
`/_matrix/client/v3/rooms/${room_id}/invite`,
{ user_id: 'inviteme' },
sessionOption
)
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
mockedAxios.post.mockClear()
})
test('accepting invitation by joining the room', async () => {
await expect(
acceptInvitation(
{
rcId: 'whatever',
matrixId: 'Neo',
accessToken: 'secretAuthToken',
type: 0,
},
room_id
)
).resolves.toBe(undefined)
expect(mockedAxios.post).toHaveBeenCalledWith(
`/_matrix/client/v3/join/${room_id}`,
{},
{ headers: sessionOption.headers }
)
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
mockedAxios.post.mockClear()
})
test('filtering members', async () => {
const members = [
'creator',
'existingUser',
'otherExistingUser',
'excludedUser',
]
function mockMapping(rcId: string, type?: number): IdMapping {
return {
rcId,
matrixId: `@${rcId}:matrix`,
type: type || 0,
accessToken: 'accessToken',
}
}
mockedStorage.getMapping.mockImplementation(async (rcId, type) =>
rcId.includes('excluded') || !rcId ? null : mockMapping(rcId, type)
)
await expect(getFilteredMembers(members, members[0])).resolves.toStrictEqual([
mockMapping('existingUser'),
mockMapping('otherExistingUser'),
])
expect(mockedStorage.getMapping).toBeCalledWith('existingUser', 0)
expect(mockedStorage.getMapping).toBeCalledWith('otherExistingUser', 0)
expect(mockedStorage.getMapping).toBeCalledWith('excludedUser', 0)
})

View File

@ -5,7 +5,11 @@ import {
getMapping, getMapping,
getMemberships, getMemberships,
} from '../helpers/storage' } from '../helpers/storage'
import { axios, getUserSessionOptions } from '../helpers/synapse' import {
axios,
formatUserSessionOptions,
getUserSessionOptions,
} from '../helpers/synapse'
import { RcUser } from './users' import { RcUser } from './users'
export const enum RcRoomTypes { export const enum RcRoomTypes {
@ -47,7 +51,6 @@ export type MatrixRoom = {
is_direct?: boolean is_direct?: boolean
preset?: MatrixRoomPresets preset?: MatrixRoomPresets
visibility?: MatrixRoomVisibility visibility?: MatrixRoomVisibility
_creatorId?: string
} }
export function mapRoom(rcRoom: RcRoom): MatrixRoom { export function mapRoom(rcRoom: RcRoom): MatrixRoom {
@ -55,7 +58,6 @@ export function mapRoom(rcRoom: RcRoom): MatrixRoom {
creation_content: { creation_content: {
'm.federate': false, 'm.federate': false,
}, },
_creatorId: '',
} }
rcRoom.name && (room.name = rcRoom.name) rcRoom.name && (room.name = rcRoom.name)
rcRoom.name && (room.room_alias_name = rcRoom.name) rcRoom.name && (room.room_alias_name = rcRoom.name)
@ -65,18 +67,15 @@ export function mapRoom(rcRoom: RcRoom): MatrixRoom {
case RcRoomTypes.direct: case RcRoomTypes.direct:
room.is_direct = true room.is_direct = true
room.preset = MatrixRoomPresets.trusted room.preset = MatrixRoomPresets.trusted
room._creatorId = rcRoom.uids?.[0] || ''
break break
case RcRoomTypes.chat: case RcRoomTypes.chat:
room.preset = MatrixRoomPresets.public room.preset = MatrixRoomPresets.public
room.visibility = MatrixRoomVisibility.public room.visibility = MatrixRoomVisibility.public
room._creatorId = rcRoom.u?._id || ''
break break
case RcRoomTypes.private: case RcRoomTypes.private:
room.preset = MatrixRoomPresets.private room.preset = MatrixRoomPresets.private
room._creatorId = rcRoom.u?._id || ''
break break
case RcRoomTypes.live: case RcRoomTypes.live:
@ -85,15 +84,23 @@ export function mapRoom(rcRoom: RcRoom): MatrixRoom {
log.error(message) log.error(message)
throw new Error(message) throw new Error(message)
} }
if (!room._creatorId) {
log.warn(
`Creator ID could not be determined for room ${rcRoom.name} of type ${rcRoom.t}.`
)
}
return room return room
} }
export async function parseMemberships(rcRoom: RcRoom) { export function getCreator(rcRoom: RcRoom): string {
if (rcRoom.u && rcRoom.u._id) {
return rcRoom.u._id
} else if (rcRoom.uids && rcRoom.uids.length > 1) {
return rcRoom.uids[0]
} else {
log.warn(
`Creator ID could not be determined for room ${rcRoom.name} of type ${rcRoom.t}. This is normal for the default room.`
)
return ''
}
}
export async function parseMemberships(rcRoom: RcRoom): Promise<void> {
if (rcRoom.t == RcRoomTypes.direct && rcRoom.uids) { if (rcRoom.t == RcRoomTypes.direct && rcRoom.uids) {
await Promise.all( await Promise.all(
[...new Set(rcRoom.uids)] // Deduplicate users [...new Set(rcRoom.uids)] // Deduplicate users
@ -105,62 +112,107 @@ export async function parseMemberships(rcRoom: RcRoom) {
} }
} }
export async function createRoom(rcRoom: RcRoom): Promise<MatrixRoom> { export async function getCreatorSessionOptions(
const room: MatrixRoom = mapRoom(rcRoom) creatorId: string
const creatorId = room._creatorId || '' ): Promise<object> {
delete room._creatorId if (creatorId) {
await parseMemberships(rcRoom)
let sessionOptions = {}
if (room._creatorId) {
try { try {
sessionOptions = await getUserSessionOptions(creatorId) const creatorSessionOptions = await getUserSessionOptions(creatorId)
log.debug('Room user session generated:', sessionOptions) log.debug('Room owner session generated:', creatorSessionOptions)
return creatorSessionOptions
} catch (error) { } catch (error) {
log.warn(error) log.warn(error)
// TODO: Skip room, if it has 0-1 member or is a direct chat?
} }
} }
log.debug('Creating room:', room) return {}
}
room.room_id = ( export async function registerRoom(
await axios.post('/_matrix/client/v3/createRoom', room, sessionOptions) room: MatrixRoom,
creatorSessionOptions: object
): Promise<string> {
return (
await axios.post(
'/_matrix/client/v3/createRoom',
room,
creatorSessionOptions
)
).data.room_id ).data.room_id
}
// TODO: Invite members and let them join export async function inviteMember(
const members = await getMemberships(rcRoom._id) inviteeId: string,
log.info(`Inviting members to room ${rcRoom._id}:`, members) roomId: string,
creatorSessionOptions: object
): Promise<void> {
log.http(`Invite member ${inviteeId}`)
await axios.post(
`/_matrix/client/v3/rooms/${roomId}/invite`,
{ user_id: inviteeId },
creatorSessionOptions
)
}
export async function acceptInvitation(
inviteeMapping: IdMapping,
roomId: string
): Promise<void> {
log.http(
`Accepting invitation for member ${inviteeMapping.rcId} aka. ${inviteeMapping.matrixId}`
)
await axios.post(
`/_matrix/client/v3/join/${roomId}`,
{},
formatUserSessionOptions(inviteeMapping.accessToken || '')
)
}
export async function getFilteredMembers(
rcMemberIds: string[],
creatorId: string
): Promise<IdMapping[]> {
const memberMappings = ( const memberMappings = (
await Promise.all( await Promise.all(
members rcMemberIds
.filter((rcMemberId) => rcMemberId != creatorId) .filter((rcMemberId) => rcMemberId != creatorId)
.map(async (rcMemberId) => await getMapping(rcMemberId, 0)) .map(async (rcMemberId) => await getMapping(rcMemberId, 0))
) )
).filter((memberMapping): memberMapping is IdMapping => memberMapping != null)
return memberMappings
}
export async function createRoom(rcRoom: RcRoom): Promise<MatrixRoom> {
const room: MatrixRoom = mapRoom(rcRoom)
const creatorId = getCreator(rcRoom)
await parseMemberships(rcRoom)
const creatorSessionOptions = await getCreatorSessionOptions(creatorId)
log.debug('Creating room:', room)
room.room_id = await registerRoom(room, creatorSessionOptions)
const rcMemberIds = await getMemberships(rcRoom._id)
const memberMappings = await getFilteredMembers(rcMemberIds, creatorId)
log.info(
`Inviting members to room ${rcRoom._id}:`,
memberMappings.map((mapping) => mapping.matrixId)
)
log.debug(
'Excluded members:',
rcMemberIds.filter(
(x) => !memberMappings.map((mapping) => mapping.rcId).includes(x)
) )
.filter((mapping): mapping is IdMapping => mapping != null)
.map(async (mapping) => {
log.http(`Invite member ${mapping.rcId} aka. ${mapping.matrixId}`)
await axios.post(
`/_matrix/client/v3/rooms/${room.room_id}/invite`,
{ user_id: mapping.matrixId },
sessionOptions
) )
log.http( await Promise.all(
`Accepting invitation for member ${mapping.rcId} aka. ${mapping.matrixId}` memberMappings.map(async (memberMapping) => {
) await inviteMember(
await axios.post( memberMapping.matrixId || '',
`/_matrix/client/v3/join/${room.room_id}`, room.room_id || '',
{}, creatorSessionOptions
{
headers: {
Authorization: `Bearer ${mapping.accessToken}`,
},
}
) )
await acceptInvitation(memberMapping, room.room_id || '')
}) })
)
await Promise.all(memberMappings)
return room return room
} }

View File

@ -0,0 +1,27 @@
import { getUserSessionOptions } from '../helpers/synapse'
import { jest, expect, test } from '@jest/globals'
import { getCreatorSessionOptions } from './rooms'
jest.mock('../helpers/synapse')
const mockedGetUserSessionOptions = getUserSessionOptions as jest.Mocked<
typeof getUserSessionOptions
>
const sessionOption = {
headers: { Authorization: 'Bearer secretAuthToken' },
}
test('getting token for different room creators', async () => {
mockedGetUserSessionOptions.mockImplementation(async (id: string) => {
if (id.includes('excluded')) {
throw new Error(`Could not retrieve access token for ID ${id}`)
}
return sessionOption
})
expect(await getCreatorSessionOptions('')).toStrictEqual({})
expect(await getCreatorSessionOptions('excludedUser')).toStrictEqual({})
expect(await getCreatorSessionOptions('creator')).toStrictEqual(sessionOption)
expect(mockedGetUserSessionOptions).toHaveBeenCalledWith('excludedUser')
expect(mockedGetUserSessionOptions).toHaveBeenCalledWith('creator')
})

View File

@ -1,4 +1,5 @@
process.env.REGISTRATION_SHARED_SECRET = 'ThisIsSoSecretWow' process.env.REGISTRATION_SHARED_SECRET = 'ThisIsSoSecretWow'
import { expect, jest, test } from '@jest/globals'
import axios from 'axios' import axios from 'axios'
import { import {
MatrixUser, MatrixUser,
@ -32,6 +33,7 @@ const nonce = 'test-nonce'
test('mapping users', () => { test('mapping users', () => {
expect(mapUser(rcUser)).toStrictEqual(matrixUser) expect(mapUser(rcUser)).toStrictEqual(matrixUser)
}) })
test('generating correct hmac', () => { test('generating correct hmac', () => {
expect(generateHmac({ ...matrixUser, nonce })).toStrictEqual( expect(generateHmac({ ...matrixUser, nonce })).toStrictEqual(
'be0537407ab3c82de908c5763185556e98a7211c' 'be0537407ab3c82de908c5763185556e98a7211c'

View File

@ -22,10 +22,14 @@ export const whoami = () =>
}) })
}) })
export function formatUserSessionOptions(accessToken: string) {
return { headers: { Authorization: `Bearer ${accessToken}` } }
}
export async function getUserSessionOptions(id: string) { export async function getUserSessionOptions(id: string) {
const accessToken = await getAccessToken(id) const accessToken = await getAccessToken(id)
if (!accessToken) { if (!accessToken) {
throw new Error(`Could not retrieve access token for ID ${id}`) throw new Error(`Could not retrieve access token for ID ${id}`)
} }
return { headers: { Authorization: `Bearer ${accessToken}` } } return formatUserSessionOptions(accessToken)
} }