@nearform/trail-core
Version:
Audit trail logging service
461 lines (365 loc) • 20.7 kB
JavaScript
'use strict'
const { expect } = require('code')
const Lab = require('@hapi/lab')
const sinon = require('sinon')
module.exports.lab = Lab.script()
const { describe, it: test, before, after } = module.exports.lab
const SQL = require('@nearform/sql')
const { DateTime } = require('luxon')
const { TrailsManager } = require('../lib')
const sampleTrail = function () {
const date = DateTime.fromISO('2018-04-11T07:00:00.123-09:00', { setZone: true })
return { id: null, when: date, who: 'who', what: { id: 'what', additional: true }, subject: 'subject' }
}
const makeTrailsManager = opts => new TrailsManager({
db: {
database: 'trails_test'
},
...opts
})
describe('TrailsManager', () => {
before(() => {
this.subject = makeTrailsManager()
})
after(async () => {
await this.subject.performDatabaseOperations(client => client.query('TRUNCATE trails'))
await this.subject.close()
})
describe('.performDatabaseOperations', () => {
test('should rollback transaction when something bad happens', async () => {
const count = parseInt((await this.subject.performDatabaseOperations(client => client.query('SELECT COUNT(*) FROM trails'), false)).rows[0].count, 0)
try {
await this.subject.performDatabaseOperations(async client => {
await client.query(SQL`
INSERT
INTO trails ("when", who_id, what_id, subject_id, who_data, what_data, subject_data, "where", why, meta)
VALUES('2018-01-01T12:34:56+00:00', 'who', 'what', 'subject', ${{}}, ${{}}, ${{}}, ${{}}, ${{}}, ${{}})
`)
throw new Error('rollback please')
})
} catch (e) {
expect(e.message).to.equal('rollback please')
}
const newCount = parseInt((await this.subject.performDatabaseOperations(client => client.query('SELECT COUNT(*) FROM trails'), false)).rows[0].count, 0)
expect(newCount).to.equal(count)
})
test('should not use transaction if requested to ', async () => {
const count = parseInt((await this.subject.performDatabaseOperations(client => client.query('SELECT COUNT(*) FROM trails'), false)).rows[0].count, 0)
try {
await this.subject.performDatabaseOperations(async client => {
await client.query(SQL`
INSERT
INTO trails ("when", who_id, what_id, subject_id, who_data, what_data, subject_data, "where", why, meta)
VALUES('2018-01-01T12:34:56+00:00', 'who', 'what', 'subject', ${{}}, ${{}}, ${{}}, ${{}}, ${{}}, ${{}})
`)
throw new Error('rollback please')
}, false)
} catch (e) {
expect(e.message).to.equal('rollback please')
}
const newCount = parseInt((await this.subject.performDatabaseOperations(client => client.query('SELECT COUNT(*) FROM trails'), false)).rows[0].count, 0)
expect(newCount).to.equal(count + 1)
})
test('should raise an error when db pool is empty', async () => {
const badSubject = makeTrailsManager({ logger: 'logger', pool: null })
await expect(badSubject.performDatabaseOperations(client => client.query('TRUNCATE trails'))).to.reject(Error, 'Cannot read property \'connect\' of null')
})
})
describe('.close', () => {
test('should raise an error when something bad happens', async () => {
const badSubject = makeTrailsManager({ logger: null, pool: null })
await expect(badSubject.close()).to.reject(TypeError, "Cannot read property 'end' of null")
})
})
describe('.search', () => {
test('should return the right records with counts', async () => {
await this.subject.performDatabaseOperations(client => client.query('TRUNCATE trails'))
const records = [
{ when: '2018-01-01T12:34:56+00:00', who: 'dog cat fish', what: 'open morning', subject: 'window' },
{ when: '2018-01-02T12:34:56+00:00', who: 'dog cat shark', what: 'open evening', subject: 'window' },
{ when: '2018-01-03T12:34:56+00:00', who: 'wolf cat whale', what: 'open morning', subject: 'door' },
{ when: '2018-01-04T12:34:56+00:00', who: 'hyena lion fish', what: 'close evening', subject: 'door' },
{ when: '2018-01-05T12:34:56+00:00', who: 'hyena tiger whal', what: 'close night', subject: 'world' }
]
const ids = await Promise.all(records.map(r => this.subject.insert(r)))
let trail = await this.subject.search({ from: '2018-01-01T11:00:00+00:00', to: '2018-01-04T13:34:56+00:00', who: 'dog', sort: 'when' })
expect(trail).to.be.object()
expect(trail.count).to.equal(2)
expect(trail.data
.map(r => r.id)).to.equal([ids[0], ids[1]])
trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-04T13:34:56+00:00', what: 'evening', sort: 'id' })
expect(trail).to.be.object()
expect(trail.count).to.equal(2)
expect(trail.data
.map(r => r.id)).to.equal([ids[1], ids[3]].sort())
trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-04T13:34:56+00:00', what: 'evening', sort: '-subject' })
expect(trail).to.be.object()
expect(trail.count).to.equal(2)
expect(trail.data
.map(r => r.id)).to.equal([ids[1], ids[3]])
trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-05T13:34:56+00:00', subject: 'world' })
expect(trail).to.be.object()
expect(trail.count).to.equal(1)
expect(trail.data
.map(r => r.id)).to.equal([ids[4]])
trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-05T13:34:56+00:00', what: 'world' })
expect(trail).to.be.object()
expect(trail.count).to.equal(0)
expect(trail.data
.map(r => r.id)).to.equal([])
trail = await this.subject.search({ from: '2018-01-01T00:00:00+00:00', to: '2018-01-05T13:34:56+00:00', sort: 'when', page: 2, pageSize: 2 })
expect(trail).to.be.object()
expect(trail.count).to.equal(5)
expect(trail.data
.map(r => r.id)).to.equal([ids[2], ids[3]])
await Promise.all(ids.map(i => this.subject.delete(i)))
})
test('should return the records with exact match', async () => {
await this.subject.performDatabaseOperations(client => client.query('TRUNCATE trails'))
const records = [
{ when: '2018-01-01T12:34:56+00:00', who: 'dog cat fish', what: 'open morning', subject: 'window' },
{ when: '2018-01-02T12:34:56+00:00', who: 'dog cat shark', what: 'evening', subject: 'window' },
{ when: '2018-01-03T12:34:56+00:00', who: 'wolf cat whale', what: 'open morning', subject: 'door' },
{ when: '2018-01-04T12:34:56+00:00', who: 'hyena lion fish', what: 'evening', subject: 'door' },
{ when: '2018-01-05T12:34:56+00:00', who: 'hyena tiger whal', what: 'close night', subject: 'world' }
]
const ids = await Promise.all(records.map(r => this.subject.insert(r)))
let trail = await this.subject.search({ from: '2018-01-01T11:00:00+00:00', to: '2018-01-04T13:34:56+00:00', who: 'dog cat fish', sort: 'when', exactMatch: true })
expect(trail).to.be.object()
expect(trail.count).to.equal(1)
expect(trail.data
.map(r => r.id)).to.equal([ids[0]])
trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-04T13:34:56+00:00', what: 'evening', sort: 'when', exactMatch: true })
expect(trail).to.be.object()
expect(trail.count).to.equal(2)
expect(trail.data
.map(r => r.id)).to.equal([ids[1], ids[3]])
trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-05T13:34:56+00:00', subject: 'door', sort: 'when', exactMatch: true })
expect(trail).to.be.object()
expect(trail.count).to.equal(2)
expect(trail.data
.map(r => r.id)).to.equal([ids[2], ids[3]])
await Promise.all(ids.map(i => this.subject.delete(i)))
})
test('should validate parameters', async () => {
await expect(this.subject.search()).to.reject(Error, 'You must specify a starting date ("from" attribute) when querying trails.')
await expect(this.subject.search({ from: DateTime.local() }))
.to.reject(Error, 'You must specify a ending date ("to" attribute) when querying trails.')
await expect(this.subject.search({ from: 'whatever', to: DateTime.local() }))
.to.reject(Error, 'Invalid date "whatever". Please specify a valid UTC date in ISO8601 format.')
await expect(this.subject.search({ from: DateTime.local(), to: DateTime.local(), who: 1 }))
.to.reject(Error, 'Only strings are supporting for searching in the id of the "who" field.')
await expect(this.subject.search({ from: DateTime.local(), to: DateTime.local(), what: 1 }))
.to.reject(Error, 'Only strings are supporting for searching in the id of the "what" field.')
await expect(this.subject.search({ from: DateTime.local(), to: DateTime.local(), subject: 1 }))
.to.reject(Error, 'Only strings are supporting for searching in the id of the "subject" field.')
await expect(this.subject.search({ from: DateTime.local(), to: DateTime.local(), sort: '-metadata' }))
.to.reject(Error, 'Only "id", "when", "who", "what" and "subject" are supported for sorting.')
})
test('should return the records with case insensitiveness', async () => {
await this.subject.performDatabaseOperations(client => client.query('TRUNCATE trails'))
const records = [
{ when: '2018-01-01T12:34:56+00:00', who: 'dog cat fish', what: 'open MORNing', subject: 'window' },
{ when: '2018-01-02T12:34:56+00:00', who: 'dog cat shark', what: 'evening', subject: 'window' },
{ when: '2018-01-03T12:34:56+00:00', who: 'wolf cat whale', what: 'open morning', subject: 'door' },
{ when: '2018-01-04T12:34:56+00:00', who: 'hyena lion fish', what: 'evening', subject: 'DOOr' },
{ when: '2018-01-05T12:34:56+00:00', who: 'hyena tiger whal', what: 'close night', subject: 'world' }
]
const ids = await Promise.all(records.map(r => this.subject.insert(r)))
let trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-04T13:34:56+00:00', subject: 'DOOr', sort: 'when', exactMatch: true, caseInsensitive: true })
expect(trail).to.be.object()
expect(trail.count).to.equal(2)
expect(trail.data
.map(r => r.id)).to.equal([ids[2], ids[3]])
trail = await this.subject.search({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-04T13:34:56+00:00', subject: 'DOOr', sort: 'when', exactMatch: true })
expect(trail).to.be.object()
expect(trail.count).to.equal(1)
expect(trail.data
.map(r => r.id)).to.equal([ids[3]])
trail = await this.subject.search({ from: '2018-01-01T12:00:00+00:00', to: '2018-01-05T13:34:56+00:00', what: 'MORNing', sort: 'when', caseInsensitive: true })
expect(trail).to.be.object()
expect(trail.count).to.equal(2)
expect(trail.data
.map(r => r.id)).to.equal([ids[0], ids[2]])
trail = await this.subject.search({ from: '2018-01-01T12:00:00+00:00', to: '2018-01-05T13:34:56+00:00', what: 'MORNing', sort: 'when' })
expect(trail).to.be.object()
expect(trail.count).to.equal(1)
expect(trail.data
.map(r => r.id)).to.equal([ids[0]])
await Promise.all(ids.map(i => this.subject.delete(i)))
})
test('should sanitize pagination parameters', async () => {
const client = {
query () { }
}
const stub = sinon.stub(this.subject, 'performDatabaseOperations').callsFake(function fakeFn (operations, useTransaction = true) {
operations(client)
return [{ rows: [{ count: 0 }] }, { rows: [] }]
})
const spy = sinon.spy(client, 'query')
await this.subject.search({ from: DateTime.local(), to: DateTime.local(), page: 12 })
expect(spy.getCall(1).args[0].text).to.include('LIMIT 25 OFFSET 275')
await this.subject.search({ from: DateTime.local(), to: DateTime.local(), pageSize: 12 })
expect(spy.getCall(3).args[0].text).to.include('LIMIT 12 OFFSET 0')
await this.subject.search({ from: DateTime.local(), to: DateTime.local(), page: 3, pageSize: 12 })
expect(spy.getCall(5).args[0].text).to.include('LIMIT 12 OFFSET 24')
await this.subject.search({ from: DateTime.local(), to: DateTime.local(), page: '12', pageSize: NaN })
expect(spy.getCall(7).args[0].text).to.include('LIMIT 25 OFFSET 275')
await this.subject.search({ from: DateTime.local(), to: DateTime.local(), page: NaN, pageSize: 2 })
expect(spy.getCall(9).args[0].text).to.include('LIMIT 2 OFFSET 0')
spy.restore()
stub.restore()
})
})
describe('.enumerate', () => {
test('should return the right records', async () => {
await this.subject.performDatabaseOperations(client => client.query('TRUNCATE trails'))
const records = [
{ when: '2018-01-01T12:34:56+00:00', who: 'dog', what: 'open', subject: 'window' },
{ when: '2018-01-02T12:34:56+00:00', who: 'cat', what: 'open', subject: 'window' },
{ when: '2018-01-03T12:34:56+00:00', who: 'whale', what: 'close', subject: 'door' },
{ when: '2018-01-04T12:34:56+00:00', who: 'cat', what: 'close', subject: 'door' },
{ when: '2018-01-05T12:34:56+00:00', who: 'shark', what: 'check', subject: 'world' }
]
const ids = await Promise.all(records.map(r => this.subject.insert(r)))
expect((await this.subject.enumerate({ from: '2018-01-01T11:00:00+00:00', to: '2018-01-04T13:34:56+00:00', type: 'who' })))
.to.equal(['cat', 'dog', 'whale'])
expect((await this.subject.enumerate({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-04T13:34:56+00:00', type: 'what' })))
.to.equal(['close', 'open'])
expect((await this.subject.enumerate({ from: '2018-01-01T11:00:00+00:00', to: '2018-01-04T13:34:56+00:00', type: 'who', desc: true })))
.to.equal(['whale', 'dog', 'cat'])
expect((await this.subject.enumerate({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-05T13:34:56+00:00', type: 'who' })))
.to.equal(['cat', 'shark', 'whale'])
expect((await this.subject.enumerate({ from: '2018-01-01T15:00:00+00:00', to: '2018-01-05T13:34:56+00:00', type: 'what' })))
.to.equal(['check', 'close', 'open'])
expect((await this.subject.enumerate({ from: '2018-01-01T00:00:00+00:00', to: '2018-01-05T13:34:56+00:00', type: 'who', page: 2, pageSize: 1 })))
.to.equal(['dog'])
expect((await this.subject.enumerate({ from: '2018-02-01T11:00:00+00:00', to: '2018-02-04T13:34:56+00:00', type: 'who' }))).to.equal([])
await Promise.all(ids.map(i => this.subject.delete(i)))
})
test('should validate parameters', async () => {
await expect(this.subject.enumerate()).to.reject(Error, 'You must specify a starting date ("from" attribute) when enumerating.')
await expect(this.subject.enumerate({ from: DateTime.local() }))
.to.reject(Error, 'You must specify a ending date ("to" attribute) when enumerating.')
await expect(this.subject.enumerate({ from: 'whatever', to: DateTime.local() }))
.to.reject(Error, 'Invalid date "whatever". Please specify a valid UTC date in ISO8601 format.')
await expect(this.subject.enumerate({ from: DateTime.local(), to: DateTime.local(), type: 'foo' }))
.to.reject(Error, 'You must select between "who", "what" or "subject" type ("type" attribute) when enumerating.')
})
test('should sanitize pagination parameters', async () => {
const spy = sinon.spy(this.subject, 'performDatabaseOperations')
await this.subject.enumerate({ from: DateTime.local(), to: DateTime.local(), type: 'who', page: 12 })
expect(spy.getCall(0).args[0].text).to.include('LIMIT 25 OFFSET 275')
await this.subject.enumerate({ from: DateTime.local(), to: DateTime.local(), type: 'who', pageSize: 12 })
expect(spy.getCall(1).args[0].text).to.include('LIMIT 12 OFFSET 0')
await this.subject.enumerate({ from: DateTime.local(), to: DateTime.local(), type: 'who', page: 3, pageSize: 12 })
expect(spy.getCall(2).args[0].text).to.include('LIMIT 12 OFFSET 24')
await this.subject.enumerate({ from: DateTime.local(), to: DateTime.local(), type: 'who', page: '12', pageSize: NaN })
expect(spy.getCall(3).args[0].text).to.include('LIMIT 25 OFFSET 275')
await this.subject.enumerate({ from: DateTime.local(), to: DateTime.local(), type: 'who', page: NaN, pageSize: 2 })
expect(spy.getCall(4).args[0].text).to.include('LIMIT 2 OFFSET 0')
spy.restore()
})
})
describe('.insert', () => {
test('should accept a single argument', async () => {
const id = await this.subject.insert(sampleTrail())
expect(id).to.be.least(0)
})
test('should raise an error when something bad happens', async () => {
const badSubject = makeTrailsManager({ logger: null, pool: null })
await expect(badSubject.insert(sampleTrail())).to.reject(TypeError, "Cannot read property 'connect' of null")
})
})
describe('.get', () => {
test('should retrieve an existing trail', async () => {
const id = await this.subject.insert(sampleTrail())
const trail = await this.subject.get(id)
expect(trail).to.be.object()
expect(trail).to.include({
when: DateTime.fromISO('2018-04-11T16:00:00.123', { zone: 'utc' }),
who: {
id: 'who',
attributes: {}
},
what: {
id: 'what',
attributes: {
additional: true
}
},
subject: {
id: 'subject',
attributes: {}
},
where: {},
why: {},
meta: {}
})
})
test('should return null if trail doesn\'t exist when performing a get', async () => {
const nonExistantId = '0000'
const trail = await this.subject.get(nonExistantId)
expect(trail).to.equal(null)
})
test('should raise an error when something bad happens', async () => {
const id = await this.subject.insert(sampleTrail())
const badSubject = makeTrailsManager({ logger: null, pool: null })
await expect(badSubject.get(id)).to.reject(TypeError, "Cannot read property 'connect' of null")
})
})
describe('.update', () => {
test('should be able to update an existing trail', async () => {
const date = DateTime.fromISO('2018-04-11T07:00:00.123-09:00', { setZone: true })
const id = await this.subject.insert(sampleTrail())
const res = await this.subject.update(
id,
{ id: null, when: date, who: { id: 'who', updated: 1 }, what: { id: 'what', updated: 2 }, subject: { id: 'subject', updated: 3 } }
)
expect(res).to.equal(true)
const trail = await this.subject.get(id)
expect(trail).include({
who: {
id: 'who',
attributes: { updated: 1 }
},
what: {
id: 'what',
attributes: { updated: 2 }
},
subject: {
id: 'subject',
attributes: { updated: 3 }
}
})
})
test('should raise an error when something bad happens', async () => {
const date = DateTime.fromISO('2018-04-11T07:00:00.123-09:00', { setZone: true })
const id = await this.subject.insert(sampleTrail())
const badSubject = makeTrailsManager({ logger: null, pool: null })
await expect(badSubject.update(
id,
{ id: null, when: date, who: { id: 'who', updated: 1 }, what: { id: 'what', updated: 2 }, subject: { id: 'subject', updated: 3 } }
))
.to.reject(TypeError, "Cannot read property 'connect' of null")
})
})
describe('.delete', () => {
test('should be able to delete an existing trail', async () => {
const id = await this.subject.insert(sampleTrail())
const res = await this.subject.delete(id)
expect(res).to.equal(true)
})
test('should return null if trail doesn\'t exist when performing a delete', async () => {
const nonExistantId = '0000'
const res = await this.subject.delete(nonExistantId)
expect(res).to.equal(false)
})
test('should raise an error when something bad happens', async () => {
const id = await this.subject.insert(sampleTrail())
const badSubject = makeTrailsManager({ logger: null, pool: null })
expect(badSubject.delete(id))
.to.reject(TypeError, "Cannot read property 'connect' of null")
})
})
})