diff --git a/tools/api-tester/.gitignore b/tools/api-tester/.gitignore new file mode 100644 index 00000000..e9abc7f6 --- /dev/null +++ b/tools/api-tester/.gitignore @@ -0,0 +1 @@ +config.yml \ No newline at end of file diff --git a/tools/api-tester/README.md b/tools/api-tester/README.md new file mode 100644 index 00000000..eeaa526a --- /dev/null +++ b/tools/api-tester/README.md @@ -0,0 +1,9 @@ +## It takes 3 steps to run the tests :) + +1. run `npm install` +2. copy `example_config.yml` and add the correct values +3. run `node apitest.js --config=your_config_file.yml` + +## Here's what it looks like when it's working + +![image](https://github.com/HeyPuter/puter-api-test/assets/7225168/115aca70-02ea-4ce1-9d5c-1568feb1f851) diff --git a/tools/api-tester/apitest.js b/tools/api-tester/apitest.js new file mode 100644 index 00000000..a7aba8ea --- /dev/null +++ b/tools/api-tester/apitest.js @@ -0,0 +1,112 @@ +const YAML = require('yaml'); + +const TestSDK = require('./lib/TestSDK'); +const log_error = require('./lib/log_error'); +const TestRegistry = require('./lib/TestRegistry'); + +const fs = require('node:fs'); +const { parseArgs } = require('node:util'); + +const args = process.argv.slice(2); + +let config, report; + +try { + ({ values: { + config, + report, + bench, + unit, + }, positionals: [id] } = parseArgs({ + options: { + config: { + type: 'string', + }, + report: { + type: 'string', + }, + bench: { type: 'boolean' }, + unit: { type: 'boolean' }, + }, + allowPositionals: true, + })); +} catch (e) { + if ( args.length < 1 ) { + console.error( + 'Usage: apitest [OPTIONS]\n' + + '\n' + + 'Options:\n' + + ' --config= (required) Path to configuration file\n' + + ' --report= (optional) Output file for full test results\n' + + '' + ); + process.exit(1); + } +} + + +const conf = YAML.parse(fs.readFileSync(config).toString()); + + +const main = async () => { + const ts = new TestSDK(conf); + try { + await ts.delete('api_test', { recursive: true }); + } catch (e) { + } + await ts.mkdir('api_test', { overwrite: true }); + ts.cd('api_test'); + + const registry = new TestRegistry(ts); + + registry.add_test_sdk('puter-rest.v1', require('./test_sdks/puter-rest')({ + config: conf, + })); + + require('./tests/__entry__.js')(registry); + require('./benches/simple.js')(registry); + + if ( id ) { + if ( unit ) { + await registry.run_test(id); + } else if ( bench ) { + await registry.run_bench(id); + } else { + await registry.run(id); + } + return; + } + + if ( unit ) { + await registry.run_all_tests(); + } else if ( bench ) { + await registry.run_all_benches(); + } else { + await registry.run_all(); + } + + + // await ts.runTestPackage(require('./tests/write_cart')); + // await ts.runTestPackage(require('./tests/move_cart')); + // await ts.runTestPackage(require('./tests/copy_cart')); + // await ts.runTestPackage(require('./tests/write_and_read')); + // await ts.runTestPackage(require('./tests/move')); + // await ts.runTestPackage(require('./tests/stat')); + // await ts.runTestPackage(require('./tests/readdir')); + // await ts.runTestPackage(require('./tests/mkdir')); + // await ts.runTestPackage(require('./tests/batch')); + // await ts.runTestPackage(require('./tests/delete')); + const all = unit && bench; + if ( all || unit ) ts.printTestResults(); + if ( all || bench ) ts.printBenchmarkResults(); +} + +const main_e = async () => { + try { + await main(); + } catch (e) { + log_error(e); + } +} + +main_e(); diff --git a/tools/api-tester/benches/simple.js b/tools/api-tester/benches/simple.js new file mode 100644 index 00000000..465f47ad --- /dev/null +++ b/tools/api-tester/benches/simple.js @@ -0,0 +1,121 @@ +const log_error = require("../lib/log_error"); + +module.exports = registry => { + registry.add_bench('write.tiny', { + name: 'write 30 tiny files', + do: async t => { + for ( let i=0 ; i < 30 ; i++ ) { + await t.write(`tiny_${i}.txt`, 'example\n', { overwrite: true }); + } + } + }); + + registry.add_bench('batch.mkdir-and-write', { + name: 'make directories and write', + do: async t => { + const batch = []; + for ( let i=0 ; i < 30 ; i++ ) { + batch.push({ + op: 'mkdir', + path: t.resolve(`dir_${i}`), + }); + batch.push({ + op: 'write', + path: t.resolve(`tiny_${i}.txt`), + }); + } + await t.batch('batch', batch, Array(30).fill('example\n')); + } + }); + + registry.add_bench('batch.mkdir-deps.1', { + name: 'make directories and write', + do: async t => { + const batch = []; + const blobs = []; + for ( let j=0 ; j < 3 ; j++ ) { + batch.push({ + op: 'mkdir', + path: t.resolve('dir_root'), + as: 'root', + }) + for ( let i=0 ; i < 10 ; i++ ) { + batch.push({ + op: 'write', + path: `$root/test_${i}.txt` + }); + blobs.push('example\n'); + } + } + await t.batch('batch', batch, blobs); + } + }); + + // TODO: write explicit test for multiple directories with the same name + // in a batch so that batch can eventually resolve this situation and not + // do something incredibly silly. + registry.add_bench('batch.mkdir-deps.2', { + name: 'make directories and write', + do: async t => { + const batch = []; + const blobs = []; + for ( let j=0 ; j < 3 ; j++ ) { + batch.push({ + op: 'mkdir', + path: t.resolve(`dir_${j}`), + as: `dir_${j}`, + }) + for ( let k=0 ; k < 3 ; k++ ) { + batch.push({ + op: 'mkdir', + parent: `$dir_${j}`, + path: `subdir_${k}`, + as: `subdir_${j}-${k}`, + }) + + for ( let i=0 ; i < 5 ; i++ ) { + batch.push({ + op: 'write', + path: `$subdir_${j}-${k}/test_${i}.txt` + }); + blobs.push('example\n'); + } + } + } + try { + const response = await t.batch('batch', batch, blobs); + console.log('response?', response); + } catch (e) { + log_error(e); + } + } + }); + + registry.add_bench('write.batch.tiny', { + name: 'Write 30 tiny files in a batch', + do: async t => { + const batch = []; + for ( let i=0 ; i < 30 ; i++ ) { + batch.push({ + op: 'write', + path: t.resolve(`tiny_${i}.txt`), + }); + } + await t.batch('batch', batch, Array(30).fill('example\n')); + } + }); + + // const fiftyMB = Array(50 * 1024 * 1024).map(() => + // String.fromCharCode( + // Math.floor(Math.random() * 26) + 97 + // )); + + // registry.add_bench('files.mb50', { + // name: 'write 10 50MB files', + // do: async t => { + // for ( let i=0 ; i < 10 ; i++ ) { + // await t.write(`mb50_${i}.txt`, 'example\n', { overwrite: true }); + // } + // } + // }); +}; \ No newline at end of file diff --git a/tools/api-tester/coverage_models/copy.js b/tools/api-tester/coverage_models/copy.js new file mode 100644 index 00000000..ccd03884 --- /dev/null +++ b/tools/api-tester/coverage_models/copy.js @@ -0,0 +1,16 @@ +const CoverageModel = require("../lib/CoverageModel"); + +module.exports = new CoverageModel({ + subject: ['file', 'directory-full', 'directory-empty'], + source: { + format: ['path', 'uid'], + }, + destination: { + format: ['path', 'uid'], + }, + name: ['default', 'specified'], + conditions: { + destinationIsFile: [] + }, + overwrite: [false, 'overwrite', 'dedupe_name'], +}); diff --git a/tools/api-tester/coverage_models/move.js b/tools/api-tester/coverage_models/move.js new file mode 100644 index 00000000..51c5447d --- /dev/null +++ b/tools/api-tester/coverage_models/move.js @@ -0,0 +1,15 @@ +const CoverageModel = require("../lib/CoverageModel"); + +module.exports = new CoverageModel({ + source: { + format: ['path', 'uid'], + }, + destination: { + format: ['path', 'uid'], + }, + name: ['default', 'specified'], + conditions: { + destinationIsFile: [] + }, + overwrite: [false, 'overwrite', 'dedupe_name'] +}); diff --git a/tools/api-tester/coverage_models/write.js b/tools/api-tester/coverage_models/write.js new file mode 100644 index 00000000..8b7e4d33 --- /dev/null +++ b/tools/api-tester/coverage_models/write.js @@ -0,0 +1,16 @@ +const CoverageModel = require("../lib/CoverageModel"); + +// ?? What's a coverage model ?? +// +// See doc/cartesian.md + +module.exports = new CoverageModel({ + path: { + format: ['path', 'uid'], + }, + name: ['default', 'specified'], + conditions: { + destinationIsFile: [] + }, + overwrite: [false, 'overwrite', 'dedupe_name'], +}); diff --git a/tools/api-tester/doc/cartesian.md b/tools/api-tester/doc/cartesian.md new file mode 100644 index 00000000..2a1e80cf --- /dev/null +++ b/tools/api-tester/doc/cartesian.md @@ -0,0 +1,83 @@ +# Cartesian Tests + +A cartesian test is a test the tries every combination of possible +inputs based on some model. It's called this because the set of +possible states is mathematically the cartesian product of the +list of sets of options. + +## Coverage Model + +A coverage model is what defines all the variables and their +possible value. The coverage model implies the set of all +possible states (cartesian product). + +The following is an example of a coverage model for testing +the `/write` API method for Puter's filesystem: + +```javascript +module.exports = new CoverageModel({ + path: { + format: ['path', 'uid'], + }, + name: ['default', 'specified'], + conditions: { + destinationIsFile: [] + }, + overwrite: [], +}); +``` + +The object will first be flattened. `format` inside `path` will +become a single key: `path.format`, +just as `{ a: { x: 1, y: 2 }, b: { z: 3 } }` +becomes `{ "a.x": 1, "a.y": 2, "b.z": 3 }` + +Then, each possible state will be generated to use in tests. +For example, this is one arbitrary state: + +```json +{ + "path.format": "path", + "name": "specified", + "conditions.destinationIsFile": true, + "overwrite": false +} +``` + +Wherever an empty list is specified for the list of possible values, +it will be assumed to be `[false, true]`. + +## Finding the Culprit + +When a cartesian test fails, if you know the _index_ of the test which +failed you can determine what the state was just by looking at the +coverage model. + +For example, if tests are failing at indices `1` and `5` +(starting from `0`, of course) for the `/write` example above, +the failures are likely related and occur when the default +filename is used and the destination (`path`) parameter points +to an existing file. + +``` +destination is file: 0 1 0 1 0 1 0 1 +name is the default: 0 0 1 1 0 0 1 1 +test results: P F P P P F P P +``` + +### Interesting note about the anme + +I didn't know what this type of test was called at first. I simply knew +I wanted to try all the combinations of possible inputs, and I knew what +the algorithm to do this looked like. I then asked Chat GPT the following +question: + +> What do you call the act of choosing one item from each set in a list of sets? + +which it answered with: + +> The act of choosing one item from each set in a list of sets is typically called the Cartesian Product. + +Then after a bit of searching, it turns out neither Chat GPT nor I are the +first to use this term to describe the same thing in automated testing for +software. diff --git a/tools/api-tester/example_config.yml b/tools/api-tester/example_config.yml new file mode 100644 index 00000000..d9036482 --- /dev/null +++ b/tools/api-tester/example_config.yml @@ -0,0 +1,3 @@ +url: http://puter.localhost:4001/ +username: lavender_hat_6100 +token: --- diff --git a/tools/api-tester/lib/Assert.js b/tools/api-tester/lib/Assert.js new file mode 100644 index 00000000..f107c01e --- /dev/null +++ b/tools/api-tester/lib/Assert.js @@ -0,0 +1,11 @@ +module.exports = class Assert { + equal (expected, actual) { + this.assert(expected === actual); + } + + assert (b) { + if ( ! b ) { + throw new Error('assertion failed'); + } + } +} diff --git a/tools/api-tester/lib/CoverageModel.js b/tools/api-tester/lib/CoverageModel.js new file mode 100644 index 00000000..ef4c0de3 --- /dev/null +++ b/tools/api-tester/lib/CoverageModel.js @@ -0,0 +1,71 @@ +const cartesianProduct = (obj) => { + // Get array of keys + let keys = Object.keys(obj); + + // Generate the Cartesian Product + return keys.reduce((acc, key) => { + let appendArrays = Array.isArray(obj[key]) ? obj[key] : [obj[key]]; + + let newAcc = []; + acc.forEach(arr => { + appendArrays.forEach(item => { + newAcc.push([...arr, item]); + }); + }); + + return newAcc; + }, [[]]); // start with the "empty product" +} + +let obj = { + a: [1, 2], + b: ["a", "b"] +}; + +console.log(cartesianProduct(obj)); + +module.exports = class CoverageModel { + constructor (spec) { + const flat = {}; + + const flatten = (object, prefix) => { + for ( const k in object ) { + let targetKey = k; + if ( prefix ) { + targetKey = prefix + '.' + k; + } + + let type = typeof object[k]; + if ( Array.isArray(object[k]) ) type = 'array'; + + if ( type === 'object' ) { + flatten(object[k], targetKey); + continue; + } + + if ( object[k].length == 0 ) { + object[k] = [false, true]; + } + + flat[targetKey] = object[k]; + } + }; + flatten(spec); + + this.flat = flat; + + const states = cartesianProduct(flat).map( + values => { + const o = {}; + const keys = Object.keys(flat); + for ( let i=0 ; i < keys.length ; i++ ) { + o[keys[i]] = values[i]; + } + return o; + } + ); + + this.states = states; + this.covered = Array(this.states.length).fill(false); + } +} \ No newline at end of file diff --git a/tools/api-tester/lib/ReportGenerator.js b/tools/api-tester/lib/ReportGenerator.js new file mode 100644 index 00000000..49bcb311 --- /dev/null +++ b/tools/api-tester/lib/ReportGenerator.js @@ -0,0 +1,3 @@ +module.exports = class ReportGenerator { + // +} diff --git a/tools/api-tester/lib/TestFactory.js b/tools/api-tester/lib/TestFactory.js new file mode 100644 index 00000000..3f9e6719 --- /dev/null +++ b/tools/api-tester/lib/TestFactory.js @@ -0,0 +1,27 @@ +module.exports = class TestFactory { + static cartesian ( + name, + coverageModel, + { each, init } + ) { + const do_ = async t => { + const states = coverageModel.states; + + if ( init ) await init(t); + + for ( let i=0 ; i < states.length ; i++ ) { + const state = states[i]; + + await t.case(`case ${i}`, async () => { + console.log('state', state); + await each(t, state, i); + }) + } + }; + + return { + name, + do: do_, + }; + } +} diff --git a/tools/api-tester/lib/TestRegistry.js b/tools/api-tester/lib/TestRegistry.js new file mode 100644 index 00000000..d336e2cd --- /dev/null +++ b/tools/api-tester/lib/TestRegistry.js @@ -0,0 +1,68 @@ +module.exports = class TestRegistry { + constructor (t) { + this.t = t; + this.sdks = {}; + this.tests = {}; + this.benches = {}; + } + + add_test_sdk (id, instance) { + this.t.sdks[id] = instance; + } + + add_test (id, testDefinition) { + this.tests[id] = testDefinition; + } + + add_bench (id, benchDefinition) { + this.benches[id] = benchDefinition; + } + + async run_all_tests () { + for ( const id in this.tests ) { + const testDefinition = this.tests[id]; + await this.t.runTestPackage(testDefinition); + } + } + + // copilot was able to write everything below this line + // and I think that's pretty cool + + async run_all_benches () { + for ( const id in this.benches ) { + const benchDefinition = this.benches[id]; + await this.t.runBenchmark(benchDefinition); + } + } + + async run_all () { + await this.run_all_tests(); + await this.run_all_benches(); + } + + async run_test (id) { + const testDefinition = this.tests[id]; + if ( ! testDefinition ) { + throw new Error(`Test not found: ${id}`); + } + await this.t.runTestPackage(testDefinition); + } + + async run_bench (id) { + const benchDefinition = this.benches[id]; + if ( ! benchDefinition ) { + throw new Error(`Bench not found: ${id}`); + } + await this.t.runBenchmark(benchDefinition); + } + + async run (id) { + if ( this.tests[id] ) { + await this.run_test(id); + } else if ( this.benches[id] ) { + await this.run_bench(id); + } else { + throw new Error(`Test or bench not found: ${id}`); + } + } +} diff --git a/tools/api-tester/lib/TestSDK.js b/tools/api-tester/lib/TestSDK.js new file mode 100644 index 00000000..0043581b --- /dev/null +++ b/tools/api-tester/lib/TestSDK.js @@ -0,0 +1,378 @@ +const axios = require('axios'); +const YAML = require('yaml'); + +const fs = require('node:fs'); +const path_ = require('node:path'); +const url = require('node:url'); +const https = require('node:https'); +const Assert = require('./Assert'); +const log_error = require('./log_error'); + +module.exports = class TestSDK { + constructor (conf) { + this.conf = conf; + this.cwd = `/${conf.username}`; + this.httpsAgent = new https.Agent({ + rejectUnauthorized: false + }) + const url_origin = new url.URL(conf.url).origin; + this.headers_ = { + 'Origin': url_origin, + 'Authorization': `Bearer ${conf.token}` + }; + + this.installAPIMethodShorthands_(); + + this.assert = new Assert(); + + this.sdks = {}; + + this.results = []; + this.failCount = 0; + this.caseCount = 0; + this.nameStack = []; + + this.packageResults = []; + + this.benchmarkResults = []; + } + + async get_sdk (name) { + return await this.sdks[name].create(); + } + + // === test related methods === + + async runTestPackage (testDefinition) { + this.nameStack.push(testDefinition.name); + this.packageResults.push({ + name: testDefinition.name, + failCount: 0, + caseCount: 0, + }); + const imported = {}; + for ( const key of Object.keys(testDefinition.import ?? {}) ) { + imported[key] = this.sdks[key]; + } + await testDefinition.do(this, imported); + this.nameStack.pop(); + } + + async runBenchmark (benchDefinition) { + const strid = '' + + '\x1B[35;1m[bench]\x1B[0m' + + this.nameStack.join(` \x1B[36;1m->\x1B[0m `); + process.stdout.write(strid + ' ... \n'); + + this.nameStack.push(benchDefinition.name); + let results; + this.benchmarkResults.push(results = { + name: benchDefinition.name, + start: Date.now(), + }); + try { + await benchDefinition.do(this); + } catch (e) { + results.error = e; + } finally { + results.end = Date.now(); + const dur = results.end - results.start; + process.stdout.write(`...\x1B[32;1m[${dur}]\x1B[0m\n`); + } + } + + recordResult (result) { + const pkg = this.packageResults[this.packageResults.length - 1]; + this.caseCount++; + pkg.caseCount++; + if ( ! result.success ) { + this.failCount++; + pkg.failCount++; + } + this.results.push(result); + } + + async case (id, fn) { + this.nameStack.push(id); + + const tabs = Array(this.nameStack.length - 2).fill(' ').join(''); + const strid = tabs + this.nameStack.join(` \x1B[36;1m->\x1B[0m `); + process.stdout.write(strid + ' ... \n'); + + try { + await fn(); + } catch (e) { + process.stdout.write(`${tabs}...\x1B[31;1m[FAIL]\x1B[0m\n`); + this.recordResult({ + strid, + e, + success: false, + }); + log_error(e); + return; + } finally { + this.nameStack.pop(); + } + + process.stdout.write(`${tabs}...\x1B[32;1m[PASS]\x1B[0m\n`); + this.recordResult({ + strid, + success: true + }); + } + + quirk (msg) { + console.log(`\x1B[33;1mignoring known quirk: ${msg}\x1B[0m`); + } + + // === information display methods === + + printTestResults () { + console.log(`\n\x1B[33;1m=== Test Results ===\x1B[0m`); + + let tbl = {}; + for ( const pkg of this.packageResults ) { + tbl[pkg.name] = { + passed: pkg.caseCount - pkg.failCount, + failed: pkg.failCount, + total: pkg.caseCount, + } + } + console.table(tbl); + + process.stdout.write(`\x1B[36;1m${this.caseCount} tests were run\x1B[0m - `); + if ( this.failCount > 0 ) { + console.log(`\x1B[31;1m✖ ${this.failCount} tests failed!\x1B[0m`); + } else { + console.log(`\x1B[32;1m✔ All tests passed!\x1B[0m`) + } + } + + printBenchmarkResults () { + console.log(`\n\x1B[33;1m=== Benchmark Results ===\x1B[0m`); + + let tbl = {}; + for ( const bench of this.benchmarkResults ) { + tbl[bench.name] = { + time: bench.end - bench.start, + error: bench.error ? bench.error.message : '', + } + } + console.table(tbl); + } + + // === path related methods === + + cd (path) { + this.cwd = path_.posix.join(this.cwd, path); + } + resolve (path) { + if ( path.startsWith('$') ) return path; + if ( path.startsWith('/') ) return path; + return path_.posix.join(this.cwd, path); + } + + // === API calls === + + installAPIMethodShorthands_ () { + const p = this.resolve.bind(this); + this.read = async path => { + const res = await this.get('read', { path: p(path) }); + return res.data; + } + this.mkdir = async (path, opts) => { + const res = await this.post('mkdir', { + path: p(path), + ...(opts ?? {}) + }); + return res.data; + }; + this.write = async (path, bin, params) => { + path = p(path); + params = params ?? {}; + let mime = 'text/plain'; + if ( params.hasOwnProperty('mime') ) { + mime = params.mime; + delete params.mime; + } + let name = path_.posix.basename(path); + path = path_.posix.dirname(path); + params.path = path; + const res = await this.upload('write', name, mime, bin, params); + return res.data; + } + this.stat = async (path, params) => { + path = p(path); + const res = await this.post('stat', { ...params, path }); + return res.data; + } + this.statu = async (uid, params) => { + const res = await this.post('stat', { ...params, uid }); + return res.data; + } + this.readdir = async (path, params) => { + path = p(path); + const res = await this.post('readdir', { + ...params, + path + }) + return res.data; + } + this.delete = async (path, params) => { + path = p(path); + const res = await this.post('delete', { + ...params, + paths: [path] + }); + return res.data; + } + this.move = async (src, dst, params = {}) => { + src = p(src); + dst = p(dst); + const destination = path_.dirname(dst); + const source = src; + const new_name = path_.basename(dst); + console.log('move', { destination, source, new_name }); + const res = await this.post('move', { + ...params, + destination, + source, + new_name, + }); + return res.data; + } + } + + getURL (...path) { + const apiURL = new url.URL(this.conf.url); + apiURL.pathname = path_.posix.join( + apiURL.pathname, + ...path + ); + return apiURL.href; + }; + + // === HTTP methods === + + get (ep, params) { + return axios.request({ + httpsAgent: this.httpsAgent, + method: 'get', + url: this.getURL(ep), + params, + headers: { + ...this.headers_ + } + }); + } + + post (ep, params) { + return axios.request({ + httpsAgent: this.httpsAgent, + method: 'post', + url: this.getURL(ep), + data: params, + headers: { + ...this.headers_, + 'Content-Type': 'application/json', + } + }) + } + + upload (ep, name, mime, bin, params) { + const adapt_file = (bin, mime) => { + if ( typeof bin === 'string' ) { + return new Blob([bin], { type: mime }); + } + return bin; + }; + const fd = new FormData(); + for ( const k in params ) fd.append(k, params[k]); + const blob = adapt_file(bin, mime); + fd.append('size', blob.size); + fd.append('file', adapt_file(bin, mime), name) + return axios.request({ + httpsAgent: this.httpsAgent, + method: 'post', + url: this.getURL(ep), + data: fd, + headers: { + ...this.headers_, + 'Content-Type': 'multipart/form-data' + }, + }); + } + + async batch (ep, ops, bins) { + const adapt_file = (bin, mime) => { + if ( typeof bin === 'string' ) { + return new Blob([bin], { type: mime }); + } + return bin; + }; + const fd = new FormData(); + + fd.append('original_client_socket_id', ''); + fd.append('socket_id', ''); + fd.append('operation_id', ''); + + let fileI = 0; + for ( let i=0 ; i < ops.length ; i++ ) { + const op = ops[i]; + + fd.append('operation', JSON.stringify(op)); + } + + const files = []; + + for ( let i=0 ; i < ops.length ; i++ ) { + const op = ops[i]; + + if ( op.op === 'mkdir' ) continue; + if ( op.op === 'mktree' ) continue; + + let mime = op.mime ?? 'text/plain'; + const file = adapt_file(bins[fileI++], mime); + fd.append('fileinfo', JSON.stringify({ + size: file.size, + name: op.name, + mime, + })); + files.push({ + op, file, + }) + + delete op.name; + } + + for ( const file of files ) { + const { op, file: blob } = file; + fd.append('file', blob, op.name); + } + + const res = await axios.request({ + httpsAgent: this.httpsAgent, + method: 'post', + url: this.getURL(ep), + data: fd, + headers: { + ...this.headers_, + 'Content-Type': 'multipart/form-data' + }, + }); + return res.data.results; + } + + batch_json (ep, ops, bins) { + return axios.request({ + httpsAgent: this.httpsAgent, + method: 'post', + url: this.getURL(ep), + data: ops, + headers: { + ...this.headers_, + 'Content-Type': 'application/json', + }, + }); + } +} \ No newline at end of file diff --git a/tools/api-tester/lib/log_error.js b/tools/api-tester/lib/log_error.js new file mode 100644 index 00000000..48441909 --- /dev/null +++ b/tools/api-tester/lib/log_error.js @@ -0,0 +1,35 @@ +const log_http_error = e => { + console.log('\x1B[31;1m' + e.message + '\x1B[0m'); + + console.log('HTTP Method: ', e.config.method.toUpperCase()); + console.log('URL: ', e.config.url); + + if (e.config.params) { + console.log('URL Parameters: ', e.config.params); + } + + if (e.config.method.toLowerCase() === 'post' && e.config.data) { + console.log('Post body: ', e.config.data); + } + + console.log('Request Headers: ', JSON.stringify(e.config.headers, null, 2)); + + if (e.response) { + console.log('Response Status: ', e.response.status); + console.log('Response Headers: ', JSON.stringify(e.response.headers, null, 2)); + console.log('Response body: ', e.response.data); + } + + console.log('\x1B[31;1m' + e.message + '\x1B[0m'); +}; + +const log_error = e => { + if ( e.request ) { + log_http_error(e); + return; + } + + console.error(e); +}; + +module.exports = log_error; \ No newline at end of file diff --git a/tools/api-tester/lib/sleep.js b/tools/api-tester/lib/sleep.js new file mode 100644 index 00000000..d6c7d88b --- /dev/null +++ b/tools/api-tester/lib/sleep.js @@ -0,0 +1,5 @@ +module.exports = async function sleep (ms) { + await new Promise(rslv => { + setTimeout(rslv, ms); + }) +} diff --git a/tools/api-tester/package-lock.json b/tools/api-tester/package-lock.json new file mode 100644 index 00000000..ef0ad653 --- /dev/null +++ b/tools/api-tester/package-lock.json @@ -0,0 +1,204 @@ +{ + "name": "puter-api-test", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "puter-api-test", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "axios": "^1.4.0", + "chai": "^4.3.7", + "chai-as-promised": "^7.1.1", + "yaml": "^2.3.1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/chai": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", + "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^4.1.2", + "get-func-name": "^2.0.0", + "loupe": "^2.3.1", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dependencies": { + "check-error": "^1.0.2" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 5" + } + }, + "node_modules/check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", + "engines": { + "node": "*" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "engines": { + "node": "*" + } + }, + "node_modules/loupe": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", + "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "dependencies": { + "get-func-name": "^2.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "engines": { + "node": "*" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/tools/api-tester/package.json b/tools/api-tester/package.json new file mode 100644 index 00000000..f84ab832 --- /dev/null +++ b/tools/api-tester/package.json @@ -0,0 +1,17 @@ +{ + "name": "@heyputer/puter-api-test", + "version": "0.1.0", + "description": "", + "main": "apitest.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Puter Technologies Inc.", + "license": "UNLICENSED", + "dependencies": { + "axios": "^1.4.0", + "chai": "^4.3.7", + "chai-as-promised": "^7.1.1", + "yaml": "^2.3.1" + } +} diff --git a/tools/api-tester/test_sdks/puter-rest.js b/tools/api-tester/test_sdks/puter-rest.js new file mode 100644 index 00000000..c55abc46 --- /dev/null +++ b/tools/api-tester/test_sdks/puter-rest.js @@ -0,0 +1,23 @@ +const axios = require('axios'); + +class PuterRestTestSDK { + constructor (config) { + this.config = config; + } + async create() { + const conf = this.config; + const axiosInstance = axios.create({ + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + baseURL: conf.url, + headers: { + 'Authorization': `Bearer ${conf.token}`, // common headers + //... other headers + } + }); + return axiosInstance; + } +} + +module.exports = ({ config }) => new PuterRestTestSDK(config); diff --git a/tools/api-tester/tests/__entry__.js b/tools/api-tester/tests/__entry__.js new file mode 100644 index 00000000..784375d9 --- /dev/null +++ b/tools/api-tester/tests/__entry__.js @@ -0,0 +1,13 @@ +module.exports = registry => { + registry.add_test('write_cart', require('./write_cart')); + registry.add_test('move_cart', require('./move_cart')); + registry.add_test('copy_cart', require('./copy_cart')); + registry.add_test('write_and_read', require('./write_and_read')); + registry.add_test('move', require('./move')); + registry.add_test('stat', require('./stat')); + registry.add_test('readdir', require('./readdir')); + registry.add_test('mkdir', require('./mkdir')); + registry.add_test('batch', require('./batch')); + registry.add_test('delete', require('./delete')); + registry.add_test('telem_write', require('./telem_write')); +}; diff --git a/tools/api-tester/tests/batch.js b/tools/api-tester/tests/batch.js new file mode 100644 index 00000000..32e97487 --- /dev/null +++ b/tools/api-tester/tests/batch.js @@ -0,0 +1,226 @@ +const { expect } = require("chai"); +const { verify_fsentry } = require("./fsentry"); + +module.exports = { + name: 'batch', + do: async t => { + let results; + /* + await t.case('batch write', async () => { + results = null; + results = await t.batch('/batch/write', [ + { + path: t.resolve('test_1.txt'), + overwrite: true, + }, + { + path: t.resolve('test_3.txt'), + } + ], [ + 'first file', + 'second file', + ]) + console.log('results?', results) + expect(results.length).equal(2); + for ( const result of results ) { + await verify_fsentry(t, result) + } + }); + t.case('batch mkdir', async () => { + results = null; + results = await t.batch_json('batch/mkdir', [ + { + path: t.resolve('test_1_dir'), + overwrite: true, + }, + { + path: t.resolve('test_3_dir'), + } + ]) + expect(results.length).equal(2); + for ( const result of results ) { + await verify_fsentry(t, result) + } + }); + */ + await t.case('3-3 nested directores', async () => { + results = null; + results = await t.batch('batch', [ + { + op: 'mktree', + parent: t.cwd, + tree: [ + 'a/b/c', + [ + 'a/b/c', + ['a/b/c'], + ['d/e/f'], + ['g/h/i'], + ['j/k/l'], + ], + [ + 'd/e/f', + ['a/b/c'], + ['d/e/f'], + ['g/h/i'], + ['j/k/l'], + ], + [ + 'g/h/i', + ['a/b/c'], + ['d/e/f'], + ['g/h/i'], + ['j/k/l'], + ], + [ + 'j/k/l', + ['a/b/c'], + ['d/e/f'], + ['g/h/i'], + ['j/k/l'], + ], + ] + } + ], []); + }); + await t.case('path reference resolution', async () => { + results = null; + results = await t.batch('batch', [ + { + op: 'mkdir', + as: 'dest_1', + path: t.resolve('q/w'), + create_missing_parents: true, + }, + { + op: 'mkdir', + as: 'dest_2', + path: t.resolve('q/w'), // "q/w (1)" + dedupe_name: true, + create_missing_parents: true, + }, + { + op: 'write', + path: t.resolve('$dest_1/file_1.txt'), + }, + { + op: 'write', + path: t.resolve('$dest_2/file_2.txt'), + }, + ], [ + 'file 1 contents', + 'file 2 contents', + ]); + console.log('res?', results) + expect(results.length).equal(4); + expect(results[0].name).equal('w'); + expect(results[1].name).equal('w (1)'); + expect(results[2].path).equal(t.resolve('q/w/file_1.txt')); + expect(results[3].path).equal(t.resolve('q/w (1)/file_2.txt')); + }); + + await t.case('batch mkdir and write', async () => { + results = null; + results = await t.batch('batch', [ + { + op: 'mkdir', + path: t.resolve('test_x_1_dir'), + overwrite: true, + }, + { + op: 'write', + path: t.resolve('test_x_1.txt'), + }, + { + op: 'mkdir', + path: t.resolve('test_x_2_dir'), + }, + { + op: 'write', + path: t.resolve('test_x_2.txt'), + } + ], [ + 'first file', + 'second file', + ]); + console.log('res?', results) + expect(results.length).equal(4); + for ( const result of results ) { + // await verify_fsentry(t, result) + } + }); + + await t.case('path reference resolution (without dedupe)', async () => { + results = null; + results = await t.batch('batch', [ + { + op: 'mkdir', + as: 'dest_1', + path: t.resolve('q/w'), + create_missing_parents: true, + }, + { + op: 'write', + path: t.resolve('$dest_1/file_1.txt'), + }, + ], [ + 'file 1 contents', + ]); + console.log('res?', results) + expect(results.length).equal(2); + expect(results[0].name).equal('w'); + expect(results[1].path).equal(t.resolve('q/w/file_1.txt')); + }); + + // Test for path reference resolution + await t.case('path reference resolution', async () => { + results = null; + results = await t.batch('batch', [ + { + op: 'mkdir', + as: 'dest_1', + path: t.resolve('q/w'), + create_missing_parents: true, + }, + { + op: 'mkdir', + as: 'dest_2', + path: t.resolve('q/w'), // "q/w (1)" + dedupe_name: true, + create_missing_parents: true, + }, + { + op: 'write', + path: t.resolve('$dest_1/file_1.txt'), + }, + { + op: 'write', + path: t.resolve('$dest_2/file_2.txt'), + }, + ], [ + 'file 1 contents', + 'file 2 contents', + ]); + console.log('res?', results) + expect(results.length).equal(4); + expect(results[0].name).equal('w'); + expect(results[1].name).equal('w (1)'); + expect(results[2].path).equal(t.resolve('q/w/file_1.txt')); + expect(results[3].path).equal(t.resolve('q/w (1)/file_2.txt')); + }); + + // Test for a single write + await t.case('single write', async () => { + results = null; + results = await t.batch('batch', [ + { + op: 'write', + path: t.resolve('just_one_file.txt'), + }, + ], [ + 'file 1 contents', + ]); + console.log('res?', results) + }); + } +}; diff --git a/tools/api-tester/tests/copy_cart.js b/tools/api-tester/tests/copy_cart.js new file mode 100644 index 00000000..33b531aa --- /dev/null +++ b/tools/api-tester/tests/copy_cart.js @@ -0,0 +1,131 @@ +const { default: axios } = require("axios"); +const { expect } = require("chai"); +const copy = require("../coverage_models/copy"); +const TestFactory = require("../lib/TestFactory"); + +/* + CARTESIAN TEST FOR /copy + + NOTE: This test is very similar to the test for /move, + but DRYing it would add too much complexity. + + It is best to have both tests open side-by-side + when making changes to either one. +*/ + +const PREFIX = 'copy_cart_'; + +module.exports = TestFactory.cartesian('Cartesian Test for /copy', copy, { + each: async (t, state, i) => { + // 1. Common setup for all states + await t.mkdir(`${PREFIX}${i}`); + const dir = `/${t.cwd}/${PREFIX}${i}`; + + await t.mkdir(`${PREFIX}${i}/a`); + + let pathOfThingToCopy = ''; + + if ( state.subject === 'file' ) { + await t.write(`${PREFIX}${i}/a/a_file.txt`, 'file a contents\n'); + pathOfThingToCopy = `/a/a_file.txt`; + } else { + await t.mkdir(`${PREFIX}${i}/a/a_directory`); + pathOfThingToCopy = `/a/a_directory`; + + // for test purposes, a "full" directory has each of three classes: + // - a file + // - an empty directory + // - a directory with a file in it + if ( state.subject === 'directory-full' ) { + // add a file + await t.write(`${PREFIX}${i}/a/a_directory/a_file.txt`, 'file a contents\n'); + + // add a directory with a file inside of it + await t.mkdir(`${PREFIX}${i}/a/a_directory/b_directory`); + await t.write(`${PREFIX}${i}/a/a_directory/b_directory/b_file.txt`, 'file a contents\n'); + + // add an empty directory + await t.mkdir(`${PREFIX}${i}/a/a_directory/c_directory`); + } + } + + // 2. Situation setup for this state + + if ( state['conditions.destinationIsFile'] ) { + await t.write(`${PREFIX}${i}/b`, 'placeholder\n'); + } else { + await t.mkdir(`${PREFIX}${i}/b`); + await t.write(`${PREFIX}${i}/b/b_file.txt`, 'file b contents\n'); + } + + const srcUID = (await t.stat(`${PREFIX}${i}${pathOfThingToCopy}`)).uid; + const dstUID = (await t.stat(`${PREFIX}${i}/b`)).uid; + + // 3. Parameter setup for this state + const data = {}; + data.source = state['source.format'] === 'uid' + ? srcUID : `${dir}${pathOfThingToCopy}` ; + data.destination = state['destination.format'] === 'uid' + ? dstUID : `${dir}/b` ; + + if ( state.name === 'specified' ) { + data.new_name = 'x_renamed'; + } + + if ( state.overwrite ) { + data[state.overwrite] = true; + } + + // 4. Request + let e = null; + let resp; + try { + resp = await axios.request({ + method: 'post', + httpsAgent: t.httpsAgent, + url: t.getURL('copy'), + data, + headers: { + ...t.headers_, + 'Content-Type': 'application/json' + } + }); + } catch (e_) { + e = e_; + } + + // 5. Check Response + let error_expected = null; + + if ( + state['conditions.destinationIsFile'] && + state.name === 'specified' + ) { + error_expected = { + code: 'dest_is_not_a_directory', + message: `Destination must be a directory.`, + }; + } + + else if ( + state['conditions.destinationIsFile'] && + ! state.overwrite && + ! state.dedupe_name + ) { + console.log('AN ERROR IS EXPECTED'); + error_expected = { + code: 'item_with_same_name_exists', + message: 'An item with name `b` already exists.', + entry_name: 'b', + } + } + + if ( error_expected ) { + expect(e).to.exist; + const data = e.response.data; + expect(data).deep.equal(error_expected); + } else { + if ( e ) throw e; + } + } +}) diff --git a/tools/api-tester/tests/delete.js b/tools/api-tester/tests/delete.js new file mode 100644 index 00000000..3e27e9ff --- /dev/null +++ b/tools/api-tester/tests/delete.js @@ -0,0 +1,100 @@ +const { expect } = require("chai"); +const sleep = require("../lib/sleep"); + +module.exports = { + name: 'delete', + do: async t => { + await t.case('delete for normal file', async () => { + await t.write('test_delete.txt', 'delete test\n', { overwrite: true }); + await t.delete('test_delete.txt'); + let threw = false; + try { + await t.stat('test_delete.txt'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + }); + await t.case('error for non-existing file', async () => { + let threw = false; + try { + await t.delete('test_delete.txt'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + }); + await t.case('delete for directory', async () => { + await t.mkdir('test_delete_dir', { overwrite: true }); + await t.delete('test_delete_dir'); + let threw = false; + try { + await t.stat('test_delete_dir'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + }); + await t.case('delete for non-empty directory', async () => { + await t.mkdir('test_delete_dir', { overwrite: true }); + await t.write('test_delete_dir/test.txt', 'delete test\n', { overwrite: true }); + let threw = false; + try { + await t.delete('test_delete_dir'); + } catch (e) { + expect(e.response.status).equal(400); + threw = true; + } + expect(threw).true; + }); + await t.case('delete for non-empty directory with recursive=true', async () => { + await t.mkdir('test_delete_dir', { overwrite: true }); + await t.write('test_delete_dir/test.txt', 'delete test\n', { overwrite: true }); + await t.delete('test_delete_dir', { recursive: true }); + let threw = false; + await sleep(500); + try { + await t.stat('test_delete_dir'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + }); + await t.case('non-empty deep recursion', async () => { + await t.mkdir('del/a/b/c/d', { + create_missing_parents: true, + }); + await t.write('del/a/b/c/d/test.txt', 'delete test\n'); + await t.delete('del', { + recursive: true, + descendants_only: true, + }); + let threw = false; + t.quirk('delete too asynchronous'); + await new Promise(rslv => setTimeout(rslv, 500)); + try { + await t.stat('del/a/b/c/d/test.txt'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + threw = false; + try { + await t.stat('del/a'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + await t.case('parent directory still exists', async () => { + const stat = await t.stat('del'); + expect(stat.name).equal('del'); + }); + }); + } +}; \ No newline at end of file diff --git a/tools/api-tester/tests/fsentry.js b/tools/api-tester/tests/fsentry.js new file mode 100644 index 00000000..b4097e2e --- /dev/null +++ b/tools/api-tester/tests/fsentry.js @@ -0,0 +1,78 @@ +const { expect } = require("chai"); + +const _bitBooleans = [ + 'immutable', + 'is_shortcut', + 'is_symlink', + 'is_dir', +]; + +const _integers = [ + 'created', + 'accessed', + 'modified', +]; + +const _strings = [ + 'id', 'uid', 'parent_id', 'name', +] + +const verify_fsentry = async (t, o) => { + await t.case('fsentry is valid', async () => { + for ( const k of _strings ) { + await t.case(`${k} is a string`, () => { + expect(typeof o[k]).equal('string'); + }); + } + if ( o.is_dir ) { + await t.case(`type is null for directories`, () => { + expect(o.type).equal(null); + }); + } + if ( ! o.is_dir ) { + await t.case(`type is a string for files`, () => { + expect(typeof o.type).equal('string'); + }); + } + await t.case('id === uid', () => { + expect(o.id).equal(o.uid); + }); + await t.case('uid is string', () => { + expect(typeof o.uid).equal('string'); + }); + for ( const k of _bitBooleans ) { + await t.case(`${k} is 0 or 1`, () => { + expect(o[k]).oneOf([0, 1], `${k} should be 0 or 1`); + }); + } + t.quirk('is_shared is not populated currently'); + // expect(o.is_shared).oneOf([true, false]); + for ( const k of _integers ) { + if ( o.is_dir && k === 'accessed' ) { + t.quirk('accessed is null for new directories'); + continue; + } + + await t.case(`${k} is numeric type`, () => { + expect(typeof o[k]).equal('number'); + }); + await t.case(`${k} has no fractional component`, () => { + expect(Number.isInteger(o[k])).true; + }); + } + await t.case('symlink_path is null or string', () => { + expect( + o.symlink_path === null || + typeof o.symlink_path === 'string' + ).true; + }); + await t.case('owner object has expected properties', () => { + expect(o.owner).to.haveOwnProperty('username'); + expect(o.owner).to.haveOwnProperty('email'); + }); + }) +} + +module.exports = { + verify_fsentry, +}; \ No newline at end of file diff --git a/tools/api-tester/tests/mkdir.js b/tools/api-tester/tests/mkdir.js new file mode 100644 index 00000000..8d5442ad --- /dev/null +++ b/tools/api-tester/tests/mkdir.js @@ -0,0 +1,69 @@ +const { expect } = require("chai"); +const { verify_fsentry } = require("./fsentry"); + +module.exports = { + name: 'mkdir', + do: async t => { + await t.case('recursive mkdir', async () => { + // Can create a chain of directories + const path = 'a/b/c/d/e/f/g'; + let result; + await t.case('no exception thrown', async () => { + result = await t.mkdir(path, { + create_missing_parents: true, + }); + console.log('result?', result) + }); + + // Returns the last directory in the chain + // await verify_fsentry(t, result); + await t.case('filename is correct', () => { + expect(result.name).equal('g'); + }); + + await t.case('can stat the directory', async () => { + const stat = await t.stat(path); + // await verify_fsentry(t, stat); + await t.case('filename is correct', () => { + expect(stat.name).equal('g'); + }); + }); + + // can stat the first directory in the chain + await t.case('can stat the first directory in the chain', async () => { + const stat = await t.stat('a'); + // await verify_fsentry(t, stat); + await t.case('filename is correct', () => { + expect(stat.name).equal('a'); + }); + }); + }); + + // NOTE: It looks like we removed this behavior and we always create missing parents + // await t.case('fails with missing parent', async () => { + // let threw = false; + // try { + // const result = await t.mkdir('a/b/x/g'); + + // console.log('unexpected result', result); + // } catch (e) { + // expect(e.response.status).equal(422); + // console.log('response?', e.response.data) + // expect(e.response.data).deep.equal({ + // code: 'dest_does_not_exist', + // message: 'Destination was not found.', + // }); + // threw = true; + // } + // expect(threw).true; + // }); + + await t.case('mkdir dedupe name', async () => { + for ( let i = 1; i <= 3; i++ ) { + await t.mkdir('a', { dedupe_name: true }); + const stat = await t.stat(`a (${i})`); + expect(stat.name).equal(`a (${i})`); + } + }); + } +}; \ No newline at end of file diff --git a/tools/api-tester/tests/move.js b/tools/api-tester/tests/move.js new file mode 100644 index 00000000..885bd2f4 --- /dev/null +++ b/tools/api-tester/tests/move.js @@ -0,0 +1,94 @@ +const { expect } = require("chai"); +const fs = require('fs'); + +module.exports = { + name: 'move', + do: async t => { + // setup conditions for tests + await t.mkdir('dir_with_contents'); + await t.write('dir_with_contents/a.txt', 'move test\n'); + await t.write('dir_with_contents/b.txt', 'move test\n'); + await t.write('dir_with_contents/c.txt', 'move test\n'); + await t.mkdir('dir_with_contents/q'); + await t.mkdir('dir_with_contents/w'); + await t.mkdir('dir_with_contents/e'); + await t.mkdir('dir_no_contents'); + await t.write('just_a_file.txt', 'move test\n'); + + await t.case('move file', async () => { + await t.move('just_a_file.txt', 'just_a_file_moved.txt'); + const moved = await t.stat('just_a_file_moved.txt'); + let threw = false; + try { + await t.stat('just_a_file.txt'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + expect(moved.name).equal('just_a_file_moved.txt'); + }); + + await t.case('move file to existing file', async () => { + await t.write('just_a_file.txt', 'move test\n'); + let threw = false; + try { + await t.move('just_a_file.txt', 'dir_with_contents/a.txt'); + } catch (e) { + expect(e.response.status).equal(409); + threw = true; + } + expect(threw).true; + }); + + /* + await t.case('move file to existing directory', async () => { + await t.move('just_a_file.txt', 'dir_with_contents'); + const moved = await t.stat('dir_with_contents/just_a_file.txt'); + let threw = false; + try { + await t.stat('just_a_file.txt'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + expect(moved.name).equal('just_a_file.txt'); + }); + */ + + await t.case('move directory', async () => { + await t.move('dir_no_contents', 'dir_no_contents_moved'); + const moved = await t.stat('dir_no_contents_moved'); + let threw = false; + try { + await t.stat('dir_no_contents'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + expect(moved.name).equal('dir_no_contents_moved'); + }); + + await t.case('move file and create parents', async () => { + await t.write('just_a_file.txt', 'move test\n', { overwrite: true }); + const res = await t.move( + 'just_a_file.txt', + 'dir_with_contents/q/w/e/just_a_file.txt', + { create_missing_parents: true } + ); + expect(res.parent_dirs_created).length(2); + const moved = await t.stat('dir_with_contents/q/w/e/just_a_file.txt'); + let threw = false; + try { + await t.stat('just_a_file.txt'); + } catch (e) { + expect(e.response.status).equal(404); + threw = true; + } + expect(threw).true; + expect(moved.name).equal('just_a_file.txt'); + }); + } +}; diff --git a/tools/api-tester/tests/move_cart.js b/tools/api-tester/tests/move_cart.js new file mode 100644 index 00000000..e4060b8e --- /dev/null +++ b/tools/api-tester/tests/move_cart.js @@ -0,0 +1,103 @@ +const { default: axios } = require("axios"); +const { expect } = require("chai"); +const move = require("../coverage_models/move"); +const TestFactory = require("../lib/TestFactory"); + +/* + CARTESIAN TEST FOR /move + + NOTE: This test is very similar to the test for /copy, + but DRYing it would add too much complexity. + + It is best to have both tests open side-by-side + when making changes to either one. +*/ + +const PREFIX = 'move_cart_'; + +module.exports = TestFactory.cartesian('Cartesian Test for /move', move, { + each: async (t, state, i) => { + // 1. Common setup for all states + await t.mkdir(`${PREFIX}${i}`); + const dir = `/${t.cwd}/${PREFIX}${i}`; + + await t.mkdir(`${PREFIX}${i}/a`); + await t.write(`${PREFIX}${i}/a/a_file.txt`, 'file a contents\n'); + + // 2. Situation setup for this state + if ( state['conditions.destinationIsFile'] ) { + await t.write(`${PREFIX}${i}/b`, 'placeholder\n'); + } else { + await t.mkdir(`${PREFIX}${i}/b`); + await t.write(`${PREFIX}${i}/b/b_file.txt`, 'file b contents\n'); + } + + const srcUID = (await t.stat(`${PREFIX}${i}/a/a_file.txt`)).uid; + const dstUID = (await t.stat(`${PREFIX}${i}/b`)).uid; + + // 3. Parameter setup for this state + const data = {}; + data.source = state['source.format'] === 'uid' + ? srcUID : `${dir}/a/a_file.txt` ; + data.destination = state['destination.format'] === 'uid' + ? dstUID : `${dir}/b` ; + + if ( state.name === 'specified' ) { + data.new_name = 'x_file.txt'; + } + + if ( state.overwrite ) { + data[state.overwrite] = true; + } + + // 4. Request + let e = null; + let resp; + try { + resp = await axios.request({ + method: 'post', + httpsAgent: t.httpsAgent, + url: t.getURL('move'), + data, + headers: { + ...t.headers_, + 'Content-Type': 'application/json' + } + }); + } catch (e_) { + e = e_; + } + + // 5. Check Response + let error_expected = null; + + if ( + state['conditions.destinationIsFile'] && + state.name === 'specified' + ) { + error_expected = { + code: 'dest_is_not_a_directory', + message: `Destination must be a directory.`, + }; + } + + else if ( + state['conditions.destinationIsFile'] && + ! state.overwrite + ) { + error_expected = { + code: 'item_with_same_name_exists', + message: 'An item with name `b` already exists.', + entry_name: 'b', + } + } + + if ( error_expected ) { + expect(e).to.exist; + const data = e.response.data; + expect(data).deep.equal(error_expected); + } else { + if ( e ) throw e; + } + } +}) \ No newline at end of file diff --git a/tools/api-tester/tests/readdir.js b/tools/api-tester/tests/readdir.js new file mode 100644 index 00000000..3ae6e597 --- /dev/null +++ b/tools/api-tester/tests/readdir.js @@ -0,0 +1,39 @@ + +const { verify_fsentry } = require("./fsentry"); +const { expect } = require("chai"); + +module.exports = { + name: 'readdir', + do: async t => { + // let result; + + await t.mkdir('test_readdir', { overwrite: true }); + t.cd('test_readdir'); + + const files = ['a.txt', 'b.txt', 'c.txt']; + const dirs = ['q', 'w', 'e']; + + for ( const file of files ) { + await t.write(file, 'readdir test\n', { overwrite: true }); + } + for ( const dir of dirs ) { + await t.mkdir(dir, { overwrite: true }); + } + + for ( const file of files ) { + const result = await t.stat(file); + await verify_fsentry(t, result); + } + for ( const dir of dirs ) { + const result = await t.stat(dir); + await verify_fsentry(t, result); + } + + await t.case('readdir of root shouldn\'t return everything', async () => { + const result = await t.readdir('/', { recursive: true }); + console.log('result?', result) + }) + + // t.cd('..'); + } +}; diff --git a/tools/api-tester/tests/stat.js b/tools/api-tester/tests/stat.js new file mode 100644 index 00000000..46038454 --- /dev/null +++ b/tools/api-tester/tests/stat.js @@ -0,0 +1,100 @@ +const { verify_fsentry } = require("./fsentry"); +const { expect } = require("chai"); + +module.exports = { + name: 'stat', + do: async t => { + let result; + + const TEST_FILENAME = 'test_stat.txt'; + + let recorded_uid = null; + + await t.case('stat with path (no flags)', async () => { + await t.write(TEST_FILENAME, 'stat test\n', { overwrite: true }); + result = await t.stat(TEST_FILENAME); + + await verify_fsentry(t, result); + recorded_uid = result.uid; + await t.case('filename is correct', () => { + expect(result.name).equal('test_stat.txt'); + }); + }) + + await t.case('stat with uid (no flags)', async () => { + result = await t.statu(recorded_uid); + + await verify_fsentry(t, result); + await t.case('filename is correct', () => { + expect(result.name).equal('test_stat.txt'); + }); + }) + + await t.case('stat with no path or uid provided fails', async () => { + let threw = false; + try { + const res = await t.get('stat', {}); + } catch (e) { + expect(e.response.status).equal(400); + expect(e.response.data).deep.equal({ + code: 'field_missing', + message: 'Field `subject` is required.', + key: 'subject', + }); + threw = true; + } + expect(threw).true; + }); + + const flags = ['permissions', 'versions']; + for ( const flag of flags ) { + await t.case('stat with ' + flag, async () => { + result = await t.stat(TEST_FILENAME, { + ['return_' + flag]: true, + }); + + await verify_fsentry(t, result); + await t.case('filename is correct', () => { + expect(result.name).equal(`test_stat.txt`); + }); + await t.case(`result has ${flag} array`, () => { + expect(Array.isArray(result[flag])).true; + }); + }) + } + + await t.mkdir('test_stat_subdomains', { overwrite: true }); + await t.case('stat with subdomains', async () => { + result = await t.stat('test_stat_subdomains', { + return_subdomains: true, + }); + + await verify_fsentry(t, result); + await t.case('directory name is correct', () => { + expect(result.name).equal(`test_stat_subdomains`); + }); + await t.case(`result has subdomains array`, () => { + expect(Array.isArray(result.subdomains)).true; + }); + console.log('RESULT', result); + }) + + { + const flag = 'size'; + await t.case('stat with ' + flag, async () => { + result = await t.stat(TEST_FILENAME, { + ['return_' + flag]: true, + }); + + await verify_fsentry(t, result); + await t.case('filename is correct', () => { + expect(result.name).equal(`test_stat.txt`); + }); + console.log('RESULT', result); + }) + } + + + // console.log('result?', result); + } +}; diff --git a/tools/api-tester/tests/telem_write.js b/tools/api-tester/tests/telem_write.js new file mode 100644 index 00000000..ceff110e --- /dev/null +++ b/tools/api-tester/tests/telem_write.js @@ -0,0 +1,14 @@ +const chai = require('chai'); +chai.use(require('chai-as-promised')) +const expect = chai.expect; + +module.exports = { + name: 'single write for trace and span', + do: async t => { + let result; + + const TEST_FILENAME = 'test_telem.txt'; + + await t.write(TEST_FILENAME, 'example\n', { overwrite: true }); + } +}; diff --git a/tools/api-tester/tests/write_and_read.js b/tools/api-tester/tests/write_and_read.js new file mode 100644 index 00000000..82dfdca3 --- /dev/null +++ b/tools/api-tester/tests/write_and_read.js @@ -0,0 +1,69 @@ +const chai = require('chai'); +chai.use(require('chai-as-promised')) +const expect = chai.expect; + +module.exports = { + name: 'write and read', + do: async t => { + let result; + + const TEST_FILENAME = 'test_rw.txt'; + + await t.write(TEST_FILENAME, 'example\n', { overwrite: true }); + + await t.case('read matches what was written', async () => { + result = await t.read(TEST_FILENAME); + expect(result).equal('example\n'); + }); + + await t.case('write throws for overwrite=false', () => { + expect( + t.write(TEST_FILENAME, 'no-change\n') + ).rejectedWith(Error); + }); + + await t.case('write updates for overwrite=true', async () => { + await t.write(TEST_FILENAME, 'yes-change\n', { + overwrite: true, + }); + result = await t.read(TEST_FILENAME); + expect(result).equal('yes-change\n'); + }); + + await t.case('write updates for overwrite=true', async () => { + await t.write(TEST_FILENAME, 'yes-change\n', { + overwrite: true, + }); + result = await t.read(TEST_FILENAME, { version_id: '1' }); + expect(result).equal('yes-change\n'); + }); + + await t.case('read with no path or uid provided fails', async () => { + let threw = false; + try { + const res = await t.get('read', {}); + } catch (e) { + expect(e.response.status).equal(400); + expect(e.response.data).deep.equal({ + message: 'Field \`file\` is required.', + code: 'field_missing', + key: 'file', + }); + threw = true; + } + expect(threw).true; + }); + + await t.case('read for non-existing path fails', async () => { + let threw = false; + try { + await t.read('i-do-not-exist.txt'); + } catch (e) { + expect(e.response.status).equal(404); + expect(e.response.data).deep.equal({ message: 'Path not found.' }); + threw = true; + } + expect(threw).true; + }); + } +}; diff --git a/tools/api-tester/tests/write_cart.js b/tools/api-tester/tests/write_cart.js new file mode 100644 index 00000000..1f01e522 --- /dev/null +++ b/tools/api-tester/tests/write_cart.js @@ -0,0 +1,91 @@ +const { default: axios } = require("axios"); +const write = require("../coverage_models/write"); +const TestFactory = require("../lib/TestFactory"); + +const chai = require('chai'); +chai.use(require('chai-as-promised')) +const expect = chai.expect; + +module.exports = TestFactory.cartesian('Cartesian Test for /write', write, { + each: async (t, state, i) => { + if ( state['conditions.destinationIsFile'] ) { + await t.write('write_cart_' + i, 'placeholder\n'); + } else { + await t.mkdir('write_cart_' + i); + } + + const dir = `/${t.cwd}/write_cart_` + i; + const dirUID = (await t.stat('write_cart_' + i)).uid; + + const contents = new Blob( + [`case ${i}\n`], + { type: 'text/plain' }, + ); + + console.log('DIR UID', dirUID) + + const fd = new FormData(); + + if ( state.name === 'specified' ) { + fd.append('name', 'specified_name.txt'); + } + if ( state.overwrite ) { + fd.append(state.overwrite, true); + } + + fd.append('path', state.format === 'path' ? dir : dirUID); + fd.append('size', contents.size), + fd.append('file', contents, 'uploaded_name.txt'); + + let e = null; + + let resp; + try { + resp = await axios.request({ + method: 'post', + httpsAgent: t.httpsAgent, + url: t.getURL('write'), + data: fd, + headers: { + ...t.headers_, + 'Content-Type': 'multipart/form-data' + } + }) + } catch (e_) { + e = e_; + } + + let error_expected = null; + + // Error conditions + if ( + state['conditions.destinationIsFile'] && + state.name === 'specified' + ) { + error_expected = { + code: 'dest_is_not_a_directory', + message: `Destination must be a directory.`, + }; + } + + if ( + state['conditions.destinationIsFile'] && + state.name === 'default' && + ! state.overwrite + ) { + error_expected = { + code: 'item_with_same_name_exists', + message: 'An item with name `write_cart_'+i+'` already exists.', + entry_name: 'write_cart_' + i, + }; + } + + if ( error_expected ) { + expect(e).to.exist; + const data = e.response.data; + expect(data).deep.equal(error_expected); + } else { + if ( e ) throw e; + } + } +}) diff --git a/tools/api-tester/tools/readdir_profile.js b/tools/api-tester/tools/readdir_profile.js new file mode 100644 index 00000000..50f4de6e --- /dev/null +++ b/tools/api-tester/tools/readdir_profile.js @@ -0,0 +1,109 @@ +const axios = require('axios'); +const YAML = require('yaml'); + +const https = require('node:https'); +const { parseArgs } = require('node:util'); +const url = require('node:url'); + +const path_ = require('path'); +const fs = require('fs'); + +let config; + +try { + ({ values: { + config, + }, positionals: [id] } = parseArgs({ + options: { + config: { + type: 'string', + }, + }, + allowPositionals: true, + })); +} catch (e) { + if ( args.length < 1 ) { + console.error( + 'Usage: readdir_profile [OPTIONS]\n' + + '\n' + + 'Options:\n' + + ' --config= (required) Path to configuration file\n' + + '' + ); + process.exit(1); + } +} + +const conf = YAML.parse(fs.readFileSync(config).toString()); + +const dir = `/${conf.username}/readdir_test` + +// process.on('SIGINT', async () => { +// process.exit(0); +// }); + +const httpsAgent = new https.Agent({ + rejectUnauthorized: false +}) +const getURL = (...path) => { + const apiURL = new url.URL(conf.url); + apiURL.pathname = path_.posix.join( + apiURL.pathname, + ...path + ); + return apiURL.href; +}; + +const epoch = Date.now(); +const TIME_BEFORE_TEST = 20 * 1000; // 10 seconds + +const NOOP = () => {}; +let check = () => { + if ( Date.now() - epoch >= TIME_BEFORE_TEST ) { + console.log( + `\x1B[36;1m !!! START THE TEST !!! \x1B[0m` + ); + check = NOOP; + } +}; + +const measure_readdir = async () => { + const ts_start = Date.now(); + + await axios.request({ + httpsAgent, + method: 'post', + url: getURL('readdir'), + data: { + path: dir, + }, + headers: { + 'Authorization': `Bearer ${conf.token}`, + 'Content-Type': 'application/json' + } + }) + + const ts_end = Date.now(); + + const diff = ts_end - ts_start; + + await fs.promises.appendFile( + `readdir_profile.txt`, + `${Date.now()},${diff}\n` + ) + + check(); + + await new Promise(rslv => { + setTimeout(rslv, 5); + }); +} + + +const main = async () => { + while (true) { + await measure_readdir(); + } +} + +main(); diff --git a/tools/api-tester/tools/test_read.js b/tools/api-tester/tools/test_read.js new file mode 100644 index 00000000..54f4818e --- /dev/null +++ b/tools/api-tester/tools/test_read.js @@ -0,0 +1,73 @@ +const axios = require('axios'); +const YAML = require('yaml'); + +const https = require('node:https'); +const { parseArgs } = require('node:util'); +const url = require('node:url'); + +const path_ = require('path'); +const fs = require('fs'); + +let config; + +try { + ({ values: { + config, + }, positionals: [id] } = parseArgs({ + options: { + config: { + type: 'string', + }, + }, + allowPositionals: true, + })); +} catch (e) { + if ( args.length < 1 ) { + console.error( + 'Usage: readdir_profile [OPTIONS]\n' + + '\n' + + 'Options:\n' + + ' --config= (required) Path to configuration file\n' + + '' + ); + process.exit(1); + } +} + +const conf = YAML.parse(fs.readFileSync(config).toString()); + +const entry = `/${conf.username}/read_test.txt`; + +// process.on('SIGINT', async () => { +// process.exit(0); +// }); + +const httpsAgent = new https.Agent({ + rejectUnauthorized: false +}) +const getURL = (...path) => { + const apiURL = new url.URL(conf.url); + apiURL.pathname = path_.posix.join( + apiURL.pathname, + ...path + ); + return apiURL.href; +}; + +const main = async () => { + const resp = await axios.request({ + httpsAgent, + method: 'get', + url: getURL('read'), + params: { + file: entry, + }, + headers: { + 'Authorization': `Bearer ${conf.token}`, + } + }) + console.log(resp.data); +} + +main(); + diff --git a/tools/api-tester/toxiproxy/toxiproxy.json b/tools/api-tester/toxiproxy/toxiproxy.json new file mode 100644 index 00000000..b7dee2d0 --- /dev/null +++ b/tools/api-tester/toxiproxy/toxiproxy.json @@ -0,0 +1,8 @@ +[ + { + "name": "mysql", + "listen": "[::]:8888", + "upstream": "localhost:8889", + "enabled": true + } +] diff --git a/tools/api-tester/toxiproxy/toxiproxy_control.json b/tools/api-tester/toxiproxy/toxiproxy_control.json new file mode 100644 index 00000000..b7dee2d0 --- /dev/null +++ b/tools/api-tester/toxiproxy/toxiproxy_control.json @@ -0,0 +1,8 @@ +[ + { + "name": "mysql", + "listen": "[::]:8888", + "upstream": "localhost:8889", + "enabled": true + } +]