├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── ngi-eu-footer.png ├── package.json ├── src ├── add │ ├── add-mock.ts │ └── add-rule-builder.ts ├── admin-bin.ts ├── cat │ └── cat-mock.ts ├── content-retrieval-rule-builder.ts ├── get │ └── get-mock.ts ├── ipns │ ├── ipns-mock.ts │ ├── name-publish-rule-builder.ts │ └── name-resolve-rule-builder.ts ├── main.ts ├── mocked-endpoint.ts ├── mockipfs-node.ts ├── pinning │ ├── pin-add-rule-builder.ts │ ├── pin-ls-rule-builder.ts │ ├── pin-remote-add-rule-builder.ts │ ├── pin-remote-ls-rule-builder.ts │ ├── pin-rm-rule-builder.ts │ └── pinning-mock.ts └── utils │ ├── http.ts │ ├── ipfs.ts │ └── util.ts ├── test ├── integration │ ├── add.spec.ts │ ├── cat.spec.ts │ ├── get.spec.ts │ ├── name.spec.ts │ ├── pin.spec.ts │ ├── proxy.spec.ts │ └── smoke-test.spec.ts ├── mock-cids.spec.ts ├── run-karma.ts ├── run-test-ipfs-node.ts ├── test-setup.ts └── tsconfig.json ├── tsconfig.json ├── typedoc.json └── wallaby.js /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build & test 6 | runs-on: ubuntu-latest 7 | 8 | strategy: 9 | matrix: 10 | node-version: [16.x, 18.x, v18.16, '*'] 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | 19 | - run: npm install --no-package-lock 20 | - run: npm test 21 | 22 | - name: Deploy docs 23 | if: github.ref == 'refs/heads/main' && matrix.node-version == 'v18.16.0' 24 | uses: JamesIves/github-pages-deploy-action@v4.2.2 25 | with: 26 | single-commit: true 27 | branch: gh-pages 28 | folder: typedoc -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | typedoc -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MockIPFS [![Build Status](https://github.com/httptoolkit/mockipfs/workflows/CI/badge.svg)](https://github.com/httptoolkit/mockipfs/actions) [![Available on NPM](https://img.shields.io/npm/v/mockipfs.svg)](https://npmjs.com/package/mockipfs) 2 | 3 | > _Part of [HTTP Toolkit](https://httptoolkit.tech): powerful tools for building, testing & debugging HTTP(S), Ethereum, IPFS, and more_ 4 | 5 | MockIPFS lets you build a fake IPFS node, or proxy traffic to a real IPFS node, and inspect & mock the interactions made by your IPFS API client (e.g. [ipfs-http-client](https://www.npmjs.com/package/ipfs-http-client)). 6 | 7 | --- 8 | 9 | :warning: _MockIPFS is still new & rapidly developing!_ :warning: 10 | 11 | _Everything described here works today, but there's lots more to come, and some advanced use cases may run into rough edges. If you hit any problems or missing features, please [open an issue](https://github.com/httptoolkit/mockipfs/issues/new)._ 12 | 13 | --- 14 | 15 | ## Features 16 | 17 | More specifically, MockIPFS lets you: 18 | 19 | * Write automated tests for an IPFS web application, and confirm that it interacts with the IPFS network correctly, with no real IPFS node required. 20 | * Speed up & reduce overhead from IPFS automated tests: run hundreds of IPFS integration tests a second with a completely fresh state every time. 21 | * Safely & reliably run IPFS tests in parallel, with autoconfiguration of ports and URLs that allows for totally independent parallel testing. 22 | * Test how your client behaves with hard-to-reproduce issues like slow content retrieval, invalid (hash mismatch) node responses, or failed publishes. 23 | * Inspect interactions to understand exactly what an IPFS client or webapp is reading & publishing. 24 | * Build your own IPFS proxy that transforms content as it's published (anywhere) and read (in environments that don't validate hashes). 25 | * Easily play with IPFS in a fully isolated environment, with none of the unreliability & latency of interaction with the real world. 26 | 27 | ## Example 28 | 29 | Let's write our first automated test with MockIPFS. To test IPFS-based code, you will typically need to: 30 | 31 | * Start a MockIPFS node 32 | * Mock the behaviour that you're interested in 33 | * Create a real IPFS client that is configured to use your MockIPFS node 34 | * Run your real IPFS code that you're testing 35 | * Assert on the calls the MockIPFS node saw 36 | 37 | A simple example of that, testing code based on [ipfs-http-client](https://www.npmjs.com/package/ipfs-http-client), might look like this: 38 | 39 | ```typescript 40 | // Standard packages to make IPFS requests: 41 | import * as IPFS from "ipfs-http-client"; 42 | import itAll from 'it-all'; 43 | import { 44 | concat as uint8ArrayConcat, 45 | toString as uint8ToString 46 | } from 'uint8arrays'; 47 | 48 | // Import MockIPFS and create a fake node: 49 | import * as MockIPFS from 'mockipfs' 50 | const mockNode = MockIPFS.getLocal(); 51 | 52 | describe("MockIPFS", () => { 53 | // Start & stop your mock node to reset state between tests 54 | beforeEach(() => mockNode.start()); 55 | afterEach(() => mockNode.stop()); 56 | 57 | it("lets you mock behaviour and assert on node interactions", async () => { 58 | const ipfsPath = "/ipfs/a-fake-IPFS-id"; 59 | 60 | // Mock some node endpoints: 61 | await mockNode.forCat(ipfsPath).thenReturn("Mock content"); 62 | 63 | // Lookup some content with a real IPFS client: 64 | const ipfsClient = IPFS.create(mockNode.ipfsOptions); 65 | const content = await itAll(ipfsClient.cat(ipfsPath)); 66 | 67 | // Assert on the response: 68 | const contentText = uint8ToString(uint8ArrayConcat(content)); 69 | expect(contentText).to.equal("Mock content"); 70 | 71 | // Assert that we saw the requests we expected 72 | const catRequests = await mockNode.getQueriedContent(); 73 | expect(catRequests).to.deep.equal([ 74 | { path: ipfsPath } 75 | ]); 76 | }); 77 | }); 78 | ``` 79 | 80 | ## Getting Started 81 | 82 | First, install MockIPFS: 83 | 84 | ```bash 85 | npm install --save-dev mockipfs 86 | ``` 87 | 88 | Once you've installed the library, you'll want to use it in your test or automation code. To do so you need to: 89 | 90 | * Create an new MockIPFS node 91 | * Start the node, to make it listen for requests (and stop it when you're done) 92 | * Use the URL of the MockIPFS node as your IPFS node URL 93 | * Define some rules to mock behaviour 94 | 95 | ### Creating a MockIPFS node 96 | 97 | To create a node in Node.js, you can simply call `MockIPFS.getLocal()` and you're done. 98 | 99 | In many cases though, to test a web application you'll want to run your tests inside a browser, and create & manage your mock IPFS node there too. It's not possible to launch a node from inside a browser, but MockIPFS provides a separate admin server you can run, which will host your mock IPFS node externally. 100 | 101 | Once your admin server is running, you can use the exact same code as for Node.js, but each method call is transparently turned into a remote-control call to the admin server. 102 | 103 | To do this, you just need to run the admin server before you start your tests, and stop it afterwards. You can do that in one of two ways: 104 | 105 | * You can run your test suite using the provided launch helper: 106 | ``` 107 | mockipfs -c 108 | ``` 109 | This will start & stop the admin server automatically before and after your tests. 110 | * Or you can launch the admin server programmatically like so: 111 | ```javascript 112 | import * as MockIPFS from 'mockipfs'; 113 | 114 | const adminServer = MockIPFS.getAdminServer(); 115 | adminServer.start().then(() => 116 | console.log('Admin server started') 117 | ); 118 | ``` 119 | 120 | Note that as this is a universal library (it works in Node.js & browsers) this code does reference some Node.js modules & globals in a couple of places. If you're using MockIPFS from inside a browser, this needs to be handled by your bundler. In many bundlers this will be handled automatically, but if it's not you may need to enable node polyfills for this. In Webpack that usually means enabling [node-polyfill-webpack-plugin](https://www.npmjs.com/package/node-polyfill-webpack-plugin), or in ESBuild you'll want the [`@esbuild-plugins/node-modules-polyfill`](https://www.npmjs.com/package/@esbuild-plugins/node-modules-polyfill) and [`@esbuild-plugins/node-globals-polyfill`](https://www.npmjs.com/package/@esbuild-plugins/node-globals-polyfill) plugins. 121 | 122 | Once you have an admin server running, you can call `MockIPFS.getLocal()` in the browser in exactly the same way as in Node.js, and it will automatically find & use the local admin server to create your mock IPFS node. 123 | 124 | ### Starting & stopping your MockIPFS node 125 | 126 | Nodes expose `.start()` and `.stop()` methods to start & stop the node. You should call `.start()` before you use the node, call `.stop()` when you're done with it, and in both cases wait for the promise that's returned to ensure everything is completed before continuing. 127 | 128 | In automation, you'll want to create the node and start it immediately, and only stop it at shutdown. In testing environments it's usually better to start & stop the node between tests, like so: 129 | 130 | ```javascript 131 | import * as MockIPFS from 'mockipfs'; 132 | 133 | const mockNode = MockIPFS.getLocal(); 134 | 135 | describe("A suite of tests", () => { 136 | 137 | beforeEach(async () => { 138 | await mockNode.start(); 139 | }); 140 | 141 | afterEach(async () => { 142 | await mockNode.stop(); 143 | }); 144 | 145 | it("A single test", () => { 146 | // ... 147 | }); 148 | 149 | }); 150 | ``` 151 | 152 | ### Using your MockIPFS node 153 | 154 | To use your MockIPFS node instead of connecting to a real IPFS node and the real network, you just need to use the mock node's address for your IPFS HTTP client. For `ipfs-http-client` you can pass the exposed `ipfsOptions` object directly, like so: 155 | 156 | ```javascript 157 | import * as IPFS from "ipfs-http-client"; 158 | 159 | const ipfsClient = IPFS.create(mockNode.ipfsOptions); 160 | 161 | // Now use ipfsClient as normal, and all interactions will be sent to the mock node instead of 162 | // any real IPFS node, and so will not touch the real IPFS network (unless you explicitly proxy 163 | // them - see 'Proxying IPFS Traffic' below). 164 | ``` 165 | 166 | ### Mocking IPFS interactions 167 | 168 | MockIPFS provides an API that defines rules for interactions with the IPFS node. For example, you can: 169 | 170 | * Mock `ipfs cat` data, so real IPFS API calls return custom content for any id you request. 171 | * Create timeouts so that `ipfs cat` for a given CID times out entirely. 172 | * Simulate node connection errors, where IPFS requests fail entirely. 173 | * Define fake IPNS records and change them dynamically during your test. 174 | * Query the list of pinned IPFS content at the end of your test. 175 | * (More API methods to come - [open an issue]((https://github.com/httptoolkit/mockipfs/issues/new)) if you have suggestions!) 176 | 177 | All in an isolated environment that can be set up & torn down in <1ms. 178 | 179 | MockIPFS allows you to define any behaviours you like for a wide variety of IPFS interactions, to simulate everything from normal IPFS add/cat interactions, to tricky to test failure cases (like timeouts or unpinned content disappearing due to garbage collection as your code runs), to scenarios that are difficult to impossible to intentionally create with real IPFS (incorrect content for a hash, low-level connection errors, or ) 180 | 181 | By default, for all supported 'submit' methods in IPFS (e.g. `ipfs add` and `ipfs name publish`) MockIPFS will accept the submitted data, record the request, and send a successful response, but without changing any real state. Meanwhile all query methods behave by default as if the requested content was not found/not available for all requests. 182 | 183 | To change this, you define mock rules, with a chain of method calls on your MockIPFS node (typically created with `MockIPFS.getLocal()`). 184 | 185 | Defining a rule always starts with `mockNode.forX()` for some X that you want to change (e.g. `.forCat('QmcKQ...')`), followed by further calls for advanced configuration, and ending with a `.thenY()` method for some Y result you want to set. 186 | 187 | For example, you can call `mockNode.forCat('QmcKQ...').thenReturn('Fake IPFS content')` to 188 | 189 | After calling any `.thenY()` method, the new rule will take effect. In Node.js with a local node this happens synchronously, while in browsers or using remote mock nodes this may be asynchronous. All methods return promises regardless, so that you can easily write consistent await-based code that works in both environments. 190 | 191 | The full list of methods available is: 192 | 193 | * `forCat(ipfsPath?: string)` - Mock `ipfs cat` for a specific path (or all paths, if no path is provided) 194 | * `thenReturn(rawData: string`) - The mock data to return 195 | * `thenTimeout()` - Wait forever, returning no response 196 | * `thenCloseConnection()` - Kills the TCP connection, causing a network error 197 | * `forGet(ipfsPath?: string)` - Mock `ipfs get` for a specific path (or all paths, if no path is provided) 198 | * `thenReturn(rawData: string`) - The mock data to return 199 | * `thenTimeout()` - Wait forever, returning no response 200 | * `thenCloseConnection()` - Kills the TCP connection, causing a network error 201 | * `forAdd()` - Mock content publishing (`ipfs add`) for all content 202 | * `thenAcceptPublish()` - Return a successful result with mock CID values 203 | * `thenAcceptPublishAs(result: string | Array)` - Return a successful result with the given result values 204 | * `thenTimeout()` - Wait forever, returning no response 205 | * `thenCloseConnection()` - Kills the TCP connection, causing a network error 206 | * `forAddIncluding(...content: Array)` - Return a given list of pins. 236 | * `thenTimeout()` - Wait forever, returning no response 237 | * `thenCloseConnection()` - Kills the TCP connection, causing a network error 238 | * `forPinRemoteLs()` - Mock listing registered remote pinning services 239 | * `thenReturn(values: Array<{ service: string, endpoint: URL, stat?: Stat }>)` - Return a given list of remote services. 240 | * `thenTimeout()` - Wait forever, returning no response 241 | * `thenCloseConnection()` - Kills the TCP connection, causing a network error 242 | 243 | ### Examining IPFS requests 244 | 245 | Once you have made some IPFS requests to your mock IPFS node, you can then query the seen requests using a few methods: 246 | 247 | * `mockNode.getQueriedContent()` 248 | * `mockNode.getAddedContent()` 249 | * `mockNode.getIPNSQueries()` 250 | * `mockNode.getIPNSPublications()` 251 | * `mockNode.getAddedPins()` 252 | * `mockNode.getRemovedPins()` 253 | 254 | Each of these will return an array summarizing the details of the calls made since the node was started, allowing you to assert on all IPFS interactions made during your tests. 255 | 256 | ### Proxying IPFS traffic 257 | 258 | MockIPFS can also proxy IPFS traffic to a real IPFS node. This allows two advanced use cases: 259 | 260 | * In tests, you can intercept only some specific IPFS interactions, while leaving all other interactions working as normal using the real IPFS network. 261 | * You can build an IPFS proxy, configure an IPFS client (e.g. your browser) to use this as your IPFS node, and then examine the recorded interactions and/or mock specific interactions to understand exactly how that client is using IPFS, for debugging or reverse engineering. 262 | 263 | To do this, pass `unmatchedRequests: { proxyTo: "a-real-ipfs-node-HTTP-url" }` as an option when creating your mock IPFS node. This will disable the default stub responses, and proxy all unmatched requests to the given node instead. For example: 264 | 265 | ```javascript 266 | import * as MockIPFS from 'mockipfs' 267 | const mockNode = MockIPFS.getLocal({ 268 | unmatchedRequests: { proxyTo: "http://localhost:5001" } 269 | }); 270 | mockNode.start(); 271 | ``` 272 | 273 | This only changes the unmatched request behaviour, and all other methods will continue to define behaviour and query seen request data as normal. 274 | 275 | ## API Reference Documentation 276 | 277 | For more details, see the [MockIPFS reference docs](https://httptoolkit.github.io/mockipfs/). 278 | 279 | --- 280 | 281 | _This‌ ‌project‌ ‌has‌ ‌received‌ ‌funding‌ ‌from‌ ‌the‌ ‌European‌ ‌Union’s‌ ‌Horizon‌ ‌2020‌‌ research‌ ‌and‌ ‌innovation‌ ‌programme‌ ‌within‌ ‌the‌ ‌framework‌ ‌of‌ ‌the‌ ‌NGI-POINTER‌‌ Project‌ ‌funded‌ ‌under‌ ‌grant‌ ‌agreement‌ ‌No‌ 871528._ 282 | 283 | ![The NGI logo and EU flag](./ngi-eu-footer.png) 284 | -------------------------------------------------------------------------------- /ngi-eu-footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/httptoolkit/mockipfs/e53b629fec057c479d5de6d861e9db3d31f913d0/ngi-eu-footer.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mockipfs", 3 | "version": "0.3.2", 4 | "description": "Powerful friendly IPFS mock node & proxy", 5 | "main": "dist/main.js", 6 | "types": "dist/main.d.ts", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "bin": { 11 | "mockipfs": "./dist/admin-bin.js" 12 | }, 13 | "scripts": { 14 | "build": "npm run build:src && npm run build:doc", 15 | "build:src": "tsc && chmod +x ./dist/admin-bin.js", 16 | "build:doc": "typedoc src/main.ts", 17 | "pretest": "npm run build", 18 | "test": "npm run test:node && npm run test:browser", 19 | "test:node": "mocha -r ts-node/register 'test/**/*.spec.ts'", 20 | "test:browser": "ts-node test/run-karma.ts", 21 | "test:browser:dev": "CONTINUOUS_TEST=true npm run test:browser", 22 | "test:browser:debug": "HEADFUL_TEST=true CONTINUOUS_TEST=true npm run test:browser", 23 | "prepack": "npm run build" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/httptoolkit/mockipfs.git" 28 | }, 29 | "keywords": [ 30 | "ipfs", 31 | "mock", 32 | "test", 33 | "proxy" 34 | ], 35 | "author": "Tim Perry ", 36 | "license": "Apache-2.0", 37 | "bugs": { 38 | "url": "https://github.com/httptoolkit/mockipfs/issues" 39 | }, 40 | "homepage": "https://github.com/httptoolkit/mockipfs#readme", 41 | "engines": { 42 | "node": ">=16.0.0" 43 | }, 44 | "peerDependencies": { 45 | "mockttp": "^3.1.0" 46 | }, 47 | "devDependencies": { 48 | "@esbuild-plugins/node-globals-polyfill": "^0.1.1", 49 | "@esbuild-plugins/node-modules-polyfill": "^0.1.4", 50 | "@types/chai": "4.2.22", 51 | "@types/karma": "6.3.3", 52 | "@types/lodash": "4.14.177", 53 | "@types/mocha": "9.0.0", 54 | "@types/node": "17.0.34", 55 | "chai": "^4.3.4", 56 | "esbuild": "^0.14.38", 57 | "ipfs": "^0.62.3", 58 | "ipfs-core-types": "^0.10.3", 59 | "ipfs-http-client": "^56.0.3", 60 | "ipfs-utils": "^9.0.5", 61 | "it-all": "^1.0.6", 62 | "karma": "^6.3.19", 63 | "karma-chai": "^0.1.0", 64 | "karma-chrome-launcher": "^3.1.1", 65 | "karma-esbuild": "^2.2.4", 66 | "karma-mocha": "^2.0.1", 67 | "karma-spec-reporter": "^0.0.34", 68 | "mocha": "^9.1.3", 69 | "mockttp": "^3.1.0", 70 | "multiformats": "^9.9.0", 71 | "node-abort-controller": "^3.0.1", 72 | "ts-node": "^10.4.0", 73 | "typedoc": "^0.23.11", 74 | "typescript": "4.6.4", 75 | "uint8arrays": "^3.0.0" 76 | }, 77 | "dependencies": { 78 | "parse-multipart-data": "^1.3.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/add/add-mock.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp" 7 | import { buildIpfsStreamResponse, MultipartPart, parseMultipartBody } from "../utils/http"; 8 | import { mockCid } from "../utils/ipfs"; 9 | 10 | type MockttpRequestCallback = (request: Mockttp.CompletedRequest) => 11 | Promise; 12 | 13 | export const mockAddResultPart = (part: MultipartPart, cid = mockCid()) => ({ 14 | Name: decodeURIComponent(part.filename ?? '') || cid, 15 | Hash: cid, 16 | Size: -1 17 | }); 18 | 19 | /** 20 | * Defines default behaviour for IPFS add APIs, convenient methods for creating rules 21 | * from the rule-builder data, and query methods to find and expose relevant request 22 | * data from a list of collected HTTP requests. 23 | */ 24 | export class AddMock { 25 | 26 | constructor( 27 | private mockttpServer: Mockttp.Mockttp 28 | ) {} 29 | 30 | addMockttpFallbackRules() { 31 | return this.mockttpServer.addRequestRules({ 32 | priority: Mockttp.RulePriority.FALLBACK, 33 | matchers: [ 34 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 35 | new Mockttp.matchers.SimplePathMatcher('/api/v0/add') 36 | ], 37 | completionChecker: new Mockttp.completionCheckers.Always(), 38 | handler: new Mockttp.requestHandlers.CallbackHandler(this.defaultHandler) 39 | }); 40 | } 41 | 42 | defaultHandler: MockttpRequestCallback = async (req: Mockttp.CompletedRequest) => { 43 | const addedParts = parseMultipartBody((await req.body.getDecodedBuffer())!, req.headers); 44 | 45 | return buildIpfsStreamResponse(200, 46 | ...addedParts.map((part) => mockAddResultPart(part)) 47 | ); 48 | }; 49 | 50 | addAddRule = async (ruleData: Mockttp.RequestRuleData) => { 51 | await this.mockttpServer.addRequestRules({ 52 | ...ruleData, 53 | matchers: [ 54 | ...ruleData.matchers, 55 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 56 | new Mockttp.matchers.SimplePathMatcher('/api/v0/add') 57 | ] 58 | }); 59 | }; 60 | 61 | async getAddedContent(seenRequests: Mockttp.CompletedRequest[]) { 62 | const relevantRequests = seenRequests 63 | .filter((request) => request.path.startsWith('/api/v0/add')); 64 | 65 | return (await Promise.all(relevantRequests.map(async (req) => { 66 | const addedParts = parseMultipartBody((await req.body.getDecodedBuffer())!, req.headers); 67 | 68 | return addedParts.map((part) => ({ 69 | path: part.filename ? decodeURIComponent(part.filename) : undefined, 70 | content: part.data 71 | })); 72 | }))).flat(); 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /src/add/add-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | 8 | import { 9 | buildIpfsStreamResponse, 10 | parseMultipartBody 11 | } from "../utils/http"; 12 | import { mockAddResultPart } from "./add-mock"; 13 | 14 | export type ContentDefinition = 15 | | string 16 | | Uint8Array 17 | | { path?: string, content?: string | Uint8Array }; 18 | 19 | /** 20 | * A builder to allow defining rules that will mock IPFS add requests. 21 | */ 22 | export class AddRuleBuilder { 23 | 24 | /** 25 | * This builder should not be constructed directly. Call `mockNode.forAdd()` or 26 | * `mockNode.forAddIncluding(content)` instead. 27 | */ 28 | constructor( 29 | contentMatchers: Array | undefined, 30 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 31 | ) { 32 | if (contentMatchers?.length) { 33 | this.matchers.push(new Mockttp.matchers.MultipartFormDataMatcher( 34 | contentMatchers.map((contentMatcher): Mockttp.matchers.MultipartFieldMatchCondition => { 35 | if (typeof contentMatcher === 'string'|| contentMatcher instanceof Uint8Array) { 36 | return { content: contentMatcher }; 37 | } else if ('path' in contentMatcher) { 38 | return { 39 | content: contentMatcher.content, 40 | filename: encodeURIComponent(contentMatcher.path!) 41 | }; 42 | } else { 43 | return contentMatcher; 44 | } 45 | }) 46 | )); 47 | } else { 48 | this.matchers.push(new Mockttp.matchers.WildcardMatcher()); 49 | } 50 | } 51 | 52 | private matchers: Mockttp.matchers.RequestMatcher[] = []; 53 | 54 | /** 55 | * Return a successful result, as if the content was published to IPFS, and returning mock CID values. 56 | * 57 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 58 | */ 59 | thenAcceptPublish() { 60 | return this.addRuleCallback({ 61 | matchers: this.matchers, 62 | handler: new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition(async (req) => { 63 | const addedParts = parseMultipartBody((await req.body.getDecodedBuffer())!, req.headers); 64 | 65 | return buildIpfsStreamResponse(200, 66 | ...addedParts.map((part) => mockAddResultPart(part)) 67 | ); 68 | }) 69 | }); 70 | } 71 | 72 | /** 73 | * Return a successful result, as if the content was published to IPFS, and returning a given fixed mock CID 74 | * value, array of values, or full array of `{ Name, Hash, Size }` results. 75 | * 76 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 77 | */ 78 | thenAcceptPublishAs(result: string | Array) { 79 | return this.addRuleCallback({ 80 | matchers: this.matchers, 81 | handler: new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition(async (req) => { 82 | const addedParts = parseMultipartBody((await req.body.getDecodedBuffer())!, req.headers); 83 | 84 | if (!Array.isArray(result)) { 85 | return buildIpfsStreamResponse(200, 86 | ...addedParts.map((part) => mockAddResultPart(part, result)) 87 | ); 88 | } else { 89 | return buildIpfsStreamResponse(200, 90 | ...result.map((resultPart) => { 91 | if (typeof resultPart === 'string') { 92 | return { Name: resultPart, Hash: resultPart, Size: -1 }; 93 | } else { 94 | return { 95 | Size: -1, 96 | ...resultPart 97 | }; 98 | } 99 | }) 100 | ); 101 | } 102 | }) 103 | }); 104 | } 105 | 106 | /** 107 | * Timeout, accepting the request but never returning a response. 108 | * 109 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 110 | */ 111 | thenTimeout() { 112 | return this.addRuleCallback({ 113 | matchers: this.matchers, 114 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 115 | }); 116 | } 117 | 118 | /** 119 | * Close the connection immediately after receiving the matching request, without sending any response. 120 | * 121 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 122 | */ 123 | thenCloseConnection() { 124 | return this.addRuleCallback({ 125 | matchers: this.matchers, 126 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 127 | }); 128 | } 129 | 130 | } -------------------------------------------------------------------------------- /src/admin-bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * SPDX-FileCopyrightText: 2022 Tim Perry 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import childProcess = require('child_process'); 8 | import MockIPFS = require('./main'); 9 | 10 | handleArgs(process.argv).catch((e) => { 11 | console.error(e); 12 | process.exit(1); 13 | }); 14 | 15 | async function handleArgs(args: string[]) { 16 | const remainingArgs = args.slice(2); 17 | let nextArg = remainingArgs.shift(); 18 | while (nextArg) { 19 | if (nextArg === '-c') { 20 | await runCommandWithServer(remainingArgs.join(' ')); 21 | return; 22 | } else { 23 | break; 24 | } 25 | } 26 | 27 | console.log("Usage: mockipfs -c "); 28 | process.exit(1); 29 | } 30 | 31 | async function runCommandWithServer(command: string) { 32 | const server = MockIPFS.getAdminServer(); 33 | await server.start(); 34 | 35 | let realProcess = childProcess.spawn(command, [], { 36 | shell: true, 37 | stdio: 'inherit' 38 | }); 39 | 40 | realProcess.on('error', (error) => { 41 | server.stop().then(function () { 42 | console.error(error); 43 | process.exit(1); 44 | }); 45 | }); 46 | 47 | realProcess.on('exit', (code, signal) => { 48 | server.stop().then(function () { 49 | if (code == null) { 50 | console.error('Executed process exited due to signal: ' + signal); 51 | process.exit(1); 52 | } else { 53 | process.exit(code); 54 | } 55 | }); 56 | }); 57 | } -------------------------------------------------------------------------------- /src/cat/cat-mock.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp" 7 | 8 | export class CatMock { 9 | 10 | constructor( 11 | private mockttpServer: Mockttp.Mockttp 12 | ) {} 13 | 14 | addMockttpFallbackRules() { 15 | return this.mockttpServer.addRequestRules({ 16 | priority: Mockttp.RulePriority.FALLBACK, 17 | matchers: [ 18 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 19 | new Mockttp.matchers.SimplePathMatcher('/api/v0/cat') 20 | ], 21 | completionChecker: new Mockttp.completionCheckers.Always(), 22 | handler: new Mockttp.requestHandlers.TimeoutHandler() 23 | }); 24 | } 25 | 26 | async getQueriedContent(seenRequests: Mockttp.CompletedRequest[]) { 27 | const relevantRequests = seenRequests 28 | .filter((request) => request.path.startsWith('/api/v0/cat')); 29 | 30 | return Promise.all(relevantRequests.map(async (req) => { 31 | const parsedURL = new URL(req.url) 32 | return { path: parsedURL.searchParams.get('arg')! }; 33 | })); 34 | } 35 | } -------------------------------------------------------------------------------- /src/content-retrieval-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { RequestRuleBuilder } from "mockttp"; 7 | import { MockedIPFSEndpoint } from "./mocked-endpoint"; 8 | import { buildIpfsStreamDefaultHeaders } from "./utils/http"; 9 | 10 | /** 11 | * A builder to allow defining rules that will mock IPFS get requests. 12 | */ 13 | export class ContentRetrievalRuleBuilder { 14 | 15 | /** 16 | * This builder should not be constructed directly. Call `mockNode.forGet(cid)` instead. 17 | */ 18 | constructor( 19 | private httpRuleBuilder: RequestRuleBuilder 20 | ) {} 21 | 22 | /** 23 | * Return a successful result, returning the the raw content provided here as if it 24 | * came instantly from IPFS. 25 | * 26 | * This method completes the rule definition, and returns a promise that resolves to a 27 | * MockedIPFSEndpoint once the rule is active. This endpoint can be used to query the rule, 28 | * and check what requests were received for this content. 29 | */ 30 | async thenReturn(rawData: string) { 31 | return new MockedIPFSEndpoint( 32 | await this.httpRuleBuilder.thenReply(200, rawData, buildIpfsStreamDefaultHeaders()) 33 | ); 34 | } 35 | 36 | /** 37 | * Timeout, accepting the request but never returning a response. 38 | * 39 | * This method completes the rule definition, and returns a promise that resolves to a 40 | * MockedIPFSEndpoint once the rule is active. This endpoint can be used to query the rule, 41 | * and check what requests were received for this content. 42 | */ 43 | async thenTimeout() { 44 | return new MockedIPFSEndpoint( 45 | await this.httpRuleBuilder.thenTimeout() 46 | ); 47 | } 48 | 49 | /** 50 | * Close the connection immediately after receiving the matching request, without sending any response. 51 | * 52 | * This method completes the rule definition, and returns a promise that resolves to a 53 | * MockedIPFSEndpoint once the rule is active. This endpoint can be used to query the rule, 54 | * and check what requests were received for this content. 55 | */ 56 | async thenCloseConnection() { 57 | return new MockedIPFSEndpoint( 58 | await this.httpRuleBuilder.thenCloseConnection() 59 | ); 60 | } 61 | } -------------------------------------------------------------------------------- /src/get/get-mock.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp" 7 | 8 | export class GetMock { 9 | 10 | constructor( 11 | private mockttpServer: Mockttp.Mockttp 12 | ) {} 13 | 14 | addMockttpFallbackRules() { 15 | return this.mockttpServer.addRequestRules({ 16 | priority: Mockttp.RulePriority.FALLBACK, 17 | matchers: [ 18 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 19 | new Mockttp.matchers.SimplePathMatcher('/api/v0/get') 20 | ], 21 | completionChecker: new Mockttp.completionCheckers.Always(), 22 | handler: new Mockttp.requestHandlers.TimeoutHandler() 23 | }); 24 | } 25 | 26 | async getQueriedContent(seenRequests: Mockttp.CompletedRequest[]) { 27 | const relevantRequests = seenRequests 28 | .filter((request) => request.path.startsWith('/api/v0/get')); 29 | 30 | return Promise.all(relevantRequests.map(async (req) => { 31 | const parsedURL = new URL(req.url) 32 | return { path: parsedURL.searchParams.get('arg')! }; 33 | })); 34 | } 35 | } -------------------------------------------------------------------------------- /src/ipns/ipns-mock.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp" 7 | import { buildIpfsFixedValueResponse } from "../utils/http"; 8 | 9 | type MockttpRequestCallback = (request: Mockttp.CompletedRequest) => 10 | Promise; 11 | 12 | const notFoundResponse = (name: string) => buildIpfsFixedValueResponse(500, { 13 | Message: `queryTxt ENOTFOUND _dnslink.${name}`, 14 | Code: 0, 15 | Type: 'error' 16 | }); 17 | 18 | const badRequestResponse = (message: string) => buildIpfsFixedValueResponse(400, { 19 | Message: message, 20 | Code: 1, 21 | Type: "error" 22 | }); 23 | 24 | const RESOLVE_PATHS = ['/api/v0/name/resolve', '/api/v0/resolve']; 25 | 26 | /** 27 | * Defines default behaviour for IPNS APIs, convenient methods for creating the rules 28 | * from the rule-builder data, and query methods to find and expose relevant request 29 | * data from a list of collected HTTP requests. 30 | */ 31 | export class IPNSMock { 32 | 33 | constructor( 34 | private mockttpServer: Mockttp.Mockttp 35 | ) {} 36 | 37 | async addMockttpFallbackRules() { 38 | await Promise.all([ 39 | this.mockttpServer.addRequestRules({ 40 | priority: Mockttp.RulePriority.FALLBACK, 41 | matchers: [ 42 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 43 | new Mockttp.matchers.SimplePathMatcher('/api/v0/name/publish') 44 | ], 45 | completionChecker: new Mockttp.completionCheckers.Always(), 46 | handler: new Mockttp.requestHandlers.CallbackHandler(this.fallbackPublishHandler) 47 | }), 48 | 49 | this.addResolveRule({ 50 | priority: Mockttp.RulePriority.FALLBACK, 51 | matchers: [], // Both paths are added in addResolveRule 52 | completionChecker: new Mockttp.completionCheckers.Always(), 53 | handler: new Mockttp.requestHandlers.CallbackHandler(this.fallbackResolveHandler) 54 | }) 55 | ]); 56 | } 57 | 58 | addPublishRule = async (ruleData: Mockttp.RequestRuleData) => { 59 | await this.mockttpServer.addRequestRules({ 60 | ...ruleData, 61 | matchers: [ 62 | ...ruleData.matchers, 63 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 64 | new Mockttp.matchers.SimplePathMatcher('/api/v0/name/publish') 65 | ] 66 | }); 67 | }; 68 | 69 | addResolveRule = async (ruleData: Mockttp.RequestRuleData) => { 70 | await this.mockttpServer.addRequestRules( 71 | ...RESOLVE_PATHS.map((resolvePath) => ({ 72 | ...ruleData, 73 | matchers: [ 74 | ...ruleData.matchers, 75 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 76 | new Mockttp.matchers.SimplePathMatcher(resolvePath) 77 | ] 78 | })) 79 | ); 80 | }; 81 | 82 | private fallbackResolveHandler: MockttpRequestCallback = async (request: Mockttp.CompletedRequest) => { 83 | const parsedURL = new URL(request.url); 84 | const name = parsedURL.searchParams.get('arg'); 85 | 86 | if (!name) { 87 | return badRequestResponse("Invalid request query input"); 88 | } 89 | 90 | return notFoundResponse(name); 91 | }; 92 | 93 | private fallbackPublishHandler: MockttpRequestCallback = async (req: Mockttp.CompletedRequest) => { 94 | const parsedURL = new URL(req.url); 95 | 96 | const value = parsedURL.searchParams.get('arg')!; 97 | 98 | return buildIpfsFixedValueResponse(200, { 99 | // We can't guess the name (the CID for the given key) so we return a mock string 100 | Name: 'mock-ipns-name', 101 | Value: value 102 | }); 103 | }; 104 | 105 | async getIPNSQueries(seenRequests: Mockttp.Request[]) { 106 | const relevantRequests = seenRequests 107 | .filter((request) => 108 | RESOLVE_PATHS.some(resolvePath => request.path.startsWith(resolvePath)) 109 | ); 110 | 111 | return relevantRequests.map((request) => { 112 | const parsedURL = new URL(request.url) 113 | return { name: parsedURL.searchParams.get('arg') }; 114 | }); 115 | } 116 | 117 | async getIPNSPublications(seenRequests: Mockttp.Request[]) { 118 | const relevantRequests = seenRequests 119 | .filter((request) => request.path.startsWith('/api/v0/name/publish')); 120 | 121 | return relevantRequests.map((request) => { 122 | const parsedURL = new URL(request.url) 123 | return { 124 | name: parsedURL.searchParams.get('key'), 125 | value: parsedURL.searchParams.get('arg')!, 126 | }; 127 | }); 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/ipns/name-publish-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | import { buildIpfsFixedValueResponse } from "../utils/http"; 8 | 9 | /** 10 | * A builder to allow defining rules that will mock IPNS name publication requests. 11 | */ 12 | export class NamePublishRuleBuilder { 13 | 14 | /** 15 | * This builder should not be constructed directly. Call `mockNode.forNamePublish(name)` instead. 16 | */ 17 | constructor( 18 | nameKey: string | undefined, 19 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 20 | ) { 21 | if (nameKey === 'self') { 22 | // Self is special - it's the default value, so we're flexible on matching: 23 | this.matchers.push(new Mockttp.matchers.CallbackMatcher((req) => { 24 | const params = new URL(req.url).searchParams; 25 | return (params.get('key') ?? 'self') === 'self'; 26 | })); 27 | } else if (nameKey) { 28 | this.matchers.push(new Mockttp.matchers.QueryMatcher({ key: nameKey })); 29 | } else { 30 | this.matchers.push(new Mockttp.matchers.WildcardMatcher()); 31 | } 32 | } 33 | 34 | private matchers: Mockttp.matchers.RequestMatcher[] = []; 35 | 36 | /** 37 | * Limit the rule so that it matches only publications of a specific IPFS path. 38 | */ 39 | withContent(cid: string) { 40 | this.matchers.push(new Mockttp.matchers.QueryMatcher({ arg: cid })); 41 | return this; 42 | } 43 | 44 | /** 45 | * Return a successful result, as if the IPNS name was published, returning a default mock IPNS result. 46 | * 47 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 48 | */ 49 | thenAcceptPublish() { 50 | return this.addRuleCallback({ 51 | matchers: this.matchers, 52 | handler: new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 53 | const parsedURL = new URL(req.url); 54 | 55 | const value = parsedURL.searchParams.get('arg')!; 56 | 57 | return buildIpfsFixedValueResponse(200, { 58 | // We can't guess the name (the CID for the given key) so we return a mock string 59 | Name: 'mock-ipns-name', 60 | Value: value 61 | }); 62 | }) 63 | }); 64 | } 65 | 66 | /** 67 | * Return a successful result, as if the IPNS name was published, returning a given IPNS name. 68 | * 69 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 70 | */ 71 | thenAcceptPublishAs(name: string) { 72 | return this.addRuleCallback({ 73 | matchers: this.matchers, 74 | handler: new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 75 | const parsedURL = new URL(req.url); 76 | 77 | const value = parsedURL.searchParams.get('arg')!; 78 | 79 | return buildIpfsFixedValueResponse(200, { 80 | Name: name, 81 | Value: value 82 | }); 83 | }) 84 | }); 85 | } 86 | 87 | /** 88 | * Timeout, accepting the request but never returning a response. 89 | * 90 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 91 | */ 92 | thenTimeout() { 93 | return this.addRuleCallback({ 94 | matchers: this.matchers, 95 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 96 | }); 97 | } 98 | 99 | /** 100 | * Close the connection immediately after receiving the matching request, without sending any response. 101 | * 102 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 103 | */ 104 | thenCloseConnection() { 105 | return this.addRuleCallback({ 106 | matchers: this.matchers, 107 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 108 | }); 109 | } 110 | 111 | } -------------------------------------------------------------------------------- /src/ipns/name-resolve-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | import { IpfsFixedResponseHandlerDefinition } from "../utils/http"; 8 | 9 | /** 10 | * A builder to allow defining rules that will mock IPNS name resolution requests. 11 | */ 12 | export class NameResolveRuleBuilder { 13 | 14 | /** 15 | * This builder should not be constructed directly. Call `mockNode.forNameResolve(name)` instead. 16 | */ 17 | constructor( 18 | private name: string | undefined, 19 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 20 | ) { 21 | if (name) { 22 | this.matchers.push(new Mockttp.matchers.QueryMatcher({ arg: name })); 23 | } else { 24 | this.matchers.push(new Mockttp.matchers.WildcardMatcher()); 25 | } 26 | } 27 | 28 | private matchers: Mockttp.matchers.RequestMatcher[] = []; 29 | 30 | /** 31 | * Return a successful name resolution result, resolving to the given IPFS path. 32 | * 33 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 34 | */ 35 | thenResolveTo(path: string) { 36 | return this.addRuleCallback({ 37 | matchers: this.matchers, 38 | handler: new IpfsFixedResponseHandlerDefinition(200, { 39 | Path: path 40 | }) 41 | }); 42 | } 43 | 44 | /** 45 | * Return a failing name resolution result, rejecting the request as if the name was not found. 46 | * 47 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 48 | */ 49 | thenFailToResolve() { 50 | return this.addRuleCallback({ 51 | matchers: this.matchers, 52 | handler: new IpfsFixedResponseHandlerDefinition(500, { 53 | Message: `queryTxt ENOTFOUND _dnslink.${this.name}`, 54 | Code: 0, 55 | Type: 'error' 56 | }) 57 | }); 58 | } 59 | 60 | /** 61 | * Timeout, accepting the request but never returning a response. 62 | * 63 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 64 | */ 65 | thenTimeout() { 66 | return this.addRuleCallback({ 67 | matchers: this.matchers, 68 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 69 | }); 70 | } 71 | 72 | /** 73 | * Close the connection immediately after receiving the matching request, without sending any response. 74 | * 75 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 76 | */ 77 | thenCloseConnection() { 78 | return this.addRuleCallback({ 79 | matchers: this.matchers, 80 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 81 | }); 82 | } 83 | 84 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as mockttp from 'mockttp'; 7 | import { 8 | MockIPFSNode, 9 | MockIPFSOptions 10 | } from './mockipfs-node'; 11 | 12 | export function getLocal(options?: mockttp.MockttpOptions & MockIPFSOptions) { 13 | return new MockIPFSNode(mockttp.getLocal(options), options); 14 | } 15 | 16 | export function getRemote(options?: mockttp.MockttpClientOptions & MockIPFSOptions) { 17 | return new MockIPFSNode(mockttp.getRemote(options), options); 18 | } 19 | 20 | export function getAdminServer(options?: mockttp.MockttpAdminServerOptions) { 21 | return mockttp.getAdminServer(options); 22 | } 23 | 24 | export { mockCid } from './utils/ipfs'; 25 | 26 | export type { 27 | MockIPFSNode, 28 | MockIPFSOptions 29 | }; 30 | 31 | // Export various internal types: 32 | export type { AddRuleBuilder, ContentDefinition } from './add/add-rule-builder'; 33 | export type { ContentRetrievalRuleBuilder } from './content-retrieval-rule-builder'; 34 | export type { NamePublishRuleBuilder } from './ipns/name-publish-rule-builder'; 35 | export type { NameResolveRuleBuilder } from './ipns/name-resolve-rule-builder'; 36 | export type { PinAddRuleBuilder } from './pinning/pin-add-rule-builder'; 37 | export type { PinRemoteAddRuleBuilder } from './pinning/pin-remote-add-rule-builder'; 38 | export type { PinLsRuleBuilder } from './pinning/pin-ls-rule-builder'; 39 | export type { PinRemoteLsRuleBuilder } from './pinning/pin-remote-ls-rule-builder'; 40 | export type { PinRmRuleBuilder } from './pinning/pin-rm-rule-builder'; 41 | 42 | export type { MockedIPFSEndpoint } from './mocked-endpoint'; -------------------------------------------------------------------------------- /src/mocked-endpoint.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { MockedEndpoint } from "mockttp"; 7 | 8 | export class MockedIPFSEndpoint { 9 | constructor( 10 | private mockedHttpEndpoint: MockedEndpoint 11 | ) {} 12 | 13 | isPending = () => this.mockedHttpEndpoint.isPending(); 14 | 15 | /** 16 | * Return an array of the requests seen by this endpoint. 17 | */ 18 | async getSeenRequests() { 19 | const requests = await this.mockedHttpEndpoint.getSeenRequests(); 20 | 21 | return requests.map((request) => { 22 | const { searchParams } = new URL(request.url); 23 | const params = Array.from(searchParams.entries()) 24 | .reduce<{ 25 | [key: string]: string | string[] 26 | }>((result, [key, value]) => { 27 | const currentValue = result[key]; 28 | if (!currentValue) { 29 | result[key] = value; 30 | } else if (!Array.isArray(currentValue)) { 31 | result[key] = [currentValue, value]; 32 | } else { 33 | currentValue.push(value); 34 | } 35 | 36 | return result; 37 | }, {}); 38 | 39 | 40 | return { params }; 41 | }); 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/mockipfs-node.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | 8 | import { delay, isNode } from "./utils/util"; 9 | import { GetMock } from "./get/get-mock"; 10 | import { CatMock } from "./cat/cat-mock"; 11 | import { ContentRetrievalRuleBuilder } from "./content-retrieval-rule-builder"; 12 | import { AddMock } from "./add/add-mock"; 13 | import { AddRuleBuilder } from "./add/add-rule-builder"; 14 | 15 | import { IPNSMock } from "./ipns/ipns-mock"; 16 | import { NamePublishRuleBuilder } from "./ipns/name-publish-rule-builder"; 17 | import { NameResolveRuleBuilder } from "./ipns/name-resolve-rule-builder"; 18 | 19 | import { PinningMock } from "./pinning/pinning-mock"; 20 | import { PinAddRuleBuilder } from "./pinning/pin-add-rule-builder"; 21 | import { PinRemoteAddRuleBuilder } from "./pinning/pin-remote-add-rule-builder"; 22 | import { PinRmRuleBuilder } from "./pinning/pin-rm-rule-builder"; 23 | import { PinLsRuleBuilder } from "./pinning/pin-ls-rule-builder"; 24 | import { PinRemoteLsRuleBuilder } from "./pinning/pin-remote-ls-rule-builder"; 25 | 26 | export interface MockIPFSOptions { 27 | /** 28 | * Specify the behaviour of unmatched requests. 29 | * 30 | * By default this is set to `stub`, in which case default responses will be 31 | * returned, emulating a constantly available but empty IPFS node: all queries 32 | * will return no data, and all submitted data will be accepted (but ignored). 33 | * 34 | * Alternatively, this can be set to an object including a `proxyTo` property, 35 | * defining the URL of a remote IPFS node to which unmatched requests should be 36 | * forwarded. In this case all default behaviours will be disabled, and all 37 | * unmatched requests will receive real responses from that upstream IPFS node. 38 | */ 39 | unmatchedRequests?: 40 | | 'stub' 41 | | { proxyTo: string } 42 | } 43 | 44 | /** 45 | * A MockIPFS node provides default behaviours and allows defining custom behaviour 46 | * rules to simulate interactions with the IPFS network without requiring a full 47 | * node or access to the real IPFS network. 48 | * 49 | * This should not be created directly: instead, call then `getLocal()` or `getRemote()` 50 | * methods exported from this module. 51 | * 52 | * Once you have a MockIPFS node, you can start defining rules using any of the 53 | * `forX()` methods. Each method returns a rule builder, allowing you to add extra 54 | * matching constraints, followed by a `thenX()` final method which enables the rule, 55 | * returning a promise that resolves once the rule is constructed and active. 56 | */ 57 | export class MockIPFSNode { 58 | 59 | private ipnsMock: IPNSMock; 60 | private pinningMock: PinningMock; 61 | private addMock: AddMock; 62 | private catMock: CatMock; 63 | private getMock: GetMock; 64 | 65 | private seenRequests: Mockttp.CompletedRequest[] = [] 66 | 67 | constructor( 68 | private mockttpServer: Mockttp.Mockttp, 69 | private options: MockIPFSOptions = {} 70 | ) { 71 | // Can't initialize this in the field or it breaks in ESBuild's browser output 72 | this.ipnsMock = new IPNSMock(this.mockttpServer) 73 | this.pinningMock = new PinningMock(this.mockttpServer); 74 | this.addMock = new AddMock(this.mockttpServer); 75 | this.catMock = new CatMock(this.mockttpServer); 76 | this.getMock = new GetMock(this.mockttpServer); 77 | } 78 | 79 | /** 80 | * The node must be started before use. Starting the node resets it, removing any 81 | * rules that may have been added previously and configuring default behaviours 82 | * for unmatched requests. 83 | */ 84 | async start() { 85 | this.reset(); 86 | await this.mockttpServer.start(); 87 | await this.addBaseRules(); 88 | } 89 | 90 | async stop() { 91 | await this.mockttpServer.stop(); 92 | } 93 | 94 | reset() { 95 | this.seenRequests = []; 96 | this.mockttpServer.reset(); 97 | } 98 | 99 | /** 100 | * The IPFS options required to connect to this MockIPFS node. These can be passed 101 | * directly to `IPFS.create` from ipfs-http-client to create a real IPFS client 102 | * that connects to this mock node. 103 | */ 104 | get ipfsOptions() { 105 | return { 106 | protocol: 'http', 107 | host: 'localhost', 108 | port: this.mockttpServer.port 109 | }; 110 | } 111 | 112 | private async addBaseRules() { 113 | await Promise.all([ 114 | this.mockttpServer.on('request', this.onRequest), 115 | 116 | ...(!this.options.unmatchedRequests || this.options.unmatchedRequests === 'stub' 117 | ? [ 118 | this.ipnsMock.addMockttpFallbackRules(), 119 | this.pinningMock.addMockttpFallbackRules(), 120 | this.addMock.addMockttpFallbackRules(), 121 | this.catMock.addMockttpFallbackRules(), 122 | this.getMock.addMockttpFallbackRules() 123 | ] 124 | : [ 125 | this.mockttpServer.forUnmatchedRequest() 126 | .thenForwardTo(this.options.unmatchedRequests.proxyTo) 127 | ]) 128 | ]); 129 | } 130 | 131 | private onRequest = (request: Mockttp.CompletedRequest) => { 132 | if (request.path.startsWith('/api/v0')) { 133 | this.seenRequests.push(request); 134 | } 135 | } 136 | 137 | /** 138 | * Mock IPFS cat requests, returning fake content instead of the 139 | * real content for the given CID. 140 | * 141 | * This takes an optional CID argument. If not provided, the mock 142 | * will match all cat requests for any CID. 143 | */ 144 | forCat(cid?: string) { 145 | let catRuleBuilder = this.mockttpServer.forPost('/api/v0/cat'); 146 | 147 | if (cid !== undefined) { 148 | catRuleBuilder = catRuleBuilder.withQuery({ arg: cid }); 149 | } 150 | 151 | return new ContentRetrievalRuleBuilder(catRuleBuilder); 152 | } 153 | 154 | /** 155 | * Mock IPFS add requests, mocking the behaviour of the add command 156 | * while ensuring that the added content is never actually sent 157 | * to a real IPFS node. 158 | */ 159 | forAdd() { 160 | return this.forAddIncluding(); 161 | } 162 | 163 | /** 164 | * Mock IPFS get requests, returning fake content instead of the 165 | * real content for the given CID. 166 | * 167 | * This takes an optional CID argument. If not provided, the mock 168 | * will match all get requests for any CID. 169 | */ 170 | forGet(cid?: string) { 171 | let getRuleBuilder = this.mockttpServer.forPost('/api/v0/get'); 172 | 173 | if (cid !== undefined) { 174 | getRuleBuilder = getRuleBuilder.withQuery({ arg: cid }); 175 | } 176 | 177 | return new ContentRetrievalRuleBuilder(getRuleBuilder); 178 | } 179 | 180 | /** 181 | * Mock IPFS add requests containing certain types of content, mocking 182 | * the behaviour of the add command while ensuring that the added content 183 | * is never actually sent to a real IPFS node. 184 | */ 185 | forAddIncluding(...content: Array< 186 | | string 187 | | Uint8Array 188 | | { path: string, content?: string | Uint8Array } 189 | >) { 190 | return new AddRuleBuilder( 191 | content, 192 | this.addMock.addAddRule 193 | ); 194 | } 195 | 196 | /** 197 | * Mock the behaviour of IPNS name resolutions. 198 | * 199 | * This takes an optional name argument to match. If not provided, the 200 | * defined behaviour will apply for all IPFS resolutions for any name. 201 | */ 202 | forNameResolve(name?: string) { 203 | return new NameResolveRuleBuilder( 204 | name, 205 | this.ipnsMock.addResolveRule 206 | ); 207 | } 208 | 209 | /** 210 | * Mock the behaviour of IPNS name publishing. 211 | * 212 | * This takes an optional name argument to match. If not provided, the 213 | * defined behaviour will apply for all IPFS resolutions for any name. 214 | */ 215 | forNamePublish(nameKey?: string) { 216 | return new NamePublishRuleBuilder( 217 | nameKey, 218 | this.ipnsMock.addPublishRule 219 | ); 220 | } 221 | 222 | /** 223 | * Mock the behaviour of IPFS pinning. 224 | * 225 | * This takes an optional CID argument. If not provided, the mock 226 | * will match pinning of any CID. 227 | */ 228 | forPinAdd(cid?: string) { 229 | return new PinAddRuleBuilder( 230 | cid, 231 | this.pinningMock.addPinAddRule 232 | ); 233 | } 234 | 235 | /** 236 | * Mock the behaviour of IPFS pinning to a remote service 237 | * 238 | * This takes an optional CID argument. If not provided, the mock 239 | * will match remote pinning of any CID. 240 | */ 241 | forPinRemoteAdd(cid?: string) { 242 | return new PinRemoteAddRuleBuilder( 243 | cid, 244 | this.pinningMock.addPinRemoteAddRule 245 | ); 246 | } 247 | 248 | /** 249 | * Mock the behaviour of IPFS pin removal. 250 | * 251 | * This takes an optional CID argument. If not provided, the mock 252 | * will match unpinning of any CID. 253 | */ 254 | forPinRm(cid?: string) { 255 | return new PinRmRuleBuilder( 256 | cid, 257 | this.pinningMock.addPinRmRule 258 | ); 259 | } 260 | 261 | /** 262 | * Mock the behaviour of IPFS pin listing. 263 | */ 264 | forPinLs() { 265 | return new PinLsRuleBuilder( 266 | this.pinningMock.addPinLsRule 267 | ); 268 | } 269 | 270 | /** 271 | * Mock the behaviour of IPFS remote pinning service listing. 272 | */ 273 | forPinRemoteLs() { 274 | return new PinRemoteLsRuleBuilder( 275 | this.pinningMock.addPinRemoteLsRule 276 | ); 277 | } 278 | 279 | /** 280 | * Get the list of all IPFS CIDs that have been requested 281 | * from this IPFS mock node since it started. 282 | */ 283 | async getQueriedContent(): Promise> { 284 | // In browsers, the ipfs-http-client sometimes completes just a moment left 285 | // before the request 'officially' completes. To avoid confusion here, we 286 | // wait briefly to ensure all queried content is collected correctly. 287 | if (!isNode) await delay(1); 288 | 289 | const cats = await this.catMock.getQueriedContent(this.seenRequests); 290 | const gets = await this.getMock.getQueriedContent(this.seenRequests); 291 | 292 | return cats.concat(gets); 293 | } 294 | 295 | /** 296 | * Get the list of all IPFS CIDs & content that have been added to 297 | * this IPFS mock node since it started. 298 | */ 299 | async getAddedContent(): Promise> { 300 | return this.addMock.getAddedContent(this.seenRequests); 301 | } 302 | 303 | /** 304 | * Get the list of all IPNS resolution queries that have been received by 305 | * this IPFS mock node since it started. 306 | */ 307 | async getIPNSQueries(): Promise> { 308 | return this.ipnsMock.getIPNSQueries(this.seenRequests); 309 | } 310 | 311 | /** 312 | * Get the list of all IPNS publications that have been received by 313 | * this IPFS mock node since it started. 314 | */ 315 | async getIPNSPublications(): Promise> { 316 | return this.ipnsMock.getIPNSPublications(this.seenRequests); 317 | } 318 | 319 | /** 320 | * Get the list of all IPFS pins that have been added to this IPFS mock 321 | * node since it started. 322 | */ 323 | async getAddedPins(): Promise> { 324 | return this.pinningMock.getAddedPins(this.seenRequests); 325 | } 326 | 327 | /** 328 | * Get the list of all IPFS pins that have been removed from this IPFS mock 329 | * node since it started. 330 | */ 331 | async getRemovedPins(): Promise> { 332 | return this.pinningMock.getRemovedPins(this.seenRequests); 333 | } 334 | } -------------------------------------------------------------------------------- /src/pinning/pin-add-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | import { 8 | buildIpfsFixedValueResponse, 9 | IpfsFixedResponseHandlerDefinition 10 | } from "../utils/http"; 11 | 12 | /** 13 | * A builder to allow defining rules that will mock IPFS pin add requests. 14 | */ 15 | export class PinAddRuleBuilder { 16 | 17 | /** 18 | * This builder should not be constructed directly. Call `mockNode.forPinAdd(cid)` instead. 19 | */ 20 | constructor( 21 | private cid: string | undefined, 22 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 23 | ) { 24 | if (cid) { 25 | this.matchers.push(new Mockttp.matchers.QueryMatcher({ arg: cid })); 26 | } else { 27 | this.matchers.push(new Mockttp.matchers.WildcardMatcher()); 28 | } 29 | } 30 | 31 | private matchers: Mockttp.matchers.RequestMatcher[] = []; 32 | 33 | /** 34 | * Return a successful result, as if the IPFS content was pinned successfully. 35 | * 36 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 37 | */ 38 | thenPinSuccessfully() { 39 | return this.addRuleCallback({ 40 | matchers: this.matchers, 41 | handler: this.cid 42 | ? new IpfsFixedResponseHandlerDefinition(200, { 43 | Pins: [this.cid] 44 | }) 45 | : new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 46 | const url = new URL(req.url); 47 | const cid = url.searchParams.get('arg'); 48 | return buildIpfsFixedValueResponse(200, { Pins: [cid] }); 49 | }) 50 | }); 51 | } 52 | 53 | /** 54 | * Timeout, accepting the request but never returning a response. 55 | * 56 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 57 | */ 58 | thenTimeout() { 59 | return this.addRuleCallback({ 60 | matchers: this.matchers, 61 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 62 | }); 63 | } 64 | 65 | /** 66 | * Close the connection immediately after receiving the matching request, without sending any response. 67 | * 68 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 69 | */ 70 | thenCloseConnection() { 71 | return this.addRuleCallback({ 72 | matchers: this.matchers, 73 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 74 | }); 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/pinning/pin-ls-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | import { 8 | buildIpfsStreamResponse, 9 | } from "../utils/http"; 10 | 11 | /** 12 | * A builder to allow defining rules that will mock IPFS pin ls requests. 13 | */ 14 | export class PinLsRuleBuilder { 15 | 16 | /** 17 | * This builder should not be constructed directly. Call `mockNode.forPinLs()` instead. 18 | */ 19 | constructor( 20 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 21 | ) {} 22 | 23 | /** 24 | * Return a successful result, returning the given fixed list of pinned content. The parameter should 25 | * be an array of `{ type, cid }` objects, where type is the pin type (direct, recursive, indirect, or 26 | * all), and cid is the CID of the pinned content. 27 | * 28 | * When this matches a request that specifies a filter (e.g. ?type=direct) only the values with the 29 | * matching type will be returned. 30 | * 31 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 32 | */ 33 | thenReturn(values: Array<{ type: string, cid: string }>) { 34 | return this.addRuleCallback({ 35 | matchers: [], 36 | handler: new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 37 | const searchParams = new URL(req.url).searchParams; 38 | 39 | const typeFilter = searchParams.get('type'); 40 | 41 | const valuesToReturn = values 42 | .filter(v => typeFilter && typeFilter !== 'all' 43 | ? v.type === typeFilter 44 | : true 45 | ).map(({ type, cid }) => ({ 46 | Type: type, 47 | Cid: cid 48 | })); 49 | 50 | return buildIpfsStreamResponse(200, ...valuesToReturn); 51 | }) 52 | }); 53 | } 54 | 55 | /** 56 | * Timeout, accepting the request but never returning a response. 57 | * 58 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 59 | */ 60 | thenTimeout() { 61 | return this.addRuleCallback({ 62 | matchers: [], 63 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 64 | }); 65 | } 66 | 67 | /** 68 | * Close the connection immediately after receiving the matching request, without sending any response. 69 | * 70 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 71 | */ 72 | thenCloseConnection() { 73 | return this.addRuleCallback({ 74 | matchers: [], 75 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 76 | }); 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /src/pinning/pin-remote-add-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | import { 8 | buildIpfsFixedValueResponse, 9 | IpfsFixedResponseHandlerDefinition 10 | } from "../utils/http"; 11 | 12 | /** 13 | * A builder to allow defining rules that will mock IPFS pin remote add requests. 14 | */ 15 | export class PinRemoteAddRuleBuilder { 16 | 17 | /** 18 | * This builder should not be constructed directly. Call `mockNode.forPinRemoteAdd(cid)` instead. 19 | */ 20 | constructor( 21 | private cid: string | undefined, 22 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 23 | ) { 24 | if (cid) { 25 | this.matchers.push(new Mockttp.matchers.QueryMatcher({ arg: cid })); 26 | } else { 27 | this.matchers.push(new Mockttp.matchers.WildcardMatcher()); 28 | } 29 | } 30 | 31 | private matchers: Mockttp.matchers.RequestMatcher[] = []; 32 | 33 | /** 34 | * Return a successful result, as if the IPFS content was pinned successfully. 35 | * 36 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 37 | */ 38 | thenPinSuccessfully() { 39 | return this.addRuleCallback({ 40 | matchers: this.matchers, 41 | handler: this.cid 42 | ? new IpfsFixedResponseHandlerDefinition(200, { 43 | Cid: this.cid 44 | }) 45 | : new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 46 | const url = new URL(req.url); 47 | const cid = url.searchParams.get('arg'); 48 | return buildIpfsFixedValueResponse(200, { 49 | Cid: cid 50 | }); 51 | }) 52 | }); 53 | } 54 | 55 | thenFailAsDuplicate(message?: string) { 56 | return this.addRuleCallback({ 57 | matchers: this.matchers, 58 | handler: new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 59 | return buildIpfsFixedValueResponse(500, { 60 | "Message": message || "reason: \"DUPLICATE_OBJECT\"", 61 | "Code": 0, 62 | "Type": "error" 63 | }); 64 | }) 65 | }); 66 | } 67 | 68 | /** 69 | * Timeout, accepting the request but never returning a response. 70 | * 71 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 72 | */ 73 | thenTimeout() { 74 | return this.addRuleCallback({ 75 | matchers: this.matchers, 76 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 77 | }); 78 | } 79 | 80 | /** 81 | * Close the connection immediately after receiving the matching request, without sending any response. 82 | * 83 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 84 | */ 85 | thenCloseConnection() { 86 | return this.addRuleCallback({ 87 | matchers: this.matchers, 88 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 89 | }); 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /src/pinning/pin-remote-ls-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | import { 8 | RemotePinService, 9 | RemotePinServiceWithStat, 10 | Stat 11 | } from "ipfs-core-types/src/pin/remote/service"; 12 | import { 13 | buildIpfsFixedValueResponse, 14 | } from "../utils/http"; 15 | 16 | /** 17 | * A builder to allow defining rules that will mock IPFS pin remote services ls requests. 18 | */ 19 | export class PinRemoteLsRuleBuilder { 20 | 21 | /** 22 | * This builder should not be constructed directly. Call `mockNode.forPinRemoteLs()` instead. 23 | */ 24 | constructor( 25 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 26 | ) {} 27 | 28 | /** 29 | * Return a successful result, returning the given fixed list of remote pinning services. The parameter 30 | * should be an array of `{ service, endpoint, stat? }` objects, where service is the name of the service, 31 | * endpoint is the URL of the remote service, and stat is the status and optional pin count object. 32 | * 33 | * When a request includes the stat=true querystring parameter return the stat object, otherwise exclude it. 34 | * Note: if the mock data did not include a stat object, the service is set to have an invalid status. 35 | * 36 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 37 | */ 38 | thenReturn(values: Array) { 39 | return this.addRuleCallback({ 40 | matchers: [], 41 | handler: new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 42 | const searchParams = new URL(req.url).searchParams; 43 | const sendStat = searchParams.get('stat'); 44 | 45 | const RemoteServices = values 46 | .map(val => { 47 | const retVal: ServiceResponse = { 48 | Service: val.service, 49 | ApiEndpoint: val.endpoint.toString() 50 | } 51 | if (sendStat === 'true') { 52 | retVal.Stat = getStat(val); 53 | } 54 | return retVal; 55 | }); 56 | 57 | return buildIpfsFixedValueResponse(200, { RemoteServices }); 58 | }) 59 | }); 60 | } 61 | 62 | /** 63 | * Timeout, accepting the request but never returning a response. 64 | * 65 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 66 | */ 67 | thenTimeout() { 68 | return this.addRuleCallback({ 69 | matchers: [], 70 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 71 | }); 72 | } 73 | 74 | /** 75 | * Close the connection immediately after receiving the matching request, without sending any response. 76 | * 77 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 78 | */ 79 | thenCloseConnection() { 80 | return this.addRuleCallback({ 81 | matchers: [], 82 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 83 | }); 84 | } 85 | 86 | } 87 | 88 | /** 89 | * interfaces that matches a real-life http response 90 | */ 91 | interface ServiceResponse { 92 | Service: string; 93 | ApiEndpoint: string; 94 | Stat?: ServiceStat; 95 | } 96 | 97 | interface ServiceStat { 98 | Status: 'valid' | 'invalid'; 99 | PinCount?: { 100 | Queued: number; 101 | Pinning: number; 102 | Pinned: number; 103 | Failed: number; 104 | }; 105 | } 106 | 107 | /** 108 | * extract the stat from a value given to the mock 109 | */ 110 | function getStat(service: RemotePinServiceWithStat | RemotePinService): ServiceStat { 111 | // if the mock was not given a stat value use a default of 'invalid' service 112 | if (!serviceHasStat(service)) { 113 | return { 114 | Status: 'invalid' 115 | }; 116 | } 117 | 118 | const _stat = service.stat; 119 | const stat: ServiceStat = { 120 | Status: _stat.status 121 | }; 122 | // pinCount may not exist for 'invalid' 123 | if (_stat.pinCount) { 124 | stat.PinCount = { 125 | Queued: _stat.pinCount.queued, 126 | Pinning: _stat.pinCount.pinning, 127 | Pinned: _stat.pinCount.pinned, 128 | Failed: _stat.pinCount.failed 129 | }; 130 | } 131 | 132 | return stat; 133 | } 134 | 135 | function serviceHasStat( 136 | service: RemotePinServiceWithStat | RemotePinService 137 | ): service is RemotePinServiceWithStat { 138 | return (service as RemotePinServiceWithStat).stat !== undefined; 139 | } -------------------------------------------------------------------------------- /src/pinning/pin-rm-rule-builder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp"; 7 | import { 8 | buildIpfsFixedValueResponse, 9 | IpfsFixedResponseHandlerDefinition 10 | } from "../utils/http"; 11 | 12 | /** 13 | * A builder to allow defining rules that will mock IPFS pin rm requests. 14 | */ 15 | export class PinRmRuleBuilder { 16 | 17 | /** 18 | * This builder should not be constructed directly. Call `mockNode.forPinRm(cid)` instead. 19 | */ 20 | constructor( 21 | private cid: string | undefined, 22 | private addRuleCallback: (data: Mockttp.RequestRuleData) => Promise 23 | ) { 24 | if (cid) { 25 | this.matchers.push(new Mockttp.matchers.QueryMatcher({ arg: cid })); 26 | } else { 27 | this.matchers.push(new Mockttp.matchers.WildcardMatcher()); 28 | } 29 | } 30 | 31 | private matchers: Mockttp.matchers.RequestMatcher[] = []; 32 | 33 | /** 34 | * Return a successful result, as if the IPFS content was unpinned successfully. 35 | * 36 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 37 | */ 38 | thenRemoveSuccessfully() { 39 | return this.addRuleCallback({ 40 | matchers: this.matchers, 41 | handler: this.cid 42 | ? new IpfsFixedResponseHandlerDefinition(200, { 43 | Pins: [this.cid] 44 | }) 45 | : new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 46 | const url = new URL(req.url); 47 | const cid = url.searchParams.get('arg'); 48 | return buildIpfsFixedValueResponse(200, { Pins: [cid] }); 49 | }) 50 | }); 51 | } 52 | 53 | /** 54 | * Return a failing result, as if the IPFS content was not previously pinned. 55 | * 56 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 57 | */ 58 | thenFailAsMissing() { 59 | return this.addRuleCallback({ 60 | matchers: this.matchers, 61 | handler: this.cid 62 | ? new IpfsFixedResponseHandlerDefinition(500, { 63 | "Message": `Failed to remove pin: ${this.cid} is not pinned`, 64 | "Code": 0, 65 | "Type": "error" 66 | }) 67 | : new Mockttp.requestHandlerDefinitions.CallbackHandlerDefinition((req) => { 68 | const url = new URL(req.url); 69 | const cid = url.searchParams.get('arg'); 70 | 71 | return buildIpfsFixedValueResponse(500, { 72 | "Message": `Failed to remove pin: ${cid} is not pinned`, 73 | "Code": 0, 74 | "Type": "error" 75 | }); 76 | }) 77 | }); 78 | } 79 | 80 | /** 81 | * Timeout, accepting the request but never returning a response. 82 | * 83 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 84 | */ 85 | thenTimeout() { 86 | return this.addRuleCallback({ 87 | matchers: this.matchers, 88 | handler: new Mockttp.requestHandlerDefinitions.TimeoutHandlerDefinition() 89 | }); 90 | } 91 | 92 | /** 93 | * Close the connection immediately after receiving the matching request, without sending any response. 94 | * 95 | * This method completes the rule definition, and returns a promise that resolves once the rule is active. 96 | */ 97 | thenCloseConnection() { 98 | return this.addRuleCallback({ 99 | matchers: this.matchers, 100 | handler: new Mockttp.requestHandlerDefinitions.CloseConnectionHandlerDefinition() 101 | }); 102 | } 103 | 104 | } -------------------------------------------------------------------------------- /src/pinning/pinning-mock.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Mockttp from "mockttp" 7 | import { buildIpfsFixedValueResponse, IpfsStreamHandlerDefinition } from "../utils/http"; 8 | 9 | type MockttpRequestCallback = (request: Mockttp.CompletedRequest) => 10 | Promise; 11 | 12 | /** 13 | * Defines default behaviour for IPFS pin APIs, convenient methods for creating rules 14 | * from the rule-builder data, and query methods to find and expose relevant request 15 | * data from a list of collected HTTP requests. 16 | */ 17 | export class PinningMock { 18 | 19 | constructor( 20 | private mockttpServer: Mockttp.Mockttp 21 | ) {} 22 | 23 | async addMockttpFallbackRules() { 24 | await Promise.all([ 25 | this.mockttpServer.addRequestRules({ 26 | priority: Mockttp.RulePriority.FALLBACK, 27 | matchers: [ 28 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 29 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/add') 30 | ], 31 | completionChecker: new Mockttp.completionCheckers.Always(), 32 | handler: new Mockttp.requestHandlers.CallbackHandler(this.defaultAddRmHandler) 33 | }), 34 | this.mockttpServer.addRequestRules({ 35 | priority: Mockttp.RulePriority.FALLBACK, 36 | matchers: [ 37 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 38 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/remote/add') 39 | ], 40 | completionChecker: new Mockttp.completionCheckers.Always(), 41 | handler: new Mockttp.requestHandlers.CallbackHandler(this.defaultPinRemoteAddHandler) 42 | }), 43 | this.mockttpServer.addRequestRules({ 44 | priority: Mockttp.RulePriority.FALLBACK, 45 | matchers: [ 46 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 47 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/rm') 48 | ], 49 | completionChecker: new Mockttp.completionCheckers.Always(), 50 | handler: new Mockttp.requestHandlers.CallbackHandler(this.defaultAddRmHandler) 51 | }), 52 | this.mockttpServer.addRequestRules({ 53 | priority: Mockttp.RulePriority.FALLBACK, 54 | matchers: [ 55 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 56 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/ls') 57 | ], 58 | completionChecker: new Mockttp.completionCheckers.Always(), 59 | handler: new IpfsStreamHandlerDefinition(200) 60 | }), 61 | this.mockttpServer.addRequestRules({ 62 | priority: Mockttp.RulePriority.FALLBACK, 63 | matchers: [ 64 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 65 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/remote/service/ls') 66 | ], 67 | completionChecker: new Mockttp.completionCheckers.Always(), 68 | handler: new Mockttp.requestHandlers.CallbackHandler(this.defaultRemoteLsHandler) 69 | }) 70 | ]); 71 | } 72 | 73 | defaultAddRmHandler: MockttpRequestCallback = async (request: Mockttp.CompletedRequest) => { 74 | const parsedURL = new URL(request.url); 75 | 76 | const value = parsedURL.searchParams.get('arg')!; 77 | 78 | return buildIpfsFixedValueResponse(200, { Pins: [value] }); 79 | }; 80 | 81 | defaultPinRemoteAddHandler: MockttpRequestCallback = async (request: Mockttp.CompletedRequest) => { 82 | const parsedURL = new URL(request.url); 83 | 84 | const value = parsedURL.searchParams.get('arg')!; 85 | 86 | return buildIpfsFixedValueResponse(200, { Cid: value }); 87 | }; 88 | 89 | defaultRemoteLsHandler: MockttpRequestCallback = async (request: Mockttp.CompletedRequest) => { 90 | return buildIpfsFixedValueResponse(200, { RemoteServices: [] }); 91 | }; 92 | 93 | addPinAddRule = async (ruleData: Mockttp.RequestRuleData) => { 94 | await this.mockttpServer.addRequestRules({ 95 | ...ruleData, 96 | matchers: [ 97 | ...ruleData.matchers, 98 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 99 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/add') 100 | ] 101 | }); 102 | }; 103 | 104 | addPinRemoteAddRule = async (ruleData: Mockttp.RequestRuleData) => { 105 | await this.mockttpServer.addRequestRules({ 106 | ...ruleData, 107 | matchers: [ 108 | ...ruleData.matchers, 109 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 110 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/remote/add') 111 | ] 112 | }); 113 | }; 114 | 115 | addPinRmRule = async (ruleData: Mockttp.RequestRuleData) => { 116 | await this.mockttpServer.addRequestRules({ 117 | ...ruleData, 118 | matchers: [ 119 | ...ruleData.matchers, 120 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 121 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/rm') 122 | ] 123 | }); 124 | }; 125 | 126 | addPinLsRule = async (ruleData: Mockttp.RequestRuleData) => { 127 | await this.mockttpServer.addRequestRules({ 128 | ...ruleData, 129 | matchers: [ 130 | ...ruleData.matchers, 131 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 132 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/ls') 133 | ] 134 | }); 135 | }; 136 | 137 | addPinRemoteLsRule = async (ruleData: Mockttp.RequestRuleData) => { 138 | await this.mockttpServer.addRequestRules({ 139 | ...ruleData, 140 | matchers: [ 141 | ...ruleData.matchers, 142 | new Mockttp.matchers.MethodMatcher(Mockttp.Method.POST), 143 | new Mockttp.matchers.SimplePathMatcher('/api/v0/pin/remote/service/ls') 144 | ] 145 | }); 146 | }; 147 | 148 | async getAddedPins(seenRequests: Mockttp.Request[]) { 149 | const relevantRequests = seenRequests 150 | .filter((req) => { 151 | return req.path.startsWith('/api/v0/pin/add') || req.path.startsWith('/api/v0/pin/remote/add'); 152 | }); 153 | 154 | return relevantRequests.map((request) => { 155 | const parsedURL = new URL(request.url) 156 | return { cid: parsedURL.searchParams.get('arg')! }; 157 | }); 158 | } 159 | 160 | async getRemovedPins(seenRequests: Mockttp.Request[]) { 161 | const relevantRequests = seenRequests 162 | .filter((req) => req.path.startsWith('/api/v0/pin/rm')); 163 | 164 | return relevantRequests.map((request) => { 165 | const parsedURL = new URL(request.url) 166 | return { cid: parsedURL.searchParams.get('arg')! }; 167 | }); 168 | } 169 | 170 | } -------------------------------------------------------------------------------- /src/utils/http.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { requestHandlerDefinitions, Headers } from 'mockttp'; 7 | import * as multipart from 'parse-multipart-data'; 8 | 9 | import { byteLength } from './util'; 10 | 11 | 12 | // Using the exact headers here is useful for correctness, but also important because in Node 18 Undici 13 | // has bugs where it doesn't handle some responses correctly (no explicit length, close without warning). 14 | // See https://github.com/nodejs/undici/issues/1414 and https://github.com/nodejs/undici/issues/1415. 15 | export const buildIpfsFixedValueDefaultHeaders = (body: string) => ({ 16 | 'cache-control': 'no-cache', 17 | 'connection': 'close', 18 | 'date': new Date().toUTCString(), 19 | 'content-type': 'application/json; charset=utf-8', 20 | 'content-length': byteLength(body).toString(), 21 | }); 22 | 23 | export const buildIpfsStreamDefaultHeaders = () => ({ 24 | 'cache-control': 'no-cache', 25 | 'connection': 'close', 26 | 'date': new Date().toUTCString(), 27 | 'content-type': 'application/json; charset=utf-8', 28 | 'transfer-encoding': 'chunked', 29 | // 'trailer': 'X-Stream-Error', 30 | // ^ This is normally present but we skip it for now, since it causes issues with Node 18: 31 | // https://github.com/nodejs/undici/issues/1418 32 | 'x-chunked-output': '1' 33 | }); 34 | 35 | export const buildIpfsFixedValueResponse = (status: number, json: any) => { 36 | const jsonBody = JSON.stringify(json); 37 | return { 38 | status, 39 | headers: buildIpfsFixedValueDefaultHeaders(jsonBody), 40 | body: jsonBody 41 | } 42 | }; 43 | 44 | export const buildIpfsStreamResponse = (status: number, ...jsonValues: Array) => { 45 | const body = jsonValues.map(json => JSON.stringify(json)).join('\n'); 46 | return { 47 | status, 48 | headers: buildIpfsStreamDefaultHeaders(), 49 | body: body 50 | } 51 | }; 52 | 53 | export class IpfsFixedResponseHandlerDefinition extends requestHandlerDefinitions.SimpleHandlerDefinition { 54 | 55 | constructor( 56 | status: number, 57 | json: any 58 | ) { 59 | const jsonBody = JSON.stringify(json); 60 | super(status, undefined, jsonBody, buildIpfsFixedValueDefaultHeaders(jsonBody)); 61 | } 62 | }; 63 | 64 | export class IpfsStreamHandlerDefinition extends requestHandlerDefinitions.SimpleHandlerDefinition { 65 | 66 | constructor( 67 | status: number, 68 | ...jsonValues: Array 69 | ) { 70 | const body = jsonValues.map(json => JSON.stringify(json)).join('\n'); 71 | super(status, undefined, body, buildIpfsStreamDefaultHeaders()); 72 | } 73 | }; 74 | 75 | export interface MultipartPart { 76 | filename?: string; 77 | type: string; 78 | data: Buffer; 79 | } 80 | 81 | export function parseMultipartBody( 82 | body: Buffer, 83 | headers: Headers 84 | ): Array { 85 | // This is closely related to the code in Mockttp's MultipartFormDataMatcher 86 | const contentType = headers['content-type']; 87 | 88 | if (!contentType || !contentType.includes("multipart/form-data")) { 89 | throw new Error("Could not parse multipart body without multipart content-type header"); 90 | } 91 | 92 | const boundary = contentType.match(/;\s*boundary=(\S+)/); 93 | if (!boundary) throw new Error("Could not parse multipart body with bad multipart content-type"); 94 | 95 | return multipart.parse(body, boundary[1]); 96 | } -------------------------------------------------------------------------------- /src/utils/ipfs.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | // Global counter used to generate maybe-unique easy to understand ids 7 | let mockCidIdCounter = 1; 8 | const nextMockCidId = () => { 9 | let id = mockCidIdCounter++; 10 | 11 | // We can't use simple numeric ids, because 0 is banned in base58btc. To handle this, 12 | // we just convert 0 into A, and always start at 1 (not A) to help make the pattern 13 | // clearer: 8, 9, 1A, 11, 12, 13... 14 | 15 | return id.toString().replace('0', 'A'); 16 | }; 17 | 18 | // Used just for unit testing this logic, not exposed 19 | export const resetMockCidCounter = () => { 20 | mockCidIdCounter = 1; 21 | }; 22 | 23 | /** 24 | * A quick helper to generate a v0 CID containing a readable string, for easy testing 25 | * & debugging. 26 | * 27 | * If an argument is provided, it will be inserted in the string and padded with X 28 | * characters to make it a valid CID. The string must be Base58btc, so must be 29 | * alphanumeric minus the Il0O characters. 30 | * 31 | * If no argument is provided, a simple process-unique CID will be created, containing 32 | * "mockXipfsXcid" followed by an incrementing numeric id (using A instead of 0, which 33 | * is not valid base58btc). This is not globally unique like a UUID, but is intended 34 | * to provide easy debugging when tracing ids in a test run or similar. 35 | */ 36 | export const mockCid = (value: string = 'mockXipfsXcid' + nextMockCidId()) => { 37 | if (value.length > 43) throw new Error('Mock CID content must be 43 chars max') 38 | if (value && !value.match(/^[1-9A-HJ-NP-Za-km-z]+$/)) { 39 | throw new Error('Mock CID content must be base58btc'); 40 | } 41 | 42 | const missingLength = 43 - value.length; 43 | 44 | value = value.padStart(value.length + missingLength / 2, 'X'); 45 | value = value.padEnd(43, 'X'); 46 | 47 | return 'QmP' + value; 48 | // ^ Base58btc, cidv1, dag-pb, sha1, arbitrary base58btc (no +/Il0O) 49 | // Would be more flexible to use Base64, but isn't decodeable by default 50 | }; -------------------------------------------------------------------------------- /src/utils/util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | declare const WorkerGlobalScope: Function | undefined; 7 | export const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; 8 | export const isWeb = typeof Window !== 'undefined' && self instanceof Window; 9 | export const isNode = !isWorker && !isWeb && typeof process === 'object' && process.version; 10 | 11 | // Get the length of the given data in bytes, not characters. 12 | // If that's a buffer, the length is used raw, but if it's a string 13 | // it returns the length when encoded as UTF8. 14 | export function byteLength(input: string | Uint8Array | Buffer) { 15 | if (typeof input === 'string') { 16 | return isNode 17 | ? Buffer.from(input, 'utf8').byteLength 18 | : new Blob([input]).size; 19 | } else { 20 | return input.length; 21 | } 22 | } 23 | 24 | export const delay = (durationMs: number) => 25 | new Promise((resolve) => setTimeout(resolve, durationMs)); -------------------------------------------------------------------------------- /test/integration/add.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { 7 | expect, 8 | MockIPFS, 9 | IpfsClient, 10 | delay, 11 | itAll 12 | } from '../test-setup'; 13 | 14 | describe("IPFS add mocking", () => { 15 | 16 | const mockNode = MockIPFS.getLocal(); 17 | 18 | beforeEach(async () => await mockNode.start()); 19 | afterEach(async () => await mockNode.stop()); 20 | 21 | it("accepts all content additions by default", async () => { 22 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 23 | 24 | const result = await ipfsClient.add("test content"); 25 | 26 | expect(result.path).to.include('mockXipfsXcid'); 27 | expect(result.cid.toString()).to.include('mockXipfsXcid'); 28 | }); 29 | 30 | it("accepts all content additions wtih explicit paths by default", async () => { 31 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 32 | 33 | const result = await ipfsClient.add({ path: 'test-path', content: 'test content' }); 34 | 35 | expect(result.path).to.include('test-path'); // <-- Uses the given path correctly 36 | expect(result.cid.toString()).to.include('mockXipfsXcid'); 37 | }); 38 | 39 | it("can explicitly accept content addition", async () => { 40 | await mockNode.forAdd().thenAcceptPublish(); 41 | 42 | await mockNode.forAdd().thenCloseConnection(); 43 | // ^-- We add this to make sure the rule is applied, not default behaviour 44 | 45 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 46 | 47 | const result = await ipfsClient.add('test content'); 48 | 49 | expect(result.path).to.include('mockXipfsXcid'); 50 | expect(result.cid.toString()).to.include('mockXipfsXcid'); 51 | }); 52 | 53 | it("can explicitly accept content addition with custom hash", async () => { 54 | await mockNode.forAdd().thenAcceptPublishAs(MockIPFS.mockCid('injectedXipfsXhash')); 55 | 56 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 57 | 58 | const result = await ipfsClient.add('test content'); 59 | 60 | expect(result.path).to.include('injectedXipfsXhash'); 61 | expect(result.cid.toString()).to.include('injectedXipfsXhash'); 62 | }); 63 | 64 | it("can explicitly accept content addition with custom names", async () => { 65 | await mockNode.forAdd().thenAcceptPublishAs([ 66 | { Name: 'test1', Hash: MockIPFS.mockCid('injectedXipfsXhash') }, 67 | { Name: 'test2', Hash: MockIPFS.mockCid('anotherXipfsXhash') }, 68 | ]); 69 | 70 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 71 | 72 | const results = await itAll(ipfsClient.addAll([ 73 | 'test content1', 74 | 'test content2', 75 | ])); 76 | 77 | expect(results.length).to.equal(2); 78 | 79 | expect(results[0].path).to.equal('test1'); 80 | expect(results[0].cid.toString()).to.include('injectedXipfsXhash'); 81 | expect(results[1].path).to.equal('test2'); 82 | expect(results[1].cid.toString()).to.include('anotherXipfsXhash'); 83 | }); 84 | 85 | it("can timeout content addition", async () => { 86 | await mockNode.forAdd().thenTimeout(); 87 | 88 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 89 | 90 | const publish = ipfsClient.add('test content'); 91 | 92 | expect(await Promise.race([ 93 | publish, 94 | delay(500).then(() => 'timeout') 95 | ])).to.equal('timeout'); 96 | }); 97 | 98 | it("can match specific content addition", async () => { 99 | await mockNode.forAddIncluding('matching content') 100 | .thenAcceptPublishAs(MockIPFS.mockCid('matched')); 101 | 102 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 103 | 104 | const matchingResult = await ipfsClient.add('matching content'); 105 | const otherResult = await ipfsClient.add('other content'); 106 | 107 | expect(matchingResult.path).to.include('matched'); 108 | expect(matchingResult.cid.toString()).to.include('matched'); 109 | expect(otherResult.path).to.include('mockXipfsXcid'); 110 | expect(otherResult.cid.toString()).to.include('mockXipfsXcid'); 111 | }); 112 | 113 | it("can match specific path addition", async () => { 114 | await mockNode.forAddIncluding({ path: 'test/doc.txt' }) 115 | .thenAcceptPublishAs(MockIPFS.mockCid('matched')); 116 | 117 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 118 | 119 | const matchingResult = await ipfsClient.add({ path: 'test/doc.txt' }); 120 | const otherResult = await ipfsClient.add('other content'); 121 | 122 | expect(matchingResult.path).to.include('test/doc.txt'); 123 | expect(matchingResult.cid.toString()).to.include('matched'); 124 | expect(otherResult.path).to.include('mockXipfsXcid'); 125 | expect(otherResult.cid.toString()).to.include('mockXipfsXcid'); 126 | }); 127 | 128 | it("can match specific path & content addition", async () => { 129 | await mockNode.forAddIncluding({ path: 'matching.txt', content: 'matching' }) 130 | .thenAcceptPublishAs(MockIPFS.mockCid('matched')); 131 | 132 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 133 | 134 | const matchingResult = await ipfsClient.add({ path: 'matching.txt', content: 'matching' }); 135 | const otherContentResult = await ipfsClient.add({ path: 'matching.txt', content: 'other' }); 136 | const otherPathResult = await ipfsClient.add({ path: 'other', content: 'matching' }); 137 | 138 | expect(matchingResult.path).to.include('matching.txt'); 139 | expect(matchingResult.cid.toString()).to.include('matched'); 140 | 141 | expect(otherContentResult.cid.toString()).to.include('mockXipfsXcid'); 142 | expect(otherPathResult.cid.toString()).to.include('mockXipfsXcid'); 143 | }); 144 | 145 | it("can record content addition", async () => { 146 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 147 | 148 | await ipfsClient.add("bare content a"); 149 | await itAll(ipfsClient.addAll([ 150 | { path: 'dir/a.txt', content: "file a" }, 151 | { path: 'dir/b.txt', content: "file b" }, 152 | "bare content b" 153 | ])); 154 | 155 | const addedContent = await mockNode.getAddedContent(); 156 | 157 | expect(addedContent.length).to.equal(4); 158 | 159 | expect(addedContent.map(({ path, content }) => ({ 160 | path, 161 | content: content?.toString() 162 | }))).to.deep.equal([ 163 | { path: undefined, content: 'bare content a' }, 164 | { path: 'dir/a.txt', content: 'file a' }, 165 | { path: 'dir/b.txt', content: 'file b' }, 166 | { path: undefined, content: 'bare content b' }, 167 | ]); 168 | }); 169 | 170 | }); -------------------------------------------------------------------------------- /test/integration/cat.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { 7 | expect, 8 | MockIPFS, 9 | IpfsClient, 10 | itValue, 11 | delay 12 | } from '../test-setup'; 13 | 14 | describe("IPFS cat mocking", () => { 15 | 16 | const mockNode = MockIPFS.getLocal(); 17 | 18 | beforeEach(async () => await mockNode.start()); 19 | afterEach(async () => await mockNode.stop()); 20 | 21 | it("should time out for all content by default", async () => { 22 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 23 | 24 | const result = itValue(ipfsClient.cat('ipfs.io')); 25 | 26 | expect(await Promise.race([ 27 | result, 28 | delay(200).then(() => 'timeout') 29 | ])).to.equal('timeout'); 30 | }); 31 | 32 | it("should return mocked content for a given id", async () => { 33 | await mockNode.forCat('mock-id').thenReturn('mock-response'); 34 | 35 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 36 | 37 | const result = await itValue(ipfsClient.cat('mock-id')); 38 | 39 | expect(Buffer.from(result).toString()).to.equal('mock-response'); 40 | }); 41 | 42 | it("should not return mocked content for the wrong id", async () => { 43 | await mockNode.forCat('mock-id').thenReturn('mock-response'); 44 | 45 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 46 | 47 | const result = itValue(ipfsClient.cat('wrong-id')); 48 | 49 | expect(await Promise.race([ 50 | result, 51 | delay(200).then(() => 'timeout') 52 | ])).to.equal('timeout'); 53 | }); 54 | 55 | it("should record cat queries", async () => { 56 | await mockNode.forCat().thenReturn('mock-response'); 57 | 58 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 59 | 60 | await itValue(ipfsClient.cat('an-IPFS-id')).catch(() => {}); 61 | await itValue(ipfsClient.cat('another-id')).catch(() => {}); 62 | 63 | expect(await mockNode.getQueriedContent()).to.deep.equal([ 64 | { path: 'an-IPFS-id' }, 65 | { path: 'another-id' } 66 | ]); 67 | }); 68 | 69 | }); -------------------------------------------------------------------------------- /test/integration/get.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { 7 | expect, 8 | MockIPFS, 9 | IpfsClient, 10 | itValue, 11 | delay 12 | } from '../test-setup'; 13 | 14 | describe("IPFS get mocking", () => { 15 | 16 | const mockNode = MockIPFS.getLocal(); 17 | 18 | beforeEach(async () => await mockNode.start()); 19 | afterEach(async () => await mockNode.stop()); 20 | 21 | it("should time out for all content by default", async () => { 22 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 23 | 24 | const result = itValue(ipfsClient.get('ipfs.io')); 25 | 26 | expect(await Promise.race([ 27 | result, 28 | delay(200).then(() => 'timeout') 29 | ])).to.equal('timeout'); 30 | }); 31 | 32 | it("should return mocked content for a given id", async () => { 33 | await mockNode.forGet('mock-id').thenReturn('mock-response'); 34 | 35 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 36 | 37 | const result = await itValue(ipfsClient.get('mock-id')); 38 | 39 | expect(Buffer.from(result).toString()).to.equal('mock-response'); 40 | }); 41 | 42 | it("should not return mocked content for the wrong id", async () => { 43 | await mockNode.forGet('mock-id').thenReturn('mock-response'); 44 | 45 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 46 | 47 | const result = itValue(ipfsClient.get('wrong-id')); 48 | 49 | expect(await Promise.race([ 50 | result, 51 | delay(200).then(() => 'timeout') 52 | ])).to.equal('timeout'); 53 | }); 54 | 55 | it("should record get queries", async () => { 56 | await mockNode.forGet().thenReturn('mock-response'); 57 | 58 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 59 | 60 | await itValue(ipfsClient.get('an-IPFS-id')).catch(() => {}); 61 | await itValue(ipfsClient.get('another-id')).catch(() => {}); 62 | 63 | expect(await mockNode.getQueriedContent()).to.deep.equal([ 64 | { path: 'an-IPFS-id' }, 65 | { path: 'another-id' } 66 | ]); 67 | }); 68 | 69 | }); -------------------------------------------------------------------------------- /test/integration/name.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { 7 | expect, 8 | MockIPFS, 9 | IpfsClient, 10 | HTTPError, 11 | itValue, 12 | delay 13 | } from '../test-setup'; 14 | 15 | describe("IPNS mocking", () => { 16 | 17 | const mockNode = MockIPFS.getLocal(); 18 | 19 | beforeEach(async () => await mockNode.start()); 20 | afterEach(async () => await mockNode.stop()); 21 | 22 | it("refuses to resolve all names by default", async () => { 23 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 24 | 25 | const result: HTTPError = await itValue(ipfsClient.name.resolve('ipfs.io')) 26 | .catch(e => e); 27 | 28 | expect(result).to.be.instanceOf(HTTPError); 29 | expect(result.response.status).to.equal(500); 30 | expect(result.message).to.equal("queryTxt ENOTFOUND _dnslink.ipfs.io"); 31 | }); 32 | 33 | it("can resolve names when requested", async () => { 34 | await mockNode.forNameResolve('ipfs.io').thenResolveTo("/ipfs/mock-address"); 35 | 36 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 37 | 38 | const result: string = await itValue(ipfsClient.name.resolve('ipfs.io')) 39 | .catch(e => e); 40 | 41 | expect(result).to.equal("/ipfs/mock-address"); 42 | }); 43 | 44 | it("can overide all name resolution", async () => { 45 | await mockNode.forNameResolve().thenResolveTo("/ipfs/mock-address"); 46 | 47 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 48 | 49 | const result: string = await itValue(ipfsClient.name.resolve('any-name.test')) 50 | .catch(e => e); 51 | 52 | expect(result).to.equal("/ipfs/mock-address"); 53 | }); 54 | 55 | it("can change name resolution", async () => { 56 | await mockNode.forNameResolve('ipfs.io').thenResolveTo("/ipfs/initial-mock-address"); 57 | await mockNode.forNameResolve('ipfs.io').thenResolveTo("/ipfs/subsequent-mock-address"); 58 | 59 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 60 | 61 | let result: string; 62 | 63 | result = await itValue(ipfsClient.name.resolve('ipfs.io')) 64 | .catch(e => e); 65 | expect(result).to.equal("/ipfs/initial-mock-address"); 66 | 67 | result = await itValue(ipfsClient.name.resolve('ipfs.io')) 68 | .catch(e => e); 69 | expect(result).to.equal("/ipfs/subsequent-mock-address"); 70 | }); 71 | 72 | it("can timeout name resolution", async () => { 73 | await mockNode.forNameResolve('ipfs.io').thenTimeout(); 74 | 75 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 76 | 77 | const lookup = itValue(ipfsClient.name.resolve('ipfs.io')); 78 | 79 | expect(await Promise.race([ 80 | lookup, 81 | delay(500).then(() => 'timeout') 82 | ])).to.equal('timeout'); 83 | }); 84 | 85 | it("accepts all name publications by default", async () => { 86 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 87 | 88 | const result = await ipfsClient.name.publish('/ipfs/content', { key: 'mock-ipns-name' }); 89 | 90 | expect(result).to.deep.equal({ name: 'mock-ipns-name', value: '/ipfs/content' }); 91 | }); 92 | 93 | it("can timeout name publications", async () => { 94 | await mockNode.forNamePublish('mykey').thenTimeout(); 95 | 96 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 97 | 98 | const publish = ipfsClient.name.publish('/ipfs/content', { key: 'mykey' }); 99 | 100 | expect(await Promise.race([ 101 | publish, 102 | delay(500).then(() => 'timeout') 103 | ])).to.equal('timeout'); 104 | }); 105 | 106 | it("can explicitly accept name publications", async () => { 107 | await mockNode.forNamePublish('mykey').thenAcceptPublish(); 108 | await mockNode.forNamePublish().thenTimeout(); 109 | 110 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 111 | 112 | const result = await ipfsClient.name.publish('/ipfs/content', { key: 'mykey' }); 113 | 114 | expect(result).to.deep.equal({ name: 'mock-ipns-name', value: '/ipfs/content' }); 115 | }); 116 | 117 | it("can accept name publications with an explicit resulting name", async () => { 118 | await mockNode.forNamePublish('mykey').thenAcceptPublishAs("ipns-hash"); 119 | 120 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 121 | 122 | const result = await ipfsClient.name.publish('/ipfs/content', { key: 'mykey' }); 123 | 124 | expect(result).to.deep.equal({ name: 'ipns-hash', value: '/ipfs/content' }); 125 | }); 126 | 127 | it("can accept name publications with an explicit CID", async () => { 128 | await mockNode.forNamePublish() 129 | .withContent('/ipfs/matching-content') 130 | .thenAcceptPublishAs("matched-hash"); 131 | 132 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 133 | 134 | const otherResult = await ipfsClient.name.publish('/ipfs/other-content'); 135 | const contentResult = await ipfsClient.name.publish('/ipfs/matching-content'); 136 | 137 | expect(otherResult).to.deep.equal({ name: 'mock-ipns-name', value: '/ipfs/other-content' }); 138 | expect(contentResult).to.deep.equal({ name: 'matched-hash', value: '/ipfs/matching-content' }); 139 | }); 140 | 141 | it("can match 'self' name publication", async () => { 142 | await mockNode.forNamePublish('self').thenAcceptPublishAs('self-name'); 143 | 144 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 145 | 146 | const defaultResult = await ipfsClient.name.publish('/ipfs/content'); // Self is the default 147 | expect(defaultResult).to.deep.equal({ name: 'self-name', value: '/ipfs/content' }); 148 | 149 | const explicitResult = await ipfsClient.name.publish('/ipfs/content', { key: 'self' }); // Can be explicit 150 | expect(explicitResult).to.deep.equal({ name: 'self-name', value: '/ipfs/content' }); 151 | }); 152 | 153 | it("can record name resolutions", async () => { 154 | await mockNode.forNameResolve('ipfs.io') 155 | .thenResolveTo("/ipfs/mock-address"); 156 | 157 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 158 | 159 | await itValue(ipfsClient.name.resolve('ipfs.io')); 160 | await itValue(ipfsClient.name.resolve('does-not-exist')).catch(() => {}); 161 | 162 | expect(await mockNode.getIPNSQueries()).to.deep.equal([ 163 | { name: "ipfs.io" }, 164 | { name: "does-not-exist" }, 165 | ]); 166 | }); 167 | 168 | it("can record name publication", async () => { 169 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 170 | 171 | await ipfsClient.name.publish('/ipfs/content-hash'); 172 | await ipfsClient.name.publish('/ipfs/named-content-hash', { key: 'custom-key' }); 173 | 174 | expect(await mockNode.getIPNSPublications()).to.deep.equal([ 175 | { name: null, value: '/ipfs/content-hash' }, 176 | { name: 'custom-key', value: '/ipfs/named-content-hash' } 177 | ]); 178 | }); 179 | 180 | }); -------------------------------------------------------------------------------- /test/integration/pin.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { CID } from 'multiformats/cid'; 7 | import { 8 | expect, 9 | MockIPFS, 10 | IpfsClient, 11 | HTTPError, 12 | delay, 13 | EXAMPLE_CID, 14 | ALTERNATE_CID, 15 | EXAMPLE_SERVICE, 16 | EXAMPLE_ALT_SERVICE, 17 | normalizeService, 18 | itAll 19 | } from '../test-setup'; 20 | 21 | describe("IPFS pin mocking", () => { 22 | 23 | const mockNode = MockIPFS.getLocal(); 24 | 25 | beforeEach(async () => await mockNode.start()); 26 | afterEach(async () => await mockNode.stop()); 27 | 28 | describe("for addition", () => { 29 | 30 | it("should return success for additions by default", async () => { 31 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 32 | 33 | const result = await ipfsClient.pin.add(EXAMPLE_CID); 34 | 35 | expect(result.toString()).to.equal(EXAMPLE_CID); 36 | }); 37 | 38 | it("should allow timing out for a CID to simulate pinning missing content", async () => { 39 | await mockNode.forPinAdd(EXAMPLE_CID).thenTimeout(); 40 | 41 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 42 | const result = ipfsClient.pin.add(EXAMPLE_CID); 43 | 44 | expect(await Promise.race([ 45 | result, 46 | delay(200).then(() => 'timeout') 47 | ])).to.equal('timeout'); 48 | }); 49 | 50 | it("should allow timing out for all CIDs to simulate pinning missing content", async () => { 51 | await mockNode.forPinAdd().thenTimeout(); 52 | 53 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 54 | const result = ipfsClient.pin.add(EXAMPLE_CID); 55 | 56 | expect(await Promise.race([ 57 | result, 58 | delay(200).then(() => 'timeout') 59 | ])).to.equal('timeout'); 60 | }); 61 | 62 | it("should allow making only some specific pins successful", async () => { 63 | await mockNode.forPinAdd(EXAMPLE_CID).thenPinSuccessfully(); 64 | await mockNode.forPinAdd().thenTimeout(); 65 | 66 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 67 | const timeoutResult = ipfsClient.pin.add(ALTERNATE_CID); 68 | const successResult = ipfsClient.pin.add(EXAMPLE_CID); 69 | 70 | expect(await Promise.race([ 71 | timeoutResult, 72 | delay(200).then(() => 'timeout') 73 | ])).to.equal('timeout'); 74 | expect((await successResult).toString()).to.equal(EXAMPLE_CID); 75 | }); 76 | 77 | it("should allow querying the list of added pins", async () => { 78 | await mockNode.forPinAdd(EXAMPLE_CID).thenPinSuccessfully(); 79 | 80 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 81 | await Promise.all([ 82 | ipfsClient.pin.add(EXAMPLE_CID), 83 | ipfsClient.pin.add(ALTERNATE_CID), 84 | ]); 85 | 86 | expect(await mockNode.getAddedPins()).to.deep.equal([ 87 | { cid: EXAMPLE_CID }, 88 | { cid: ALTERNATE_CID } 89 | ]); 90 | }); 91 | 92 | }); 93 | 94 | describe("for remote addition", () => { 95 | 96 | it("should return success for additions by default", async () => { 97 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 98 | 99 | const exampleCid = CID.parse(EXAMPLE_CID); 100 | const result = await ipfsClient.pin.remote.add(exampleCid, { 101 | service: 'pinbar', 102 | name: 'fooz-baz' 103 | }); 104 | 105 | expect(result.cid.toString()).to.equal(EXAMPLE_CID); 106 | }); 107 | 108 | it("should allow timing out for a CID to simulate pinning missing content", async () => { 109 | await mockNode.forPinRemoteAdd(EXAMPLE_CID).thenTimeout(); 110 | 111 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 112 | const exampleCid = CID.parse(EXAMPLE_CID); 113 | const result = ipfsClient.pin.remote.add(exampleCid, { 114 | service: 'pinbar', 115 | name: 'fooz-baz' 116 | }); 117 | 118 | expect(await Promise.race([ 119 | result, 120 | delay(200).then(() => 'timeout') 121 | ])).to.equal('timeout'); 122 | }); 123 | 124 | it("should allow timing out for all CIDs to simulate pinning missing content", async () => { 125 | await mockNode.forPinRemoteAdd().thenTimeout(); 126 | 127 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 128 | const exampleCid = CID.parse(EXAMPLE_CID); 129 | const result = ipfsClient.pin.remote.add(exampleCid, { 130 | service: 'pinbar', 131 | name: 'fooz-baz' 132 | }); 133 | 134 | expect(await Promise.race([ 135 | result, 136 | delay(200).then(() => 'timeout') 137 | ])).to.equal('timeout'); 138 | }); 139 | 140 | it("should allow mocking duplicate pin error", async () => { 141 | await mockNode.forPinRemoteAdd().thenFailAsDuplicate("mock error"); 142 | 143 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 144 | const exampleCid = CID.parse(EXAMPLE_CID); 145 | const result = await ipfsClient.pin.remote.add(exampleCid, { 146 | service: 'pinbar', 147 | name: 'fooz-baz' 148 | }).catch(e => e); 149 | 150 | expect(result).to.be.instanceOf(HTTPError); 151 | expect(result.message).to.equal("mock error"); 152 | }); 153 | 154 | it("should allow making only some specific pins successful", async () => { 155 | await mockNode.forPinRemoteAdd(EXAMPLE_CID).thenPinSuccessfully(); 156 | await mockNode.forPinRemoteAdd().thenTimeout(); 157 | 158 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 159 | const altCid = CID.parse(ALTERNATE_CID); 160 | const timeoutResult = ipfsClient.pin.remote.add(altCid, { 161 | service: 'pinbar', 162 | name: 'fooz-baz' 163 | }); 164 | const exampleCid = CID.parse(EXAMPLE_CID); 165 | const successResult = ipfsClient.pin.remote.add(exampleCid, { 166 | service: 'pinbar', 167 | name: 'fooz-baz' 168 | }); 169 | 170 | expect(await Promise.race([ 171 | timeoutResult, 172 | delay(200).then(() => 'timeout') 173 | ])).to.equal('timeout'); 174 | expect((await successResult).cid.toString()).to.equal(EXAMPLE_CID); 175 | }); 176 | 177 | it("should allow querying the list of added pins", async () => { 178 | await mockNode.forPinRemoteAdd(EXAMPLE_CID).thenPinSuccessfully(); 179 | 180 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 181 | const altCid = CID.parse(ALTERNATE_CID); 182 | const exampleCid = CID.parse(EXAMPLE_CID); 183 | await Promise.all([ 184 | ipfsClient.pin.remote.add(exampleCid, { 185 | service: 'pinbar', 186 | name: 'fooz-baz' 187 | }), 188 | ipfsClient.pin.remote.add(altCid, { 189 | service: 'pinbar', 190 | name: 'fooz-baz' 191 | }), 192 | ]); 193 | 194 | expect(await mockNode.getAddedPins()).to.deep.equal([ 195 | { cid: EXAMPLE_CID }, 196 | { cid: ALTERNATE_CID } 197 | ]); 198 | }); 199 | 200 | }); 201 | 202 | describe("for rm", () => { 203 | 204 | it("should return success for removal by default", async () => { 205 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 206 | 207 | const result = await ipfsClient.pin.rm(EXAMPLE_CID); 208 | 209 | expect(result.toString()).to.equal(EXAMPLE_CID); 210 | }); 211 | 212 | it("should allow mocking pin rm failure", async () => { 213 | await mockNode.forPinRm(EXAMPLE_CID).thenFailAsMissing(); 214 | 215 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 216 | 217 | const result = await ipfsClient.pin.rm(EXAMPLE_CID).catch(e => e); 218 | 219 | expect(result).to.be.instanceOf(HTTPError); 220 | expect(result.response.status).to.equal(500); 221 | expect(result.message).to.equal( 222 | `Failed to remove pin: ${EXAMPLE_CID} is not pinned` 223 | ); 224 | }); 225 | 226 | it("can timeout pin removal resolution", async () => { 227 | await mockNode.forPinRm(EXAMPLE_CID).thenTimeout(); 228 | 229 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 230 | 231 | const result = ipfsClient.pin.rm(EXAMPLE_CID); 232 | 233 | expect(await Promise.race([ 234 | result, 235 | delay(500).then(() => 'timeout') 236 | ])).to.equal('timeout'); 237 | }); 238 | 239 | it("should allow querying the list of removed pins", async () => { 240 | await mockNode.forPinRm(EXAMPLE_CID).thenRemoveSuccessfully(); 241 | 242 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 243 | await Promise.all([ 244 | ipfsClient.pin.rm(EXAMPLE_CID), 245 | ipfsClient.pin.rm(ALTERNATE_CID), 246 | ]); 247 | 248 | expect(await mockNode.getRemovedPins()).to.deep.equal([ 249 | { cid: EXAMPLE_CID }, 250 | { cid: ALTERNATE_CID } 251 | ]); 252 | }); 253 | 254 | }); 255 | 256 | describe("for ls", () => { 257 | 258 | it("should return an empty list when listing pins by default", async () => { 259 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 260 | 261 | const result = await itAll(ipfsClient.pin.ls()); 262 | 263 | expect(result).to.deep.equal([]); 264 | }); 265 | 266 | it("should allow mocking the list of pinned hashes", async () => { 267 | await mockNode.forPinLs().thenReturn([ 268 | { type: 'recursive', cid: EXAMPLE_CID }, 269 | { type: 'indirect', cid: ALTERNATE_CID } 270 | ]); 271 | 272 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 273 | 274 | const result = await itAll(ipfsClient.pin.ls()); 275 | 276 | expect( 277 | result.map(({ type, cid }) => ({ type, cid: cid.toString() })) 278 | ).to.deep.equal([ 279 | { type: 'recursive', cid: EXAMPLE_CID }, 280 | { type: 'indirect', cid: ALTERNATE_CID } 281 | ]); 282 | }); 283 | 284 | it("should support filtering when mocking the list of pinned hashes", async () => { 285 | await mockNode.forPinLs().thenReturn([ 286 | { type: 'recursive', cid: ALTERNATE_CID }, 287 | { type: 'direct', cid: EXAMPLE_CID }, 288 | { type: 'indirect', cid: ALTERNATE_CID } 289 | ]); 290 | 291 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 292 | 293 | const result = await itAll(ipfsClient.pin.ls({ 294 | type: 'direct' 295 | })); 296 | 297 | expect( 298 | result.map(({ type, cid }) => ({ type, cid: cid.toString() })) 299 | ).to.deep.equal([ 300 | { type: 'direct', cid: EXAMPLE_CID } 301 | ]); 302 | }); 303 | 304 | it("should support filtering for 'all' when mocking the list of pinned hashes", async () => { 305 | await mockNode.forPinLs().thenReturn([ 306 | { type: 'recursive', cid: EXAMPLE_CID }, 307 | { type: 'direct', cid: EXAMPLE_CID }, 308 | { type: 'indirect', cid: EXAMPLE_CID } 309 | ]); 310 | 311 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 312 | 313 | const result = await itAll(ipfsClient.pin.ls({ 314 | type: 'all' 315 | })); 316 | 317 | expect( 318 | result.map(({ type, cid }) => ({ type, cid: cid.toString() })) 319 | ).to.deep.equal([ 320 | { type: 'recursive', cid: EXAMPLE_CID }, 321 | { type: 'direct', cid: EXAMPLE_CID }, 322 | { type: 'indirect', cid: EXAMPLE_CID } 323 | ]); 324 | }); 325 | 326 | }); 327 | 328 | describe("for remote ls", () => { 329 | 330 | it("should return an empty list when listing remote services by default", async () => { 331 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 332 | const res = await ipfsClient.pin.remote.service.ls(); 333 | 334 | const result = await itAll(res); 335 | 336 | expect(result).to.deep.equal([]); 337 | }); 338 | 339 | it("should allow mocking the list of remote services", async () => { 340 | await mockNode.forPinRemoteLs().thenReturn([ 341 | EXAMPLE_SERVICE, 342 | EXAMPLE_ALT_SERVICE 343 | ]); 344 | 345 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 346 | 347 | const result = await itAll(await ipfsClient.pin.remote.service.ls()); 348 | 349 | expect( 350 | result.map(normalizeService) 351 | ).to.deep.equal([ 352 | EXAMPLE_SERVICE, 353 | EXAMPLE_ALT_SERVICE 354 | ].map(normalizeService)); 355 | }); 356 | 357 | it("should support including stat for remote services", async () => { 358 | const validStat = { 359 | stat: { 360 | status: 'valid', 361 | pinCount: { 362 | queued: 1, 363 | pinning: 1, 364 | pinned: 1, 365 | failed: 1 366 | } 367 | } 368 | }; 369 | const invalidStat = { 370 | stat: { 371 | status: 'invalid' 372 | } 373 | }; 374 | 375 | await mockNode.forPinRemoteLs().thenReturn([ 376 | { 377 | ...EXAMPLE_SERVICE, 378 | ...validStat 379 | }, 380 | { 381 | ...EXAMPLE_ALT_SERVICE, 382 | ...invalidStat 383 | } 384 | ]); 385 | 386 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 387 | 388 | const result = await itAll(await ipfsClient.pin.remote.service.ls({ stat: true })); 389 | 390 | expect( 391 | result.map(normalizeService) 392 | ).to.deep.equal([ 393 | { 394 | ...EXAMPLE_SERVICE, 395 | ...validStat 396 | }, 397 | { 398 | ...EXAMPLE_ALT_SERVICE, 399 | ...invalidStat 400 | } 401 | ].map(normalizeService)); 402 | }); 403 | 404 | it("should return default stat for remote services", async () => { 405 | await mockNode.forPinRemoteLs().thenReturn([ 406 | EXAMPLE_SERVICE 407 | ]); 408 | 409 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 410 | 411 | const result = await itAll(await ipfsClient.pin.remote.service.ls({ stat: true })); 412 | 413 | expect( 414 | result.map(normalizeService) 415 | ).to.deep.equal([ 416 | { 417 | ...EXAMPLE_SERVICE, 418 | stat: { 419 | status: 'invalid' 420 | } 421 | } 422 | ].map(normalizeService)); 423 | }); 424 | 425 | it("can timeout remote service resolution", async () => { 426 | await mockNode.forPinRemoteLs().thenTimeout(); 427 | 428 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 429 | 430 | const result = ipfsClient.pin.remote.service.ls(); 431 | 432 | expect(await Promise.race([ 433 | result, 434 | delay(500).then(() => 'timeout') 435 | ])).to.equal('timeout'); 436 | }); 437 | 438 | }); 439 | 440 | }); -------------------------------------------------------------------------------- /test/integration/proxy.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | 7 | import { 8 | expect, 9 | MockIPFS, 10 | IpfsClient, 11 | RealIpfsClient, 12 | realIpfsNodePromise, 13 | itValue 14 | } from '../test-setup'; 15 | 16 | describe("Proxying to a real IPFS node", function () { 17 | 18 | let mockNode: MockIPFS.MockIPFSNode; 19 | let realNode: RealIpfsClient; 20 | 21 | before(async () => { 22 | realNode = await realIpfsNodePromise; 23 | const apiMultiaddr = (await realNode.config.getAll()) 24 | .Addresses!.API!; 25 | 26 | const [ , , ip, , port] = apiMultiaddr!.split('/'); 27 | const apiUrl = `http://${ip}:${port}`; 28 | 29 | mockNode = MockIPFS.getLocal({ 30 | // Set default behaviour to be backed by this real IPFS node: 31 | unmatchedRequests: { 'proxyTo': apiUrl } 32 | }); 33 | 34 | }); 35 | 36 | beforeEach(async () => await mockNode.start()); 37 | afterEach(async () => await mockNode.stop()); 38 | 39 | it("should forward unmatched requests", async () => { 40 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 41 | 42 | // Write through the mock node via an HTTP client: 43 | const addResult = await ipfsClient.add('test content'); 44 | 45 | // Read from the real node, to check the write made it through: 46 | const realReadValue = await itValue(realNode.cat(addResult.cid)); 47 | expect(Buffer.from(realReadValue).toString()).to.equal('test content'); 48 | }); 49 | 50 | }); -------------------------------------------------------------------------------- /test/integration/smoke-test.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { 7 | expect, 8 | MockIPFS, 9 | IpfsClient, 10 | itAll, 11 | uint8ArrayConcat, 12 | uint8ToString 13 | } from '../test-setup'; 14 | 15 | const mockNode = MockIPFS.getLocal(); 16 | 17 | describe("MockIPFS", () => { 18 | // Start & stop your mock node to reset state between tests 19 | beforeEach(async () => await mockNode.start()); 20 | afterEach(async () => await mockNode.stop()); 21 | 22 | it("lets you mock behaviour and assert on node interactions", async () => { 23 | const ipfsPath = "/ipfs/a-fake-IPFS-id"; 24 | 25 | // Mock some node endpoints: 26 | await mockNode.forCat(ipfsPath).thenReturn("Mock content"); 27 | 28 | // Lookup some content with a real IPFS client: 29 | const ipfsClient = IpfsClient.create(mockNode.ipfsOptions); 30 | const content = await itAll(ipfsClient.cat(ipfsPath)); 31 | 32 | // Assert on the response: 33 | const contentText = uint8ToString(uint8ArrayConcat(content)); 34 | expect(contentText).to.equal("Mock content"); 35 | 36 | // Assert that we saw the requests we expected 37 | const catRequests = await mockNode.getQueriedContent(); 38 | expect(catRequests).to.deep.equal([ 39 | { path: ipfsPath } 40 | ]); 41 | }); 42 | }); -------------------------------------------------------------------------------- /test/mock-cids.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { expect } from 'chai'; 7 | import { CID } from 'ipfs-http-client'; 8 | import { 9 | mockCid, 10 | resetMockCidCounter 11 | } from '../src/utils/ipfs'; 12 | 13 | describe("Mock CIDs", () => { 14 | 15 | beforeEach(() => { 16 | resetMockCidCounter(); 17 | }); 18 | 19 | it("should start at 1 by default", () => { 20 | expect(mockCid()).to.equal( 21 | `QmPXXXXXXXXXXXXXXmockXipfsXcid1XXXXXXXXXXXXXXX` 22 | ); 23 | }); 24 | 25 | it("should generate valid CIDs", () => { 26 | const id = mockCid(); 27 | const cid = CID.parse(id); 28 | 29 | expect(cid.toString()).to.equal(id); 30 | expect(cid.version).to.equal(0); 31 | }); 32 | 33 | it("should count default ids into double digits", () => { 34 | const ids = [...new Array(22).keys()] 35 | .map(() => mockCid()); 36 | 37 | expect(ids).to.deep.equal([ 38 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid1XXXXXXXXXXXXXXX', 39 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid2XXXXXXXXXXXXXXX', 40 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid3XXXXXXXXXXXXXXX', 41 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid4XXXXXXXXXXXXXXX', 42 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid5XXXXXXXXXXXXXXX', 43 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid6XXXXXXXXXXXXXXX', 44 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid7XXXXXXXXXXXXXXX', 45 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid8XXXXXXXXXXXXXXX', 46 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid9XXXXXXXXXXXXXXX', 47 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid1AXXXXXXXXXXXXXX', 48 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid11XXXXXXXXXXXXXX', 49 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid12XXXXXXXXXXXXXX', 50 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid13XXXXXXXXXXXXXX', 51 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid14XXXXXXXXXXXXXX', 52 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid15XXXXXXXXXXXXXX', 53 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid16XXXXXXXXXXXXXX', 54 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid17XXXXXXXXXXXXXX', 55 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid18XXXXXXXXXXXXXX', 56 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid19XXXXXXXXXXXXXX', 57 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid2AXXXXXXXXXXXXXX', 58 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid21XXXXXXXXXXXXXX', 59 | 'QmPXXXXXXXXXXXXXXmockXipfsXcid22XXXXXXXXXXXXXX' 60 | ]); 61 | }); 62 | 63 | it("should allow custom values", () => { 64 | expect(mockCid("customXcid")).to.equal( 65 | `QmPXXXXXXXXXXXXXXXXcustomXcidXXXXXXXXXXXXXXXXX` 66 | ); 67 | }); 68 | 69 | it("should reject invalid chars explicitly", () => { 70 | expect(() => mockCid("INVALID VALUE")).to.throw( 71 | "Mock CID content must be base58btc" 72 | ); 73 | }); 74 | 75 | it("should not increment the counter for custom values", () => { 76 | mockCid("customXcid"); 77 | mockCid("customXcid"); 78 | mockCid("customXcid"); 79 | 80 | expect(mockCid()).to.equal( 81 | `QmPXXXXXXXXXXXXXXmockXipfsXcid1XXXXXXXXXXXXXXX` 82 | ); 83 | }); 84 | 85 | }); -------------------------------------------------------------------------------- /test/run-karma.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as karma from 'karma'; 7 | 8 | import { NodeModulesPolyfillPlugin } from '@esbuild-plugins/node-modules-polyfill'; 9 | import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; 10 | 11 | import { ipfsNodePromise } from './run-test-ipfs-node'; 12 | 13 | const CONTINUOUS = process.env.CONTINUOUS_TEST === 'true'; 14 | const HEADFUL = process.env.HEADFUL_TEST === 'true'; 15 | 16 | import * as MockIPFS from '../src/main'; 17 | const adminServer = MockIPFS.getAdminServer(); 18 | 19 | Promise.all([ 20 | ipfsNodePromise, 21 | adminServer.start() 22 | ]).then(async ([ipfsNode]) => { 23 | const config = await karma.config.parseConfig(undefined, { 24 | frameworks: ['mocha', 'chai'], 25 | files: [ 26 | 'test/**/*.spec.ts' 27 | ], 28 | preprocessors: { 29 | 'src/**/*.ts': ['esbuild'], 30 | 'test/**/*.ts': ['esbuild'] 31 | }, 32 | esbuild: { 33 | format: 'esm', 34 | target: 'esnext', 35 | define: { 36 | IPFS_NODE_ADDRESS: JSON.stringify(ipfsNode.httpUrl) 37 | }, 38 | external: [ 39 | 'http-encoding', 40 | './run-test-ipfs-node' 41 | ], 42 | plugins: [ 43 | NodeModulesPolyfillPlugin(), 44 | NodeGlobalsPolyfillPlugin({ 45 | process: true, 46 | buffer: true 47 | }) 48 | ] 49 | }, 50 | plugins: [ 51 | 'karma-chrome-launcher', 52 | 'karma-chai', 53 | 'karma-mocha', 54 | 'karma-spec-reporter', 55 | 'karma-esbuild' 56 | ], 57 | reporters: ['spec'], 58 | port: 9876, 59 | logLevel: karma.constants.LOG_INFO, 60 | 61 | browsers: HEADFUL 62 | ? ['Chrome'] 63 | : ['ChromeHeadless'], 64 | 65 | autoWatch: CONTINUOUS, 66 | singleRun: !CONTINUOUS 67 | } as karma.ConfigOptions, { throwErrors: true }); 68 | 69 | const karmaServer = new karma.Server(config, async () => { 70 | await Promise.all([ 71 | ipfsNode.shutdown(), 72 | adminServer.stop() 73 | ]) 74 | }); 75 | 76 | await karmaServer.start(); 77 | karmaServer.on('run_complete', (_browsers, results) => { 78 | process.exit(results.exitCode); 79 | }); 80 | }).catch(e => { 81 | console.error(e); 82 | process.exit(1); 83 | }); -------------------------------------------------------------------------------- /test/run-test-ipfs-node.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import * as Ipfs from 'ipfs'; 7 | import * as IpfsHttpServer from 'ipfs-http-server'; 8 | 9 | export const ipfsNodePromise: Promise<{ 10 | node: Ipfs.IPFS, 11 | multiaddr: string, 12 | httpUrl: string, 13 | shutdown: () => Promise 14 | }> = Ipfs.create({ 15 | config: { 16 | API: { 17 | HTTPHeaders: { 'Access-Control-Allow-Origin': ['*'] } 18 | } 19 | } 20 | }).then(async (ipfsNode: Ipfs.IPFS) => { 21 | const ipfsApi = new IpfsHttpServer.HttpApi(ipfsNode); 22 | await ipfsApi.start(); 23 | 24 | const multiaddr = (await ipfsNode.config.getAll()) 25 | .Addresses!.API! 26 | 27 | const [ , , ip, , port] = multiaddr!.split('/'); 28 | const httpUrl = `http://${ip}:${port}`; 29 | 30 | return { 31 | node: ipfsNode, 32 | multiaddr, 33 | httpUrl, 34 | shutdown: () => Promise.all([ipfsNode.stop(), ipfsApi.stop()]) 35 | }; 36 | }); -------------------------------------------------------------------------------- /test/test-setup.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * SPDX-FileCopyrightText: 2022 Tim Perry 3 | * SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | import { AbortController } from "node-abort-controller"; 7 | // @ts-ignore 8 | globalThis.AbortController ??= AbortController; 9 | 10 | import { expect } from "chai"; 11 | 12 | import type * as IPFS from "ipfs"; 13 | import * as IpfsClient from "ipfs-http-client"; 14 | import { 15 | RemotePinService, 16 | RemotePinServiceWithStat 17 | } from "ipfs-core-types/src/pin/remote/service"; 18 | 19 | import itAll = require('it-all'); 20 | import { 21 | concat as uint8ArrayConcat, 22 | toString as uint8ToString 23 | } from 'uint8arrays'; 24 | 25 | export { HTTPError } from "ipfs-utils/src/http"; 26 | 27 | import * as MockIPFS from '../src/main'; 28 | import { isNode, delay } from "../src/utils/util"; 29 | export { 30 | expect, 31 | MockIPFS, 32 | IpfsClient, 33 | itAll, 34 | uint8ArrayConcat, 35 | uint8ToString, 36 | delay 37 | }; 38 | 39 | export const EXAMPLE_CID = 'QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQuPU'; 40 | export const ALTERNATE_CID = 'QmY7Yh4UquoXHLPFo2XbhXkhBvFoPwmQUSa92pxnxjQABC'; 41 | export const EXAMPLE_SERVICE = { service: 'foo', endpoint: new URL('http://localhost:9876/') }; 42 | export const EXAMPLE_ALT_SERVICE = { service: 'bar', endpoint: new URL('http://localhost:6789/') }; 43 | 44 | export const normalizeService = function (service: RemotePinServiceWithStat | RemotePinService) { 45 | const _service = { ...service, endpoint: service.endpoint.toString() }; 46 | return _service; 47 | }; 48 | 49 | export const itValue = async (asyncIterable: AsyncIterable|Iterable): Promise => { 50 | const values = await itAll(asyncIterable); 51 | 52 | expect(values.length).to.equal(1, `Expected iterable to have exactly 1 value, but it had ${ 53 | values.length 54 | }: ${ 55 | values.map(v => JSON.stringify(v)).join(', ') 56 | }`); 57 | 58 | return values[0]; 59 | }; 60 | 61 | export type RealIpfsClient = IPFS.IPFS | IpfsClient.IPFSHTTPClient; 62 | export let realIpfsNodePromise: Promise; 63 | 64 | if (isNode) { 65 | // We start a real IPFS node in-process, for proxy tests, but we don't wait 66 | // for startup until the proxy test itself 67 | let nodeSetupPromise = import('./run-test-ipfs-node') 68 | .then(m => m.ipfsNodePromise); 69 | 70 | realIpfsNodePromise = nodeSetupPromise.then(({ node }) => node); 71 | 72 | after(async () => { 73 | await (await nodeSetupPromise).shutdown(); 74 | }); 75 | } else { 76 | // In the browser, this is launched & shutdown independently by Karma, we just create a client: 77 | realIpfsNodePromise = Promise.resolve( 78 | IpfsClient.create({ url: IPFS_NODE_ADDRESS }) 79 | ); 80 | } 81 | declare const IPFS_NODE_ADDRESS: string; // Inject in browsers by Esbuild via Karma -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "strict": true 6 | }, 7 | "include": [ 8 | "../custom-typings/*.d.ts", 9 | "**/*.ts", 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": true 9 | }, 10 | "include": [ 11 | "custom-typings/*.d.ts", 12 | "src/**/*.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MockIPFS API Reference", 3 | "readme": "none", 4 | "validation": { 5 | "invalidLink": true 6 | }, 7 | "treatWarningsAsErrors": true, 8 | "excludePrivate": true, 9 | "excludeProtected": true, 10 | "excludeInternal": true, 11 | "out": "typedoc/" 12 | } -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = (wallaby) => { 2 | return { 3 | files: [ 4 | 'package.json', 5 | 'src/**/*.ts', 6 | 'test/**/*.ts', 7 | '!test/**/*.spec.ts' 8 | ], 9 | tests: [ 10 | 'test/**/*.spec.ts' 11 | ], 12 | 13 | preprocessors: { 14 | // Package.json points `main` to the built output. We use this a lot in the integration tests, but we 15 | // want wallaby to run on raw source. This is a simple remap of paths to lets us do that. 16 | 'test/integration/**/*.ts': file => { 17 | return file.content.replace( 18 | /("|')..((\/..)+)("|')/g, 19 | '"..$2/src/main"' 20 | ); 21 | } 22 | }, 23 | 24 | workers: { 25 | initial: 1, 26 | regular: 1, 27 | restart: true 28 | }, 29 | 30 | testFramework: 'mocha', 31 | env: { 32 | type: 'node' 33 | }, 34 | debug: true 35 | }; 36 | }; --------------------------------------------------------------------------------