54 | -------------------------------------------------------------------------------- /lib/views/host.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | <% for (var i = 0, n = links.length; i < n; i++) { -%> 4 | 7 | <% } -%> 8 | 9 | -------------------------------------------------------------------------------- /lib/views/index.html: -------------------------------------------------------------------------------- 1 | <%- include('header.html'); %> 2 | 3 |
4 | 5 |
6 |
7 | 8 | armadietto logo 9 | 10 | 11 |
12 |

Welcome to Armadietto!

13 |

14 | This is an open-source server for syncing your data between different devices and applications. It is built on 15 | remoteStorage, 16 | an open protocol for user data storage. 17 |

18 |
19 | 20 |

21 | If you would like to host your own server,
22 | visit armadietto on GitHub. 23 |

24 | 25 |
26 | 27 | <%- include('footer.html'); %> 28 | -------------------------------------------------------------------------------- /lib/views/index2.html: -------------------------------------------------------------------------------- 1 | <%- include('./begin.html'); %> 2 | 3 |
4 | 5 |
6 |
7 | 8 | armadietto logo 9 | 10 | 11 |
12 |

Welcome to Armadietto!

13 |

14 | This is an open-source server for syncing your data between different devices and applications. It is built on 15 | remoteStorage, 16 | an open protocol for user data storage. 17 |

18 |
19 | 20 |

21 | If you would like to host your own server,
22 | visit armadietto on GitHub. 23 |

24 | 25 |
26 | 27 | <%- include('./end.html'); %> 28 | -------------------------------------------------------------------------------- /lib/views/login.html: -------------------------------------------------------------------------------- 1 | <%- include('header.html'); %> 2 | 3 |
4 |
5 |

Log In

6 |
7 | 8 |
9 | <% if (error) { %> 10 |

<%= error.message %>

11 | <% } %> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 |
26 | 27 | <%- include('footer.html'); %> 28 | -------------------------------------------------------------------------------- /lib/views/login/error.html: -------------------------------------------------------------------------------- 1 | <%- include('../begin.html'); %> 2 | 3 |
4 | 5 |
6 |
7 | 8 | armadietto logo 9 | 10 | 11 |
12 |

<%= title %>

13 |

<%= subtitle %>

14 |

15 | <%= message %> 16 |

17 |
18 | 19 |

20 | If you would like to host your own server,
21 | visit armadietto on GitHub. 22 |

23 | 24 |
25 | 26 | <%- include('../end.html'); %> 27 | -------------------------------------------------------------------------------- /lib/views/login/login.html: -------------------------------------------------------------------------------- 1 | <%- include('../begin.html'); %> 2 | 3 | 4 | 5 |
6 | 7 |
8 | <% if (title === 'Login') { %> 9 |
10 | 11 | armadietto logo 12 | 13 | 14 |
15 | <% } %> 16 |

<%= title %>

17 |
18 |
19 |

<%= message %>

20 | 21 |
22 | 23 |
24 | 25 |
26 | 27 | <%- include('../end.html'); %> 28 | -------------------------------------------------------------------------------- /lib/views/login/logout.html: -------------------------------------------------------------------------------- 1 | <%- include('../begin.html'); %> 2 | 3 |
4 | 5 |
6 |
7 | 8 | armadietto logo 9 | 10 | 11 |
12 |

<%= title %>

13 |
14 |
15 | 16 | <%- include('../end.html'); %> 17 | -------------------------------------------------------------------------------- /lib/views/login/request-invite-success.html: -------------------------------------------------------------------------------- 1 | <%- include('../begin.html'); %> 2 | 3 |
4 |

Invitation Requested

5 |
6 | 7 |

Check that clicking this link starts a message to you:

8 | 9 |

<%= params.contactURL %>

10 | 11 | <%- include('../end.html'); %> 12 | -------------------------------------------------------------------------------- /lib/views/login/request-invite.html: -------------------------------------------------------------------------------- 1 | <%- include('../begin.html'); %> 2 | 3 | 4 | 5 |
6 |

Request an Invitation

7 | 8 |

How should we contact you?

9 | 10 | <%- include('../contact-url.html'); %> 11 |
12 | 13 | <%- include('../end.html'); %> 14 | -------------------------------------------------------------------------------- /lib/views/resource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | <% for (var i = 0, n = links.length; i < n; i++) { -%> 4 | 7 | <% for (var key in links[i].properties) { i-%> 8 | <%= links[i].properties[key] %> 9 | <% } -%> 10 | 11 | <% } -%> 12 | 13 | -------------------------------------------------------------------------------- /lib/views/signup-success.html: -------------------------------------------------------------------------------- 1 | <%- include('header.html'); %> 2 | 3 |

You’ve signed up

4 | 5 |

6 | Welcome! ☺ 7 |

8 |

9 | Your account's user address is: <%= params.username %>@<%= host %> 10 |

11 |

12 | Explore remoteStorage apps 13 |

14 | 15 | <%- include('footer.html'); %> 16 | -------------------------------------------------------------------------------- /lib/views/signup.html: -------------------------------------------------------------------------------- 1 | <%- include('header.html'); %> 2 | 3 |

Sign up

4 | 5 |
6 | <% if (error) { %> 7 |

<%= error.message %>

8 | <% } %> 9 | 10 |
11 | 12 | 13 |
14 | 15 |
16 | 17 | 18 |
19 | 20 |
21 | 22 | 23 | 24 |
25 | 26 | 27 |
28 | 29 | <%- include('footer.html'); %> 30 | -------------------------------------------------------------------------------- /notes/S3-store-router.md: -------------------------------------------------------------------------------- 1 | # S3-compatible Streaming Store 2 | 3 | Note: S3-compatible storage trades the strong consistency of a file system for higher performance and multi-datacenter capability, so this store only offers eventual consistency. 4 | 5 | Streaming Stores like this can only be used with the modular server. 6 | 7 | ## Compatible S3 Implementations 8 | 9 | Tested services include: 10 | 11 | ### AWS S3 12 | 13 | * Fully working 14 | 15 | ### DigitalOcean Spaces 16 | 17 | * Fully working 18 | 19 | ### Scaleway Object Storage 20 | 21 | * Fully working 22 | 23 | ### Garage 24 | 25 | * doesn't implement versioning (which would be nice for recovery) 26 | 27 | ### min.io (both self-hosted and cloud) 28 | 29 | * web console can't be used with this, and probably won't ever 30 | 31 | 32 | ### OpenIO 33 | Disrecommended — bugs can't be worked around 34 | 35 | * fails simultaneous delete test 36 | * doesn't implement DeleteObjectsCommand 37 | 38 | ### Other S3-compatible implementations 39 | 40 | Run the Mocha test `spec/store_handlers/S3_store_handler.spec.js` with environment variables set. (See next section.) 41 | 42 | 43 | ## Configuration 44 | 45 | Configure the store router by passing to the constructor the endpoint (host name, and port if not 9000), access key (admin user name) and secret key (password). (If you don't pass any arguments, S3 will use the public account on `play.min.io`, where the documents & folders can be **read, altered and deleted** by anyone in the world! Also, the Min.IO browser can't list your documents or folders.) If you're using a AWS and a region other than `us-east-1`, include that as a fourth argument. You can provide these however you like, but typically they are stored in these environment variables: 46 | 47 | * S3_ENDPOINT 48 | * S3_ACCESS_KEY 49 | * S3_SECRET_KEY 50 | * S3_REGION 51 | 52 | For AWS, you must also pass a fifth argument — a user name suffix so bucket names don't collide with other users. By default, this is a hyphen plus `conf.host_identity`, but you can set `conf.user_name_suffix` to override. 53 | 54 | Creating an app server then resembles: 55 | 56 | ```javascript 57 | const s3handler = new S3Handler({ 58 | endPoint: process.env.S3_ENDPOINT, 59 | accessKey: process.env.S3_ACCESS_KEY, 60 | secretKey: process.env.S3_SECRET_KEY, 61 | userNameSuffix}); 62 | const app = require('../../lib/appFactory')({accountMgr: s3handler, storeRouter: s3handler, ...}); 63 | ``` 64 | 65 | HTTPS is used if the endpoint is not localhost. If you must use http, you can include the scheme in the endpoint: `http://myhost.example.org`. 66 | 67 | This one access key is used to create a bucket for each user. 68 | The bucket name is the id plus the suffix, if any. 69 | If other non-remoteStorage buckets are created at that endpoint, those bucket names will be unavailable as usernames. 70 | Buckets can be administered using the service's tools, such as a webapp console or command-line tools. 71 | The bucket **SHOULD NOT** contain non-RS blobs with these prefixes: 72 | 73 | * remoteStorageBlob/ 74 | * remoteStorageAuth/ 75 | 76 | ## Limits 77 | 78 | Document and folder paths are distinguished only by the first 942 characters. 79 | 80 | The characters allowed in paths are limited to what the provider supports. For MinIO, this is the underlying filesystem characters. 81 | -------------------------------------------------------------------------------- /notes/monolithic-server.md: -------------------------------------------------------------------------------- 1 | # Monolithic (old) Server 2 | 3 | ## Use 4 | 5 | 1. Run `armadietto -e` to see a sample configuration file. 6 | 2. Create a configuration file at `/etc/armadietto/conf.json` (or elsewhere). See below for values and their meanings. 7 | 3. Run `armadietto -c /etc/armadietto/conf.json` 8 | 9 | To see all options, run `armadietto -h`. Set the environment `DEBUG` to log the headers of every request. 10 | 11 | ## Use as a library 12 | 13 | The following Node script will run a basic server: 14 | ```js 15 | process.umask(077); 16 | 17 | const Armadietto = require('armadietto'); 18 | store = new Armadietto.FileTree({path: 'path/to/storage'}), 19 | server = new Armadietto({ 20 | store: store, 21 | http: {host: '127.0.0.1', port: 8000} 22 | }); 23 | server.boot(); 24 | ``` 25 | 26 | The `host` option is optional and specifies the hostname the server will listen on. Its default value is `0.0.0.0`, meaning it will listen on all interfaces. 27 | 28 | The server does not allow users to sign up, out of the box. If you need to allow that, use the `allow_signup` option: 29 | ```js 30 | 31 | var server = new Armadietto({ 32 | store: store, 33 | http: { host: '127.0.0.1', port: 8000 }, 34 | allow_signup: true 35 | }); 36 | ``` 37 | 38 | If you navigate to `http://localhost:8000/` you should then see a sign-up link in the navigation. 39 | 40 | ## Storage backends 41 | 42 | Armadietto supports pluggable storage backends, and comes with a file system 43 | implementation out of the box (redis storage backend is on the way in 44 | `feature/redis` branch): 45 | 46 | * `Armadietto.FileTree` - Uses the filesystem hierarchy and stores each item in its 47 | own individual file. Content and metadata are stored in separate files so the 48 | content does not need base64-encoding and can be hand-edited. Must only be run 49 | using a single server process. 50 | 51 | All the backends support the same set of features, including the ability to 52 | store arbitrary binary data with content types and modification times. 53 | 54 | They are configured as follows: 55 | 56 | ```js 57 | // To use the file tree store: 58 | const store = new Armadietto.FileTree({path: 'path/to/storage'}); 59 | 60 | // Then create the server with your store: 61 | const server = new Armadietto({ 62 | store: store, 63 | http: {port: process.argv[2]} 64 | }); 65 | 66 | server.boot(); 67 | ``` 68 | 69 | ## Lock file contention 70 | 71 | The data-access locking mechanism is lock-file based. 72 | You may need to tune the lock-file timeouts in your configuration: 73 | - *lock_timeout_ms* - millis to wait for lock file to be available 74 | - *lock_stale_after_ms* - millis to wait to deem lockfile stale 75 | 76 | To tune, run the [hosted RS load test](https://overhide.github.io/armadietto/example/load.html) or follow instructions in [example/README.md](example/README.md) for local setup and subsequently run [example/load.html](example/load.html) off of `npm run serve` therein. 77 | -------------------------------------------------------------------------------- /notes/reverse-proxy-configuration.md: -------------------------------------------------------------------------------- 1 | # Configuring a Reverse Proxy for Armadietto 2 | 3 | 1. [optional] Set a DNS A record for a new domain name, if Armadietto will appear as a different host than other websites served by your reverse proxy. 4 | 2. Ensure your TLS certificate includes the domain name Armadietto be will visible as. 5 | 3. [optional] Set up a name-based virtual server, if Armadietto will appear as a different host than other websites served by your reverse proxy. 6 | 4. Configure your reverse proxy, and have it set the header `x-forwarded-proto` (or `x-forwarded-ssl` or `x-forwarded-scheme`) in the request passed to Armadietto. Armadietto does not yet support the `Forwarded` header, nor the PROXY protocol. For Apache, the directives are `ProxyPass`, `ProxyPassReverse`, and `RequestHeader`. If the proxy is running on a different host than Armadietto, you must also set the `X-Forwarded-Host` header. For Apache, a name-based virtual server and reverse proxy on the same host will resemble: 7 | ``` 8 | 9 | ServerName storage.example.com 10 | DocumentRoot /var/www/remotestorage 11 | SSLEngine on 12 | SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem 13 | SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem 14 | SSLCertificateChainFile /etc/letsencrypt/live/example.com/fullchain.pem 15 | 16 | ProxyPass "/" "http://127.0.0.1:8000/" connectiontimeout=5 timeout=30 17 | ProxyPassReverse "/" "http://127.0.0.1:8000/" 18 | RequestHeader set x-forwarded-proto "https" 19 | RequestHeader unset x-forwarded-ssl 20 | RequestHeader unset x-forwarded-scheme 21 | RequestHeader unset x-forwarded-host 22 | 23 | ``` 24 | For nginx, a name-based virtual server and reverse proxy on the same host will resemble 25 | ``` 26 | server { 27 | server_name storage.example.com 28 | listen 0.0.0.0:443 ssl; 29 | 30 | include /etc/nginx/include/ssl; 31 | 32 | access_log /var/log/nginx/armadietto.access.log; 33 | error_log /var/log/nginx/armadietto.error.log; 34 | 35 | location / { 36 | proxy_set_header Host $host; 37 | # if listening on an non-standard port, use 38 | # proxy_set_header Host $host:$server_port; 39 | proxy_set_header X-Forwarded-Proto https; 40 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 41 | proxy_set_header X-Forwarded-SSL ""; 42 | proxy_set_header X-Forwarded-Scheme ""; 43 | proxy_set_header X-Forwarded-Host ""; 44 | 45 | proxy_pass http://127.0.0.1:8000; 46 | proxy_redirect off; 47 | proxy_buffering off; 48 | } 49 | } 50 | 51 | ``` 52 | 5. Run `armadietto -e` to see a sample configuration file. 53 | 6. Create a configuration file at `/etc/armadietto/conf.json` (or elsewhere). See [the modular-server-specific documentation](../notes/modular-server.md) or [the monolithic-server-specific documentation](../notes/monolithic-server.md) for values. 54 | 7. Run `armadietto -c /etc/armadietto/conf.json` or configure systemd (or the equivalent in your OS) to run armadietto. See [the systemd docs](../contrib/systemd/README.md). 55 | 56 | Don't use shell scripts nor `nodemon` to keep Armadietto running. 57 | They respond much slower, are more fragile to unexpected situations, are harder to maintain, and can't be administered like other services. 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "armadietto", 3 | "description": "Node.js remoteStorage server", 4 | "homepage": "https://github.com/remotestorage/armadietto", 5 | "keywords": [ 6 | "remoteStorage", 7 | "webfinger", 8 | "oauth", 9 | "webdav", 10 | "remotestorage.js", 11 | "my own storage", 12 | "privacy", 13 | "decentralization" 14 | ], 15 | "license": "MIT", 16 | "version": "0.6.0", 17 | "engines": { 18 | "node": ">=20.0" 19 | }, 20 | "bin": { 21 | "armadietto": "./bin/armadietto.js" 22 | }, 23 | "main": "./lib/armadietto.js", 24 | "dependencies": { 25 | "@aws-sdk/client-s3": "^3.523.0", 26 | "@aws-sdk/lib-storage": "^3.525.1", 27 | "@simplewebauthn/server": "^9.0.3", 28 | "@smithy/node-http-handler": "^2.5.0", 29 | "argparse": "^2.0.1", 30 | "cors": "^2.8.5", 31 | "ejs": "^3.1.9", 32 | "express": "^4.18.2", 33 | "express-jwt": "^8.4.1", 34 | "express-session": "^1.18.0", 35 | "helmet": "^7.1.0", 36 | "http-errors": "^2.0.0", 37 | "jsonwebtoken": "^9.0.2", 38 | "lockfile": "^1.0.4", 39 | "memorystore": "^1.6.7", 40 | "mkdirp": "^1.0.4", 41 | "node-mocks-http": "^1.14.1", 42 | "proquint": "^0.0.1", 43 | "rate-limiter-flexible": "^5.0.3", 44 | "robots.txt": "^1.1.0", 45 | "ua-parser-js": "^1.0.38", 46 | "winston": "^3.11.0", 47 | "yaml": "^2.4.0" 48 | }, 49 | "devDependencies": { 50 | "bdd-lazy-var": "^2.6.1", 51 | "chai": "^4.4.1", 52 | "chai-as-promised": "^7.1.1", 53 | "chai-http": "^4.4.0", 54 | "chai-spies": "^1.1.0", 55 | "eslint": "^8.56.0", 56 | "eslint-config-standard": "^17.1.0", 57 | "eslint-plugin-import": "^2.29.1", 58 | "eslint-plugin-n": "^16.6.2", 59 | "eslint-plugin-node": "^11.1.0", 60 | "eslint-plugin-promise": "^6.1.1", 61 | "mocha": "^10.3.0", 62 | "nodemon": "^3.0.3", 63 | "rimraf": "^3.0.2" 64 | }, 65 | "scripts": { 66 | "dev": "nodemon --inspect -w ./lib ./bin/armadietto.js -c ./bin/dev-conf.json", 67 | "modular": "PORT=8001 node --watch --trace-warnings ./bin/www -c ./bin/dev-conf.json", 68 | "test": "mocha -u bdd-lazy-var/getter spec/runner.js", 69 | "test-watch": "mocha --watch -u bdd-lazy-var/getter spec/runner.js", 70 | "test-s3-wo-configured-server": "mocha spec/store_handlers/S3_store_handler.spec.js", 71 | "lint": "eslint --max-warnings=0 \"lib/**/*.js\" \"bin/**/*.js\" \"spec/**/*.js\"", 72 | "lint:fix": "eslint --fix \"lib/**/*.js\" \"bin/**/*.js\" \"spec/**/*.js\"", 73 | "build-monolithic": "docker build . -f docker/Dockerfile-monolithic -t remotestorage/armadietto-monolithic" 74 | }, 75 | "repository": { 76 | "type": "git", 77 | "url": "git://github.com/remotestorage/armadietto.git" 78 | }, 79 | "bugs": { 80 | "url": "https://github.com/remotestorage/armadietto/issues" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /spec/armadietto/a_not_found_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const Armadietto = require('../../lib/armadietto'); 4 | const { shouldHandleNonexistingResource } = require('../not_found.spec'); 5 | 6 | const mockStore = { 7 | authorize (clientId, username, permissions) { 8 | return 'a_token'; 9 | }, 10 | authenticate (params) { 11 | } 12 | }; 13 | 14 | describe('Nonexistant resource (monolithic)', function () { 15 | beforeEach(function () { 16 | this.app = new Armadietto({ 17 | bare: true, 18 | store: mockStore, 19 | allow: { signup: true }, 20 | http: { }, 21 | logging: { log_dir: './test-log', stdout: [], log_files: ['error'] } 22 | }); 23 | }); 24 | 25 | shouldHandleNonexistingResource(); 26 | }); 27 | -------------------------------------------------------------------------------- /spec/armadietto/a_oauth_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const chai = require('chai'); 4 | const expect = chai.expect; 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | const spies = require('chai-spies'); 8 | chai.use(spies); 9 | const Armadietto = require('../../lib/armadietto'); 10 | const { shouldImplementOAuth } = require('../oauth.spec'); 11 | const { configureLogger } = require('../../lib/logger'); 12 | 13 | const sandbox = chai.spy.sandbox(); 14 | 15 | async function post (app, url, params) { 16 | return chai.request(app).post(url).type('form').send(params).redirects(0); 17 | } 18 | 19 | const store = { 20 | authorize (_clientId, _username, _permissions) { 21 | return 'a_token'; 22 | }, 23 | authenticate ({ username, email, password }) { 24 | if (username === 'zebcoe' && password === 'locog') { return; } 25 | throw new Error('Incorrect password'); 26 | } 27 | }; 28 | 29 | describe('OAuth (monolithic)', function () { 30 | before(function () { 31 | configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); 32 | 33 | this.store = store; 34 | this.app = new Armadietto({ 35 | bare: true, 36 | store, 37 | http: { }, 38 | logging: { stdout: [], log_dir: './test-log', log_files: ['debug'] } 39 | }); 40 | }); 41 | 42 | shouldImplementOAuth(); 43 | 44 | describe('with valid login credentials (old account module)', async function () { 45 | beforeEach(function () { 46 | this.auth_params = { 47 | username: 'zebcoe', 48 | password: 'locog', 49 | client_id: 'the_client_id', 50 | redirect_uri: 'http://example.com/cb', 51 | response_type: 'token', 52 | scope: 'the_scope', 53 | state: 'the_state' 54 | }; 55 | 56 | sandbox.on(this.store, ['authorize', 'authenticate']); 57 | }); 58 | 59 | afterEach(function () { 60 | sandbox.restore(); 61 | }); 62 | 63 | describe('without explicit read/write permissions', async function () { 64 | it('authorizes the client to read and write', async function () { 65 | await post(this.app, '/oauth', this.auth_params); 66 | expect(this.store.authorize).to.have.been.called.with('the_client_id', 'zebcoe', { the_scope: ['r', 'w'] }); 67 | }); 68 | }); 69 | 70 | describe('with explicit read permission', async function () { 71 | it('authorizes the client to read', async function () { 72 | this.auth_params.scope = 'the_scope:r'; 73 | await post(this.app, '/oauth', this.auth_params); 74 | expect(this.store.authorize).to.have.been.called.with('the_client_id', 'zebcoe', { the_scope: ['r'] }); 75 | }); 76 | }); 77 | 78 | describe('with explicit read/write permission', async function () { 79 | it('authorizes the client to read and write', async function () { 80 | this.auth_params.scope = 'the_scope:rw'; 81 | await post(this.app, '/oauth', this.auth_params); 82 | expect(this.store.authorize).to.have.been.called.with('the_client_id', 'zebcoe', { the_scope: ['r', 'w'] }); 83 | }); 84 | }); 85 | }); 86 | 87 | describe('with invalid login credentials (old account module)', async function () { 88 | beforeEach(function () { 89 | this.auth_params = { 90 | username: 'zebcoe', 91 | password: 'locog', 92 | client_id: 'the_client_id', 93 | redirect_uri: 'http://example.com/cb', 94 | response_type: 'token', 95 | scope: 'the_scope', 96 | state: 'the_state' 97 | }; 98 | 99 | sandbox.on(this.store, ['authorize', 'authenticate']); 100 | }); 101 | 102 | afterEach(function () { 103 | sandbox.restore(); 104 | }); 105 | 106 | it('does not authorize the client', async function () { 107 | this.auth_params.password = 'incorrect'; 108 | await post(this.app, '/oauth', this.auth_params); 109 | expect(this.store.authorize).to.have.been.called.exactly(0); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /spec/armadietto/a_root_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const Armadietto = require('../../lib/armadietto'); 4 | const { shouldBeWelcomeWithoutSignup, shouldBeWelcomeWithSignup } = require('../root.spec'); 5 | 6 | const store = { 7 | authorize (clientId, username, permissions) { 8 | return 'a_token'; 9 | }, 10 | authenticate (params) { 11 | } 12 | }; 13 | 14 | describe('root page (monolithic)', function () { 15 | describe('w/o signup', function () { 16 | beforeEach(function () { 17 | this.app = new Armadietto({ 18 | bare: true, 19 | store, 20 | http: { }, 21 | logging: { log_dir: './test-log', stdout: [], log_files: ['error'] } 22 | }); 23 | }); 24 | 25 | shouldBeWelcomeWithoutSignup(); 26 | }); 27 | 28 | describe('with signup', function () { 29 | beforeEach(function () { 30 | this.app = new Armadietto({ 31 | bare: true, 32 | allow: { signup: true }, 33 | store, 34 | http: { }, 35 | logging: { log_dir: './test-log', stdout: [], log_files: ['error'] } 36 | }); 37 | }); 38 | 39 | shouldBeWelcomeWithSignup(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /spec/armadietto/a_signup_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const Armadietto = require('../../lib/armadietto'); 4 | const { shouldBlockSignups, shouldAllowSignupsBasePath } = require('../signup.spec'); 5 | const core = require('../../lib/stores/core'); 6 | 7 | const store = { 8 | async createUser (params) { 9 | const errors = core.validateUser(params); 10 | if (errors.length > 0) throw new Error(errors[0]); 11 | } 12 | }; 13 | 14 | describe('Signup (monolithic)', function () { 15 | describe('Signup disabled and no base path', function () { 16 | before(function () { 17 | this.app = new Armadietto({ 18 | bare: true, 19 | store, 20 | http: { }, 21 | logging: { log_dir: './test-log', stdout: [], log_files: ['notice'] } 22 | }); 23 | }); 24 | 25 | // test of home page w/ signup disabled moved to root.spec.js 26 | 27 | // test that style sheet can be fetched moved to static_files.spec.js 28 | 29 | shouldBlockSignups(); 30 | }); 31 | 32 | describe('Signup w/ base path & signup enabled', function () { 33 | before(function () { 34 | this.app = new Armadietto({ 35 | bare: true, 36 | store, 37 | allow: { signup: true }, 38 | http: { }, 39 | logging: { log_dir: './test-log', stdout: [], log_files: ['notice'] }, 40 | basePath: '/basic' 41 | }); 42 | this.username = 'john'; 43 | }); 44 | 45 | shouldAllowSignupsBasePath(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /spec/armadietto/a_static_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const Armadietto = require('../../lib/armadietto'); 4 | const { shouldServeStaticFiles } = require('../static_files.spec'); 5 | 6 | const mockStore = { 7 | authorize (clientId, username, permissions) { 8 | return 'a_token'; 9 | }, 10 | authenticate (params) { 11 | } 12 | }; 13 | 14 | describe('Static asset handler (monolithic)', function () { 15 | beforeEach(function () { 16 | this.app = new Armadietto({ 17 | bare: true, 18 | store: mockStore, 19 | http: { }, 20 | logging: { log_dir: './test-log', stdout: [], log_files: ['error'] } 21 | }); 22 | }); 23 | 24 | shouldServeStaticFiles(); 25 | }); 26 | -------------------------------------------------------------------------------- /spec/armadietto/a_web_finger.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const Armadietto = require('../../lib/armadietto'); 4 | const http = require('http'); 5 | const { shouldImplementWebFinger } = require('../web_finger.spec'); 6 | 7 | describe('Web Finger (monolithic)', function () { 8 | before(function (done) { 9 | const app = new Armadietto({ 10 | bare: true, 11 | store: {}, 12 | http: { }, 13 | logging: { stdout: [], log_dir: './test-log', log_files: ['debug'] } 14 | }); 15 | this.server = http.createServer(app); 16 | this.server.listen(); 17 | this.server.on('listening', () => { 18 | this.port = this.server.address().port; 19 | this.host = this.server.address().address + ':' + this.server.address().port; 20 | done(); 21 | }); 22 | }); 23 | 24 | after(function (done) { 25 | this.server.close(done); 26 | }); 27 | 28 | shouldImplementWebFinger(); 29 | }); 30 | -------------------------------------------------------------------------------- /spec/modular/account.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | /* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ 3 | /* eslint-disable no-unused-expressions */ 4 | 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const chaiAsPromised = require('chai-as-promised'); 8 | chai.use(chaiAsPromised); 9 | const chaiHttp = require('chai-http'); 10 | chai.use(chaiHttp); 11 | const { configureLogger } = require('../../lib/logger'); 12 | const { mockAccountFactory, USER } = require('../util/mockAccount'); 13 | const path = require('path'); 14 | const loginFactory = require('../../lib/routes/login'); 15 | const accountRouterFactory = require('../../lib/routes/account'); 16 | const express = require('express'); 17 | const session = require('express-session'); 18 | const crypto = require('crypto'); 19 | 20 | const HOST_IDENTITY = 'psteniusubi.github.io'; 21 | 22 | describe('account router', function () { 23 | before(async function () { 24 | configureLogger({ log_dir: './test-log', stdout: ['notice'], log_files: ['error'] }); 25 | this.hostIdentity = HOST_IDENTITY; 26 | 27 | this.accountMgr = mockAccountFactory(HOST_IDENTITY); 28 | 29 | this.jwtSecret = 'scrimshaw'; 30 | this.loginRouter = await loginFactory(this.hostIdentity, this.jwtSecret, this.accountMgr, false); 31 | this.accountRouter = await accountRouterFactory(this.hostIdentity, this.jwtSecret, this.accountMgr); 32 | 33 | this.app = express(); 34 | this.app.locals.basePath = ''; 35 | this.app.set('views', path.join(__dirname, '../../lib/views')); 36 | this.app.set('view engine', 'html'); 37 | this.app.engine('.html', require('ejs').__express); 38 | 39 | const developSession = session({ 40 | name: 'id', 41 | secret: crypto.randomBytes(32 / 8).toString('base64') 42 | }); 43 | this.app.use(developSession); 44 | this.sessionValues = {}; 45 | this.app.use((req, res, next) => { // shim for testing 46 | Object.assign(req.session, this.sessionValues); 47 | res.logNotes = new Set(); 48 | next(); 49 | }); 50 | this.app.use('/account', this.loginRouter); 51 | this.app.use('/account', this.accountRouter); 52 | 53 | this.app.locals.title = 'Test Armadietto'; 54 | this.app.locals.host = HOST_IDENTITY; 55 | this.app.locals.signup = false; 56 | }); 57 | 58 | beforeEach(function () { 59 | this.sessionValues = { privileges: {} }; 60 | }); 61 | 62 | it('account page displays account data', async function () { 63 | this.sessionValues = { user: USER }; 64 | const res = await chai.request(this.app).get('/account'); 65 | expect(res).to.have.status(200); 66 | expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); 67 | expect(res).to.have.header('Cache-Control', /\bprivate\b/); 68 | expect(res).to.have.header('Cache-Control', /\bno-cache\b/); 69 | const resText = res.text.replace(/"/g, '"'); 70 | expect(resText).to.contain('

Your Account

'); 71 | expect(resText).to.match(new RegExp('

' + USER.username + '@')); 72 | expect(resText).to.contain('STORE'); 73 | expect(resText).to.contain('Apple Mac Firefox'); 74 | expect(resText).to.match(/5\/\d\/2024<\/td>/); 75 | expect(resText).to.contain('never'); 76 | 77 | expect(resText).to.contain('To create a passkey on a new device, invite yourself to create another passkey'); 78 | expect(resText).to.contain('data-username="nisar-dazan-dafig-kanih"'); 79 | expect(resText).to.contain('data-contacturl="skype:skye"'); 80 | expect(resText).to.contain('>Invite yourself to create another passkey'); 81 | }); 82 | 83 | it('account page, when not logged in, redirect to login page', async function () { 84 | const res = await chai.request(this.app).get('/account'); 85 | expect(res).to.redirectTo(/http:\/\/127.0.0.1:\d{1,5}\/account\/login/); 86 | expect(res).to.have.status(200); 87 | expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); 88 | const resText = res.text.replace(/"/g, '"'); 89 | expect(resText).to.contain('

Login

'); 90 | }); 91 | 92 | it('login page displays messages & contains options', async function () { 93 | const res = await chai.request(this.app).get('/account/login'); 94 | expect(res).to.have.status(200); 95 | expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); 96 | expect(res).to.have.header('Cache-Control', /\bprivate\b/); 97 | expect(res).to.have.header('Cache-Control', /\bno-store\b/); 98 | const resText = res.text.replace(/"/g, '"'); 99 | expect(resText).to.contain('

Login

'); 100 | expect(resText).to.contain('

Click the button below to log in with a passkey.\n\nIf you need to create a passkey for this device or browser, log in from your old device and invite yourself to create a new passkey.

'); 101 | expect(resText).to.contain('"challenge":"'); 102 | expect(resText).to.contain('"userVerification":"preferred"'); 103 | expect(resText).to.contain('"rpId":"psteniusubi.github.io"'); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /spec/modular/m_root.spec.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const chaiHttp = require('chai-http'); 3 | const expect = chai.expect; 4 | const { mockAccountFactory } = require('../util/mockAccount'); 5 | const appFactory = require('../../lib/appFactory'); 6 | const { configureLogger } = require('../../lib/logger'); 7 | const { shouldBeWelcomeWithoutSignup, shouldBeWelcomeWithSignup } = require('../root.spec'); 8 | 9 | /* eslint-env mocha */ 10 | 11 | chai.use(chaiHttp); 12 | 13 | describe('root page (modular)', function () { 14 | describe('w/o signup', function () { 15 | beforeEach(async function () { 16 | configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); 17 | 18 | this.app = await appFactory({ 19 | hostIdentity: 'autotest', 20 | jwtSecret: 'swordfish', 21 | accountMgr: mockAccountFactory('autotest'), 22 | storeRouter: (_req, _res, next) => next() 23 | }); 24 | this.app.locals.title = 'Armadietto without Signup'; 25 | this.app.locals.host = 'localhost:xxxx'; 26 | this.app.locals.signup = false; 27 | }); 28 | 29 | shouldBeWelcomeWithoutSignup(); 30 | }); 31 | 32 | describe('with signup', function () { 33 | beforeEach(async function () { 34 | configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); 35 | 36 | this.app = await appFactory({ 37 | hostIdentity: 'autotest', 38 | jwtSecret: 'swordfish', 39 | accountMgr: mockAccountFactory('autotest'), 40 | storeRouter: (_req, _res, next) => next() 41 | }); 42 | this.app.locals.title = 'Armadietto with Signup'; 43 | this.app.locals.host = 'localhost:xxxx'; 44 | this.app.locals.signup = true; 45 | }); 46 | 47 | shouldBeWelcomeWithSignup(); 48 | }); 49 | 50 | /** This suite starts a server on an open port on each test */ 51 | describe('Headers', () => { 52 | before(async () => { 53 | configureLogger({}); 54 | 55 | this.app = await appFactory({ 56 | hostIdentity: 'autotest', 57 | jwtSecret: 'swordfish', 58 | accountMgr: mockAccountFactory('autotest'), 59 | storeRouter: (_req, _res, next) => next() 60 | }); 61 | this.app.locals.title = 'Armadietto with Signup'; 62 | this.app.locals.host = 'localhost:xxxx'; 63 | this.app.locals.signup = true; 64 | }); 65 | 66 | it('should return Welcome page w/ security headers', async () => { 67 | const res = await chai.request(this.app).get('/'); 68 | expect(res).to.have.status(200); 69 | expect(res.get('Content-Security-Policy')).to.contain('sandbox allow-scripts allow-forms allow-popups allow-same-origin allow-orientation-lock;'); 70 | expect(res.get('Content-Security-Policy')).to.contain('default-src \'self\';'); 71 | expect(res.get('Content-Security-Policy')).to.contain('script-src \'self\';'); 72 | expect(res.get('Content-Security-Policy')).to.contain('script-src-attr \'none\';'); 73 | expect(res.get('Content-Security-Policy')).to.contain('style-src \'self\';'); 74 | expect(res.get('Content-Security-Policy')).to.contain('img-src \'self\';'); 75 | expect(res.get('Content-Security-Policy')).to.contain('font-src \'self\';'); 76 | expect(res.get('Content-Security-Policy')).to.contain('object-src \'none\';'); 77 | expect(res.get('Content-Security-Policy')).to.contain('child-src \'none\';'); 78 | expect(res.get('Content-Security-Policy')).to.contain('connect-src \'self\';'); 79 | expect(res.get('Content-Security-Policy')).to.contain('base-uri \'self\';'); 80 | expect(res.get('Content-Security-Policy')).to.contain('frame-ancestors \'none\';'); 81 | expect(res.get('Content-Security-Policy')).to.contain('form-action https:'); // in dev may also allow http: 82 | expect(res.get('Content-Security-Policy')).to.contain('upgrade-insecure-requests'); 83 | expect(res).to.have.header('Cross-Origin-Opener-Policy', 'same-origin'); 84 | expect(res).to.have.header('Cross-Origin-Resource-Policy', 'same-origin'); 85 | expect(res).to.have.header('Origin-Agent-Cluster'); 86 | expect(res).to.have.header('Referrer-Policy', 'no-referrer'); 87 | expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); 88 | expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); 89 | expect(res).not.to.have.header('X-Powered-By'); 90 | expect(res).to.have.header('X-XSS-Protection', '0'); // disabled because counterproductive 91 | expect(res).to.have.header('Content-Type', /^text\/html/); 92 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(2500); 93 | expect(res).to.have.header('ETag'); 94 | expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); 95 | expect(res).to.have.header('Cache-Control', /public/); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /spec/modular/m_static.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | const chai = require('chai'); 4 | const expect = chai.expect; 5 | chai.use(require('chai-http')); 6 | const { mockAccountFactory } = require('../util/mockAccount'); 7 | const appFactory = require('../../lib/appFactory'); 8 | const { configureLogger } = require('../../lib/logger'); 9 | const { shouldServeStaticFiles } = require('../static_files.spec'); 10 | 11 | /** This suite starts a server on an open port on each test */ 12 | describe('Static asset handler (modular)', function () { 13 | before(async function () { 14 | configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); 15 | 16 | const app = await appFactory({ 17 | hostIdentity: 'autotest', 18 | jwtSecret: 'swordfish', 19 | accountMgr: mockAccountFactory('autotest'), 20 | storeRouter: (_req, _res, next) => next() 21 | }); 22 | app.locals.title = 'Test Armadietto'; 23 | app.locals.host = 'localhost:xxxx'; 24 | app.locals.signup = false; 25 | this.app = app; 26 | }); 27 | 28 | shouldServeStaticFiles(); 29 | 30 | it('should return security & caching headers', async function () { 31 | const res = await chai.request(this.app).get('/assets/outfit-variablefont_wght.woff2'); 32 | expect(res).to.have.status(200); 33 | expect(res).to.have.header('Content-Security-Policy', /\bsandbox\b/); 34 | expect(res).to.have.header('Content-Security-Policy', /\bdefault-src 'self';/); 35 | expect(res).to.have.header('Content-Security-Policy', /\bscript-src 'self';.*\bscript-src-attr 'none';/); 36 | expect(res).to.have.header('Content-Security-Policy', /\bstyle-src 'self';/); 37 | expect(res).to.have.header('Content-Security-Policy', /\bimg-src 'self';/); 38 | expect(res).to.have.header('Content-Security-Policy', /\bfont-src 'self';/); 39 | expect(res).to.have.header('Content-Security-Policy', /\bobject-src 'none';/); 40 | expect(res).to.have.header('Content-Security-Policy', /\bchild-src 'none';/); 41 | expect(res).to.have.header('Content-Security-Policy', /\bconnect-src 'self';/); 42 | expect(res).to.have.header('Content-Security-Policy', /\bbase-uri 'self';/); 43 | expect(res).to.have.header('Content-Security-Policy', /\bframe-ancestors 'none';/); 44 | expect(res).to.have.header('Content-Security-Policy', /\bform-action https: http:;/); 45 | expect(res).to.have.header('Content-Security-Policy', /\bupgrade-insecure-requests/); 46 | expect(res).to.have.header('Referrer-Policy', 'no-referrer'); 47 | expect(res).to.have.header('Cross-Origin-Opener-Policy', 'same-origin'); 48 | expect(res).to.have.header('Cross-Origin-Resource-Policy', 'same-origin'); 49 | expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); 50 | expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); 51 | expect(res).not.to.have.header('X-Powered-By'); 52 | expect(res).to.have.header('ETag'); 53 | expect(res).to.have.header('Cache-Control', /max-age=\d{4}/); 54 | expect(res).to.have.header('Content-Type', /^font\/woff2/); 55 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(20_000); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /spec/modular/m_web_finger.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const { mockAccountFactory } = require('../util/mockAccount'); 4 | const http = require('http'); 5 | const { configureLogger } = require('../../lib/logger'); 6 | const { shouldImplementWebFinger } = require('../web_finger.spec'); 7 | const chai = require('chai'); 8 | const expect = chai.expect; 9 | chai.use(require('chai-http')); 10 | 11 | describe('Web Finger (modular)', function () { 12 | before(async function () { 13 | configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); 14 | 15 | this.app = await require('../../lib/appFactory')({ 16 | hostIdentity: 'autotest.org', 17 | jwtSecret: 'swordfish', 18 | accountMgr: mockAccountFactory('autotest.org'), 19 | storeRouter: (_req, _res, next) => next() 20 | }); 21 | this.app.locals.title = 'Test Armadietto'; 22 | this.app.locals.host = 'localhost:xxxx'; 23 | this.app.locals.signup = false; 24 | this.server = http.createServer(this.app); 25 | this.server.listen(); 26 | 27 | await new Promise(resolve => { 28 | this.server.on('listening', () => { 29 | this.port = this.server.address().port; 30 | this.host = this.server.address().address + ':' + this.server.address().port; 31 | resolve(); 32 | }); 33 | }); 34 | }); 35 | 36 | after(function (done) { 37 | this.server.close(done); 38 | }); 39 | 40 | shouldImplementWebFinger(); 41 | 42 | it('redirects change-password to /signup', async function () { 43 | const res = await chai.request(this.app).get('/.well-known/change-password'); 44 | expect(res).to.redirectTo(/^http:\/\/127.0.0.1:\d{1,5}\/signup$/); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /spec/modular/protocols.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | /* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ 3 | /* eslint-disable no-unused-expressions */ 4 | 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | chai.use(require('chai-as-promised')); 8 | const { assembleContactURL, calcContactURL } = require('../../lib/util/protocols'); 9 | const ParameterError = require('../../lib/util/ParameterError'); 10 | 11 | describe('calcContactURL', function () { 12 | it('should strip query but not strip hash from Signal URL', function () { 13 | expect(calcContactURL('sgnl://signal.me/?foo=bar#p/+18005551212').href) 14 | .to.equal('sgnl://signal.me/#p/+18005551212'); 15 | }); 16 | it('should strip hash but not query param "id" from Threema: URL', function () { 17 | expect(calcContactURL('threema://compose?id=ABCDEFGH&text=Test%20Text#anotherHash').href) 18 | .to.equal('threema://compose?id=ABCDEFGH'); 19 | }); 20 | it('should strip query and hash from FaceTime: URL', function () { 21 | expect(calcContactURL('facetime:denise@place.us?subject=Something%20random#someHash').href) 22 | .to.equal('facetime:denise@place.us'); 23 | expect(calcContactURL('facetime:14085551234?subject=Something%20random#someHash').href) 24 | .to.equal('facetime:14085551234'); 25 | }); 26 | it('should strip query and hash from Jabber URL', function () { 27 | expect(calcContactURL('xmpp:username@domain.tld?subject=Something%20random#someHash').href) 28 | .to.equal('xmpp:username@domain.tld'); 29 | }); 30 | it('should strip hash but not query param "chat" from Skype: URL', function () { 31 | expect(calcContactURL('skype:username@domain.tld?add&topic=foo').href) 32 | .to.equal('skype:username@domain.tld?chat'); 33 | expect(calcContactURL('skype:+18885551212?topic=foo&chat').href) 34 | .to.equal('skype:+18885551212?chat'); 35 | }); 36 | it('should strip query and hash from e-mail URL', function () { 37 | expect(calcContactURL('mailto:denise@place.us?subject=Something%20random#someHash').href) 38 | .to.equal('mailto:denise@place.us'); 39 | }); 40 | it('should change MMS URL to SMS and strip query and hash', function () { 41 | expect(calcContactURL('mms:+15153755550?body=Hi%20there#someHash').href) 42 | .to.equal('sms:+15153755550'); 43 | }); 44 | it('should strip hash but not query param "phone" from Whatsapp URL', function () { 45 | expect(calcContactURL('whatsapp://send/?foo=bar&phone=447700900123#yetAnotherHash').href) 46 | .to.equal('whatsapp://send/?phone=447700900123'); 47 | }); 48 | it('should strip hash but not query param "phone" or username from Telegram URL', function () { 49 | expect(calcContactURL('tg://resolve?foo=bar&phone=19995551212#andAnotherHash').href) 50 | .to.equal('tg://resolve?phone=19995551212'); 51 | expect(calcContactURL('tg://resolve?foo=bar&domain=bobroberts#andAnotherHash').href) 52 | .to.equal('tg://resolve?domain=bobroberts'); 53 | }); 54 | }); 55 | 56 | describe('assembleContactURL', function () { 57 | it('should throw error when protocol missing', function () { 58 | expect(() => assembleContactURL(undefined, '8885551212')).to.throw(ParameterError, /not supported/); 59 | }); 60 | it('should throw error when address missing', function () { 61 | expect(() => assembleContactURL('sgnl:', undefined)).to.throw(ParameterError, /Missing address/); 62 | }); 63 | for (const protocol of [ 64 | ['sgnl:', '(800) 555-1212', 'sgnl://signal.me/#p/+18005551212'], 65 | ['threema:', 'ABCDEFGH', 'threema://compose?id=ABCDEFGH'], 66 | ['facetime:', '+1 408 555-1234', 'facetime:+14085551234'], 67 | ['facetime:', 'user@example.com', 'facetime:user@example.com'], 68 | ['xmpp:', 'username@domain.tld', 'xmpp:username@domain.tld'], 69 | ['skype:', 'username', 'skype:username?chat'], 70 | ['skype:', '+1-888-999-7777', 'skype:+18889997777?chat'], 71 | ['mailto:', 'me@myschool.edu', 'mailto:me@myschool.edu'], 72 | ['sms:', '(888) 555 6666', 'sms:+18885556666'], 73 | ['mms:', '(800) 555 6666', 'sms:+18005556666'], 74 | ['whatsapp:', '+44 7700 900123', 'whatsapp://send/?phone=+447700900123'], 75 | ['tg:', '1 (999) 555-1212', 'tg://resolve?phone=19995551212'], 76 | ['tg:', 'bobroberts', 'tg://resolve?domain=bobroberts'] 77 | ]) { 78 | it(`should assemble ${protocol[0]} URL`, function () { 79 | expect(assembleContactURL(protocol[0], protocol[1]).href).to.equal(protocol[2]); 80 | }); 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /spec/modular/robots.txt.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | const chai = require('chai'); 4 | const expect = chai.expect; 5 | chai.use(require('chai-http')); 6 | const { mockAccountFactory } = require('../util/mockAccount'); 7 | const appFactory = require('../../lib/appFactory'); 8 | const { configureLogger } = require('../../lib/logger'); 9 | 10 | describe('robots.txt', function () { 11 | before(async function () { 12 | configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); 13 | 14 | const app = await appFactory({ 15 | hostIdentity: 'autotest.ch', 16 | jwtSecret: 'swordfish', 17 | accountMgr: mockAccountFactory('autotest.ch'), 18 | storeRouter: (_req, _res, next) => next() 19 | }); 20 | app.locals.title = 'Test Armadietto'; 21 | app.locals.host = 'localhost:xxxx'; 22 | app.locals.signup = false; 23 | this.app = app; 24 | }); 25 | 26 | it('should serve robots.txt at expected location', async function () { 27 | const res = await chai.request(this.app).get('/robots.txt'); 28 | expect(res).to.have.status(200); 29 | expect(res).to.have.header('Content-Type', /^text\/plain/); 30 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(0); 31 | 32 | expect(res.text).to.match(/^User-agent: \*$/m); 33 | expect(res.text).to.match(/^Disallow: \/$/m); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /spec/modular/updateSessionPrivileges.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | /* eslint no-unused-vars: ["warn", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }] */ 3 | /* eslint-disable no-unused-expressions */ 4 | 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | chai.use(require('chai-as-promised')); 8 | const updateSessionPrivileges = require('../../lib/util/updateSessionPrivileges'); 9 | 10 | class MockSession { 11 | regenerate (callback) { 12 | for (const name of Object.keys(this)) { 13 | delete this[name]; 14 | } 15 | callback(undefined); 16 | } 17 | } 18 | 19 | describe('updateSessionPrivileges', function () { 20 | describe('when isAdminLogin is false', function () { 21 | it('doesn\'t retain privileges user no longer holds', async function () { 22 | const session = new MockSession(); 23 | session.privileges = { STORE: true, FOO: true }; 24 | const user = { privileges: { FOO: true } }; 25 | 26 | await updateSessionPrivileges({ session }, user, false); 27 | 28 | expect(session.privileges).to.deep.equal({ FOO: true }); 29 | }); 30 | 31 | it('adds STORE & doesn\'t add ADMIN nor OWNER', async function () { 32 | const session = new MockSession(); 33 | session.privileges = { BAR: true }; 34 | const user = { privileges: { STORE: true, ADMIN: true, OWNER: true } }; 35 | 36 | await updateSessionPrivileges({ session }, user, false); 37 | 38 | expect(session.privileges).to.deep.equal({ STORE: true }); 39 | }); 40 | }); 41 | 42 | describe('when isAdminLogin is true', function () { 43 | it('doesn\'t retain privileges user no longer holds', async function () { 44 | const session = new MockSession(); 45 | session.privileges = { STORE: true, ADMIN: true }; 46 | const user = { privileges: { STORE: true, OWNER: true } }; 47 | 48 | await updateSessionPrivileges({ session }, user, true); 49 | 50 | expect(session.privileges).to.deep.equal({ STORE: true, OWNER: true }); 51 | }); 52 | 53 | it('adds STORE, ADMIN & OWNER', async function () { 54 | const session = new MockSession(); 55 | session.privileges = { SPAM: true }; 56 | const user = { privileges: { STORE: true, ADMIN: true, OWNER: true } }; 57 | 58 | await updateSessionPrivileges({ session }, user, true); 59 | 60 | expect(session.privileges).to.deep.equal({ STORE: true, ADMIN: true, OWNER: true }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /spec/not_found.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const chai = require('chai'); 4 | const expect = chai.expect; 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | 8 | exports.shouldHandleNonexistingResource = function () { 9 | it('should return 404 Not Found', async function () { 10 | const res = await chai.request(this.app).get('/account/wildebeest/'); 11 | expect(res).to.have.status(404); 12 | expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); 13 | expect(res).to.have.header('Referrer-Policy', 'no-referrer'); 14 | expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); 15 | // expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); 16 | expect(res).not.to.have.header('X-Powered-By'); 17 | expect(res).to.have.header('Content-Type', /^text\/html/); 18 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(1500); 19 | // expect(res).to.have.header('Cache-Control', /\bmax-age=\d{4}/); 20 | // expect(res).to.have.header('ETag'); 21 | expect(res.text).to.match(/(Not Found|Something went wrong) — Armadietto<\/title>/i); 22 | expect(res.text).to.match(/<h\d>(Not Found|Something went wrong)<\/h\d>/i); 23 | expect(res.text).to.contain('>404<'); 24 | expect(res.text).to.contain('>“account/wildebeest/” doesn't exist<'); 25 | 26 | // navigation 27 | expect(res.text).to.match(/<a [^>]*href="\/"[^>]*>Home<\/a>/); 28 | expect(res.text).to.match(/<a [^>]*href="\/account"[^>]*>Account<\/a>/); 29 | expect(res.text).to.match(/<a [^>]*href="\/signup"[^>]*>Sign up<\/a>/); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /spec/oauth.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | /* eslint-disable no-unused-expressions */ 3 | const chai = require('chai'); 4 | const expect = chai.expect; 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | 8 | async function post (app, url, params) { 9 | return chai.request(app).post(url).type('form').send(params).redirects(0); 10 | } 11 | 12 | exports.shouldImplementOAuth = function () { 13 | describe('with invalid client input', function () { 14 | beforeEach(function () { 15 | this.auth_params = { 16 | username: 'zebcoe', 17 | password: 'locog', 18 | client_id: 'the_client_id', 19 | redirect_uri: 'http://example.com/cb', 20 | response_type: 'token', 21 | scope: 'the_scope' 22 | // no state 23 | }; 24 | }); 25 | 26 | it('returns an error if redirect_uri is missing', async function () { 27 | delete this.auth_params.redirect_uri; 28 | const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); 29 | expect(res).to.have.status(400); 30 | expect(res.text).to.equal('error=invalid_request&error_description=Required%20parameter%20%22redirect_uri%22%20is%20missing'); 31 | }); 32 | 33 | it('returns an error if client_id is missing', async function () { 34 | delete this.auth_params.client_id; 35 | const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); 36 | expect(res).to.redirectTo('http://example.com/cb#error=invalid_request&error_description=Required%20parameter%20%22client_id%22%20is%20missing'); 37 | }); 38 | 39 | it('returns an error if response_type is missing', async function () { 40 | delete this.auth_params.response_type; 41 | const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); 42 | expect(res).to.redirectTo('http://example.com/cb#error=invalid_request&error_description=Required%20parameter%20%22response_type%22%20is%20missing'); 43 | }); 44 | 45 | it('returns an error if response_type is not recognized', async function () { 46 | this.auth_params.response_type = 'wrong'; 47 | const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); 48 | expect(res).to.redirectTo('http://example.com/cb#error=unsupported_response_type&error_description=Response%20type%20%22wrong%22%20is%20not%20supported'); 49 | }); 50 | 51 | it('returns an error if scope is missing', async function () { 52 | delete this.auth_params.scope; 53 | const res = await chai.request(this.app).get('/oauth/me').query(this.auth_params); 54 | expect(res).to.redirectTo('http://example.com/cb#error=invalid_scope&error_description=Parameter%20%22scope%22%20is%20invalid'); 55 | }); 56 | 57 | it('returns an error if username is missing', async function () { 58 | delete this.auth_params.username; 59 | const res = await post(this.app, '/oauth', this.auth_params); 60 | expect(res).to.have.status(400); 61 | }); 62 | }); 63 | 64 | describe('with valid login credentials', async function () { 65 | beforeEach(function () { 66 | this.auth_params = { 67 | username: 'zebcoe', 68 | password: 'locog', 69 | client_id: 'the_client_id', 70 | redirect_uri: 'http://example.com/cb', 71 | response_type: 'token', 72 | scope: 'the_scope', 73 | state: 'the_state' 74 | }; 75 | }); 76 | 77 | it('redirects with an access token', async function () { 78 | const res = await post(this.app, '/oauth', this.auth_params); 79 | // expect(res).to.redirectTo(/http:\/\/example\.com\/cb#access_token=[\w-]+&token_type=bearer&state=the_state/); 80 | expect(res).to.redirect; 81 | const redirect = new URL(res.get('location')); 82 | expect(redirect.origin).to.equal('http://example.com'); 83 | expect(redirect.pathname).to.equal('/cb'); 84 | const params = new URLSearchParams(redirect.hash.slice(1)); 85 | expect(params.get('token_type')).to.equal('bearer'); 86 | expect(params.get('state')).to.equal('the_state'); 87 | expect(params.get('access_token')).to.match(/\S+/); 88 | }); 89 | }); 90 | 91 | describe('with invalid login credentials', async function () { 92 | beforeEach(function () { 93 | this.auth_params = { 94 | username: 'zebcoe', 95 | password: 'incorrect', 96 | client_id: 'the_client_id', 97 | redirect_uri: 'http://example.com/cb', 98 | response_type: 'token', 99 | scope: 'the_scope', 100 | state: 'the_state' 101 | }; 102 | }); 103 | 104 | it('returns a 401 response with the login form', async function () { 105 | const res = await post(this.app, '/oauth', this.auth_params); 106 | expect(res).to.have.status(401); 107 | expect(res).to.have.header('Content-Type', 'text/html; charset=utf-8'); 108 | expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); 109 | expect(res).to.have.header('Referrer-Policy', 'no-referrer'); 110 | expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); 111 | expect(res.text).to.contain('application <em>the_client_id</em> hosted'); 112 | }); 113 | }); 114 | }; 115 | -------------------------------------------------------------------------------- /spec/root.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const chai = require('chai'); 4 | const expect = chai.expect; 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | 8 | exports.shouldBeWelcomeWithoutSignup = function () { 9 | it('should return Welcome page w/o signup link, when signup:false', async function welcomeWithout () { 10 | const res = await chai.request(this.app).get('/'); 11 | expect(res).to.have.status(200); 12 | expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); 13 | expect(res).to.have.header('Referrer-Policy', 'no-referrer'); 14 | expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); 15 | // expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); 16 | expect(res).not.to.have.header('X-Powered-By'); 17 | expect(res).to.have.header('Content-Type', /^text\/html/); 18 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(2500); 19 | // expect(res).to.have.header('ETag'); 20 | expect(res.text).to.contain('<title>Welcome — Armadietto'); 21 | expect(res.text).to.match(/Welcome to Armadietto!<\/h\d>/); 22 | expect(res.text).to.match(/]*href="\/"[^>]*>127.0.0.1:\d{1,5}<\/a>/); 23 | expect(res.text).to.match(/]*href="\/"[^>]*>Home<\/a>/); 24 | // expect(res.text).to.match(/]*href="\/account"[^>]*>Account<\/a>/); 25 | expect(res.text).not.to.contain('Sign up'); 26 | expect(res.text).to.match(/]*href="https:\/\/remotestorage.io\/"/); 27 | expect(res.text).to.match(/]*href="https:\/\/github.com\/remotestorage\/armadietto"/); 28 | }); 29 | }; 30 | 31 | exports.shouldBeWelcomeWithSignup = function () { 32 | it('should return Welcome page w/ signup link, when signup:true', async function () { 33 | const res = await chai.request(this.app).get('/'); 34 | expect(res).to.have.status(200); 35 | expect(res).to.have.header('Content-Type', /^text\/html/); 36 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(2500); 37 | expect(res.text).to.contain('Welcome — Armadietto'); 38 | expect(res.text).to.match(/Welcome to Armadietto!<\/h\d>/); 39 | expect(res.text).to.match(/]*href="\/"[^>]*>127.0.0.1:\d{1,5}<\/a>/); 40 | expect(res.text).to.match(/]*href="\/"[^>]*>Home<\/a>/); 41 | // expect(res.text).to.match(/]*href="\/account"[^>]*>Account<\/a>/); 42 | expect(res.text).to.match(/]*href="\/signup"[^>]*>(Sign up|Request invite)<\/a>/); 43 | expect(res.text).to.match(/]*href="https:\/\/remotestorage.io\/"/); 44 | expect(res.text).to.match(/]*href="https:\/\/github.com\/remotestorage\/armadietto"/); 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /spec/runner.js: -------------------------------------------------------------------------------- 1 | process.env.SILENT = '1'; 2 | 3 | require('./armadietto/web_finger_spec'); 4 | require('./armadietto/oauth_spec'); 5 | require('./armadietto/signup_spec'); 6 | require('./armadietto/storage_spec'); 7 | require('./armadietto/a_root_spec'); 8 | require('./armadietto/a_not_found_spec'); 9 | require('./armadietto/a_static_spec'); 10 | require('./armadietto/a_signup_spec'); 11 | require('./armadietto/a_web_finger.spec'); 12 | require('./armadietto/a_oauth_spec'); 13 | require('./armadietto/a_storage_spec'); 14 | 15 | require('./stores/file_tree_spec'); 16 | // require('./stores/redis_spec'); 17 | 18 | require('./modular/m_root.spec'); 19 | require('./modular/m_not_found.spec'); 20 | require('./modular/robots.txt.spec'); 21 | require('./modular/m_static.spec'); 22 | require('./modular/request_invite.spec'); 23 | require('./modular/account.spec'); 24 | require('./modular/m_web_finger.spec'); 25 | require('./modular/m_oauth.spec'); 26 | require('./modular/m_storage_common.spec'); 27 | require('./modular/admin.spec'); 28 | require('./modular/protocols.spec'); 29 | require('./modular/updateSessionPrivileges.spec'); 30 | 31 | // If a local S3 store isn't running and configured, tests are run using a shared public account on play.min.io 32 | // require('./streaming_stores/S3.spec'); 33 | -------------------------------------------------------------------------------- /spec/static_files.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | 3 | const chai = require('chai'); 4 | const expect = chai.expect; 5 | const chaiHttp = require('chai-http'); 6 | chai.use(chaiHttp); 7 | 8 | exports.shouldServeStaticFiles = function () { 9 | it('should return style sheet as text/css', async function () { 10 | const res = await chai.request(this.app).get('/assets/style.css'); 11 | expect(res).to.have.status(200); 12 | // expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); 13 | // expect(res).to.have.header('Referrer-Policy', 'no-referrer'); 14 | expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); 15 | // expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); 16 | expect(res).not.to.have.header('X-Powered-By'); 17 | expect(res).to.have.header('Content-Type', /^text\/css/); 18 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(16_000); 19 | // expect(res).to.have.header('ETag'); 20 | expect(res.text).to.contain('body {'); 21 | expect(res.text).to.contain('header.topbar {'); 22 | expect(res.text).to.contain('section.hero {'); 23 | }); 24 | 25 | it('should return client javascript as */javascript', async function () { 26 | const res = await chai.request(this.app).get('/assets/armadietto-utilities.js'); 27 | expect(res).to.have.status(200); 28 | // expect(res).to.have.header('Content-Security-Policy', /sandbox.*default-src 'self'/); 29 | // expect(res).to.have.header('Referrer-Policy', 'no-referrer'); 30 | expect(res).to.have.header('X-Content-Type-Options', 'nosniff'); 31 | // expect(res).to.have.header('Strict-Transport-Security', /^max-age=/); 32 | expect(res).not.to.have.header('X-Powered-By'); 33 | expect(res).to.have.header('Content-Type', /^(application|text)\/javascript\b/); 34 | expect(parseInt(res.get('Content-Length'))).to.be.greaterThan(1000); 35 | // expect(res).to.have.header('ETag'); 36 | // expect(res.text).to.contain('function setTheme ('); 37 | // expect(res.text).to.contain('function toggleTheme ('); 38 | // expect(res.text).to.contain('document.getElementById(\'switch\')?.addEventListener(\'click\''); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /spec/store_handlers/S3_store_handler.spec.js: -------------------------------------------------------------------------------- 1 | // If a environment variables aren't set, tests are run using a shared public account on play.min.io 2 | /* eslint-env mocha, chai, node */ 3 | /* eslint-disable no-unused-expressions */ 4 | 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const s3storeHandler = require('../../lib/routes/S3_store_router'); 8 | const { shouldStoreStreams } = require('../store_handler.spec'); 9 | const { configureLogger } = require('../../lib/logger'); 10 | const { shouldCreateDeleteAndReadAccounts } = require('../account.spec'); 11 | 12 | describe('S3 store router', function () { 13 | before(function () { 14 | this.timeout(15_000); 15 | configureLogger({ stdout: ['notice'], log_dir: './test-log', log_files: ['debug'] }); 16 | this.USER_NAME_SUFFIX = '-java.extraordinary.org'; 17 | // If the environment variables aren't set, tests are run using a shared public account on play.min.io 18 | console.info(`creating s3storeHandler with endpoint “${process.env.S3_ENDPOINT}”, accessKey “${process.env.S3_ACCESS_KEY}”, & region “${process.env.S3_REGION}”`); 19 | this.handler = s3storeHandler({ endPoint: process.env.S3_ENDPOINT, accessKey: process.env.S3_ACCESS_KEY, secretKey: process.env.S3_SECRET_KEY, region: process.env.S3_REGION || 'us-east-1', userNameSuffix: this.USER_NAME_SUFFIX }); 20 | this.accountMgr = this.store = this.handler; 21 | }); 22 | 23 | shouldCreateDeleteAndReadAccounts(); 24 | 25 | describe('createUser (S3-specific)', function () { 26 | it('rejects a user with too long an ID', async function () { 27 | const params = { username: 'aiiiiiii10iiiiiiii20iiiiiiii30iiiiiiii40iiiiiiii50iiiiiiii60iiii', contactURL: 'mailto:a@b.c' }; 28 | const logNotes = new Set(); 29 | await expect(this.store.createUser(params, logNotes)).to.be.rejectedWith(Error, 'characters long'); 30 | }); 31 | }); 32 | 33 | shouldStoreStreams(); 34 | }); 35 | -------------------------------------------------------------------------------- /spec/stores/file_tree_lockfree_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | const path = require('path'); 3 | const rmrf = require('rimraf'); 4 | const chai = require('chai'); 5 | const expect = chai.expect; 6 | const FileTree = require('../../lib/stores/file_tree'); 7 | 8 | describe('FileTree store lockfree get', async () => { 9 | const store = new FileTree({ path: path.join(__dirname, '/../../tmp/store'), lock_timeout_ms: 1000 }); 10 | 11 | before((done) => { 12 | (async () => { 13 | rmrf(path.join(__dirname, '/../../tmp/store'), () => {}); 14 | await store.createUser({ username: 'boris', email: 'boris@example.com', password: 'zipwire' }); 15 | done(); 16 | })(); 17 | }); 18 | 19 | store.__readMeta = store.readMeta; 20 | 21 | after((done) => { 22 | (async () => { 23 | rmrf(path.join(__dirname, '/../../tmp/store'), () => {}); 24 | done(); 25 | })(); 26 | }); 27 | 28 | const getReadMetaInterrupted = (numberInterruptions) => { 29 | let callNum = 0; 30 | store.readMeta = async (username, pathname, isdir) => { 31 | const metadata = await store.__readMeta(username, pathname, isdir); 32 | if (callNum < numberInterruptions) { 33 | metadata.ETag = `${callNum}`; 34 | metadata.items.zipwire.ETag = `${callNum}`; 35 | } 36 | callNum++; 37 | return metadata; 38 | }; 39 | }; 40 | 41 | it('returns the value in the response', async () => { 42 | await store.put('boris', '/photos/zipwire', 'image/poster', Buffer.from('vertibo'), null); 43 | const { item } = await store.get('boris', '/photos/zipwire', null); 44 | expect(item.value).to.be.deep.equal(Buffer.from('vertibo')); 45 | }); 46 | 47 | it('returns the value in the response after one interruption', async () => { 48 | await store.put('boris', '/photos/zipwire', 'image/poster', Buffer.from('vertibo'), null); 49 | 50 | getReadMetaInterrupted(1); 51 | 52 | const { item } = await store.get('boris', '/photos/zipwire', null); 53 | expect(item.value).to.be.deep.equal(Buffer.from('vertibo')); 54 | }); 55 | 56 | it('returns the value in the response after two interruption', async () => { 57 | await store.put('boris', '/photos/zipwire', 'image/poster', Buffer.from('vertibo'), null); 58 | 59 | getReadMetaInterrupted(3); 60 | 61 | const { item } = await store.get('boris', '/photos/zipwire', null); 62 | expect(item.value).to.be.deep.equal(Buffer.from('vertibo')); 63 | }); 64 | 65 | it('gets exception after three interruption', async () => { 66 | await store.put('boris', '/photos/zipwire', 'image/poster', Buffer.from('vertibo'), null); 67 | 68 | getReadMetaInterrupted(50); 69 | 70 | try { 71 | await store.get('boris', '/photos/zipwire', null); 72 | } catch (e) { 73 | expect(e.message).to.be.equal('ETag mismatch'); 74 | } 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /spec/stores/file_tree_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | const path = require('path'); 3 | const rmrf = require('rimraf'); 4 | const FileTree = require('../../lib/stores/file_tree'); 5 | const { itBehavesLike } = require('bdd-lazy-var'); 6 | require('../store_spec'); 7 | 8 | describe('FileTree store', () => { 9 | const store = new FileTree({ path: path.join(__dirname, '/../../tmp/store') }); 10 | after(() => { 11 | rmrf(path.join(__dirname, '/../../tmp/store'), () => {}); 12 | }); 13 | itBehavesLike('Stores', store); 14 | }); 15 | -------------------------------------------------------------------------------- /spec/stores/redis_spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha, chai, node */ 2 | const Redis = require('ioredis'); 3 | const RedisStore = require('../../lib/stores/redis'); 4 | const { itBehavesLike } = require('bdd-lazy-var'); 5 | require('../store_spec'); 6 | 7 | describe('Redis store', () => { 8 | const store = new RedisStore({ namespace: String(new Date().getTime()) }); 9 | after(async () => { 10 | const db = new Redis(); 11 | await db.select(0); 12 | await db.flushdb(); 13 | store._redis.quit(); 14 | db.quit(); 15 | }); 16 | itBehavesLike('Stores', store); 17 | }); 18 | -------------------------------------------------------------------------------- /spec/util/LongStream.js: -------------------------------------------------------------------------------- 1 | const { Readable } = require('node:stream'); 2 | 3 | module.exports = class LongStream extends Readable { 4 | limit; 5 | total = 0; 6 | 7 | constructor (limit, options) { 8 | super(options); 9 | this.limit = limit; 10 | } 11 | 12 | _read (size) { 13 | let line = '....................................................................................................'; 14 | this.total += 100; 15 | const numberStr = this.total.toLocaleString(); 16 | line = line.slice(0, -numberStr.length) + numberStr; 17 | // if (this.total % 1_000_000 === 0) { console.log(line); } 18 | this.push(line, 'utf8'); 19 | if (this.total >= this.limit) { 20 | this.push(null); 21 | console.log(`BigStream complete; ${this.total.toLocaleString()} bytes were read.`); 22 | } 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /spec/util/callMiddleware.js: -------------------------------------------------------------------------------- 1 | const { Readable } = require('node:stream'); 2 | const httpMocks = require('node-mocks-http'); 3 | const chai = require('chai'); 4 | const errToMessages = require('../../lib/util/errToMessages'); 5 | 6 | module.exports = async function callMiddleware (middleware, reqOpts) { 7 | const req = Object.assign(reqOpts.body instanceof Readable 8 | ? reqOpts.body 9 | : Readable.from([reqOpts.body], { objectMode: false }), reqOpts); 10 | req.originalUrl ||= req.url; 11 | req.baseUrl ||= req.url; 12 | req.headers = {}; 13 | for (const [key, value] of Object.entries(reqOpts.headers || {})) { 14 | req.headers[key.toLowerCase()] = String(value); 15 | } 16 | req.get = headerName => req.headers[headerName?.toLowerCase()]; 17 | req.query ||= {}; 18 | req.files ||= {}; 19 | req.socket ||= {}; 20 | req.ips = [req.ip = '127.0.0.1']; 21 | req.session ||= {}; 22 | 23 | const res = httpMocks.createResponse({ req }); 24 | res.req = req; 25 | req.res = res; 26 | res.logNotes = new Set(); 27 | const next = chai.spy(err => { 28 | if (err) { 29 | let status; 30 | if (err.Code === 'SlowDown') { 31 | status = err.$metadata?.httpStatusCode; 32 | } 33 | if (!status) { 34 | status = Array.from(errToMessages(err, new Set())).join(' ') + (err?.stack ? '|' + err.stack : ''); 35 | } 36 | res.status(status).end(); 37 | } else { 38 | res.end(); 39 | } 40 | }); 41 | 42 | await middleware(req, res, next); 43 | await waitForEnd(res); 44 | 45 | return [req, res, next]; 46 | }; 47 | 48 | async function waitForEnd (response) { 49 | return new Promise(resolve => { 50 | setTimeout(checkEnd, 100); 51 | function checkEnd () { 52 | if (response._isEndCalled()) { 53 | resolve(); 54 | } else { 55 | setTimeout(checkEnd, 100); 56 | } 57 | } 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /spec/util/longString.js: -------------------------------------------------------------------------------- 1 | module.exports = function (total, increment = 100) { 2 | let string = ''; let num; 3 | for (num = increment; num <= total; num += increment) { 4 | const numberStr = String(num); 5 | let line = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; 6 | line = line.slice(0, -numberStr.length) + numberStr; 7 | string += line; 8 | } 9 | return string; 10 | }; 11 | -------------------------------------------------------------------------------- /spec/util/mockAccount.js: -------------------------------------------------------------------------------- 1 | const proquint = require('proquint'); 2 | const { randomBytes } = require('node:crypto'); 3 | const { calcContactURL } = require('../../lib/util/protocols'); 4 | const NoSuchUserError = require('../../lib/util/NoSuchUserError'); 5 | 6 | const CREDENTIAL_STORED = { 7 | fmt: 'none', 8 | counter: 0, 9 | aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd', 10 | credentialID: 'g6PMuH2JOSapWYYIXihRmBxtqvQ', 11 | credentialPublicKey: 'pQECAyYgASFYINDLzLfpl_9XwI-ZrBRe3IZDU7lhsCBKuFGH14sOQbLdIlggtqqF5bxIO53sylxsjRN6lTZO58wCx7BbQoUOyauIcGw', 12 | credentialType: 'public-key', 13 | attestationObject: 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYLNeTz6C0GMu_DqhSIoYH2el7Mz1NsKQQF3Zq9ruMdVFZAAAAAPv8MAcVTk7MjAtuAgVX170AFIOjzLh9iTkmqVmGCF4oUZgcbar0pQECAyYgASFYINDLzLfpl_9XwI-ZrBRe3IZDU7lhsCBKuFGH14sOQbLdIlggtqqF5bxIO53sylxsjRN6lTZO58wCx7BbQoUOyauIcGw', 14 | userVerified: false, 15 | credentialDeviceType: 'multiDevice', 16 | credentialBackedUp: true, 17 | origin: 'https://psteniusubi.github.io', 18 | rpID: 'psteniusubi.github.io', 19 | transports: ['internal'], 20 | name: 'Apple Mac Firefox', 21 | createdAt: '2024-05-09T03:08:12.272Z' 22 | }; 23 | const USER = { 24 | username: 'nisar-dazan-dafig-kanih', 25 | storeId: 'nisar-dazan-dafig-kanih-psteniusubi.github.io', 26 | contactURL: 'skype:skye', 27 | privileges: { STORE: true }, 28 | credentials: { 29 | g6PMuH2JOSapWYYIXihRmBxtqvQ: CREDENTIAL_STORED 30 | } 31 | }; 32 | const CREDENTIAL_PRESENTED_RIGHT = { 33 | id: 'g6PMuH2JOSapWYYIXihRmBxtqvQ', 34 | type: 'public-key', 35 | rawId: 'g6PMuH2JOSapWYYIXihRmBxtqvQ', 36 | response: { 37 | clientDataJSON: 'eyJjaGFsbGVuZ2UiOiJtSlhFUlNCZXRMLU5STDdBTW96ZVdmbm9iWGsiLCJvcmlnaW4iOiJodHRwczovL3BzdGVuaXVzdWJpLmdpdGh1Yi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ', 38 | authenticatorData: 'LNeTz6C0GMu_DqhSIoYH2el7Mz1NsKQQF3Zq9ruMdVEZAAAAAA', 39 | signature: 'MEUCIGA31yAgnz8lLekbOOYWY01AujsCN1zr4Eci9C5ztVuMAiEAuNwvr8PsUT_1EwoJ8AaR5qCIB4TfmhJSRuzIz0pSM68', 40 | userHandle: Buffer.from(USER.username, 'utf8').toString('base64url') 41 | } 42 | }; 43 | const CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE = structuredClone(CREDENTIAL_PRESENTED_RIGHT); 44 | CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE.response.userHandle = undefined; 45 | const CREDENTIAL_PRESENTED_WRONG = { 46 | id: 'E1wdWNIfF6QkykG4Nmmknb74tKQ', 47 | rawId: 'E1wdWNIfF6QkykG4Nmmknb74tKQ', 48 | response: { 49 | authenticatorData: 'YbDxlQWOWRoWT1ph2oX7NZMyBCil85aW7pEHf8Y51GAZAAAAAA', 50 | clientDataJSON: 'eyJjaGFsbGVuZ2UiOiJxdi1Xb25zY2h2aG5vNkM4SlZiZklCUHYxRTJkU3JGVDhra21RejlFYnZ3Iiwib3JpZ2luIjoiaHR0cHM6Ly9tb2EubG9jYWwiLCJ0eXBlIjoid2ViYXV0aG4uZ2V0In0', 51 | signature: 'MEQCIERYO7mqEcmu7py-_kMTONYTfjDuTPn8E5TUmF25NvXQAiB1IV-_Q-o8fdq7qBRFJ805CUAhzQPTupcd3shTjtEu9Q', 52 | userHandle: Buffer.from(USER.username, 'utf8').toString('base64url') 53 | }, 54 | type: 'public-key', 55 | clientExtensionResults: {}, 56 | authenticatorAttachment: 'platform' 57 | }; 58 | 59 | module.exports = { 60 | USER, 61 | CREDENTIAL_STORED, 62 | CREDENTIAL_PRESENTED_RIGHT, 63 | CREDENTIAL_PRESENTED_RIGHT_NO_USERHANDLE, 64 | CREDENTIAL_PRESENTED_WRONG, 65 | 66 | mockAccountFactory: function (hostIdentity) { 67 | const users = {}; 68 | users[USER.username] = USER; 69 | 70 | return { 71 | createUser: async (params, logNotes) => { 72 | const username = params.username || proquint.encode(randomBytes(Math.ceil(64 / 16) * 2)); 73 | const storeId = (username + '-' + hostIdentity).slice(0, 63); 74 | const contactURL = calcContactURL(params.contactURL).href; // validates & normalizes 75 | const normalizedParams = { ...params, username, contactURL }; 76 | 77 | const user = { privileges: {}, ...normalizedParams, storeId, credentials: {} }; 78 | users[user.username] = user; 79 | logNotes.add(`allocated storage for user “${username}”`); 80 | return user; 81 | }, 82 | listUsers: async () => [ 83 | { username: 'FirstUser', contactURL: 'mailto:foo@bar.co', storeId: 'firstuser-' + hostIdentity, privileges: { ADMIN: true } }, 84 | { username: 'SecondUser', contactURL: 'mailto:spam@frotz.edu', storeId: 'seconduser-' + hostIdentity } 85 | ], 86 | getUser: async (username, _logNotes) => { 87 | if (username in users) { 88 | return users[username]; 89 | } else { 90 | throw new NoSuchUserError(`No user "${username}"`); 91 | } 92 | }, 93 | updateUser: async (user, _logNotes) => { 94 | if (user.username in users) { 95 | users[user.username] = { ...user }; 96 | } else { 97 | throw new NoSuchUserError(`No user "${user.username}"`); 98 | } 99 | } 100 | }; 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /spec/util/mostRecentFile.js: -------------------------------------------------------------------------------- 1 | const { readdir, stat } = require('node:fs/promises'); 2 | const path = require('path'); 3 | 4 | module.exports = async function mostRecentFile (dirPath) { 5 | const fileNames = await readdir(dirPath); 6 | const records = []; 7 | for (const fileName of fileNames) { 8 | const stats = await stat(path.join(dirPath, fileName)); 9 | records.push({ fileName, ctime: stats.ctime }); 10 | } 11 | records.sort((a, b) => b.ctime - a.ctime); 12 | return records[0].fileName; 13 | }; 14 | -------------------------------------------------------------------------------- /spec/whut2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remotestorage/armadietto/c4e5bc0dde5dacc35518c878ce3d431f7268eaee/spec/whut2.jpg --------------------------------------------------------------------------------