Added Axios + Logging & Testing Frameworks.

Rewrote mojang.ts to use axios. This included creating a more robust error handling
system and response payload structure. Also included unit tests.

Added axios (HTTP Library to replace request)
Added winston (Logging Framework)
Added mocha (Testing Framework)
Added chai (assertion library)
Added nock (mock server)
This commit is contained in:
Daniel Scalzi 2020-04-13 22:21:48 -04:00
parent 761a46060b
commit 9097bafb5d
No known key found for this signature in database
GPG Key ID: D18EA3FB4B142A57
8 changed files with 1338 additions and 168 deletions

856
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,8 @@
"dist:linux": "npm run dist -- LINUX",
"lint": "eslint --ext=jsx,js,tsx,ts src",
"dev": "electron-webpack dev",
"compile": "electron-webpack"
"compile": "electron-webpack",
"test": "cross-env TS_NODE_PROJECT='./tsconfig.test.json' mocha -r ts-node/register test/**/*.ts"
},
"engines": {
"node": "12.x.x"
@ -30,29 +31,37 @@
"dependencies": {
"adm-zip": "^0.4.14",
"async": "^3.2.0",
"axios": "^0.19.2",
"discord-rpc": "3.1.0",
"electron-updater": "^4.2.4",
"fs-extra": "^9.0.0",
"github-syntax-dark": "^0.5.0",
"jquery": "^3.4.1",
"jquery": "^3.5.0",
"moment": "^2.24.0",
"request": "^2.88.2",
"semver": "^7.1.3",
"semver": "^7.2.2",
"tar-fs": "^2.0.0",
"winreg": "^1.2.4"
"triple-beam": "^1.3.0",
"winreg": "^1.2.4",
"winston": "^3.2.1"
},
"devDependencies": {
"@babel/preset-react": "^7.9.4",
"@types/adm-zip": "^0.4.32",
"@types/adm-zip": "^0.4.33",
"@types/async": "^3.0.8",
"@types/chai": "^4.2.11",
"@types/discord-rpc": "^3.0.2",
"@types/fs-extra": "^8.1.0",
"@types/jquery": "^3.3.33",
"@types/mocha": "^7.0.2",
"@types/node": "^12.12.29",
"@types/react": "^16.9.23",
"@types/react-dom": "^16.9.5",
"@types/request": "^2.48.4",
"@types/tar-fs": "^1.16.2",
"@types/triple-beam": "^1.3.0",
"@types/winreg": "^1.2.30",
"chai": "^4.2.0",
"cross-env": "^7.0.2",
"electron": "^8.2.1",
"electron-builder": "^22.4.0",
@ -60,10 +69,13 @@
"electron-webpack-ts": "^4.0.1",
"eslint": "^6.8.0",
"helios-distribution-types": "1.0.0-pre.1",
"mocha": "^7.1.1",
"nock": "^12.0.3",
"react": "^16.13.0",
"react-dom": "^16.13.0",
"react-hot-loader": "^4.12.19",
"rimraf": "^3.0.2",
"ts-node": "^8.8.2",
"typescript": "^3.8.3",
"webpack": "^4.42.0"
},

View File

@ -0,0 +1,40 @@
import { createLogger, format, transports } from 'winston'
import { SPLAT } from 'triple-beam'
import moment from 'moment'
import { inspect } from 'util'
export class LoggerUtil {
public static getLogger(label: string) {
return createLogger({
format: format.combine(
format.label(),
format.colorize(),
format.label({ label }),
format.printf(info => {
if(info[SPLAT]) {
if(info[SPLAT].length === 1 && info[SPLAT][0] instanceof Error) {
const err = info[SPLAT][0] as Error
if(info.message.length > err.message.length && info.message.endsWith(err.message)) {
info.message = info.message.substring(0, info.message.length-err.message.length)
}
} else if(info[SPLAT].length > 0) {
info.message += ' ' + info[SPLAT].map((it: any) => {
if(typeof it === 'object' && it != null) {
return inspect(it, false, null, true)
}
return it
}).join(' ')
}
}
return `[${moment().format('YYYY-MM-DD hh:mm:ss').trim()}] [${info.level}] [${info.label}]: ${info.message}${info.stack ? `\n${info.stack}` : ''}`
})
),
level: 'debug',
transports: [
new transports.Console()
]
})
}
}

View File

@ -1,15 +1,20 @@
import request from 'request'
import { LoggerUtil } from '../loggerutil'
import { LoggerUtil } from '../logging/loggerutil'
import { Agent } from '../model/mojang/auth/Agent'
import { AuthPayload } from '../model/mojang/auth/AuthPayload'
import { Status, StatusColor } from './type/Status'
import axios, { AxiosError } from 'axios'
import { Session } from '../model/mojang/auth/Session'
import { Status } from './type/Status'
import { AuthPayload } from '../model/mojang/auth/AuthPayload'
import { MojangResponse, MojangResponseCode, deciperResponseCode, isInternalError } from './type/Response'
export class Mojang {
private static readonly logger = new LoggerUtil('%c[Mojang]', 'color: #a02d2a; font-weight: bold')
private static readonly logger = LoggerUtil.getLogger('Mojang')
private static readonly TIMEOUT = 2500
public static readonly AUTH_ENDPOINT = 'https://authserver.mojang.com'
public static readonly STATUS_ENDPOINT = 'https://status.mojang.com/check'
public static readonly MINECRAFT_AGENT: Agent = {
name: 'Minecraft',
version: 1
@ -18,37 +23,37 @@ export class Mojang {
protected static statuses: Status[] = [
{
service: 'sessionserver.mojang.com',
status: 'grey',
status: StatusColor.GREY,
name: 'Multiplayer Session Service',
essential: true
},
{
service: 'authserver.mojang.com',
status: 'grey',
status: StatusColor.GREY,
name: 'Authentication Service',
essential: true
},
{
service: 'textures.minecraft.net',
status: 'grey',
status: StatusColor.GREY,
name: 'Minecraft Skins',
essential: false
},
{
service: 'api.mojang.com',
status: 'grey',
status: StatusColor.GREY,
name: 'Public API',
essential: false
},
{
service: 'minecraft.net',
status: 'grey',
status: StatusColor.GREY,
name: 'Minecraft.net',
essential: false
},
{
service: 'account.mojang.com',
status: 'grey',
status: StatusColor.GREY,
name: 'Mojang Accounts Website',
essential: false
}
@ -58,24 +63,50 @@ export class Mojang {
* Converts a Mojang status color to a hex value. Valid statuses
* are 'green', 'yellow', 'red', and 'grey'. Grey is a custom status
* to our project which represents an unknown status.
*
* @param {string} status A valid status code.
* @returns {string} The hex color of the status code.
*/
public static statusToHex(status: string){
switch(status.toLowerCase()){
case 'green':
case StatusColor.GREEN:
return '#a5c325'
case 'yellow':
case StatusColor.YELLOW:
return '#eac918'
case 'red':
case StatusColor.RED:
return '#c32625'
case 'grey':
case StatusColor.GREY:
default:
return '#848484'
}
}
private static handleAxiosError<T>(operation: string, error: AxiosError, dataProvider: () => T): MojangResponse<T> {
const response: MojangResponse<T> = {
data: dataProvider(),
responseCode: MojangResponseCode.ERROR,
error
}
if(error.response) {
response.responseCode = deciperResponseCode(error.response.data)
Mojang.logger.error(`Error during ${operation} request (HTTP Response ${error.response.status})`, error)
Mojang.logger.debug('Response Details:')
Mojang.logger.debug('Data:', error.response.data)
Mojang.logger.debug('Headers:', error.response.headers)
} else if(error.request) {
Mojang.logger.error(`${operation} request recieved no response.`, error)
} else {
Mojang.logger.error(`Error during ${operation} request.`, error)
}
response.isInternalError = isInternalError(response.responseCode)
return response
}
private static expectSpecificSuccess(operation: string, expected: number, actual: number) {
if(actual !== expected) {
Mojang.logger.warn(`${operation} expected ${expected} response, recieved ${actual}.`)
}
}
/**
* Retrieves the status of Mojang's services.
* The response is condensed into a single object. Each service is
@ -84,40 +115,40 @@ export class Mojang {
*
* @see http://wiki.vg/Mojang_API#API_Status
*/
public static status(): Promise<Status[]>{
return new Promise((resolve, reject) => {
request.get('https://status.mojang.com/check',
{
json: true,
timeout: 2500
},
function(error, response, body: {[service: string]: 'red' | 'yellow' | 'green'}[]){
public static async status(): Promise<MojangResponse<Status[]>>{
try {
if(error || response.statusCode !== 200){
Mojang.logger.warn('Unable to retrieve Mojang status.')
Mojang.logger.debug('Error while retrieving Mojang statuses:', error)
//reject(error || response.statusCode)
const res = await axios.get<{[service: string]: StatusColor}[]>(Mojang.STATUS_ENDPOINT, { timeout: Mojang.TIMEOUT })
Mojang.expectSpecificSuccess('Mojang Status', 200, res.status)
res.data.forEach(status => {
const entry = Object.entries(status)[0]
for(let i=0; i<Mojang.statuses.length; i++) {
Mojang.statuses[i].status = 'grey'
if(Mojang.statuses[i].service === entry[0]) {
Mojang.statuses[i].status = entry[1]
break
}
resolve(Mojang.statuses)
} else {
for(let i=0; i<body.length; i++){
const key = Object.keys(body[i])[0]
inner:
for(let j=0; j<Mojang.statuses.length; j++){
if(Mojang.statuses[j].service === key) {
Mojang.statuses[j].status = body[i][key]
break inner
}
}
}
resolve(Mojang.statuses)
}
})
return {
data: Mojang.statuses,
responseCode: MojangResponseCode.SUCCESS
}
} catch(error) {
return Mojang.handleAxiosError('Mojang Status', error as AxiosError, () => {
for(let i=0; i<Mojang.statuses.length; i++){
Mojang.statuses[i].status = StatusColor.GREY
}
return Mojang.statuses
})
}
}
/**
* Authenticate a user with their Mojang credentials.
*
@ -129,43 +160,37 @@ export class Mojang {
*
* @see http://wiki.vg/Authentication#Authenticate
*/
public static authenticate(
public static async authenticate(
username: string,
password: string,
clientToken: string | null,
requestUser: boolean = true,
agent: Agent = Mojang.MINECRAFT_AGENT
): Promise<Session> {
return new Promise((resolve, reject) => {
): Promise<MojangResponse<Session | null>> {
const body: AuthPayload = {
try {
const data: AuthPayload = {
agent,
username,
password,
requestUser
}
if(clientToken != null){
body.clientToken = clientToken
data.clientToken = clientToken
}
request.post(Mojang.AUTH_ENDPOINT + '/authenticate',
{
json: true,
body
},
function(error, response, body){
if(error){
Mojang.logger.error('Error during authentication.', error)
reject(error)
} else {
if(response.statusCode === 200){
resolve(body)
} else {
reject(body || {code: 'ENOTFOUND'})
const res = await axios.post<Session>(`${Mojang.AUTH_ENDPOINT}/authenticate`, data)
Mojang.expectSpecificSuccess('Mojang Authenticate', 200, res.status)
return {
data: res.data,
responseCode: MojangResponseCode.SUCCESS
}
} catch(err) {
return Mojang.handleAxiosError('Mojang Authenticate', err, () => null)
}
})
})
}
/**
@ -177,30 +202,33 @@ export class Mojang {
*
* @see http://wiki.vg/Authentication#Validate
*/
public static validate(accessToken: string, clientToken: string): Promise<boolean> {
return new Promise((resolve, reject) => {
request.post(Mojang.AUTH_ENDPOINT + '/validate',
{
json: true,
body: {
public static async validate(accessToken: string, clientToken: string): Promise<MojangResponse<boolean>> {
try {
const data = {
accessToken,
clientToken
}
},
function(error, response, body){
if(error){
Mojang.logger.error('Error during validation.', error)
reject(error)
} else {
if(response.statusCode === 403){
resolve(false)
} else {
// 204 if valid
resolve(true)
const res = await axios.post(`${Mojang.AUTH_ENDPOINT}/validate`, data)
Mojang.expectSpecificSuccess('Mojang Validate', 204, res.status)
return {
data: res.status === 204,
responseCode: MojangResponseCode.SUCCESS
}
} catch(err) {
if(err.response && err.response.status === 403) {
return {
data: false,
responseCode: MojangResponseCode.SUCCESS
}
}
})
})
return Mojang.handleAxiosError('Mojang Validate', err, () => false)
}
}
/**
@ -212,29 +240,27 @@ export class Mojang {
*
* @see http://wiki.vg/Authentication#Invalidate
*/
public static invalidate(accessToken: string, clientToken: string): Promise<void>{
return new Promise((resolve, reject) => {
request.post(Mojang.AUTH_ENDPOINT + '/invalidate',
{
json: true,
body: {
public static async invalidate(accessToken: string, clientToken: string): Promise<MojangResponse<undefined>> {
try {
const data = {
accessToken,
clientToken
}
},
function(error, response, body){
if(error){
Mojang.logger.error('Error during invalidation.', error)
reject(error)
} else {
if(response.statusCode === 204){
resolve()
} else {
reject(body)
const res = await axios.post(`${Mojang.AUTH_ENDPOINT}/invalidate`, data)
Mojang.expectSpecificSuccess('Mojang Invalidate', 204, res.status)
return {
data: undefined,
responseCode: MojangResponseCode.SUCCESS
}
} catch(err) {
return Mojang.handleAxiosError('Mojang Invalidate', err, () => undefined)
}
})
})
}
/**
@ -248,30 +274,28 @@ export class Mojang {
*
* @see http://wiki.vg/Authentication#Refresh
*/
public static refresh(accessToken: string, clientToken: string, requestUser: boolean = true): Promise<Session> {
return new Promise((resolve, reject) => {
request.post(Mojang.AUTH_ENDPOINT + '/refresh',
{
json: true,
body: {
public static async refresh(accessToken: string, clientToken: string, requestUser: boolean = true): Promise<MojangResponse<Session | null>> {
try {
const data = {
accessToken,
clientToken,
requestUser
}
},
function(error, response, body){
if(error){
Mojang.logger.error('Error during refresh.', error)
reject(error)
} else {
if(response.statusCode === 200){
resolve(body)
} else {
reject(body)
const res = await axios.post<Session>(`${Mojang.AUTH_ENDPOINT}/refresh`, data)
Mojang.expectSpecificSuccess('Mojang Refresh', 200, res.status)
return {
data: res.data,
responseCode: MojangResponseCode.SUCCESS
}
}
})
})
} catch(err) {
return Mojang.handleAxiosError('Mojang Refresh', err, () => null)
}
}
}

View File

@ -0,0 +1,87 @@
import { AxiosError } from "axios"
/**
* @see https://wiki.vg/Authentication#Errors
*/
export enum MojangResponseCode {
SUCCESS,
ERROR,
ERROR_METHOD_NOT_ALLOWED, // INTERNAL
ERROR_NOT_FOUND, // INTERNAL
ERROR_USER_MIGRATED,
ERROR_INVALID_CREDENTIALS,
ERROR_RATELIMIT,
ERROR_INVALID_TOKEN,
ERROR_ACCESS_TOKEN_HAS_PROFILE, // ??
ERROR_CREDENTIALS_ARE_NULL, // INTERNAL
ERROR_INVALID_SALT_VERSION, // ??
ERROR_UNSUPPORTED_MEDIA_TYPE // INTERNAL
}
export interface MojangResponse<T> {
data: T
responseCode: MojangResponseCode
error?: AxiosError
isInternalError?: boolean
}
export function deciperResponseCode(body: { error: string, errorMessage: string, cause?: string }): MojangResponseCode {
if(body.error === 'Method Not Allowed') {
return MojangResponseCode.ERROR_METHOD_NOT_ALLOWED
} else if(body.error === 'Not Found') {
return MojangResponseCode.ERROR_NOT_FOUND
} else if(body.error === 'Unsupported Media Type') {
return MojangResponseCode.ERROR_UNSUPPORTED_MEDIA_TYPE
} else if(body.error === 'ForbiddenOperationException') {
if(body.cause && body.cause === 'UserMigratedException') {
return MojangResponseCode.ERROR_USER_MIGRATED
}
if(body.errorMessage === 'Invalid credentials. Invalid username or password.') {
return MojangResponseCode.ERROR_INVALID_CREDENTIALS
} else if(body.errorMessage === 'Invalid credentials.') {
return MojangResponseCode.ERROR_RATELIMIT
} else if(body.errorMessage === 'Invalid token.') {
return MojangResponseCode.ERROR_INVALID_TOKEN
}
} else if(body.error === 'IllegalArgumentException') {
if(body.errorMessage === 'Access token already has a profile assigned.') {
return MojangResponseCode.ERROR_ACCESS_TOKEN_HAS_PROFILE
} else if(body.errorMessage === 'credentials is null') {
return MojangResponseCode.ERROR_CREDENTIALS_ARE_NULL
} else if(body.errorMessage === 'Invalid salt version') {
return MojangResponseCode.ERROR_INVALID_SALT_VERSION
}
}
return MojangResponseCode.ERROR
}
// These indicate problems with the code and not the data.
export function isInternalError(responseCode: MojangResponseCode): boolean {
switch(responseCode) {
// We've sent the wrong method to an endpoint. (ex. GET to POST)
case MojangResponseCode.ERROR_METHOD_NOT_ALLOWED:
// Indicates endpoint has changed. (404)
case MojangResponseCode.ERROR_NOT_FOUND:
// Selecting profiles isn't implemented yet. (Shouldnt happen)
case MojangResponseCode.ERROR_ACCESS_TOKEN_HAS_PROFILE:
// Username/password was not submitted. (UI should forbid this)
case MojangResponseCode.ERROR_CREDENTIALS_ARE_NULL:
// ??? (Shouldnt happen)
case MojangResponseCode.ERROR_INVALID_SALT_VERSION:
// Data was not submitted as application/json
case MojangResponseCode.ERROR_UNSUPPORTED_MEDIA_TYPE:
return true
default:
return false
}
}

View File

@ -1,7 +1,14 @@
export enum StatusColor {
RED = 'red',
YELLOW = 'yellow',
GREEN = 'green',
GREY = 'grey'
}
export interface Status {
service: string
status: 'red' | 'yellow' | 'green' | 'grey'
status: StatusColor
name: string
essential: boolean

177
test/mojang/mojangTest.ts Normal file
View File

@ -0,0 +1,177 @@
import { Mojang } from "../../src/main/mojang/mojang"
import { expect } from 'chai'
import nock from 'nock'
import { URL } from 'url'
import { Session } from "../../src/main/model/mojang/auth/Session"
import { MojangResponseCode } from "../../src/main/mojang/type/Response"
function expectMojangResponse(res: any, responseCode: MojangResponseCode, negate = false) {
expect(res).to.not.be.an('error')
expect(res).to.be.an('object')
expect(res).to.have.property('responseCode')
if(!negate) {
expect(res.responseCode).to.equal(responseCode)
} else {
expect(res.responseCode).to.not.equal(responseCode)
}
}
describe('Mojang Errors', () => {
it('Status (Offline)', async () => {
const defStatusHack = Mojang['statuses']
const url = new URL(Mojang.STATUS_ENDPOINT)
nock(url.origin)
.get(url.pathname)
.reply(500, 'Service temprarily offline.')
const res = await Mojang.status();
expectMojangResponse(res, MojangResponseCode.SUCCESS, true)
expect(res.data).to.be.an('array')
expect(res.data).to.deep.equal(defStatusHack)
}).timeout(2500)
it('Authenticate (Invalid Credentials)', async () => {
nock(Mojang.AUTH_ENDPOINT)
.post('/authenticate')
.reply(403, (uri, requestBody: any): { error: string, errorMessage: string } => {
return {
error: 'ForbiddenOperationException',
errorMessage: 'Invalid credentials. Invalid username or password.'
}
})
const res = await Mojang.authenticate('user', 'pass', 'xxx', true)
expectMojangResponse(res, MojangResponseCode.ERROR_INVALID_CREDENTIALS)
expect(res.data).to.be.a('null')
expect(res.error).to.not.be.a('null')
})
})
describe('Mojang Status', () => {
it('Status (Online)', async () => {
const defStatusHack = Mojang['statuses']
const url = new URL(Mojang.STATUS_ENDPOINT)
nock(url.origin)
.get(url.pathname)
.reply(200, defStatusHack)
const res = await Mojang.status();
expectMojangResponse(res, MojangResponseCode.SUCCESS)
expect(res.data).to.be.an('array')
expect(res.data).to.deep.equal(defStatusHack)
}).timeout(2500)
})
describe('Mojang Auth', () => {
it('Authenticate', async () => {
nock(Mojang.AUTH_ENDPOINT)
.post('/authenticate')
.reply(200, (uri, requestBody: any): Session => {
const mockResponse: Session = {
accessToken: 'abc',
clientToken: requestBody.clientToken,
selectedProfile: {
id: 'def',
name: 'username'
}
}
if(requestBody.requestUser) {
mockResponse.user = {
id: 'def',
properties: []
}
}
return mockResponse
})
const res = await Mojang.authenticate('user', 'pass', 'xxx', true)
expectMojangResponse(res, MojangResponseCode.SUCCESS)
expect(res.data!.clientToken).to.equal('xxx')
expect(res.data).to.have.property('user')
})
it('Validate', async () => {
nock(Mojang.AUTH_ENDPOINT)
.post('/validate')
.times(2)
.reply((uri, requestBody: any) => {
return [
requestBody.accessToken === 'abc' ? 204 : 403
]
})
const res = await Mojang.validate('abc', 'def')
expectMojangResponse(res, MojangResponseCode.SUCCESS)
expect(res.data).to.be.a('boolean')
expect(res.data).to.equal(true)
const res2 = await Mojang.validate('def', 'def')
expectMojangResponse(res2, MojangResponseCode.SUCCESS)
expect(res2.data).to.be.a('boolean')
expect(res2.data).to.equal(false)
})
it('Invalidate', async () => {
nock(Mojang.AUTH_ENDPOINT)
.post('/invalidate')
.reply(204)
const res = await Mojang.invalidate('adc', 'def')
expectMojangResponse(res, MojangResponseCode.SUCCESS)
})
it('Refresh', async () => {
nock(Mojang.AUTH_ENDPOINT)
.post('/refresh')
.reply(200, (uri, requestBody: any): Session => {
const mockResponse: Session = {
accessToken: 'abc',
clientToken: requestBody.clientToken,
selectedProfile: {
id: 'def',
name: 'username'
}
}
if(requestBody.requestUser) {
mockResponse.user = {
id: 'def',
properties: []
}
}
return mockResponse
})
const res = await Mojang.refresh('gfd', 'xxx', true)
expectMojangResponse(res, MojangResponseCode.SUCCESS)
expect(res.data!.clientToken).to.equal('xxx')
expect(res.data).to.have.property('user')
})
})

7
tsconfig.test.json Normal file
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true
},
"extends": "./tsconfig.json"
}