ilp-plugin-mini-accounts
Version:
Plugin that implements a mini ephemeral ledger
323 lines (286 loc) • 11.4 kB
JavaScript
/* eslint-env mocha */
const BtpPacket = require('btp-packet')
const crypto = require('crypto')
const IlpPacket = require('ilp-packet')
const getPort = require('get-port')
const chai = require('chai')
chai.use(require('chai-as-promised'))
const assert = chai.assert
const sinon = require('sinon')
const PluginMiniAccounts = require('..')
const Store = require('ilp-store-memory')
const sendAuthPacket = require('./helper/btp-util')
const Token = require('../src/token').default
const http = require('http')
const fetch = require('node-fetch')
function sha256 (token) {
return BtpPacket.base64url(crypto.createHash('sha256').update(token).digest('sha256'))
}
describe('Mini Accounts Plugin', () => {
beforeEach(async function () {
this.port = await getPort()
this.plugin = new PluginMiniAccounts({
port: this.port,
debugHostIldcpInfo: {
clientAddress: 'test.example'
},
_store: new Store()
})
await this.plugin.connect()
this.from = 'test.example.35YywQ-3GYiO3MM4tvfaSGhty9NZELIBO3kmilL0Wak'
this.fulfillment = crypto.randomBytes(32)
this.condition = crypto.createHash('sha256')
.update(this.fulfillment)
.digest()
})
afterEach(async function () {
await this.plugin.disconnect()
assert.equal(this.plugin._connections.size, 0)
})
describe('Authentication', function () {
beforeEach(async function () {
this.serverUrl = 'ws://localhost:' + this.port
})
describe('new account', function () {
it('stores hashed token if account does not exist', async function () {
const spy = sinon.spy(this.plugin._store, 'set')
await sendAuthPacket(this.serverUrl, 'acc', 'secret_token')
// assert that a new account was written to the store with a hashed token
const expectedToken = sha256('secret_token')
assert.isTrue(spy.calledWith('acc:hashed-token', expectedToken),
`expected new account written to store with value ${expectedToken}, but wasn't`)
})
it('does not race when storing the token', async function () {
const realStoreLoad = this.plugin._store.load.bind(this.plugin._store)
sinon.stub(this.plugin._store, 'load').onFirstCall().callsFake(async (...args) => {
// forces a race condition
await sendAuthPacket(this.serverUrl, 'acc', '2nd_secret_token')
return realStoreLoad(...args)
})
const msg = await sendAuthPacket(this.serverUrl, 'acc', '1st_secret_token')
assert.strictEqual(msg.type, BtpPacket.TYPE_ERROR, 'expected an BTP error')
assert.strictEqual(msg.data.code, 'F00')
assert.strictEqual(msg.data.name, 'NotAcceptedError')
assert.match(msg.data.data, /incorrect token for account/)
assert.strictEqual(this.plugin._store.get('acc:hashed-token'), sha256('2nd_secret_token'))
})
})
describe('existing account', function () {
beforeEach(function () {
new Token({
account: 'acc',
token: 'secret_token',
store: this.plugin._store
}).save()
})
it('fails if received token does not match stored token', async function () {
const msg = await sendAuthPacket(this.serverUrl, 'acc', 'wrong_token')
assert.strictEqual(msg.type, BtpPacket.TYPE_ERROR, 'expected an BTP error')
assert.strictEqual(msg.data.code, 'F00')
assert.strictEqual(msg.data.name, 'NotAcceptedError')
assert.match(msg.data.data, /incorrect token for account/)
})
it('succeeds if received token matches stored token', async function () {
const msg = await sendAuthPacket(this.serverUrl, 'acc', 'secret_token')
assert.strictEqual(msg.type, BtpPacket.TYPE_RESPONSE)
})
it('migrates an unhashed token', async function () {
this.plugin._store.set('other_acc:token', 'unhashed')
const token = await Token.load({account: 'other_acc', store: this.plugin._store})
assert.isUndefined(this.plugin._store.get('other_acc:token'))
assert.strictEqual(token._account, 'other_acc')
assert.strictEqual(token._hashedToken, sha256('unhashed'))
})
})
describe('generateAccount = true', function () {
it('does not allow a random username', async function () {
const port = await getPort()
const serverUrl = 'ws://localhost:' + port
const plugin = new PluginMiniAccounts({
port: port,
debugHostIldcpInfo: { clientAddress: 'test.example' },
generateAccount: true,
_store: new Store()
})
await plugin.connect()
const msg = await sendAuthPacket(serverUrl, 'foobar', 'secret_token')
assert.strictEqual(msg.type, BtpPacket.TYPE_ERROR, 'expected a BTP error')
assert.strictEqual(msg.data.code, 'F00')
assert.strictEqual(msg.data.name, 'NotAcceptedError')
assert.strictEqual(msg.data.data, 'auth_username subprotocol is not available')
await plugin.disconnect()
})
})
describe('generateAccount = false', function () {
it('requires a username', async function () {
const port = await getPort()
const serverUrl = 'ws://localhost:' + port
const plugin = new PluginMiniAccounts({
port: port,
debugHostIldcpInfo: { clientAddress: 'test.example' },
generateAccount: false,
_store: new Store()
})
await plugin.connect()
const msg = await sendAuthPacket(serverUrl, '', 'secret_token')
assert.strictEqual(msg.type, BtpPacket.TYPE_ERROR, 'expected a BTP error')
assert.strictEqual(msg.data.code, 'F00')
assert.strictEqual(msg.data.name, 'NotAcceptedError')
assert.strictEqual(msg.data.data, 'auth_username subprotocol is required')
await plugin.disconnect()
})
})
})
describe('sendData', function () {
beforeEach(function () {
this.fulfillment = crypto.randomBytes(32)
this.condition = crypto.createHash('sha256')
.update(this.fulfillment)
.digest()
this.plugin._call = async (dest, packet) => {
return { protocolData: [ {
protocolName: 'ilp',
contentType: BtpPacket.MIME_APPLICATION_OCTET_STREAM,
data: IlpPacket.serializeIlpFulfill({
fulfillment: this.fulfillment,
data: Buffer.alloc(0)
})
} ] }
}
})
it('should return ilp reject when incorrect fulfill is returned', async function () {
this.fulfillment = Buffer.alloc(32)
const result = await this.plugin.sendData(IlpPacket.serializeIlpPrepare({
destination: this.from,
amount: '123',
executionCondition: this.condition,
expiresAt: new Date(Date.now() + 10000),
data: Buffer.alloc(0)
}))
const parsed = IlpPacket.deserializeIlpPacket(result)
assert.equal(parsed.typeString, 'ilp_reject')
assert.deepEqual(parsed.data, {
code: 'F05',
triggeredBy: 'test.example',
message: `condition and fulfillment don't match. condition=${this.condition.toString('hex')} fulfillment=0000000000000000000000000000000000000000000000000000000000000000`,
data: Buffer.alloc(0)
})
})
it('should return ilp reject when _handlePrepareResponse throws', async function () {
this.plugin._handlePrepareResponse = () => {
throw new IlpPacket.Errors.UnreachableError('cannot be reached')
}
const result = await this.plugin.sendData(IlpPacket.serializeIlpPrepare({
destination: this.from,
amount: '123',
executionCondition: this.condition,
expiresAt: new Date(Date.now() + 10000),
data: Buffer.alloc(0)
}))
const parsed = IlpPacket.deserializeIlpPacket(result)
assert.equal(parsed.typeString, 'ilp_reject')
assert.deepEqual(parsed.data, {
code: 'F02',
triggeredBy: 'test.example',
message: 'cannot be reached',
data: Buffer.alloc(0)
})
})
it('should return ilp reject when the prepare expires', async function () {
this.plugin._call = () => new Promise(() => {})
const result = await this.plugin.sendData(IlpPacket.serializeIlpPrepare({
destination: this.from,
amount: '123',
executionCondition: this.condition,
expiresAt: new Date(Date.now() + 50),
data: Buffer.alloc(0)
}))
const parsed = IlpPacket.deserializeIlpPacket(result)
assert.equal(parsed.typeString, 'ilp_reject')
assert.deepEqual(parsed.data, {
code: 'R00',
triggeredBy: 'test.example',
message: 'Packet expired',
data: Buffer.alloc(0)
})
})
})
})
describe('Mini Accounts http requests', function() {
beforeEach(function() {
this.port = 4000
this.pluginConfig = {
debugHostIldcpInfo: {
clientAddress: 'test.example'
},
_store: new Store()
}
})
describe('falls back', function() {
it('falls back to port 3000 if port unspecified', async function() {
this.plugin = new PluginMiniAccounts({
...this.pluginConfig,
})
await this.plugin.connect()
const res = await fetch('http://localhost:3000')
assert.equal(res.status, 426)
assert.equal(await res.text(), 'Upgrade Required')
await this.plugin.disconnect()
})
})
describe('health checks', function() {
afterEach(async function() {
await this.plugin.disconnect()
})
it('fails health check when port specified, wsOpt.port unspecified', async function() {
this.plugin = new PluginMiniAccounts({
...this.pluginConfig,
port: this.port,
})
await this.plugin.connect()
const res = await fetch(`http://localhost:${this.port}`)
assert.equal(res.status, 426)
assert.equal(await res.text(), 'Upgrade Required')
})
it('doesnt let user specify both opts.wsOpt.port and opts.port', async function() {
const constructPlugin = () => new PluginMiniAccounts({
...this.pluginConfig,
port: this.port,
wsOpts: {
port: this.port
}
})
assert.throws(constructPlugin, 'Specify at most one of: `ops.wsOpts.port`, `opts.port`.')
})
it('fails health check when port unspecified, wsOpt.port specified', async function() {
this.plugin = new PluginMiniAccounts({
...this.pluginConfig,
wsOpts: {
port: this.port
},
})
await this.plugin.connect()
const res = await fetch(`http://localhost:${this.port}`)
assert.equal(res.status, 426)
assert.equal(await res.text(), 'Upgrade Required')
})
it('passes health check when port specified, wsOpts.server specified', async function() {
const httpServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.write('ok')
res.end()
})
this.plugin = new PluginMiniAccounts({
port: this.port,
wsOpts: {
server: httpServer
},
...this.pluginConfig
})
await this.plugin.connect()
const res = await fetch(`http://localhost:${this.port}`)
assert.equal(res.status, 200)
assert.equal(await res.text(), 'ok')
})
})
})