diff --git a/dev/bin/test-ts.sh b/dev/bin/test-ts.sh index ef502f6..1a47941 100755 --- a/dev/bin/test-ts.sh +++ b/dev/bin/test-ts.sh @@ -1,11 +1,14 @@ #! /usr/bin/env bash # HACK: normally we could just go: -# tape tests/*.test.ts +# tape tests/**/*.test.ts # # but here we are fighting TS ... this works well enough -ONLY_FILES=`grep 'test.only' tests/*.test.ts -l` +shopt -s globstar +# required for "globalstar" (**)in bash + +ONLY_FILES=`grep 'test.only' tests/**/*.test.ts -l` if [ $ONLY_FILES ]; then # If there are files with test.only, run only those files @@ -17,7 +20,7 @@ if [ $ONLY_FILES ]; then else # Otherwise run all tests set -e; - for t in tests/*.test.ts; do + for t in tests/**/*.test.ts; do npx tsx $t; done fi diff --git a/package.json b/package.json index 1388d3c..fe01309 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "homepage": "https://github.com/entropyxyz/cli#readme", "dependencies": { "@entropyxyz/sdk": "0.4.0", + "ajv": "^8.17.1", "commander": "^12.1.0", "env-paths": "^3.0.0", "inquirer": "8.0.0", diff --git a/src/account/utils.ts b/src/account/utils.ts index d8ef02c..50da9f8 100644 --- a/src/account/utils.ts +++ b/src/account/utils.ts @@ -24,7 +24,7 @@ export async function selectAndPersistNewAccount (configPath: string, newAccount accounts.push(newAccount) await config.set(configPath, { ...storedConfig, - selectedAccount: newAccount.address + selectedAccount: newAccount.name }) } diff --git a/src/config/index.ts b/src/config/index.ts index 07cce4f..02d4f2f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -2,10 +2,12 @@ import { readFile, writeFile, rm } from 'node:fs/promises' import { mkdirp } from 'mkdirp' import { join, dirname } from 'path' import envPaths from 'env-paths' +import AJV from 'ajv' import allMigrations from './migrations' import { serialize, deserialize } from './encoding' import { EntropyConfig, EntropyConfigAccount } from './types' +import { configSchema } from './schema' const paths = envPaths('entropy-cryptography', { suffix: '' }) const OLD_CONFIG_PATH = join(process.env.HOME, '.entropy-cli.config') @@ -51,8 +53,14 @@ export async function init (configPath: string, oldConfigPath = OLD_CONFIG_PATH) const newConfig = migrateData(allMigrations, currentConfig) if (newConfig[VERSION] !== currentConfig[VERSION]) { + // a migration happened, write updated config + // "set" checks the format of the config for us await set(configPath, newConfig) } + else { + // make sure the config the app is about to run on is safe + assertConfig(newConfig) + } } export async function get (configPath) { @@ -84,38 +92,87 @@ export async function setSelectedAccount (configPath: string, account: EntropyCo /* util */ function noop () {} -function assertConfig (config: any) { - if ( - !config || - typeof config !== 'object' - ) { - throw Error('Config#set: config must be an object') - } - if (!Array.isArray(config.accounts)) { - throw Error('Config#set: config must have "accounts"') - } +export function assertConfig (config: any) { + if (isValidConfig(config)) return - if (!config.endpoints) { - throw Error('Config#set: config must have "endpoints"') - } + // @ts-expect-error this is valid Node... + throw new Error('Invalid config', { + cause: isValidConfig.errors + .map(err => { + return err.instancePath + ? `config${err.instancePath}: ${err.message}` + : err.message + }) + .join("; ") + }) - if (typeof config.selectedAccount !== 'string') { - throw Error('Config#set: config must have "selectedAccount"') - } - - if (typeof config['migration-version'] !== 'number') { - throw Error('Config#set: config must have "migration-version"') - } } + function assertConfigPath (configPath: string) { if (!configPath.endsWith('.json')) { throw Error(`configPath must be of form *.json, got ${configPath}`) } } + export function isDangerousReadError (err: any) { // file not found: if (err.code === 'ENOENT') return false return true } + +const ajv = new AJV({ + allErrors: true, +}) + +let validator +export const isValidConfig: ValidatorFunction = function (input: any) { + if (!validator) validator = ajv.compile(configSchema) + // lazy compile once, it's slowish (~20ms) + + const generalResult = validator(input) + const selectedAccountResult = isValidSelectedAccount(input) + + const isValid = generalResult && selectedAccountResult + + isValidConfig.errors = isValid + ? null + : [ + ...(validator.errors || []), + ...(isValidSelectedAccount.errors || []) + ] + + return isValid +} + +const isValidSelectedAccount: ValidatorFunction = function (input: any) { + if (input?.selectedAccount === null) { + isValidSelectedAccount.errors = null + return true + } + + if (!input?.selectedAccount || !Array.isArray(input?.accounts)) { + isValidSelectedAccount.errors = [{ + message: 'unable to check "selectedAccount" validity' + }] + return false + } + + const isValid = input.accounts.find(acct => acct.name === input.selectedAccount) + + isValidSelectedAccount.errors = isValid + ? null + : [{ message: `config/selectedAccount: "${input.selectedAccount}" "no account had a "name" matching "selectedAccount": ` }] + + return isValid +} + +type ValidatorFunction = { + errors?: null|ValidatorErrorObject[] + (input: any): boolean +} +interface ValidatorErrorObject { + instancePath?: string + message: string +} diff --git a/src/config/migrations/05.ts b/src/config/migrations/05.ts new file mode 100644 index 0000000..e99d71f --- /dev/null +++ b/src/config/migrations/05.ts @@ -0,0 +1,39 @@ +// The purpose of this migration is to: +// 1. change encoding of empty selectedAccount from "" => null +// 2. ensure selectedAccount present is an account.name + +export const version = 5 + +export function migrate (data) { + try { + const newData = { ...data } + + if (newData.selectedAccount === null) { + // nothing to do + } + else if (newData.selectedAccount === "") { + newData.selectedAccount = null + } + else { + const target = newData.selectedAccount + const accountMatchingName = newData.accounts.find(a => a.name === target) + const accountMatchingAddress = newData.accounts.find(a => a.address === target) + + if (accountMatchingName) { + // nothing to do + } + else if (accountMatchingAddress) { + // change the refference to be the account.name + newData.selectedAccount = accountMatchingAddress.name + } + else { + throw Error(`unable to correct selectedAccount - no account found which matches "${target}"`) + } + } + return newData + } catch (err) { + // @ts-expect-error : ts stupid + throw Error('Migration 5 failed', { cause: err }) + } + +} diff --git a/src/config/migrations/index.ts b/src/config/migrations/index.ts index aec7ce1..0a3f0e1 100644 --- a/src/config/migrations/index.ts +++ b/src/config/migrations/index.ts @@ -3,13 +3,15 @@ import * as migration01 from './01' import * as migration02 from './02' import * as migration03 from './03' import * as migration04 from './04' +import * as migration05 from './05' export const migrations = [ migration00, migration01, migration02, migration03, - migration04 + migration04, + migration05, ] export default migrations diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..8cddec9 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,69 @@ +import migrations from './migrations' + +const currentVersion = migrations.at(-1).version + +const accountSchema = { + type: "object", + properties: { + name: { + type: "string" + }, + address: { + type: "string", + pattern: [ + "^[", + "a-k", /* l, */ "m-z", + "A-H", /* I, */ "J-N", /* O, */ "P-Z", + /* 0 */ "1-9", + "]{48,48}$" + ].join("") + // base58: https://en.wikipedia.org/wiki/Binary-to-text_encoding#Encoding_standards + // + // Similar to Base64, but modified to avoid both non-alphanumeric characters (+ and /) and letters + // that might look ambiguous when printed (0 – zero, I – capital i, O – capital o and l – lower-case L). + }, + data: { + type: "object", + properties: { + admin: { type: "object" }, + registration: { type: "object" } + }, + required: ["admin", "registration"] + } + }, + required: ["name", "address", "data"] +} + +export const configSchema = { + type: "object", + properties: { + accounts: { + type: "array", + items: accountSchema, + uniqueItems: true + }, + selectedAccount: { + oneOf: [ + { type: "null" }, + { type: "string" } + ] + }, + endpoints: { + type: "object", + patternProperties: { + "^\\w+$": { + type: "string", + pattern: "^wss?://" + }, + }, + required: ["test-net"] + }, + "migration-version": { + type: "integer", + minimum: currentVersion, + maximum: currentVersion + } + }, + required: ["accounts", "selectedAccount", "endpoints", "migration-version"] +} + diff --git a/src/config/types.ts b/src/config/types.ts index 007a6d7..91e45d3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -6,7 +6,7 @@ export interface EntropyConfig { } // selectedAccount is account.name (alias) for the account selectedAccount: string - 'migration-version': string + 'migration-version': number } export interface EntropyConfigAccount { diff --git a/tests/config/index.test.ts b/tests/config/index.test.ts new file mode 100644 index 0000000..63d2d06 --- /dev/null +++ b/tests/config/index.test.ts @@ -0,0 +1,165 @@ +import test from 'tape' +import { writeFile } from 'node:fs/promises' + +import migrations from '../../src/config/migrations' +import { migrateData, init, get, set, assertConfig } from '../../src/config' +import * as encoding from '../../src/config/encoding' + +// used to ensure unique test ids +let id = Date.now() +const makeTmpPath = () => `/tmp/entropy-cli-${id++}.json` +const fakeOldConfigPath = '/tmp/fake-old-config.json' + + +const makeKey = () => new Uint8Array( + Array(32).fill(0).map((_, i) => i * 2 + 1) +) + +test('config - get', async t => { + const configPath = makeTmpPath() + const config = { + boop: 'doop', + secretKey: makeKey() + } + await writeFile(configPath, encoding.serialize(config)) + + const result = await get(configPath) + t.deepEqual(result, config, 'get works') + + const MSG = 'path that does not exist fails' + await get('/tmp/junk') + .then(() => t.fail(MSG)) + .catch(err => { + t.match(err.message, /ENOENT/, MSG) + }) +}) + +test('config - set', async t => { + const configPath = makeTmpPath() + + { + const message = 'set does not allow empty config' + // @ts-expect-error : wrong types + await set(configPath) + .then(() => t.fail(message)) + .catch(err => { + t.match(err.message, /Invalid config/, message + ' (message)') + t.match(err.cause, /must be object/, message + ' (cause)') + }) + } + + { + const config = { + accounts: [{ + name: 'dog', + address: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + data: { + admin: {}, + registration: {} + } + }], + selectedAccount: 'dog', + endpoints: { + "test-net": 'wss://dog.xyz' + }, + 'migration-version': migrations.at(-1)?.version + } + // @ts-expect-error : wrong types + await set(configPath, config) + .catch(err => { + t.fail('set worked') + console.log(err.cause) + }) + + const actual = await get(configPath) + t.deepEqual(config, actual, 'set works') + } + + t.end() +}) + +test('config - init', async t => { + const configPath = makeTmpPath() + + let config + { + await init(configPath, fakeOldConfigPath) + const expected = migrateData(migrations) + config = await get(configPath) + t.deepEqual(config, expected, 'init empty state') + } + + // re-run init after mutating config + { + const newConfig = { + ...config, + manualAddition: 'boop' + } + await set(configPath, newConfig) + await init(configPath, fakeOldConfigPath) + config = await get(configPath) + t.deepEqual(config, newConfig, 'init does not over-write manual changes') + } + + // NOTE: there's scope for more testsing here but this is a decent start + + t.end() +}) + +test('config - init (migration)', async t => { + const configPath = makeTmpPath().replace('/tmp', '/tmp/some-folder') + const oldConfigPath = makeTmpPath() + + // old setup + await init(oldConfigPath, '/tmp/fake-old-config-path') + + // customisation (to prove move done) + let config = await get(oldConfigPath) + const newConfig = { + ...config, + manualAddition: 'boop' + } + await set(oldConfigPath, newConfig) + + + // init with new path + await init(configPath, oldConfigPath) + config = await get(configPath) + t.deepEqual(config, newConfig, 'init migrates data to new location') + + await get(oldConfigPath) + .then(() => t.fail('old config should be empty')) + .catch(() => t.pass('old config should be empty')) + + t.end() +}) + + +test('config - assertConfig', t => { + const config = migrateData(migrations) + config.accounts.push({ + name: "miscy", + address: "woopswoopswoopswoopswoopswoopswoops", + data:{ + admin: {} + } + }) + + try { + assertConfig(config) + t.fail('should not be printing this, should have thrown') + } catch (err) { + t.equal(err.message, 'Invalid config', 'err.message') + t.equal( + err.cause, + [ + 'config/accounts/0/address: must match pattern "^[a-km-zA-HJ-NP-Z1-9]{48,48}$"', + // TODO: could pretty this up to say "a Base58 encoded key 48 characters long" + 'config/accounts/0/data: must have required property \'registration\'' + ].join('; '), + 'err.cause' + ) + } + + t.end() +}) diff --git a/tests/config/is-valid-config.test.ts b/tests/config/is-valid-config.test.ts new file mode 100644 index 0000000..2d75b7a --- /dev/null +++ b/tests/config/is-valid-config.test.ts @@ -0,0 +1,280 @@ +import test from 'tape' + +import migrations from '../../src/config/migrations' +import { migrateData, isValidConfig } from '../../src/config' + +test('config - isValidConfig', t => { + t.false(isValidConfig({}), 'empty object => false') + const initialState = migrateData(migrations) + + function sweetAs (config, msg) { + const isValid = isValidConfig(config) + t.true(isValid, msg) + if (!isValid) { + console.error(isValidConfig.errors) + console.error(config) + } + } + + sweetAs(initialState, 'initial state => true') + + console.log('---') + + /* accounts */ + { + const configAccountsMissing = makeConfig({ accounts: undefined }) + t.false(isValidConfig(configAccountsMissing), "accounts: ommitted => false") + + /* account.name */ + { + const configAccountNameUndefined = makeConfig({ + accounts: [ + makeConfigAccount({ name: undefined }) + ] + }) + t.false(isValidConfig(configAccountNameUndefined), "accounts[0].name: undefined => false") + + const configAccountNameBad = makeConfig({ + accounts: [ + makeConfigAccount({ name: 4 }) + ] + }) + t.false(isValidConfig(configAccountNameBad), "accounts[0].name: 4 => false") + } + + /* account.address */ + { + const configAccountAddressUndefined = makeConfig({ + accounts: [ + makeConfigAccount({ address: undefined }) + ] + }) + t.false(isValidConfig(configAccountAddressUndefined), "accounts[0].address: undefined => false") + + const configAccountAddressBad = makeConfig({ + accounts: [ + makeConfigAccount({ address: 'doop' }) + ] + }) + t.false(isValidConfig(configAccountAddressBad), "accounts[0].address: doop => false") + } + + /* account.data */ + { + const configAccountDataUndefined = makeConfig({ + accounts: [ + makeConfigAccount({ address: undefined }) + ] + }) + t.false(isValidConfig(configAccountDataUndefined), "accounts[0].data: undefined => false") + + const configAcountDataAdminUndefined = makeConfig() + // @ts-expect-error + delete configAcountDataAdminUndefined.accounts[0].data.admin + t.false(isValidConfig(configAcountDataAdminUndefined), "accounts[0].data.admin: undefined => false") + + const configAcountDataRegistrationUndefined = makeConfig() + // @ts-expect-error + delete configAcountDataRegistrationUndefined.accounts[0].data.registration + t.false(isValidConfig(configAcountDataRegistrationUndefined), "accounts[0].data.registration: undefined => false") + + // TODO: define more closely which data we expect / require / need + // NOTE: should do this after keyring work + } + + } + + console.log('---') + + /* selectedAccount */ + { + const config = makeConfig() + + // @ts-expect-error + config.selectedAccount = null + sweetAs(config, "selectedAccount: ommitted => true") + + config.selectedAccount = "" + // TODO: change this, it seems crazy to me! + t.false(isValidConfig(config), "selectedAccount: '' => false") + + // @ts-expect-error + config.selectedAccount = 100 + t.false(isValidConfig(config), "selectedAccount: 100 => false") + + config.selectedAccount = "noonoo" + t.false(isValidConfig(config), "selectedAccount: noonoo (does not match named account) => false") + } + + console.log('---') + + /* endpoints */ + { + const config = makeConfig() + + /* Custom endpoint */ + + // @ts-expect-error + config.endpoints.mixmix = "wss://testnet.mixmix.xyz" + sweetAs(config, "endpoints: custom valid endpoint (wss) => true") + + // @ts-expect-error + config.endpoints.mixmix = "ws://testnet.mixmix.xyz" + sweetAs(config, "endpoints: custom valid endpoint (ws) => true") + + // @ts-expect-error + config.endpoints.mixmix = "ws://testnet.mixmix.xyz" + sweetAs(config, "endpoints: custom valid endpoint (ws) => true") + + // @ts-expect-error + config.endpoints.mixmix = "http://testnet.mixmix.xyz" + t.false(isValidConfig(config), "endpoints: invalid endpoint => false") + + // @ts-expect-error + delete config.endpoints.mixmix + + /* Required endpoints */ + + // @ts-expect-error + delete config.endpoints["test-net"] + t.false(isValidConfig(config), "endpoints: no 'test-net' => false") + + // @ts-expect-error + delete config.endpoints + t.false(isValidConfig(config), "endpoints: ommitted => false") + + } + + console.log('---') + + /* migration-version */ + { + const config = makeConfig({ 'migration-version': undefined }) + t.false(isValidConfig(config), "migration-version: ommitted => false") + + config['migration-version'] = 99 + t.false(isValidConfig(config), "migration-version: wrong number => false") + + // @ts-expect-error + config['migration-version'] = "4" + t.false(isValidConfig(config), "migration-version: string int => false") + + // @ts-expect-error + config['migration-version'] = "dog" + t.false(isValidConfig(config), "migration-version: string => false") + } + + console.log('---') + + /* Errors */ + { + const config = makeConfig() + isValidConfig(config) + t.equal(isValidConfig.errors, null, 'isValidCOnfig.errors') + + // @ts-expect-error + delete config["migration-version"] + // @ts-expect-error + delete config.endpoints["test-net"] + isValidConfig(config) + t.deepEqual( + isValidConfig.errors, + [ + { + instancePath: '', + schemaPath: '#/required', + keyword: 'required', + params: { missingProperty: 'migration-version' }, + message: "must have required property 'migration-version'" + }, + { + instancePath: '/endpoints', + schemaPath: '#/properties/endpoints/required', + keyword: 'required', + params: { missingProperty: 'test-net' }, + message: "must have required property 'test-net'" + } + ], + 'isValidCOnfig.errors' + ) + } + + t.end() +}) + +// NOTE: this represents the current expected state of the config +// If you add a migration you should expect to have to update this + +function makeConfig (override?: object) { + const config = { + accounts: [makeConfigAccount()], + "selectedAccount": "naynay", + "endpoints": { + "dev": "ws://127.0.0.1:9944", + "test-net": "wss://testnet.entropy.xyz", + "stg": "wss://api.staging.testnet.testnet-2024.infrastructure.entropy.xyz" + }, + "migration-version": 5, + } + + if (override) { + return { ...config, ...override } + // NOTE: shallow merge + } + else return config +} + +function makeConfigAccount (override?: object) { + const configAccount = { + "name": "naynay", + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "data": { + "debug": true, + "seed": "0x89bf4bc476c0173237ec856cdf864dfcaff0e80d87fb3419d40100c59088eb92", + "admin": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "type": "registration", + "verifyingKeys": [], + "userContext": "ADMIN_KEY", + "seed": "0x89bf4bc476c0173237ec856cdf864dfcaff0e80d87fb3419d40100c59088eb92", + "path": "", + "pair": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "addressRaw": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "isLocked": false, + "meta": {}, + "publicKey": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "type": "sr25519", + "secretKey": "data:application/UI8A;base64,aCCXH0+nI2+tot94NPegNBCwe1bWgw57xPo9Iss2DmoE5obkxD5JUXujRpsHEoltI0hD9SAUGO9GeV+8rGEkUg==" + } + }, + "registration": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "type": "registration", + "verifyingKeys": [ + "0x02ecbc1c6777e868c8cc50c9784e95d3a4727bdb5a04d7694d2880c980f15e17c3" + ], + "userContext": "ADMIN_KEY", + "seed": "0x89bf4bc476c0173237ec856cdf864dfcaff0e80d87fb3419d40100c59088eb92", + "path": "", + "pair": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "addressRaw": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "isLocked": false, + "meta": {}, + "publicKey": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "type": "sr25519", + "secretKey": "data:application/UI8A;base64,aCCXH0+nI2+tot94NPegNBCwe1bWgw57xPo9Iss2DmoE5obkxD5JUXujRpsHEoltI0hD9SAUGO9GeV+8rGEkUg==" + } + } + } + } + + if (override) { + return { ...configAccount, ...override } + // NOTE: shallow merge + } + else return configAccount +} + + diff --git a/tests/config.test.ts b/tests/config/migrations.test.ts similarity index 77% rename from tests/config.test.ts rename to tests/config/migrations.test.ts index cee0796..fd6b132 100644 --- a/tests/config.test.ts +++ b/tests/config/migrations.test.ts @@ -1,26 +1,9 @@ import test from 'tape' -import { writeFile } from 'node:fs/promises' -import migrations from '../src/config/migrations' -import { migrateData, init, get, set } from '../src/config' -import * as encoding from '../src/config/encoding' -// used to ensure unique test ids -let id = Date.now() -const makeTmpPath = () => `/tmp/entropy-cli-${id++}.json` -const fakeOldConfigPath = '/tmp/fake-old-config.json' +import migrations from '../../src/config/migrations' +import { migrateData } from '../../src/config' -test('config/migrations', async t => { - migrations.forEach(({ migrate, version }, i) => { - const versionNum = Number(version) - t.equal(versionNum, i, `${versionNum} - version`) - t.equal(typeof migrate({}), 'object', `${versionNum} - migrate`) - }) - - // TODO: could be paranoid + check each file src/config/migrations/\d\d.ts is exported in "migrations" - t.end() -}) - -test('config - migrateData', async t => { +test('config.migrateData', async t => { const migrations = [ { version: '0', @@ -66,118 +49,23 @@ test('config - migrateData', async t => { t.end() }) -const makeKey = () => new Uint8Array( - Array(32).fill(0).map((_, i) => i * 2 + 1) -) - -test('config - get', async t => { - const configPath = makeTmpPath() - const config = { - boop: 'doop', - secretKey: makeKey() - } - await writeFile(configPath, encoding.serialize(config)) - - const result = await get(configPath) - t.deepEqual(result, config, 'get works') - - const MSG = 'path that does not exist fails' - await get('/tmp/junk') - .then(() => t.fail(MSG)) - .catch(err => { - t.match(err.message, /ENOENT/, MSG) - }) -}) - -test('config - set', async t => { - const configPath = makeTmpPath() - - { - const message = 'set does not allow empty config' - // @ts-expect-error : wrong types - await set(configPath) - .then(() => t.fail(message)) - .catch(err => { - t.match(err.message, /config must be an object/, message) - }) - } - - { - const config = { - accounts: [{ - name: 'dog' - }], - selectedAccount: 'dog', - endpoints: {}, - 'migration-version': 1200 - } - // @ts-expect-error : wrong types - await set(configPath, config) - - const actual = await get(configPath) - t.deepEqual(config, actual, 'set works') - } - - t.end() -}) - -test('config - init', async t => { - const configPath = makeTmpPath() - - let config - { - await init(configPath, fakeOldConfigPath) - const expected = migrateData(migrations) - config = await get(configPath) - t.deepEqual(config, expected, 'init empty state') - } +test('config/migrations', async t => { + migrations.forEach(({ migrate, version }, i) => { + const versionNum = Number(version) + t.equal(versionNum, i, `${versionNum} - version`) - // re-run init after mutating config - { - const newConfig = { - ...config, - manualAddition: 'boop' + try { + const result = migrate({}) + t.equal(typeof result, 'object', `${versionNum} - migrate`) + } catch (err) { + t.match(err.message, /^Migration\s\d{1,2} failed/, `${versionNum} - migrate`) } - await set(configPath, newConfig) - await init(configPath, fakeOldConfigPath) - config = await get(configPath) - t.deepEqual(config, newConfig, 'init does not over-write manual changes') - } - - // NOTE: there's scope for more testsing here but this is a decent start - - t.end() -}) - -test('config - init (migration)', async t => { - const configPath = makeTmpPath().replace('/tmp', '/tmp/some-folder') - const oldConfigPath = makeTmpPath() - - // old setup - await init(oldConfigPath, '/tmp/fake-old-config-path') - - // customisation (to prove move done) - let config = await get(oldConfigPath) - const newConfig = { - ...config, - manualAddition: 'boop' - } - await set(oldConfigPath, newConfig) - - - // init with new path - await init(configPath, oldConfigPath) - config = await get(configPath) - t.deepEqual(config, newConfig, 'init migrates data to new location') - - await get(oldConfigPath) - .then(() => t.fail('old config should be empty')) - .catch(() => t.pass('old config should be empty')) + }) + // TODO: could be paranoid + check each file src/config/migrations/\d\d.ts is exported in "migrations" t.end() }) - test('config/migrations/02', t => { const initial = JSON.parse( '{"accounts":[{"name":"Mix","address":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","data":{"debug":true,"seed":"0xc4c466182b86ff1f4a16548df79c5808ab9bcde87c22c27938ac9aabc4300840","admin":{"address":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","type":"registration","verifyingKeys":["0x03b225d2032e1dbff26316cc8b7d695b3386400d30ce004c1b42e2c28bcd834039"],"userContext":"ADMIN_KEY","seed":"0xc4c466182b86ff1f4a16548df79c5808ab9bcde87c22c27938ac9aabc4300840","path":"","pair":{"address":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","addressRaw":{"0":182,"1":241,"2":171,"3":246,"4":239,"5":100,"6":192,"7":41,"8":49,"9":32,"10":10,"11":84,"12":241,"13":225,"14":183,"15":152,"16":164,"17":182,"18":176,"19":244,"20":39,"21":237,"22":74,"23":225,"24":250,"25":244,"26":187,"27":129,"28":97,"29":222,"30":33,"31":116},"isLocked":false,"meta":{},"publicKey":{"0":182,"1":241,"2":171,"3":246,"4":239,"5":100,"6":192,"7":41,"8":49,"9":32,"10":10,"11":84,"12":241,"13":225,"14":183,"15":152,"16":164,"17":182,"18":176,"19":244,"20":39,"21":237,"22":74,"23":225,"24":250,"25":244,"26":187,"27":129,"28":97,"29":222,"30":33,"31":116},"type":"sr25519","secretKey":{"0":120,"1":247,"2":1,"3":38,"4":246,"5":195,"6":0,"7":49,"8":84,"9":240,"10":226,"11":144,"12":66,"13":172,"14":130,"15":168,"16":237,"17":74,"18":121,"19":243,"20":49,"21":217,"22":208,"23":70,"24":160,"25":220,"26":125,"27":114,"28":230,"29":17,"30":254,"31":71,"32":158,"33":68,"34":133,"35":24,"36":119,"37":34,"38":46,"39":154,"40":85,"41":62,"42":178,"43":69,"44":206,"45":217,"46":132,"47":184,"48":8,"49":219,"50":89,"51":165,"52":189,"53":106,"54":6,"55":51,"56":112,"57":76,"58":42,"59":157,"60":146,"61":130,"62":203,"63":241}},"used":true},"registration":{"address":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","type":"registration","verifyingKeys":["0x03b225d2032e1dbff26316cc8b7d695b3386400d30ce004c1b42e2c28bcd834039"],"userContext":"ADMIN_KEY","seed":"0xc4c466182b86ff1f4a16548df79c5808ab9bcde87c22c27938ac9aabc4300840","path":"","pair":{"address":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","addressRaw":{"0":182,"1":241,"2":171,"3":246,"4":239,"5":100,"6":192,"7":41,"8":49,"9":32,"10":10,"11":84,"12":241,"13":225,"14":183,"15":152,"16":164,"17":182,"18":176,"19":244,"20":39,"21":237,"22":74,"23":225,"24":250,"25":244,"26":187,"27":129,"28":97,"29":222,"30":33,"31":116},"isLocked":false,"meta":{},"publicKey":{"0":182,"1":241,"2":171,"3":246,"4":239,"5":100,"6":192,"7":41,"8":49,"9":32,"10":10,"11":84,"12":241,"13":225,"14":183,"15":152,"16":164,"17":182,"18":176,"19":244,"20":39,"21":237,"22":74,"23":225,"24":250,"25":244,"26":187,"27":129,"28":97,"29":222,"30":33,"31":116},"type":"sr25519","secretKey":{"0":120,"1":247,"2":1,"3":38,"4":246,"5":195,"6":0,"7":49,"8":84,"9":240,"10":226,"11":144,"12":66,"13":172,"14":130,"15":168,"16":237,"17":74,"18":121,"19":243,"20":49,"21":217,"22":208,"23":70,"24":160,"25":220,"26":125,"27":114,"28":230,"29":17,"30":254,"31":71,"32":158,"33":68,"34":133,"35":24,"36":119,"37":34,"38":46,"39":154,"40":85,"41":62,"42":178,"43":69,"44":206,"45":217,"46":132,"47":184,"48":8,"49":219,"50":89,"51":165,"52":189,"53":106,"54":6,"55":51,"56":112,"57":76,"58":42,"59":157,"60":146,"61":130,"62":203,"63":241}},"used":true},"deviceKey":{"address":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","type":"deviceKey","verifyingKeys":["0x03b225d2032e1dbff26316cc8b7d695b3386400d30ce004c1b42e2c28bcd834039"],"userContext":"CONSUMER_KEY","seed":"0xc4c466182b86ff1f4a16548df79c5808ab9bcde87c22c27938ac9aabc4300840","path":"","pair":{"address":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","addressRaw":{"0":182,"1":241,"2":171,"3":246,"4":239,"5":100,"6":192,"7":41,"8":49,"9":32,"10":10,"11":84,"12":241,"13":225,"14":183,"15":152,"16":164,"17":182,"18":176,"19":244,"20":39,"21":237,"22":74,"23":225,"24":250,"25":244,"26":187,"27":129,"28":97,"29":222,"30":33,"31":116},"isLocked":false,"meta":{},"publicKey":{"0":182,"1":241,"2":171,"3":246,"4":239,"5":100,"6":192,"7":41,"8":49,"9":32,"10":10,"11":84,"12":241,"13":225,"14":183,"15":152,"16":164,"17":182,"18":176,"19":244,"20":39,"21":237,"22":74,"23":225,"24":250,"25":244,"26":187,"27":129,"28":97,"29":222,"30":33,"31":116},"type":"sr25519","secretKey":{"0":120,"1":247,"2":1,"3":38,"4":246,"5":195,"6":0,"7":49,"8":84,"9":240,"10":226,"11":144,"12":66,"13":172,"14":130,"15":168,"16":237,"17":74,"18":121,"19":243,"20":49,"21":217,"22":208,"23":70,"24":160,"25":220,"26":125,"27":114,"28":230,"29":17,"30":254,"31":71,"32":158,"33":68,"34":133,"35":24,"36":119,"37":34,"38":46,"39":154,"40":85,"41":62,"42":178,"43":69,"44":206,"45":217,"46":132,"47":184,"48":8,"49":219,"50":89,"51":165,"52":189,"53":106,"54":6,"55":51,"56":112,"57":76,"58":42,"59":157,"60":146,"61":130,"62":203,"63":241}},"used":true}}}],"selectedAccount":"5GCaN3fcL6vAQQKamHzVSorwv2XqtM3WcxosCLd9JqGVrtS8","endpoints":{"dev":"ws://127.0.0.1:9944","test-net":"wss://testnet.entropy.xyz"},"migration-version":1}' @@ -338,8 +226,7 @@ test('config/migrations/04', { objectPrintDepth: 10 }, t => { "test-net": "wss://testnet.entropy.xyz", "stg": "wss://api.staging.testnet.testnet-2024.infrastructure.entropy.xyz" }, - "migration-version": 3, - "setSelectedAccount": "naynay" + "migration-version": 3 } const migrated = migrations[4].migrate(initial) @@ -400,9 +287,133 @@ test('config/migrations/04', { objectPrintDepth: 10 }, t => { "stg": "wss://api.staging.testnet.testnet-2024.infrastructure.entropy.xyz" }, "migration-version": 3, - "setSelectedAccount": "naynay" - } + }, + 'wiped verifyingKeys' ) t.end() }) + +test('config/migrations/05', { objectPrintDepth: 10 }, t => { + // empty initialState + { + const initial = { + "accounts": [], + "selectedAccount": null, + "endpoints": { + "dev": "ws://127.0.0.1:9944", + "test-net": "wss://testnet.entropy.xyz", + "stg": "wss://api.staging.testnet.testnet-2024.infrastructure.entropy.xyz" + }, + "migration-version": 4, + } + + const migrated = migrations[5].migrate(initial) + const expected = { + "accounts": [], + "selectedAccount": null, + "endpoints": { + "dev": "ws://127.0.0.1:9944", + "test-net": "wss://testnet.entropy.xyz", + "stg": "wss://api.staging.testnet.testnet-2024.infrastructure.entropy.xyz" + }, + "migration-version": 4, + } + + t.deepEqual(migrated, expected, 'changed selectedAccount: "" => null') + } + + const makeConfigV4 = () => ({ + "accounts": [ + { + "name": "naynay", + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "data": { + "debug": true, + "seed": "0x89bf4bc476c0173237ec856cdf864dfcaff0e80d87fb3419d40100c59088eb92", + "admin": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "type": "registration", + "verifyingKeys": [], + "userContext": "ADMIN_KEY", + "seed": "0x89bf4bc476c0173237ec856cdf864dfcaff0e80d87fb3419d40100c59088eb92", + "path": "", + "pair": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "addressRaw": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "isLocked": false, + "meta": {}, + "publicKey": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "type": "sr25519", + "secretKey": "data:application/UI8A;base64,aCCXH0+nI2+tot94NPegNBCwe1bWgw57xPo9Iss2DmoE5obkxD5JUXujRpsHEoltI0hD9SAUGO9GeV+8rGEkUg==" + } + }, + "registration": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "type": "registration", + "verifyingKeys": [ + "0x02ecbc1c6777e868c8cc50c9784e95d3a4727bdb5a04d7694d2880c980f15e17c3" + ], + "userContext": "ADMIN_KEY", + "seed": "0x89bf4bc476c0173237ec856cdf864dfcaff0e80d87fb3419d40100c59088eb92", + "path": "", + "pair": { + "address": "5Cfxtz2fA9qBSF1QEuELbyy41JNwai1mp9SrDaHj8rR9am8S", + "addressRaw": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "isLocked": false, + "meta": {}, + "publicKey": "data:application/UI8A;base64,GuQ30RLMK/WEPz+g1qoliXGcRH7lk20/xHY0qvjdz08=", + "type": "sr25519", + "secretKey": "data:application/UI8A;base64,aCCXH0+nI2+tot94NPegNBCwe1bWgw57xPo9Iss2DmoE5obkxD5JUXujRpsHEoltI0hD9SAUGO9GeV+8rGEkUg==" + } + } + } + }, + ], + "selectedAccount": "naynay", + "endpoints": { + "dev": "ws://127.0.0.1:9944", + "test-net": "wss://testnet.entropy.xyz", + "stg": "wss://api.staging.testnet.testnet-2024.infrastructure.entropy.xyz" + }, + "migration-version": 4, + }) + + /* selectedAccount: */ + { + const initial = makeConfigV4() + const migrated = migrations[5].migrate(initial) + const expected = makeConfigV4() + + t.deepEqual(migrated, expected, "selectedAccount: (no change)") + } + + /* selectedAccount:
*/ + { + const initial = makeConfigV4() + initial.selectedAccount = initial.accounts[0].address + const migrated = migrations[5].migrate(initial) + + const expected = makeConfigV4() + + t.deepEqual(migrated, expected, "selectedAccount:
(changes to )") + } + + /* selectedAccount:
(unhappy path) */ + { + const initial = makeConfigV4() + initial.selectedAccount = "aaaaayyyyyeee9SrDaHj8rR9am8S5Cfxtz2fA9qBSF1QEuEL" + + const MSG = "selectedAccount:
(throws if cannot find )" + try { + migrations[5].migrate(initial) + + // should not reach hree + t.fail(MSG) + } catch (err) { + t.match(err.cause.message, /unable to correct selectedAccount/, MSG) + } + } + + t.end() +}) diff --git a/yarn.lock b/yarn.lock index 2fb0eff..c4156bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1125,6 +1125,16 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" @@ -1938,6 +1948,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.3.tgz#892a1c91802d5d7860de728f18608a0573142241" + integrity sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw== + fastq@^1.6.0: version "1.17.1" resolved "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz" @@ -2582,6 +2597,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" @@ -3158,6 +3178,11 @@ repeat-string@^1.5.2: resolved "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz" integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"