@iexec/iapp
Version:
A CLI to guide you through the process of building an iExec iApp
231 lines (219 loc) • 7.62 kB
text/typescript
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { getSpinner } from '../cli-helpers/spinner.js';
import { fileExists } from '../utils/fs.utils.js';
import { PROTECTED_DATA_MOCK_DIR } from '../config/config.js';
import { handleCliError } from '../cli-helpers/handleCliError.js';
import {
createZipFromObject,
extractDataSchema,
ALLOWED_KEY_NAMES_REGEXP,
DataScalarType,
DataObject,
DataSchema,
DataSchemaEntryType,
} from '../libs/dataprotector.js';
import { goToProjectRoot } from '../cli-helpers/goToProjectRoot.js';
import * as color from '../cli-helpers/color.js';
import { hintBox, objectBox } from '../cli-helpers/box.js';
export async function mockProtectedData() {
const spinner = getSpinner();
try {
await goToProjectRoot({ spinner });
async function buildData({ dataState = {}, dataSchema = {} } = {}) {
// get data fragment key
const { key }: { key: string } = await spinner.prompt<'key'>({
type: 'text',
name: 'key',
message:
"What key do you want to use to store the piece of data? (use '.' to access nested keys)",
});
// check key is valid
const keyPath = key.split('.');
const keyFragmentErrors = keyPath
.map((fragment) => {
if (fragment === '') {
return `Empty key fragment`;
}
if (!ALLOWED_KEY_NAMES_REGEXP.test(fragment)) {
return `Unsupported special character in key`;
}
})
.filter(Boolean);
// get data fragment type
if (keyFragmentErrors.length === 0) {
const BOOLEAN = 'boolean';
const NUMBER = 'number';
const STRING = 'string';
const FILE = 'file';
const { type } = await spinner.prompt({
type: 'select',
name: 'type',
message: `What kind of data is \`${key}\`?`,
choices: [
{ title: BOOLEAN, value: BOOLEAN },
{ title: NUMBER, value: NUMBER },
{ title: STRING, value: STRING },
{ title: FILE, value: FILE },
{ title: `My bad, I don't want to add data at \`${key}\`` },
],
});
// get data fragment value
let value: DataScalarType | undefined = undefined;
switch (type) {
case BOOLEAN:
{
const res = await spinner.prompt({
type: 'select',
name: 'value',
message: `What is the value of \`${key}\`?`,
choices: [
{ title: 'true', value: true },
{ title: 'false', value: false },
],
});
value = res.value;
}
break;
case NUMBER:
{
const res = await spinner.prompt({
type: 'text',
name: 'value',
message: `What is the value of \`${key}\`? (${NUMBER})`,
});
const numValue = Number(res.value);
if (!Number.isNaN(numValue)) {
value = numValue;
} else {
spinner.warn('Invalid input, should be a number');
}
}
break;
case STRING:
{
const res = await spinner.prompt({
type: 'text',
name: 'value',
message: `What is the value of \`${key}\`? (${STRING})`,
});
value = res.value;
}
break;
case FILE:
{
const { path } = await spinner.prompt({
type: 'text',
name: 'path',
message: `Where is the file located? (path)`,
});
const exists = await fileExists(path);
if (exists) {
const stats = await stat(path);
if (stats.isFile()) {
const buffer = await readFile(path);
value = buffer;
} else {
spinner.warn(
'Invalid path, the node at specified path is not a file'
);
}
} else {
spinner.warn('Invalid path, no file at specified path');
}
}
break;
default:
break;
}
// build data fragment
if (value !== undefined) {
const setNestedKeyValue = <
O extends DataObject | DataSchema,
T extends DataScalarType | DataSchemaEntryType,
>(
obj: O,
path: string[],
value: T
) => {
const [currentKey, ...nextPath] = path;
if (nextPath.length === 0) {
obj[currentKey] = value;
} else {
// create nested object if needed
if (
typeof obj[currentKey] !== 'object' || // key is not an object
obj[currentKey].constructor.name !== 'Object' // key is a special object (file Buffer for example)
) {
obj[currentKey] = {};
}
setNestedKeyValue(
obj[currentKey] as DataObject | DataSchema,
nextPath,
value
);
}
};
setNestedKeyValue(dataState, keyPath, value);
const schema = await extractDataSchema({ dataType: value });
setNestedKeyValue(
dataSchema,
keyPath,
schema.dataType as DataSchemaEntryType
);
}
} else {
spinner.warn(`Invalid key: ${keyFragmentErrors.join(', ')}`);
}
spinner.info(
`This is how your protectedData looks so far:
${objectBox(JSON.stringify(dataSchema, null, 2))}`
);
const { addMore } = await spinner.prompt({
type: 'confirm',
name: 'addMore',
message: `Would you add more data?`,
initial: true,
});
if (addMore) {
return buildData({ dataState, dataSchema });
}
return dataState;
}
spinner.info(
'Answer a few questions to create your custom protectedData mock'
);
spinner.start('Building protectedData mock...');
const { mockName } = await spinner.prompt({
type: 'text',
name: 'mockName',
message:
'Choose a name for your protectedData mock (you will be able to use your mock in tests like this `iapp test --protectedData <name>`)',
initial: 'default',
});
const data = await buildData();
if (Object.keys(data).length === 0) {
throw Error('Data is empty, creation aborted');
}
spinner.start(
`Creating protectedData mock file in ${color.file(PROTECTED_DATA_MOCK_DIR)} directory...`
);
const unencryptedData = await createZipFromObject(data);
const schema = await extractDataSchema(data);
await mkdir(PROTECTED_DATA_MOCK_DIR, { recursive: true });
await writeFile(join(PROTECTED_DATA_MOCK_DIR, mockName), unencryptedData);
spinner.succeed(
`Mocked protectedData ${color.file(mockName)} created in ${color.file(PROTECTED_DATA_MOCK_DIR)} directory`
);
spinner.log(
hintBox(
`protectedData mock "${mockName}" schema:
${color.command(objectBox(JSON.stringify(schema, null, 2)))}
Use your mock in tests:
${color.command(`iapp test --protectedData "${mockName}"`)}`
)
);
} catch (error) {
handleCliError({ spinner, error });
}
}