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"
|
"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": {
|
"cookies": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz",
|
||||||
@@ -5388,6 +5394,12 @@
|
|||||||
"mime-types": "^2.1.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": {
|
"fragment-cache": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
|
"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": {
|
"mime-db": {
|
||||||
"version": "1.44.0",
|
"version": "1.44.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz",
|
||||||
@@ -11268,6 +11286,45 @@
|
|||||||
"debug": "^4.1.0"
|
"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": {
|
"supports-color": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"jest": "^26.0.1",
|
"jest": "^26.0.1",
|
||||||
"prettier": "2.0.5",
|
"prettier": "2.0.5",
|
||||||
"style-loader": "^1.2.1",
|
"style-loader": "^1.2.1",
|
||||||
|
"supertest": "^4.0.2",
|
||||||
"webpack": "^4.43.0",
|
"webpack": "^4.43.0",
|
||||||
"webpack-cli": "^3.3.11"
|
"webpack-cli": "^3.3.11"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ async function hashToken62(token, salt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wrapper for state data to facilitate role-scoped data access.
|
// Wrapper for state data to facilitate role-scoped data access.
|
||||||
export class StateWrapper {
|
export class StateWrapper extends EventEmitter {
|
||||||
constructor(value) {
|
constructor(value) {
|
||||||
|
super()
|
||||||
this._value = value
|
this._value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export class StateWrapper {
|
|||||||
|
|
||||||
update(value) {
|
update(value) {
|
||||||
this._value = { ...this._value, ...value }
|
this._value = { ...this._value, ...value }
|
||||||
|
this.emit('state', this)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unprivileged getter
|
// Unprivileged getter
|
||||||
@@ -64,10 +66,11 @@ export class StateWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Auth extends EventEmitter {
|
export class Auth extends EventEmitter {
|
||||||
constructor({ adminUsername, adminPassword, persistData }) {
|
constructor({ adminUsername, adminPassword, persistData, logEnabled }) {
|
||||||
super()
|
super()
|
||||||
this.adminUsername = adminUsername
|
this.adminUsername = adminUsername
|
||||||
this.adminPassword = adminPassword
|
this.adminPassword = adminPassword
|
||||||
|
this.logEnabled = logEnabled || false
|
||||||
this.salt = persistData?.salt || rand62(16)
|
this.salt = persistData?.salt || rand62(16)
|
||||||
this.tokensById = new Map()
|
this.tokensById = new Map()
|
||||||
this.tokensByHash = new Map()
|
this.tokensByHash = new Map()
|
||||||
@@ -139,8 +142,12 @@ export class Auth extends EventEmitter {
|
|||||||
this.tokensById.set(id, tokenData)
|
this.tokensById.set(id, tokenData)
|
||||||
this.tokensByHash.set(tokenHash, tokenData)
|
this.tokensByHash.set(tokenHash, tokenData)
|
||||||
this.emitState()
|
this.emitState()
|
||||||
console.log(`Created ${kind} token:`, { id, role, name })
|
|
||||||
return secret
|
if (this.logEnabled) {
|
||||||
|
console.log(`Created ${kind} token:`, { id, role, name })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, secret }
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteToken(tokenId) {
|
deleteToken(tokenId) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
import yargs from 'yargs'
|
import yargs from 'yargs'
|
||||||
import TOML from '@iarna/toml'
|
import TOML from '@iarna/toml'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
@@ -220,6 +221,7 @@ async function main() {
|
|||||||
adminUsername: argv.control.username,
|
adminUsername: argv.control.username,
|
||||||
adminPassword: argv.control.password,
|
adminPassword: argv.control.password,
|
||||||
persistData: persistData.auth,
|
persistData: persistData.auth,
|
||||||
|
logEnabled: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
let browseWindow = null
|
let browseWindow = null
|
||||||
@@ -265,8 +267,6 @@ async function main() {
|
|||||||
streamWindow.setViews(viewContentMap)
|
streamWindow.setViews(viewContentMap)
|
||||||
})
|
})
|
||||||
|
|
||||||
const getInitialState = () => clientState
|
|
||||||
let broadcast = () => {}
|
|
||||||
const onMessage = async (msg, respond) => {
|
const onMessage = async (msg, respond) => {
|
||||||
if (msg.type === 'set-listening-view') {
|
if (msg.type === 'set-listening-view') {
|
||||||
streamWindow.setListeningView(msg.viewIdx)
|
streamWindow.setListeningView(msg.viewIdx)
|
||||||
@@ -305,7 +305,7 @@ async function main() {
|
|||||||
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
|
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
|
||||||
streamdelayClient.setCensored(msg.isCensored)
|
streamdelayClient.setCensored(msg.isCensored)
|
||||||
} else if (msg.type === 'create-invite') {
|
} else if (msg.type === 'create-invite') {
|
||||||
const secret = await auth.createToken({
|
const { secret } = await auth.createToken({
|
||||||
kind: 'invite',
|
kind: 'invite',
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
name: msg.name,
|
name: msg.name,
|
||||||
@@ -318,10 +318,6 @@ async function main() {
|
|||||||
|
|
||||||
function updateState(newState) {
|
function updateState(newState) {
|
||||||
clientState.update(newState)
|
clientState.update(newState)
|
||||||
broadcast({
|
|
||||||
type: 'state',
|
|
||||||
state: clientState,
|
|
||||||
})
|
|
||||||
streamWindow.send('state', clientState.info)
|
streamWindow.send('state', clientState.info)
|
||||||
if (twitchBot) {
|
if (twitchBot) {
|
||||||
twitchBot.onState(clientState.info)
|
twitchBot.onState(clientState.info)
|
||||||
@@ -329,18 +325,21 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (argv.control.address) {
|
if (argv.control.address) {
|
||||||
;({ broadcast } = await initWebServer({
|
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||||
|
await initWebServer({
|
||||||
certDir: argv.cert.dir,
|
certDir: argv.cert.dir,
|
||||||
certProduction: argv.cert.production,
|
certProduction: argv.cert.production,
|
||||||
email: argv.cert.email,
|
email: argv.cert.email,
|
||||||
url: argv.control.address,
|
url: argv.control.address,
|
||||||
hostname: argv.control.hostname,
|
hostname: argv.control.hostname,
|
||||||
port: argv.control.port,
|
port: argv.control.port,
|
||||||
|
logEnabled: true,
|
||||||
|
webDistPath,
|
||||||
auth,
|
auth,
|
||||||
getInitialState,
|
clientState,
|
||||||
onMessage,
|
onMessage,
|
||||||
stateDoc,
|
stateDoc,
|
||||||
}))
|
})
|
||||||
if (argv.control.open) {
|
if (argv.control.open) {
|
||||||
shell.openExternal(argv.control.address)
|
shell.openExternal(argv.control.address)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { app } from 'electron'
|
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import path from 'path'
|
|
||||||
import url from 'url'
|
import url from 'url'
|
||||||
import http from 'http'
|
import http from 'http'
|
||||||
import https from 'https'
|
import https from 'https'
|
||||||
@@ -17,15 +15,21 @@ import { create as createJSONDiffPatch } from 'jsondiffpatch'
|
|||||||
|
|
||||||
import { roleCan } from '../roles'
|
import { roleCan } from '../roles'
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 's'
|
export const SESSION_COOKIE_NAME = 's'
|
||||||
|
|
||||||
const webDistPath = path.join(app.getAppPath(), 'web')
|
|
||||||
|
|
||||||
const stateDiff = createJSONDiffPatch({
|
const stateDiff = createJSONDiffPatch({
|
||||||
objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
|
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 expectedOrigin = new URL(baseURL).origin
|
||||||
const sockets = new Set()
|
const sockets = new Set()
|
||||||
|
|
||||||
@@ -44,12 +48,12 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
|||||||
if (!tokenInfo || tokenInfo.kind !== 'invite') {
|
if (!tokenInfo || tokenInfo.kind !== 'invite') {
|
||||||
return ctx.throw(403)
|
return ctx.throw(403)
|
||||||
}
|
}
|
||||||
const sessionToken = await auth.createToken({
|
const { secret } = await auth.createToken({
|
||||||
kind: 'session',
|
kind: 'session',
|
||||||
name: tokenInfo.name,
|
name: tokenInfo.name,
|
||||||
role: tokenInfo.role,
|
role: tokenInfo.role,
|
||||||
})
|
})
|
||||||
ctx.cookies.set(SESSION_COOKIE_NAME, sessionToken, {
|
ctx.cookies.set(SESSION_COOKIE_NAME, secret, {
|
||||||
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
|
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
})
|
})
|
||||||
@@ -71,10 +75,6 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
|||||||
await next()
|
await next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.cookies.set(SESSION_COOKIE_NAME, '', {
|
|
||||||
maxAge: 0,
|
|
||||||
overwrite: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
await basicAuthMiddleware(ctx, async () => {
|
await basicAuthMiddleware(ctx, async () => {
|
||||||
ctx.state.identity = auth.admin()
|
ctx.state.identity = auth.admin()
|
||||||
@@ -102,11 +102,12 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
|||||||
const { identity } = ctx.state
|
const { identity } = ctx.state
|
||||||
|
|
||||||
const ws = await ctx.ws()
|
const ws = await ctx.ws()
|
||||||
sockets.add({
|
const client = {
|
||||||
ws,
|
ws,
|
||||||
lastState: null,
|
lastState: null,
|
||||||
identity,
|
identity,
|
||||||
})
|
}
|
||||||
|
sockets.add(client)
|
||||||
|
|
||||||
ws.binaryType = 'arraybuffer'
|
ws.binaryType = 'arraybuffer'
|
||||||
|
|
||||||
@@ -120,43 +121,55 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ws.on('message', (rawData) => {
|
ws.on('message', (rawData) => {
|
||||||
|
let msg
|
||||||
|
const respond = (responseData) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
...responseData,
|
||||||
|
response: true,
|
||||||
|
id: msg && msg.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (rawData instanceof ArrayBuffer) {
|
if (rawData instanceof ArrayBuffer) {
|
||||||
if (!roleCan(identity.role, 'mutate-state-doc')) {
|
if (!roleCan(identity.role, 'mutate-state-doc')) {
|
||||||
console.warn(
|
if (logEnabled) {
|
||||||
`Unauthorized attempt to edit state doc by "${identity.name}"`,
|
console.warn(
|
||||||
)
|
`Unauthorized attempt to edit state doc by "${identity.name}"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
respond({
|
||||||
|
error: 'unauthorized',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
|
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg
|
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(rawData)
|
msg = JSON.parse(rawData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('received unexpected ws data:', rawData)
|
if (logEnabled) {
|
||||||
|
console.warn('received unexpected ws data:', rawData)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!roleCan(identity.role, msg.type)) {
|
if (!roleCan(identity.role, msg.type)) {
|
||||||
console.warn(
|
if (logEnabled) {
|
||||||
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
|
console.warn(
|
||||||
)
|
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
|
||||||
return
|
)
|
||||||
}
|
|
||||||
const respond = (responseData) => {
|
|
||||||
if (ws.readyState !== WebSocket.OPEN) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
ws.send(
|
respond({
|
||||||
JSON.stringify({
|
error: 'unauthorized',
|
||||||
...responseData,
|
})
|
||||||
response: true,
|
return
|
||||||
id: msg.id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onMessage(msg, respond)
|
onMessage(msg, respond)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -164,33 +177,30 @@ function initApp({ auth, baseURL, getInitialState, onMessage, stateDoc }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const state = getInitialState().view(identity.role)
|
const state = clientState.view(identity.role)
|
||||||
ws.send(JSON.stringify({ type: 'state', state }))
|
ws.send(JSON.stringify({ type: 'state', state }))
|
||||||
ws.send(Y.encodeStateAsUpdate(stateDoc))
|
ws.send(Y.encodeStateAsUpdate(stateDoc))
|
||||||
|
client.lastState = state
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.status = 404
|
ctx.status = 404
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const broadcast = (origMsg) => {
|
clientState.on('state', (state) => {
|
||||||
if (origMsg.type !== 'state') {
|
|
||||||
console.warn(`Unexpected ws broadcast type: ${origMsg.type}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const client of sockets) {
|
for (const client of sockets) {
|
||||||
if (client.ws.readyState !== WebSocket.OPEN) {
|
if (client.ws.readyState !== WebSocket.OPEN) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const state = origMsg.state.view(client.identity.role)
|
const stateView = state.view(client.identity.role)
|
||||||
const delta = stateDiff.diff(client.lastState, state)
|
const delta = stateDiff.diff(client.lastState, stateView)
|
||||||
client.lastState = state
|
client.lastState = stateView
|
||||||
if (!delta) {
|
if (!delta) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
|
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
stateDoc.on('update', (update) => {
|
stateDoc.on('update', (update) => {
|
||||||
for (const client of sockets) {
|
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({
|
export default async function initWebServer({
|
||||||
@@ -223,8 +233,10 @@ export default async function initWebServer({
|
|||||||
url: baseURL,
|
url: baseURL,
|
||||||
hostname: overrideHostname,
|
hostname: overrideHostname,
|
||||||
port: overridePort,
|
port: overridePort,
|
||||||
|
webDistPath,
|
||||||
auth,
|
auth,
|
||||||
getInitialState,
|
logEnabled,
|
||||||
|
clientState,
|
||||||
onMessage,
|
onMessage,
|
||||||
stateDoc,
|
stateDoc,
|
||||||
}) {
|
}) {
|
||||||
@@ -236,10 +248,12 @@ export default async function initWebServer({
|
|||||||
port = overridePort
|
port = overridePort
|
||||||
}
|
}
|
||||||
|
|
||||||
const { app, broadcast } = initApp({
|
const { app } = initApp({
|
||||||
auth,
|
auth,
|
||||||
baseURL,
|
baseURL,
|
||||||
getInitialState,
|
webDistPath,
|
||||||
|
clientState,
|
||||||
|
logEnabled,
|
||||||
onMessage,
|
onMessage,
|
||||||
stateDoc,
|
stateDoc,
|
||||||
})
|
})
|
||||||
@@ -261,5 +275,5 @@ export default async function initWebServer({
|
|||||||
const listen = promisify(server.listen).bind(server)
|
const listen = promisify(server.listen).bind(server)
|
||||||
await listen(port, overrideHostname || hostname)
|
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