Skip to content

Commit

Permalink
Add admin API key in the Armory (#285)
Browse files Browse the repository at this point in the history
  • Loading branch information
wcalderipe authored May 31, 2024
1 parent e654acd commit 156a15a
Show file tree
Hide file tree
Showing 16 changed files with 520 additions and 2 deletions.
8 changes: 8 additions & 0 deletions apps/armory/.env.default
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ NODE_ENV=development

PORT=3005

APP_ID=local-dev-armory-instance-1

# OPTIONAL: Sets the admin API key instead of generating a new one during the
# provision.
#
# Plain text API key: 2cfa9d09a28f1de9108d18c38f5d5304e6708744c7d7194cbc754aef3455edc7e9270e2f28f052622257
APP_ADMIN_API_KEY=7d4064073efcfef92d85f274c70e82f980128168e9fb171a613dc7fdaf446b5d

# MIGRATOR db credentials. host/port/name should be the same, username&password may be different
APP_DATABASE_USERNAME=postgres
APP_DATABASE_PASSWORD=postgres
Expand Down
5 changes: 5 additions & 0 deletions apps/armory/.env.test.default
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ NODE_ENV=test

PORT=3005

APP_ID=local-dev-armory-instance-1

# Plain text API key: test-armory-admin-api-key
APP_ADMIN_API_KEY=de9aa0f5cdd186f2025d770b6bd222b54e70821853c0472e34cc060bb8dc88d2

APP_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/armory_test?schema=public"

REDIS_HOST=localhost
Expand Down
85 changes: 85 additions & 0 deletions apps/armory/src/app/__test__/e2e/app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ConfigModule } from '@narval/config-module'
import { secret } from '@narval/nestjs-shared'
import { INestApplication } from '@nestjs/common'
import { Test, TestingModule } from '@nestjs/testing'
import request from 'supertest'
import { Config, load } from '../../../armory.config'
import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service'
import { AppModule } from '../../app.module'
import { AppService } from '../../core/service/app.service'

const ENDPOINT = '/apps/activate'

const testConfigLoad = (): Config => ({
...load(),
app: {
id: 'local-dev-armory-instance-1',
adminApiKeyHash: undefined
}
})

describe('App', () => {
let app: INestApplication
let module: TestingModule
let appService: AppService
let testPrismaService: TestPrismaService

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
load: [testConfigLoad],
isGlobal: true
}),
AppModule
]
}).compile()

app = module.createNestApplication()

appService = app.get(AppService)
testPrismaService = app.get(TestPrismaService)

await app.init()
})

beforeEach(async () => {
await testPrismaService.truncateAll()
})

afterAll(async () => {
await testPrismaService.truncateAll()
await module.close()
await app.close()
})

describe(`POST ${ENDPOINT}`, () => {
it('responds with generated api key', async () => {
const { body } = await request(app.getHttpServer()).post(ENDPOINT).send()

expect(body).toEqual({
state: 'READY',
app: {
appId: 'local-dev-armory-instance-1',
adminApiKey: expect.any(String)
}
})
})

it('responds already activated', async () => {
await request(app.getHttpServer()).post(ENDPOINT).send()

const { body } = await request(app.getHttpServer()).post(ENDPOINT).send()

expect(body).toEqual({ state: 'ACTIVATED' })
})

it('does not respond with hashed admin api key', async () => {
const { body } = await request(app.getHttpServer()).post(ENDPOINT).send()

const actualApp = await appService.getAppOrThrow()

expect(secret.hash(body.app.adminApiKey)).toEqual(actualApp.adminApiKey)
})
})
})
12 changes: 12 additions & 0 deletions apps/armory/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { PersistenceModule } from '../shared/module/persistence/persistence.module'
import { AppService } from './core/service/app.service'
import { AppController } from './http/rest/controller/app.controller'
import { AppRepository } from './persistence/repository/app.repository'

@Module({
imports: [PersistenceModule],
providers: [AppService, AppRepository],
controllers: [AppController]
})
export class AppModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ProvisionException } from './provision.exception'

export class AlreadyActivatedException extends ProvisionException {
constructor() {
super('App already activated')
}
}
12 changes: 12 additions & 0 deletions apps/armory/src/app/core/exception/provision.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpStatus } from '@nestjs/common'
import { ApplicationException } from '../../../shared/exception/application.exception'

export class ProvisionException extends ApplicationException {
constructor(message: string, context?: unknown) {
super({
message,
suggestedHttpStatusCode: HttpStatus.INTERNAL_SERVER_ERROR,
context
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { ConfigModule, ConfigService } from '@narval/config-module'
import { secret } from '@narval/nestjs-shared'
import { Test, TestingModule } from '@nestjs/testing'
import { MockProxy, mock } from 'jest-mock-extended'
import { get } from 'lodash/fp'
import { Config, load } from '../../../../../armory.config'
import { PersistenceModule } from '../../../../../shared/module/persistence/persistence.module'
import { TestPrismaService } from '../../../../../shared/module/persistence/service/test-prisma.service'
import { AppRepository } from '../../../../persistence/repository/app.repository'
import { AlreadyActivatedException } from '../../../exception/app-already-activated.exception'
import { AppService } from '../../app.service'

const mockConfig = (config: { appId: string; adminApiKeyHash?: string }) => (key: string) => {
if (key === 'app.id') {
return config.appId
}

if (key === 'app.adminApiKeyHash') {
return config.adminApiKeyHash
}

return get(key, config)
}

describe(AppService.name, () => {
let module: TestingModule
let appService: AppService
let testPrismaService: TestPrismaService
let configServiceMock: MockProxy<ConfigService<Config>>

const config = { appId: 'test-app-id' }

const adminApiKey = 'test-admin-api-key'

beforeEach(async () => {
configServiceMock = mock<ConfigService<Config>>()
configServiceMock.get.mockImplementation(mockConfig(config))

module = await Test.createTestingModule({
imports: [
// Satisfy the PersistenceModule dependency in a global ConfigService.
ConfigModule.forRoot({
load: [load],
isGlobal: true
}),
PersistenceModule
],
providers: [
AppService,
AppRepository,
{
// Mock the ConfigService to control the behavior of the application.
provide: ConfigService,
useValue: configServiceMock
}
]
}).compile()

appService = module.get(AppService)
testPrismaService = module.get(TestPrismaService)
})

afterEach(async () => {
await testPrismaService.truncateAll()
})

describe('provision', () => {
describe('on first boot', () => {
describe('when admin api key is set', () => {
beforeEach(async () => {
configServiceMock.get.mockImplementation(
mockConfig({
...config,
adminApiKeyHash: secret.hash(adminApiKey)
})
)
})

it('saves app with hashed admin api key', async () => {
await appService.provision()

const actualApp = await appService.getApp()

expect(actualApp).toEqual({
id: config.appId,
adminApiKey: secret.hash(adminApiKey)
})
})

it('returns the hashed admin api key', async () => {
const app = await appService.provision()

expect(app?.adminApiKey).toEqual(secret.hash(adminApiKey))
})
})

describe('when admin api key is not set', () => {
it('saves app without admin api key', async () => {
await appService.provision()

const actualApp = await appService.getApp()

expect(actualApp).toEqual({ id: config.appId })
})
})
})

describe('on boot', () => {
it('skips provision and returns the existing app', async () => {
const actualApp = await appService.save({ id: config.appId })

const app = await appService.provision()

expect(actualApp).toEqual(app)
})
})
})

describe('activate', () => {
describe('when admin api key is set', () => {
beforeEach(async () => {
configServiceMock.get.mockImplementation(
mockConfig({
...config,
adminApiKeyHash: adminApiKey
})
)
})

it('returns app is activated when admin api key is set', async () => {
await appService.provision()

await expect(appService.activate(adminApiKey)).rejects.toThrow(AlreadyActivatedException)
})
})

describe('when admin api key is not set', () => {
beforeEach(async () => {
await appService.provision()
})

it('returns the plain text admin api key', async () => {
const result = await appService.activate(adminApiKey)

expect(result).toEqual({
id: config.appId,
adminApiKey
})
})

it('hashes the new admin api key', async () => {
await appService.activate(adminApiKey)

const actualApp = await appService.getApp()

expect(actualApp?.adminApiKey).toEqual(secret.hash(adminApiKey))
})
})
})
})
Loading

0 comments on commit 156a15a

Please sign in to comment.