Skip to content

Commit

Permalink
Merge pull request #302 from TaloDev/develop
Browse files Browse the repository at this point in the history
Release 0.34.0
  • Loading branch information
tudddorrr authored Jun 14, 2024
2 parents 4c0fb06 + 551a40e commit 3fdb219
Show file tree
Hide file tree
Showing 12 changed files with 209 additions and 7 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "game-services",
"version": "0.33.0",
"version": "0.34.0",
"description": "",
"main": "src/index.ts",
"scripts": {
Expand Down
3 changes: 3 additions & 0 deletions src/entities/api-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export default class APIKey {
@Property()
createdAt: Date = new Date()

@Property({ onUpdate: () => new Date() })
updatedAt?: Date = new Date()

@Property({ nullable: true })
revokedAt?: Date

Expand Down
11 changes: 10 additions & 1 deletion src/entities/game-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export enum GameActivityType {
GAME_PROPS_UPDATED,
GAME_FEEDBACK_CATEGORY_CREATED,
GAME_FEEDBACK_CATEGORY_UPDATED,
GAME_FEEDBACK_CATEGORY_DELETED
GAME_FEEDBACK_CATEGORY_DELETED,
API_KEY_UPDATED
}

@Entity()
Expand Down Expand Up @@ -111,6 +112,14 @@ export default class GameActivity {
return `${this.user.username} deleted the group ${this.extra.groupName}`
case GameActivityType.GAME_PROPS_UPDATED:
return `${this.user.username} updated the live config`
case GameActivityType.GAME_FEEDBACK_CATEGORY_CREATED:
return `${this.user.username} created the feedback category ${this.extra.feedbackCategoryInternalName}`
case GameActivityType.GAME_FEEDBACK_CATEGORY_UPDATED:
return `${this.user.username} updated the feedback category ${this.extra.feedbackCategoryInternalName}`
case GameActivityType.GAME_FEEDBACK_CATEGORY_DELETED:
return `${this.user.username} deleted the feedback category ${this.extra.feedbackCategoryInternalName}`
case GameActivityType.API_KEY_UPDATED:
return `${this.user.username} updated an access key`
default:
return ''
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/integrations/steamworks-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ export async function syncSteamworksStats(em: EntityManager, integration: Integr
for (const steamAlias of steamAliases) {
const res = await getSteamworksStatsForPlayer(em, integration, steamAlias.identifier)
const steamworksPlayerStats = res?.playerstats?.stats ?? []

for (const steamworksPlayerStat of steamworksPlayerStats) {
const stat = await em.getRepository(GameStat).findOne({ internalName: steamworksPlayerStat.name })
const existingPlayerStat = await em.getRepository(PlayerGameStat).findOne({
Expand Down
10 changes: 10 additions & 0 deletions src/migrations/.snapshot-gs_dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -3450,6 +3450,16 @@
"length": 0,
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "datetime",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 0,
"mappedType": "datetime"
},
"revoked_at": {
"name": "revoked_at",
"type": "datetime",
Expand Down
13 changes: 13 additions & 0 deletions src/migrations/20240614122547AddAPIKeyUpdatedAtColumn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations'

export class AddAPIKeyUpdatedAtColumn extends Migration {

async up(): Promise<void> {
this.addSql('alter table `apikey` add `updated_at` datetime null;')
}

async down(): Promise<void> {
this.addSql('alter table `apikey` drop column `updated_at`;')
}

}
5 changes: 5 additions & 0 deletions src/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { UpdateTableDefaultValues } from './20230205220923UpdateTableDefaultValu
import { CreateGameSecretsTable } from './20230205220924CreateGameSecretsTable'
import { AddAPIKeyLastUsedAtColumn } from './20230205220925AddAPIKeyLastUsedAtColumn'
import { CreateGameFeedbackAndCategoryTables } from './20240606165637CreateGameFeedbackAndCategoryTables'
import { AddAPIKeyUpdatedAtColumn } from './20240614122547AddAPIKeyUpdatedAtColumn'

export default [
{
Expand Down Expand Up @@ -129,5 +130,9 @@ export default [
{
name: 'CreateGameFeedbackAndCategoryTables',
class: CreateGameFeedbackAndCategoryTables
},
{
name: 'AddAPIKeyUpdatedAtColumn',
class: AddAPIKeyUpdatedAtColumn
}
]
11 changes: 11 additions & 0 deletions src/policies/api-key.policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,22 @@ export default class APIKeyPolicy extends Policy {
}

@UserTypeGate([UserType.ADMIN], 'revoke API keys')
@EmailConfirmedGate('revoke API keys')
async delete(req: Request): Promise<PolicyResponse> {
const { id } = req.params
this.ctx.state.apiKey = await this.em.getRepository(APIKey).findOne(Number(id))
if (!this.ctx.state.apiKey) return new PolicyDenial({ message: 'API key not found' }, 404)

return await this.canAccessGame((this.ctx.state.apiKey as APIKey).game.id)
}

@UserTypeGate([UserType.ADMIN], 'update API keys')
@EmailConfirmedGate('update API keys')
async put(req: Request): Promise<PolicyResponse> {
const { id, gameId } = req.params
this.ctx.state.apiKey = await this.em.getRepository(APIKey).findOne(Number(id))
if (!this.ctx.state.apiKey) return new PolicyDenial({ message: 'API key not found' }, 404)

return await this.canAccessGame(Number(gameId))
}
}
45 changes: 43 additions & 2 deletions src/services/api-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { GameActivityType } from '../entities/game-activity'
export async function createToken(em: EntityManager, apiKey: APIKey): Promise<string> {
await em.populate(apiKey, ['game.apiSecret'])

const payload = { sub: apiKey.id, api: true }
const payload = {
sub: apiKey.id,
api: true,
iat: Math.floor(new Date(apiKey.createdAt).getTime() / 1000)
}

const token = await promisify(jwt.sign)(payload, apiKey.game.apiSecret.getPlainSecret())
return token
}
Expand All @@ -30,6 +35,9 @@ export async function createToken(em: EntityManager, apiKey: APIKey): Promise<st
},
{
method: 'DELETE'
},
{
method: 'PUT'
}
])
export default class APIKeyService extends Service {
Expand Down Expand Up @@ -84,7 +92,7 @@ export default class APIKeyService extends Service {
async delete(req: Request): Promise<Response> {
const em: EntityManager = req.ctx.em

const apiKey = req.ctx.state.apiKey as APIKey // set in the policy
const apiKey = req.ctx.state.apiKey as APIKey
apiKey.revokedAt = new Date()

const token = await createToken(em, apiKey)
Expand Down Expand Up @@ -120,4 +128,37 @@ export default class APIKeyService extends Service {
}
}
}

@Validate({ body: ['scopes'] })
@HasPermission(APIKeyPolicy, 'put')
async put(req: Request): Promise<Response> {
const em: EntityManager = req.ctx.em

const apiKey = req.ctx.state.apiKey as APIKey
apiKey.scopes = req.body.scopes

const token = await createToken(em, apiKey)

await createGameActivity(em, {
user: req.ctx.state.user,
game: req.ctx.state.game,
type: GameActivityType.API_KEY_UPDATED,
extra: {
keyId: apiKey.id,
display: {
'Key ending in': token.substring(token.length - 5, token.length),
'Scopes': apiKey.scopes.join(', ')
}
}
})

await em.flush()

return {
status: 200,
body: {
apiKey
}
}
}
}
2 changes: 1 addition & 1 deletion tests/services/api-key/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,6 @@ describe('API key service - delete', () => {
.auth(token, { type: 'bearer' })
.expect(403)

expect(res.body).toStrictEqual({ message: 'Forbidden' })
expect(res.body).toStrictEqual({ message: 'You need to confirm your email address to revoke API keys' })
})
})
109 changes: 109 additions & 0 deletions tests/services/api-key/put.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { EntityManager } from '@mikro-orm/mysql'
import request from 'supertest'
import { UserType } from '../../../src/entities/user'
import GameActivity, { GameActivityType } from '../../../src/entities/game-activity'
import createUserAndToken from '../../utils/createUserAndToken'
import userPermissionProvider from '../../utils/userPermissionProvider'
import createOrganisationAndGame from '../../utils/createOrganisationAndGame'
import APIKey from '../../../src/entities/api-key'

describe('API key service - put', () => {
it.each(userPermissionProvider([
UserType.ADMIN
]))('should return a %i for a %s user', async (statusCode, _, type) => {
const [organisation, game] = await createOrganisationAndGame()
const [token, user] = await createUserAndToken({ type, emailConfirmed: true }, organisation)

const key = new APIKey(game, user)
await (<EntityManager>global.em).persistAndFlush(key)

const res = await request(global.app)
.put(`/games/${game.id}/api-keys/${key.id}`)
.send({ scopes: ['read:players', 'write:events'] })
.auth(token, { type: 'bearer' })
.expect(statusCode)

if (statusCode === 200) {
expect(res.body.apiKey.gameId).toBe(game.id)
expect(res.body.apiKey.scopes).toStrictEqual(['read:players', 'write:events'])
}

const activity = await (<EntityManager>global.em).getRepository(GameActivity).findOne({
type: GameActivityType.API_KEY_UPDATED,
game,
extra: {
keyId: key.id,
display: {
'Scopes': 'read:players, write:events'
}
}
})

if (statusCode === 200) {
expect(activity).not.toBeNull()
} else {
expect(activity).toBeNull()
}
})

it('should not update an api key if the user\'s email is not confirmed', async () => {
const [organisation, game] = await createOrganisationAndGame()
const [token, user] = await createUserAndToken({ type: UserType.ADMIN }, organisation)

const key = new APIKey(game, user)
await (<EntityManager>global.em).persistAndFlush(key)

const res = await request(global.app)
.put(`/games/${game.id}/api-keys/${key.id}`)
.send({ scopes: ['read:players', 'write:events'] })
.auth(token, { type: 'bearer' })
.expect(403)

expect(res.body).toStrictEqual({ message: 'You need to confirm your email address to update API keys' })
})

it('should not update an api key for a non-existent game', async () => {
const [, game] = await createOrganisationAndGame()
const [token, user] = await createUserAndToken({ emailConfirmed: true, type: UserType.ADMIN })

const key = new APIKey(game, user)
await (<EntityManager>global.em).persistAndFlush(key)

const res = await request(global.app)
.put(`/games/99999/api-keys/${key.id}`)
.send({ scopes: [] })
.auth(token, { type: 'bearer' })
.expect(404)

expect(res.body).toStrictEqual({ message: 'Game not found' })
})

it('should not create an api key for a game the user has no access to', async () => {
const [, otherGame] = await createOrganisationAndGame()
const [token, user] = await createUserAndToken({ emailConfirmed: true, type: UserType.ADMIN })

const key = new APIKey(otherGame, user)
await (<EntityManager>global.em).persistAndFlush(key)

const res = await request(global.app)
.put(`/games/${otherGame.id}/api-keys/${key.id}`)
.send({ scopes: [] })
.auth(token, { type: 'bearer' })
.expect(403)

expect(res.body).toStrictEqual({ message: 'Forbidden' })
})

it('should not update an api key that does not exist', async () => {
const [organisation, game] = await createOrganisationAndGame()
const [token] = await createUserAndToken({ emailConfirmed: true, type: UserType.ADMIN }, organisation)

const res = await request(global.app)
.put(`/games/${game.id}/api-keys/99999`)
.send({ scopes: ['read:players', 'write:events'] })
.auth(token, { type: 'bearer' })
.expect(404)

expect(res.body).toStrictEqual({ message: 'API key not found' })
})
})

0 comments on commit 3fdb219

Please sign in to comment.