mirror of
https://github.com/streamwall/streamwall.git
synced 2025-12-06 01:45:37 -05:00
Add tests for auth system, refactors for testing
This commit is contained in:
57
package-lock.json
generated
57
package-lock.json
generated
@@ -3703,6 +3703,12 @@
|
||||
"safe-buffer": "~5.1.1"
|
||||
}
|
||||
},
|
||||
"cookiejar": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz",
|
||||
"integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==",
|
||||
"dev": true
|
||||
},
|
||||
"cookies": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz",
|
||||
@@ -5388,6 +5394,12 @@
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"formidable": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz",
|
||||
"integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==",
|
||||
"dev": true
|
||||
},
|
||||
"fragment-cache": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
|
||||
@@ -8739,6 +8751,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"dev": true
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
|
||||
@@ -11268,6 +11286,45 @@
|
||||
"debug": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"superagent": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
|
||||
"integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"component-emitter": "^1.2.0",
|
||||
"cookiejar": "^2.1.0",
|
||||
"debug": "^3.1.0",
|
||||
"extend": "^3.0.0",
|
||||
"form-data": "^2.3.1",
|
||||
"formidable": "^1.2.0",
|
||||
"methods": "^1.1.1",
|
||||
"mime": "^1.4.1",
|
||||
"qs": "^6.5.1",
|
||||
"readable-stream": "^2.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
|
||||
"integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"supertest": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-4.0.2.tgz",
|
||||
"integrity": "sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^3.8.3"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"jest": "^26.0.1",
|
||||
"prettier": "2.0.5",
|
||||
"style-loader": "^1.2.1",
|
||||
"supertest": "^4.0.2",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-cli": "^3.3.11"
|
||||
},
|
||||
|
||||
@@ -20,8 +20,9 @@ async function hashToken62(token, salt) {
|
||||
}
|
||||
|
||||
// Wrapper for state data to facilitate role-scoped data access.
|
||||
export class StateWrapper {
|
||||
export class StateWrapper extends EventEmitter {
|
||||
constructor(value) {
|
||||
super()
|
||||
this._value = value
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ export class StateWrapper {
|
||||
|
||||
update(value) {
|
||||
this._value = { ...this._value, ...value }
|
||||
this.emit('state', this)
|
||||
}
|
||||
|
||||
// Unprivileged getter
|
||||
@@ -64,10 +66,11 @@ export class StateWrapper {
|
||||
}
|
||||
|
||||
export class Auth extends EventEmitter {
|
||||
constructor({ adminUsername, adminPassword, persistData }) {
|
||||
constructor({ adminUsername, adminPassword, persistData, logEnabled }) {
|
||||
super()
|
||||
this.adminUsername = adminUsername
|
||||
this.adminPassword = adminPassword
|
||||
this.logEnabled = logEnabled || false
|
||||
this.salt = persistData?.salt || rand62(16)
|
||||
this.tokensById = new Map()
|
||||
this.tokensByHash = new Map()
|
||||
@@ -139,8 +142,12 @@ export class Auth extends EventEmitter {
|
||||
this.tokensById.set(id, tokenData)
|
||||
this.tokensByHash.set(tokenHash, tokenData)
|
||||
this.emitState()
|
||||
|
||||
if (this.logEnabled) {
|
||||
console.log(`Created ${kind} token:`, { id, role, name })
|
||||
return secret
|
||||
}
|
||||
|
||||
return { id, secret }
|
||||
}
|
||||
|
||||
deleteToken(tokenId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import yargs from 'yargs'
|
||||
import TOML from '@iarna/toml'
|
||||
import * as Y from 'yjs'
|
||||
@@ -220,6 +221,7 @@ async function main() {
|
||||
adminUsername: argv.control.username,
|
||||
adminPassword: argv.control.password,
|
||||
persistData: persistData.auth,
|
||||
logEnabled: true,
|
||||
})
|
||||
|
||||
let browseWindow = null
|
||||
@@ -265,8 +267,6 @@ async function main() {
|
||||
streamWindow.setViews(viewContentMap)
|
||||
})
|
||||
|
||||
const getInitialState = () => clientState
|
||||
let broadcast = () => {}
|
||||
const onMessage = async (msg, respond) => {
|
||||
if (msg.type === 'set-listening-view') {
|
||||
streamWindow.setListeningView(msg.viewIdx)
|
||||
@@ -305,7 +305,7 @@ async function main() {
|
||||
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
|
||||
streamdelayClient.setCensored(msg.isCensored)
|
||||
} else if (msg.type === 'create-invite') {
|
||||
const secret = await auth.createToken({
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: msg.role,
|
||||
name: msg.name,
|
||||
@@ -318,10 +318,6 @@ async function main() {
|
||||
|
||||
function updateState(newState) {
|
||||
clientState.update(newState)
|
||||
broadcast({
|
||||
type: 'state',
|
||||
state: clientState,
|
||||
})
|
||||
streamWindow.send('state', clientState.info)
|
||||
if (twitchBot) {
|
||||
twitchBot.onState(clientState.info)
|
||||
@@ -329,18 +325,21 @@ async function main() {
|
||||
}
|
||||
|
||||
if (argv.control.address) {
|
||||
;({ broadcast } = await initWebServer({
|
||||
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||
await initWebServer({
|
||||
certDir: argv.cert.dir,
|
||||
certProduction: argv.cert.production,
|
||||
email: argv.cert.email,
|
||||
url: argv.control.address,
|
||||
hostname: argv.control.hostname,
|
||||
port: argv.control.port,
|
||||
logEnabled: true,
|
||||
webDistPath,
|
||||
auth,
|
||||
getInitialState,
|
||||
clientState,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
}))
|
||||
})
|
||||
if (argv.control.open) {
|
||||
shell.openExternal(argv.control.address)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { app } from 'electron'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import url from 'url'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
@@ -17,15 +15,21 @@ import { create as createJSONDiffPatch } from 'jsondiffpatch'
|
||||
|
||||
import { roleCan } from '../roles'
|
||||
|
||||
const SESSION_COOKIE_NAME = 's'
|
||||
|
||||
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||
export const SESSION_COOKIE_NAME = 's'
|
||||
|
||||
const stateDiff = createJSONDiffPatch({
|
||||
objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
|
||||
})
|
||||
|
||||
function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
function initApp({
|
||||
auth,
|
||||
baseURL,
|
||||
webDistPath,
|
||||
clientState,
|
||||
logEnabled,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
}) {
|
||||
const expectedOrigin = new URL(baseURL).origin
|
||||
const sockets = new Set()
|
||||
|
||||
@@ -44,12 +48,12 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
if (!tokenInfo || tokenInfo.kind !== 'invite') {
|
||||
return ctx.throw(403)
|
||||
}
|
||||
const sessionToken = await auth.createToken({
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'session',
|
||||
name: tokenInfo.name,
|
||||
role: tokenInfo.role,
|
||||
})
|
||||
ctx.cookies.set(SESSION_COOKIE_NAME, sessionToken, {
|
||||
ctx.cookies.set(SESSION_COOKIE_NAME, secret, {
|
||||
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
|
||||
overwrite: true,
|
||||
})
|
||||
@@ -71,10 +75,6 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
await next()
|
||||
return
|
||||
}
|
||||
ctx.cookies.set(SESSION_COOKIE_NAME, '', {
|
||||
maxAge: 0,
|
||||
overwrite: true,
|
||||
})
|
||||
}
|
||||
await basicAuthMiddleware(ctx, async () => {
|
||||
ctx.state.identity = auth.admin()
|
||||
@@ -102,11 +102,12 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
const { identity } = ctx.state
|
||||
|
||||
const ws = await ctx.ws()
|
||||
sockets.add({
|
||||
const client = {
|
||||
ws,
|
||||
lastState: null,
|
||||
identity,
|
||||
})
|
||||
}
|
||||
sockets.add(client)
|
||||
|
||||
ws.binaryType = 'arraybuffer'
|
||||
|
||||
@@ -120,32 +121,7 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
})
|
||||
|
||||
ws.on('message', (rawData) => {
|
||||
if (rawData instanceof ArrayBuffer) {
|
||||
if (!roleCan(identity.role, 'mutate-state-doc')) {
|
||||
console.warn(
|
||||
`Unauthorized attempt to edit state doc by "${identity.name}"`,
|
||||
)
|
||||
return
|
||||
}
|
||||
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
|
||||
return
|
||||
}
|
||||
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(rawData)
|
||||
} catch (err) {
|
||||
console.warn('received unexpected ws data:', rawData)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!roleCan(identity.role, msg.type)) {
|
||||
console.warn(
|
||||
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const respond = (responseData) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
@@ -154,43 +130,77 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
JSON.stringify({
|
||||
...responseData,
|
||||
response: true,
|
||||
id: msg.id,
|
||||
id: msg && msg.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (rawData instanceof ArrayBuffer) {
|
||||
if (!roleCan(identity.role, 'mutate-state-doc')) {
|
||||
if (logEnabled) {
|
||||
console.warn(
|
||||
`Unauthorized attempt to edit state doc by "${identity.name}"`,
|
||||
)
|
||||
}
|
||||
respond({
|
||||
error: 'unauthorized',
|
||||
})
|
||||
return
|
||||
}
|
||||
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
msg = JSON.parse(rawData)
|
||||
} catch (err) {
|
||||
if (logEnabled) {
|
||||
console.warn('received unexpected ws data:', rawData)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!roleCan(identity.role, msg.type)) {
|
||||
if (logEnabled) {
|
||||
console.warn(
|
||||
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
|
||||
)
|
||||
}
|
||||
respond({
|
||||
error: 'unauthorized',
|
||||
})
|
||||
return
|
||||
}
|
||||
onMessage(msg, respond)
|
||||
} catch (err) {
|
||||
console.error('failed to handle ws message:', data, err)
|
||||
}
|
||||
})
|
||||
|
||||
const state = getInitialState().view(identity.role)
|
||||
const state = clientState.view(identity.role)
|
||||
ws.send(JSON.stringify({ type: 'state', state }))
|
||||
ws.send(Y.encodeStateAsUpdate(stateDoc))
|
||||
client.lastState = state
|
||||
return
|
||||
}
|
||||
ctx.status = 404
|
||||
}),
|
||||
)
|
||||
|
||||
const broadcast = (origMsg) => {
|
||||
if (origMsg.type !== 'state') {
|
||||
console.warn(`Unexpected ws broadcast type: ${origMsg.type}`)
|
||||
return
|
||||
}
|
||||
clientState.on('state', (state) => {
|
||||
for (const client of sockets) {
|
||||
if (client.ws.readyState !== WebSocket.OPEN) {
|
||||
continue
|
||||
}
|
||||
const state = origMsg.state.view(client.identity.role)
|
||||
const delta = stateDiff.diff(client.lastState, state)
|
||||
client.lastState = state
|
||||
const stateView = state.view(client.identity.role)
|
||||
const delta = stateDiff.diff(client.lastState, stateView)
|
||||
client.lastState = stateView
|
||||
if (!delta) {
|
||||
continue
|
||||
}
|
||||
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
stateDoc.on('update', (update) => {
|
||||
for (const client of sockets) {
|
||||
@@ -213,7 +223,7 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
||||
}
|
||||
})
|
||||
|
||||
return { app, broadcast }
|
||||
return { app }
|
||||
}
|
||||
|
||||
export default async function initWebServer({
|
||||
@@ -223,8 +233,10 @@ export default async function initWebServer({
|
||||
url: baseURL,
|
||||
hostname: overrideHostname,
|
||||
port: overridePort,
|
||||
webDistPath,
|
||||
auth,
|
||||
getInitialState,
|
||||
logEnabled,
|
||||
clientState,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
}) {
|
||||
@@ -236,10 +248,12 @@ export default async function initWebServer({
|
||||
port = overridePort
|
||||
}
|
||||
|
||||
const { app, broadcast } = initApp({
|
||||
const { app } = initApp({
|
||||
auth,
|
||||
baseURL,
|
||||
getInitialState,
|
||||
webDistPath,
|
||||
clientState,
|
||||
logEnabled,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
})
|
||||
@@ -261,5 +275,5 @@ export default async function initWebServer({
|
||||
const listen = promisify(server.listen).bind(server)
|
||||
await listen(port, overrideHostname || hostname)
|
||||
|
||||
return { broadcast }
|
||||
return { server }
|
||||
}
|
||||
|
||||
392
src/node/server.test.js
Normal file
392
src/node/server.test.js
Normal file
@@ -0,0 +1,392 @@
|
||||
// Mock koa middleware that require built statics
|
||||
jest.mock('koa-static', () => () => (ctx, next) => next())
|
||||
jest.mock('koa-views', () => () => (ctx, next) => {
|
||||
ctx.render = async () => {
|
||||
ctx.body = 'mock'
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
import { on, once } from 'events'
|
||||
import supertest from 'supertest'
|
||||
import * as Y from 'yjs'
|
||||
import WebSocket from 'ws'
|
||||
import { patch as patchJSON } from 'jsondiffpatch'
|
||||
import { Auth, StateWrapper } from './auth'
|
||||
import initWebServer, { SESSION_COOKIE_NAME } from './server'
|
||||
import base from 'base-x'
|
||||
|
||||
describe('streamwall server', () => {
|
||||
const adminUsername = 'admin'
|
||||
const adminPassword = 'password'
|
||||
const hostname = 'localhost'
|
||||
const port = 8081
|
||||
const baseURL = `http://${hostname}:${port}`
|
||||
|
||||
let auth
|
||||
let clientState
|
||||
let server
|
||||
let request
|
||||
let stateDoc
|
||||
let onMessage
|
||||
let onMessageCalled
|
||||
let sockets
|
||||
beforeEach(async () => {
|
||||
sockets = []
|
||||
auth = new Auth({
|
||||
adminUsername,
|
||||
adminPassword,
|
||||
})
|
||||
clientState = new StateWrapper({
|
||||
config: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
gridCount: 6,
|
||||
},
|
||||
auth: auth.getState(),
|
||||
streams: [],
|
||||
customStreams: [],
|
||||
views: [],
|
||||
streamdelay: null,
|
||||
})
|
||||
stateDoc = new Y.Doc()
|
||||
onMessageCalled = new Promise((resolve) => {
|
||||
onMessage = jest.fn(resolve)
|
||||
})
|
||||
;({ server } = await initWebServer({
|
||||
url: baseURL,
|
||||
hostname,
|
||||
port,
|
||||
auth,
|
||||
clientState,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
}))
|
||||
request = supertest(server)
|
||||
auth.on('state', (authState) => {
|
||||
clientState.update({ auth: authState })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.close()
|
||||
for (const ws of sockets) {
|
||||
ws.close()
|
||||
}
|
||||
})
|
||||
|
||||
function socket(options) {
|
||||
const ws = new WebSocket(`ws://${hostname}:${port}/ws`, [], {
|
||||
...options,
|
||||
origin: baseURL,
|
||||
})
|
||||
sockets.push(ws)
|
||||
|
||||
const msgs = on(ws, 'message')
|
||||
|
||||
async function recvMsg() {
|
||||
const {
|
||||
value: [data],
|
||||
} = await msgs.next()
|
||||
if (typeof data === 'string') {
|
||||
return JSON.parse(data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function sendMsg(msg) {
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
return { ws, recvMsg, sendMsg }
|
||||
}
|
||||
|
||||
function socketFromSecret(secret) {
|
||||
return socket({
|
||||
headers: { Cookie: `${SESSION_COOKIE_NAME}=${secret}` },
|
||||
})
|
||||
}
|
||||
|
||||
describe('basic auth', () => {
|
||||
it('rejects missing credentials', async () => {
|
||||
await request.get('/').expect(401)
|
||||
})
|
||||
|
||||
it('rejects empty credentials', async () => {
|
||||
await request.get('/').auth('', '').expect(401)
|
||||
})
|
||||
|
||||
it('rejects incorrect credentials', async () => {
|
||||
await request.get('/').auth('wrong', 'creds').expect(401)
|
||||
})
|
||||
|
||||
it('accepts correct credentials', async () => {
|
||||
await request.get('/').auth(adminUsername, adminPassword).expect(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite urls', () => {
|
||||
it('rejects missing token', async () => {
|
||||
await request.get('/invite/').expect(401)
|
||||
})
|
||||
|
||||
it('rejects invalid token', async () => {
|
||||
await request.get('/invite/badtoken').expect(403)
|
||||
})
|
||||
|
||||
it('rejects token of incorrect type', async () => {
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'session',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
await request.get(`/invite/${secret}`).expect(403)
|
||||
})
|
||||
|
||||
it('accepts valid token and creates session cookie', async () => {
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
expect(auth.getState().invites.length).toBe(1)
|
||||
await request.get(`/invite/${secret}`).expect(302)
|
||||
expect(auth.getState().invites.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('token access', () => {
|
||||
it('ignores empty tokens', async () => {
|
||||
await request
|
||||
.get('/')
|
||||
.set('Cookie', `${SESSION_COOKIE_NAME}=`)
|
||||
.expect(401)
|
||||
})
|
||||
|
||||
it('ignores invite tokens', async () => {
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
await request
|
||||
.get('/')
|
||||
.set('Cookie', `${SESSION_COOKIE_NAME}=${secret}`)
|
||||
.expect(401)
|
||||
})
|
||||
|
||||
it('accepts valid tokens', async () => {
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'session',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
await request
|
||||
.get('/')
|
||||
.set('Cookie', `${SESSION_COOKIE_NAME}=${secret}`)
|
||||
.expect(200)
|
||||
})
|
||||
|
||||
it('disconnects websocket on token deletion', async () => {
|
||||
const { id: tokenId, secret } = await auth.createToken({
|
||||
kind: 'session',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
const { recvMsg, ws } = await socketFromSecret(secret)
|
||||
await recvMsg()
|
||||
await recvMsg()
|
||||
expect(ws.readyState === WebSocket.OPEN)
|
||||
auth.deleteToken(tokenId)
|
||||
await once(ws, 'close')
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin role', () => {
|
||||
it('can view tokens', async () => {
|
||||
await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
expect(auth.getState().invites.length).toBe(1)
|
||||
|
||||
const { recvMsg } = await socket({
|
||||
auth: `${adminUsername}:${adminPassword}`,
|
||||
})
|
||||
const firstMsg = await recvMsg()
|
||||
expect(firstMsg.type).toBe('state')
|
||||
expect(firstMsg.state).toHaveProperty('auth')
|
||||
expect(firstMsg.state.auth.invites).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('receives token state updates', async () => {
|
||||
const { recvMsg } = await socket({
|
||||
auth: `${adminUsername}:${adminPassword}`,
|
||||
})
|
||||
const { state } = await recvMsg()
|
||||
expect(state.auth.invites).toHaveLength(0)
|
||||
await recvMsg()
|
||||
|
||||
await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
const stateDelta = await recvMsg()
|
||||
expect(stateDelta.type).toBe('state-delta')
|
||||
expect(stateDelta.delta).toHaveProperty('auth')
|
||||
const updatedState = patchJSON(state, stateDelta.delta)
|
||||
expect(updatedState).toHaveProperty('auth')
|
||||
expect(updatedState.auth.invites).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('can create an invite', async () => {
|
||||
const { recvMsg, sendMsg } = await socket({
|
||||
auth: `${adminUsername}:${adminPassword}`,
|
||||
})
|
||||
await recvMsg()
|
||||
await recvMsg()
|
||||
expect(auth.getState().invites.length).toBe(0)
|
||||
sendMsg({ type: 'create-invite', role: 'operator', name: 'test' })
|
||||
await onMessageCalled
|
||||
expect(
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'create-invite',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
}),
|
||||
expect.any(Function),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('operator role', () => {
|
||||
let secret
|
||||
beforeEach(async () => [
|
||||
({ secret } = await auth.createToken({
|
||||
kind: 'session',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})),
|
||||
])
|
||||
|
||||
it('cannot view tokens', async () => {
|
||||
const { recvMsg } = await socketFromSecret(secret)
|
||||
const firstMsg = await recvMsg()
|
||||
expect(firstMsg.type).toBe('state')
|
||||
expect(firstMsg.state).not.toHaveProperty('auth')
|
||||
})
|
||||
|
||||
it('cannot create invites', async () => {
|
||||
const { recvMsg, sendMsg } = await socketFromSecret(secret)
|
||||
await recvMsg()
|
||||
await recvMsg()
|
||||
sendMsg({ type: 'create-invite', role: 'operator', name: 'test' })
|
||||
const resp = await recvMsg()
|
||||
expect(resp.response).toBe(true)
|
||||
expect(resp.error).toBe('unauthorized')
|
||||
})
|
||||
|
||||
it('does not receive token state updates', async () => {
|
||||
// FIXME: a bit difficult to test the lack of a state update sent; currently, this test triggers a second state update and assumes that if it receives it, the state update for the "auth" property was never sent.
|
||||
const { recvMsg } = await socketFromSecret(secret)
|
||||
await recvMsg()
|
||||
await recvMsg()
|
||||
|
||||
clientState.update({ streams: [{ _id: 'tes' }] })
|
||||
const testUpdate = await recvMsg()
|
||||
expect(testUpdate.type).toBe('state-delta')
|
||||
expect(testUpdate.delta).toHaveProperty('streams')
|
||||
expect(testUpdate.delta).not.toHaveProperty('auth')
|
||||
|
||||
await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: 'operator',
|
||||
name: 'test',
|
||||
})
|
||||
|
||||
clientState.update({ streams: [{ _id: 'tes2' }] })
|
||||
const testUpdate2 = await recvMsg()
|
||||
expect(testUpdate2.type).toBe('state-delta')
|
||||
expect(testUpdate2.delta).toHaveProperty('streams')
|
||||
expect(testUpdate2.delta).not.toHaveProperty('auth')
|
||||
})
|
||||
|
||||
it('can change listening view', async () => {
|
||||
const { recvMsg, sendMsg } = await socketFromSecret(secret)
|
||||
await recvMsg()
|
||||
await recvMsg()
|
||||
sendMsg({ type: 'set-listening-view', viewIdx: 7 })
|
||||
await onMessageCalled
|
||||
expect(
|
||||
expect(onMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'set-listening-view',
|
||||
viewIdx: 7,
|
||||
}),
|
||||
expect.any(Function),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('can mutate state doc', async () => {
|
||||
const { ws, recvMsg } = await socketFromSecret(secret)
|
||||
await recvMsg()
|
||||
|
||||
const doc = new Y.Doc()
|
||||
const yUpdate = await recvMsg()
|
||||
Y.applyUpdate(doc, new Uint8Array(yUpdate), 'server')
|
||||
const updateEvent = on(doc, 'update')
|
||||
doc.getMap('views').set(0, new Y.Map())
|
||||
const {
|
||||
value: [updateToSend],
|
||||
} = await updateEvent.next()
|
||||
ws.send(updateToSend)
|
||||
|
||||
const yUpdate2 = await recvMsg()
|
||||
expect(yUpdate2).toBeInstanceOf(Buffer)
|
||||
})
|
||||
})
|
||||
|
||||
describe('monitor role', () => {
|
||||
let secret
|
||||
beforeEach(async () => [
|
||||
({ secret } = await auth.createToken({
|
||||
kind: 'session',
|
||||
role: 'monitor',
|
||||
name: 'test',
|
||||
})),
|
||||
])
|
||||
|
||||
it('cannot view tokens', async () => {
|
||||
const { recvMsg } = await socketFromSecret(secret)
|
||||
const firstMsg = await recvMsg()
|
||||
expect(firstMsg.type).toBe('state')
|
||||
expect(firstMsg.state).not.toHaveProperty('auth')
|
||||
})
|
||||
|
||||
it('cannot change listening view', async () => {
|
||||
const { recvMsg, sendMsg } = await socketFromSecret(secret)
|
||||
await recvMsg()
|
||||
await recvMsg()
|
||||
sendMsg({ type: 'set-listening-view', viewIdx: 7 })
|
||||
const resp = await recvMsg()
|
||||
expect(resp.response).toBe(true)
|
||||
expect(resp.error).toBe('unauthorized')
|
||||
})
|
||||
|
||||
it('cannot mutate state doc', async () => {
|
||||
const { ws, recvMsg } = await socketFromSecret(secret)
|
||||
await recvMsg()
|
||||
await recvMsg()
|
||||
|
||||
ws.send(new ArrayBuffer())
|
||||
const resp = await recvMsg()
|
||||
expect(resp.response).toBe(true)
|
||||
expect(resp.error).toBe('unauthorized')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user