Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Regex Unit Testing, Refactoring, and Documentation Cleanup #111

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 107 additions & 70 deletions src/authentication.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import cp from 'child_process'
import util from 'util'
import { exec as nodeExec } from 'child_process'
import { promisify } from 'util'
import { RIOT_GAMES_CERT } from './cert.js'

const exec = util.promisify<typeof cp.exec.__promisify__>(cp.exec)
const exec = promisify(nodeExec)

const DEFAULT_NAME = 'LeagueClientUx'
const DEFAULT_POLL_INTERVAL = 2500
Expand Down Expand Up @@ -113,6 +113,24 @@ export class ClientElevatedPermsError extends Error {
}
}

/**
* Indicates that the League Client process arguments could not be parsed
*/
export class ProcessArgsParsingError extends Error {
public readonly rawStdout: string | undefined
public readonly port: string | undefined
public readonly password: string | undefined
public readonly pid: string | undefined

constructor(rawStdout?: string, port?: string, password?: string, pid?: string) {
super('Failed to parse process arguments')
this.rawStdout = rawStdout
this.port = port
this.password = password
this.pid = pid
}
}

/**
* Locates a League Client and retrieves the credentials for the LCU API
* from the found process
Expand All @@ -121,92 +139,111 @@ export class ClientElevatedPermsError extends Error {
* rejection if a League Client is not running
*
* @param {AuthenticationOptions} [options] Authentication options, if any
*
* @throws InvalidPlatformError If the environment is not running
* windows/linux/darwin
* @throws ClientNotFoundError If the League Client could not be found
* @throws ClientElevatedPermsError If the League Client is running as administrator and the script is not (Windows only)
*/
export async function authenticate(options?: AuthenticationOptions): Promise<Credentials> {
async function tryAuthenticate() {
const name = options?.name ?? DEFAULT_NAME
const portRegex = /--app-port=([0-9]+)(?= *"| --)/
const passwordRegex = /--remoting-auth-token=(.+?)(?= *"| --)/
const pidRegex = /--app-pid=([0-9]+)(?= *"| --)/
const isWindows = process.platform === 'win32'

let command: string
if (!isWindows) {
command = `ps x -o args | grep '${name}'`
} else if (isWindows && options?.useDeprecatedWmic === true) {
command = `wmic process where caption='${name}.exe' get commandline`
} else {
command = `Get-CimInstance -Query "SELECT * from Win32_Process WHERE name LIKE '${name}.exe'" | Select-Object -ExpandProperty CommandLine`
}

const executionOptions = isWindows ? { shell: options?.windowsShell ?? ('powershell' as string) } : {}

try {
const { stdout: rawStdout } = await exec(command, executionOptions)
// TODO: investigate regression with calling .replace on rawStdout
// Remove newlines from stdout
const stdout = rawStdout.replace(/\n|\r/g, '')
const [, port] = stdout.match(portRegex)!
const [, password] = stdout.match(passwordRegex)!
const [, pid] = stdout.match(pidRegex)!
const unsafe = options?.unsafe === true
const hasCert = options?.certificate !== undefined

// See flow chart for this here: https://github.com/matsjla/league-connect/pull/44#issuecomment-790384881
// If user specifies certificate, use it
const certificate = hasCert
? options!.certificate
: // Otherwise: does the user want unsafe requests?
unsafe
? undefined
: // Didn't specify, use our own certificate
RIOT_GAMES_CERT

return {
port: Number(port),
pid: Number(pid),
password,
certificate
}
} catch (err) {
if (options?.__internalDebug) console.error(err)
// Check if the user is running the client as an administrator leading to not being able to find the process
// Requires PowerShell 3.0 or higher
if (executionOptions.shell === 'powershell') {
const { stdout: isAdmin } = await exec(
`if ((Get-Process -Name ${name} -ErrorAction SilentlyContinue | Where-Object {!$_.Handle -and !$_.Path})) {Write-Output "True"} else {Write-Output "False"}`,
executionOptions
)
if (isAdmin.includes('True')) throw new ClientElevatedPermsError()
}
throw new ClientNotFoundError()
}
}

// Does not run windows/linux/darwin
// Check if the platform is supported (Winodows, Linux, Darwin/MacOS)
if (!['win32', 'linux', 'darwin'].includes(process.platform)) {
throw new InvalidPlatformError()
}

async function tryAuthenticate(): Promise<Credentials> {
const rawStdout = await getProcessArgs(options)
return parseProcessArgs(rawStdout, options?.unsafe, options?.certificate)
}

if (options?.awaitConnection) {
// Poll until a client is found, attempting to resolve every
// `options.pollInterval` milliseconds
const pollInterval = options?.pollInterval ?? DEFAULT_POLL_INTERVAL
return new Promise(function self(resolve, reject) {
tryAuthenticate()
.then((result) => {
resolve(result)
})
.then(resolve)
.catch((err) => {
if (err instanceof ClientElevatedPermsError) reject(err)
setTimeout(self, options?.pollInterval ?? DEFAULT_POLL_INTERVAL, resolve, reject)
setTimeout(self, pollInterval, resolve, reject)
})
})
}
return tryAuthenticate()
}

/**
* Retrieves the command line arguments for the League Client or options.name if provided.
*
* @param {AuthenticationOptions} [options] Authentication options provided by the user, if any
* @throws {ClientNotFoundError} If the League Client process is not found.
* @throws {ClientElevatedPermsError} If the user is running the client as an administrator, preventing process detection.
* @internal
*/
export async function getProcessArgs(options?: AuthenticationOptions): Promise<string> {
const name = options?.name ?? DEFAULT_NAME
const isWindows = process.platform === 'win32'

let command: string
if (!isWindows) {
command = `ps x -o args | grep '${name}'`
} else if (isWindows && options?.useDeprecatedWmic === true) {
command = `wmic process where caption='${name}.exe' get commandline`
} else {
return tryAuthenticate()
command = `Get-CimInstance -Query "SELECT * from Win32_Process WHERE name LIKE '${name}.exe'" | Select-Object -ExpandProperty CommandLine`
}

const executionOptions = isWindows ? { shell: options?.windowsShell ?? ('powershell' as string) } : {}

try {
const { stdout: rawStdout } = await exec(command, executionOptions)
return rawStdout
} catch (err) {
if (options?.__internalDebug) console.error(err)
// Check if the user is running the client as an administrator leading to not being able to find the process
// Requires PowerShell 3.0 or higher
if (executionOptions.shell === 'powershell') {
const { stdout: isAdmin } = await exec(
`if ((Get-Process -Name ${name} -ErrorAction SilentlyContinue | Where-Object {!$_.Handle -and !$_.Path})) {Write-Output "True"} else {Write-Output "False"}`,
executionOptions
)
if (isAdmin.includes('True')) throw new ClientElevatedPermsError()
}
throw new ClientNotFoundError()
}
}

/**
* Process the command line arguments and return the credentials
*
* @param {string} rawStdout The raw stdout from the command line
* @param {boolean} [unsafe] Does the user want unsafe requests? Default: False
* @param {string} [cert] User specified certificate, if any
* @internal
*/
export function parseProcessArgs(rawStdout: string, unsafe: boolean = false, cert?: string): Credentials {
const portRegex = /--app-port=([0-9]+)(?= *"| --|$)/
const passwordRegex = /--remoting-auth-token=(.+?)(?= *"| --|$)/
const pidRegex = /--app-pid=([0-9]+)(?= *"| --|$)/

// Remove newlines from stdout
const stdout = rawStdout.replace(/\n|\r/g, '')
const port = stdout.match(portRegex)?.[1]
const password = stdout.match(passwordRegex)?.[1]
const pid = stdout.match(pidRegex)?.[1]
if (port === undefined || password === undefined || pid === undefined || isNaN(Number(port)) || isNaN(Number(pid)))
throw new ProcessArgsParsingError(rawStdout, port, password, pid)

/**
* If a user-provided certificate is available, use it. Otherwise, if unsafe requests are allowed, set it to undefined.
* Finally, if neither of those conditions are met, default to the Riot Games certificate.
* See flow chart for this here: https://github.com/matsjla/league-connect/pull/44#issuecomment-790384881
*/
const certificate = cert ?? (unsafe ? undefined : RIOT_GAMES_CERT)

return {
port: Number(port),
pid: Number(pid),
password,
certificate
}
}
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ export {
type AuthenticationOptions,
type Credentials,
ClientNotFoundError,
InvalidPlatformError
InvalidPlatformError,
ClientElevatedPermsError,
ProcessArgsParsingError
} from './authentication.js'
export { LeagueClient, type LeagueClientOptions } from './client.js'
export { createHttp1Request, Http1Response } from './http.js'
Expand All @@ -13,7 +15,8 @@ export {
ConnectionOptions,
LeagueWebSocket,
EventResponse,
EventCallback
EventCallback,
LeagueWebSocketInitError
} from './websocket.js'
export { DEPRECATED_request, DEPRECATED_RequestOptions, DEPRECATED_Response } from './request_deprecated.js'
export { DEPRECATED_connect } from './websocket_deprecated.js'
Expand Down
69 changes: 68 additions & 1 deletion src/tests/authentication.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { authenticate } from '../authentication'
import { Credentials, ProcessArgsParsingError, authenticate, getProcessArgs, parseProcessArgs } from '../authentication'

// Plaintext contents of riotgames.pem, selfsigned cert.
// Yes, this is intentionally supposed to be in the test code.
Expand Down Expand Up @@ -31,7 +31,74 @@ XWehWA==
-----END CERTIFICATE-----
`

describe('validating regex patterns', () => {
let expected: Credentials
beforeEach(() => {
expected = {
port: 12345,
password: 'R69heN3CknTbqW6uUFXyoE',
pid: 1234,
// '\n' makes PLAINTEXT_CERT equal to the Riot Games selfsigned cert
certificate: '\n' + PLAINTEXT_CERT
}
})
test('seperated by spaces', () => {
const stdout = `--app-port=12345 --remoting-auth-token=R69heN3CknTbqW6uUFXyoE --app-pid=1234`
const credentials = parseProcessArgs(stdout)
expect(credentials).toBeDefined()
expect(credentials).toMatchObject(expected)
})
test('surrounded by quotes', () => {
const stdout = `"--app-port=12345" "--remoting-auth-token=R69heN3CknTbqW6uUFXyoE" "--app-pid=1234"`
const credentials = parseProcessArgs(stdout)
expect(credentials).toBeDefined()
expect(credentials).toMatchObject(expected)
})
test('including possible symbols in auth token', () => {
expected.password = 'R69he__CknTbq--uUFXyoE'
const stdout = `--app-port=12345 --remoting-auth-token=R69he__CknTbq--uUFXyoE --app-pid=1234`
parseProcessArgs(stdout)
const credentials = parseProcessArgs(stdout)
expect(credentials).toBeDefined()
expect(credentials).toMatchObject(expected)
expect(credentials).toEqual(expected)
})
test('unsafe cert', () => {
expected.certificate = undefined
const stdout = `--app-port=12345 --remoting-auth-token=R69heN3CknTbqW6uUFXyoE --app-pid=1234`
const credentials = parseProcessArgs(stdout, true)
expect(credentials).toBeDefined()
expect(credentials).toEqual(expected)
expect(credentials.certificate).toBeUndefined()
})
test('custom cert', () => {
expected.certificate = PLAINTEXT_CERT
const stdout = `--app-port=12345 --remoting-auth-token=R69heN3CknTbqW6uUFXyoE --app-pid=1234`
const credentials = parseProcessArgs(stdout, false, PLAINTEXT_CERT)
expect(credentials).toBeDefined()
expect(credentials).toEqual(expected)
expect(credentials.certificate).toEqual(expected.certificate)
})
test('error class returns invalid args', () => {
const stdout = `--app-port=abcde --remoting-auth-token=R69heN3CknTbqW6uUFXyoE --app-pid=1a34`
try {
parseProcessArgs(stdout)
} catch (e: any) {
expect(e).toBeInstanceOf(ProcessArgsParsingError)
expect(e.rawStdout).toEqual(stdout)
expect(e.port).toBeUndefined()
expect(e.password).toEqual('R69heN3CknTbqW6uUFXyoE')
expect(e.pid).toBeUndefined()
}
})
})

/** Requires LCU to be open */
describe('authenticating to the api', () => {
test('getting command line arguments', async () => {
const args = await getProcessArgs()
expect(args).toBeDefined()
})
test('locating the league client', async () => {
const credentials = await authenticate()

Expand Down
9 changes: 6 additions & 3 deletions src/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ describe('league client adapter', () => {
const client = new LeagueClient(credentials)

client.start()
setTimeout(() => {
client.stop()
}, 5000)
await new Promise<void>((resolve) => {
setTimeout(() => {
client.stop()
resolve()
}, 5000)
})
}, 10_000)

// Manual test
Expand Down
Loading