diff --git a/.acl b/.acl new file mode 100644 index 000000000..05a9842d9 --- /dev/null +++ b/.acl @@ -0,0 +1,10 @@ +# Root ACL resource for the root +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; # everyone + acl:accessTo ; + acl:default ; + acl:mode acl:Read. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..c8fe3fa7d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Force bash scripts to have unix line endings +*.sh text eol=lf + +# Force bin files (executable scripts) to have unix line endings +bin/* text eol=lf + +# Ensure batch files on Windows keep CRLF line endings +*.bat text eol=crlf + +# Binary files should not be modified +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.tar.gz binary +*.tgz binary \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/nss-5-0-0-regressions.md b/.github/ISSUE_TEMPLATE/nss-5-0-0-regressions.md deleted file mode 100644 index bf539fb7a..000000000 --- a/.github/ISSUE_TEMPLATE/nss-5-0-0-regressions.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: NSS-5.0.0 Regressions -about: To report regressions in 5.0.0 (i.e. things that used to work in earlier releases), - use this template -title: '' -labels: '' -assignees: '' - ---- - -## Please describe what you did in reproducible steps - - -## How did it work with 4.x series servers? - - -## What happened when you tried the same with the 5.x series server? - - -## Any material that will help, logs, error messages, etc. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..bc7a972e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,163 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: CI + +on: + push: + branches: [ main ] + tags: ['*'] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ${{ matrix.os }} + + strategy: + matrix: + node-version: [ '^22.14.0' ] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v4 + + # extract repository name + - if: github.event_name == 'pull_request' + run: echo "REPO_NAME=${{ github.event.pull_request.head.repo.full_name }}" >> $GITHUB_ENV + + - if: github.event_name != 'pull_request' + run: echo "REPO_NAME=${GITHUB_REPOSITORY}" >> $GITHUB_ENV + + # extract branch name + - if: github.event_name == 'pull_request' + run: echo "BRANCH_NAME=${GITHUB_HEAD_REF}" >> $GITHUB_ENV + + - if: github.event_name != 'pull_request' + run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV + + # print repository name + - name: Get repository name + run: echo 'The repository name is' $REPO_NAME + + # print branch name + - name: Get branch name + run: echo 'The branch name is' $BRANCH_NAME + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + # test code + - run: npm run lint + - run: npm run validate + - run: npm run c8 + # Test global install of the package + - run: npm pack . + - run: npm install -g solid-server-*.tgz + # Run the Solid test-suite + - run: bash test/surface/run-solid-test-suite.sh $BRANCH_NAME $REPO_NAME + - name: Save build + # if: matrix.node-version == '20.x' + uses: actions/upload-artifact@v5 + with: + name: build + path: | + . + !node_modules + retention-days: 1 + + # The pipeline automate publication to npm, so that the docker build gets the correct version + npm-publish-build: + needs: [build] + name: Publish to npm + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v6 + with: + name: build + - uses: actions/setup-node@v6 + with: + node-version: 22.x + - uses: rlespinasse/github-slug-action@v3.x + - name: Append commit hash to package version + run: 'sed -i -E "s/(\"version\": *\"[^\"]+)/\1-${GITHUB_SHA_SHORT}/" package.json' + - name: Disable pre- and post-publish actions + run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json' + - uses: JS-DevTools/npm-publish@v4.1.0 + if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]' + with: + token: ${{ secrets.NPM_TOKEN }} + tag: ${{ env.GITHUB_REF_SLUG }} + + npm-publish-latest: + needs: [build, npm-publish-build] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/download-artifact@v6 + with: + name: build + - uses: actions/setup-node@v6 + with: + node-version: 20.x + - name: Disable pre- and post-publish actions + run: 'sed -i -E "s/\"((pre|post)publish)/\"ignore:\1/" package.json' + - uses: JS-DevTools/npm-publish@v4.1.0 + if: github.actor != 'dependabot[bot]' && github.actor != 'dependabot-preview[bot]' + with: + token: ${{ secrets.NPM_TOKEN }} + tag: latest + + # This job will only dockerize solid-server@latest / solid-server@ from npmjs.com! + docker-hub: + needs: build + name: Publish to docker hub + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + + - uses: actions/checkout@v4 + + - uses: olegtarasov/get-tag@v2.1 + id: tagName + with: + tagRegex: "v?(?.*)" + + - name: Lint dockerfile + working-directory: docker-image + run: pwd && ls -lah && make lint + + - name: Run tests + working-directory: docker-image + run: SOLID_SERVER_VERSION=${{ steps.tagName.outputs.version }} make test + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v3 + with: + images: nodesolidserver/node-solid-server + + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./docker-image/src + build-args: SOLID_SERVER_VERSION=${{ steps.tagName.outputs.version }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 239d52439..20fcfdda6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ /data /coverage /node_modules -/npm-debug.log \ No newline at end of file +/npm-debug.log +.history/ \ No newline at end of file diff --git a/.npmignore b/.npmignore index c5a836587..aa35cea6b 100644 --- a/.npmignore +++ b/.npmignore @@ -18,3 +18,4 @@ # Additional .npmignore entries (not in .gitignore) /test +/docker-image diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..eb800ed45 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.19.0 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f3ddcbba7..000000000 --- a/.travis.yml +++ /dev/null @@ -1,40 +0,0 @@ -sudo: false -language: node_js -node_js: - - "8" - - "10" - - "lts/*" - - "node" - -addons: - hosts: - - nic.localhost - - tim.localhost - - nicola.localhost - -before_install: - - echo "No GitHub dependencies allowed" && - ! grep '"github:' package-lock.json - - npm install -g npm@latest - -install: - - npm ci - -script: - # Test the code - - npm run standard - - npm run validate - - npm run nyc - # Test global install of the package - - npm pack . - - npm install -g solid-server-*.tgz - -after_success: - - snyk monitor - -cache: npm - -notifications: - email: - - solid@janeirodigital.com - - solid-travis@inrupt.com diff --git a/.well-known/.acl b/.well-known/.acl new file mode 100644 index 000000000..6cacb3779 --- /dev/null +++ b/.well-known/.acl @@ -0,0 +1,15 @@ +# ACL for the default .well-known/ resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/CHANGELOG.md b/CHANGELOG.md index d250c0fc2..a2610dc1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,29 @@ # History +## 6.0.0 +- CommonJs to ESM (.mjs) +- support for mashlib >= 2.0.0 +- support solid-OIDC with WebID scope, es256, and rfc9702 + +## 6.0.0 Upgrade Notes + +1.0 Automatically recreated +- delete `.db/oidc/op/provider.json` +- delete `config/templates/emails` + If not recreated then copy from `default-emails` + If there was some personalisation these need to be redone + +2.0 Manuel update the `index.html` in server root `data//index.html` + edit `common/js/index-buttons.js` to `index-buttons.mjs` + +## 5.3.0 +- Support for webid-oidc with DPop tokens + +## 5.3.0 Upgrade Notes +You may have a `.db/oidc/op/provider.json` file that was generated by an older version +of node-solid-server, which may still specify `"response_types_supported"` without listing +`"id_token code"`. You can move this file out of the way and restart node-solid-server, +it will be created again. See https://github.com/solid/node-solid-server/issues/1433 for +more info. ## 5.0.0 diff --git a/Dockerfile b/Dockerfile index 36be4e62d..362f4f54b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,14 @@ -FROM node:8.11.2-onbuild +FROM node:lts + +# build +RUN mkdir -p /usr/src/app +WORKDIR /usr/src/app +COPY package.json /usr/src/app/ +COPY package-lock.json /usr/src/app/ +RUN npm install +COPY . /usr/src/app + +# start EXPOSE 8443 COPY config.json-default config.json RUN openssl req \ diff --git a/README.md b/README.md index e9f2862af..7f760c0a1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ `solid-server` lets you run a Solid server on top of the file-system. You can use it as a [command-line tool](https://github.com/solid/node-solid-server/blob/master/README.md#command-line-usage) (easy) or as a [library](https://github.com/solid/node-solid-server/blob/master/README.md#library-usage) (advanced). +The [solid test suite](https://github.com/nodeSolidServer/node-solid-server/blob/main/test/surface/run-solid-test-suite.sh) runs as part of GitHub Actions on this repository, ensuring that this server is always (to the best of our knowledge) fully spec compliant. + ## Solid Features supported - [x] [Linked Data Platform](http://www.w3.org/TR/ldp/) - [x] [Web Access Control](http://www.w3.org/wiki/WebAccessControl) @@ -28,12 +30,15 @@ You can install and run the server either using Node.js directly or using first approach, for the second approach see the section [use Docker](#use-docker) Section below. +**Note**: If using Git for Windows, it is helpful to use the -verbose flag to see the progress of the install. + To install, first install [Node](https://nodejs.org/en/) and then run the following ```bash $ npm install -g solid-server ``` + ### Run a single-user server (beginner) The easiest way to setup `solid-server` is by running the wizard. This will create a `config.json` in your current folder @@ -64,6 +69,15 @@ $ solid start --root path/to/folder --port 8443 --ssl-key path/to/ssl-key.pem -- # Solid server (solid v0.2.24) running on https://localhost:8443/ ``` +By default, `solid` runs in `debug all` mode. To stop the debug logs, use `-q`, the quiet parameter. + +```bash +$ DEBUG="solid:*" solid start -q +# use quiet mode and set debug to all +# DEBUG="solid:ACL" logs only debug.ACL's + +``` + ### Running in development environments Solid requires SSL certificates to be valid, so you cannot use self-signed certificates. To switch off this security feature in development environments, you can use the `bin/solid-test` executable, which unsets the `NODE_TLS_REJECT_UNAUTHORIZED` flag and sets the `rejectUnauthorized` option. @@ -91,6 +105,9 @@ accidentally commit your certificates to `solid` while you're developing. If you would like to get rid of the browser warnings, import your fullchain.pem certificate into your 'Trusted Root Certificate' store. +### Running Solid behind a reverse proxy (such as NGINX) +See [Running Solid behind a reverse proxy](https://github.com/solid/node-solid-server/wiki/Running-Solid-behind-a-reverse-proxy). + ### Run multi-user server (intermediate) You can run `solid` so that new users can sign up, in other words, get their WebIDs _username.yourdomain.com_. @@ -116,15 +133,27 @@ $ solid start --multiuser --port 8443 --ssl-cert /path/to/cert --ssl-key /path/t Your users will have a dedicated folder under `./data` at `./data/.`. Also, your root domain's website will be in `./data/`. New users can create accounts on `/api/accounts/new` and create new certificates on `/api/accounts/cert`. An easy-to-use sign-up tool is found on `/api/accounts`. -### Running Solid behind a reverse proxy (such as NGINX) -See [Running Solid behind a reverse proxy](https://github.com/solid/node-solid-server/wiki/Running-Solid-behind-a-reverse-proxy). - ##### How can I send emails to my users with my Gmail? > To use Gmail you may need to configure ["Allow Less Secure Apps"](https://www.google.com/settings/security/lesssecureapps) in your Gmail account unless you are using 2FA in which case you would have to create an [Application Specific](https://security.google.com/settings/security/apppasswords) password. You also may need to unlock your account with ["Allow access to your Google account"](https://accounts.google.com/DisplayUnlockCaptcha) to use SMTP. -### Upgrading from version 4 -To upgrade from version 4 to the current version 5, you need to run a migration script, as explained in the [v5 upgrade notes](https://github.com/solid/node-solid-server/blob/master/CHANGELOG.md#500-upgrade-notes). +also add to `config.json` +``` + "useEmail": true, + "emailHost": "smtp.gmail.com", + "emailPort": "465", + "emailAuthUser": "xxxx@gmail.com", + "emailAuthPass": "gmailPass" +``` + +### Upgrading from version <6.0.0 +Please take into account the [v6.0.0 upgrade notes](https://github.com/solid/node-solid-server/blob/master/CHANGELOG.md#600-upgrade-notes). + +### Upgrading from version <5.3 +Please take into account the [v5.3 upgrade notes](https://github.com/solid/node-solid-server/blob/master/CHANGELOG.md#530-upgrade-notes). + +### Upgrading from version <5.0 +To upgrade from version 4 to the current version 5, you need to run a migration script, as explained in the [v5.0 upgrade notes](https://github.com/solid/node-solid-server/blob/master/CHANGELOG.md#500-upgrade-notes). Also, be aware that starting from version 5, third-party apps are untrusted by default. To trust a third-party app, before you can log in to it, you first need to go to your profile at https://example.com/profile/card#me (important to include the '#me' there), and then hover over the 'card' header to reveal the context menu. From there, select the 'A' symbol to go to your trusted applications pane, where you can whitelist third-party apps before using them. See also https://github.com/solid/node-solid-server/issues/1142 about streamlining this UX flow. @@ -180,6 +209,7 @@ $ solid start --help --multiuser Enable multi-user mode --idp [value] Obsolete; use --multiuser --no-live Disable live support through WebSockets + --no-prep Disable Per Resource Events --proxy [value] Obsolete; use --corsProxy --cors-proxy [value] Serve the CORS proxy on this path --suppress-data-browser Suppress provision of a data browser @@ -206,7 +236,7 @@ $ solid start --help --support-email [value] The support email you provide for your users (not required) -q, --quiet Do not print the logs to console -h, --help output usage information - ``` +``` Instead of using flags, these same options can also be configured via environment variables taking the form of `SOLID_` followed by the `SNAKE_CASE` of the flag. For example `--api-apps` can be set via the `SOLID_API_APPS`environment variable, and `--serverUri` can be set with `SOLID_SERVER_URI`. @@ -216,31 +246,57 @@ Configuring Solid via the config file can be a concise and convenient method and ## Use Docker -Build with: + +### Production usage + +See the [documentation to run Solid using docker and docker-compose](https://github.com/solid/node-solid-server/tree/master/docker-image). + +We have automatic builds set up, so commits to master will trigger a build of https://hub.docker.com/r/nodesolidserver/node-solid-server. + +### Development usage + +If you want to use Docker in development, you can build and run the image locally with either docker-compose — ```bash -docker build -t node-solid-server . +git clone https://github.com/solid/node-solid-server +cd node-solid-server +docker-compose up -d ``` -Run with: + — or these manual commands — + ```bash +git clone https://github.com/solid/node-solid-server +cd node-solid-server +docker build -t node-solid-server . + docker run -p 8443:8443 --name solid node-solid-server ``` + This will enable you to login to solid on https://localhost:8443 and then create a new account -but not yet use that account. After a new account is made you will need to create an entry for -it in your local (/etc/)hosts file in line with the account and subdomain i.e. +but not yet use that account. After a new account is made you will need to create an entry for +it in your local (/etc/)hosts file in line with the account and subdomain, i.e. -- +```pre 127.0.0.1 newsoliduser.localhost - -Then you'll be able to use solid as intended. +``` You can modify the config within the docker container as follows: - - Copy the config to the current directory with: `docker cp solid:/usr/src/app/config.json .` + - Copy the `config.json` to the current directory with: + ```bash + docker cp solid:/usr/src/app/config.json . + ``` - Edit the `config.json` file - - Copy the file back with `docker cp config.json solid:/usr/src/app/` - - Restart the server with `docker restart solid` + - Copy the file back with + ```bash + docker cp config.json solid:/usr/src/app/ + ``` + - Restart the server with + ```bash + docker restart solid + ``` ## Library Usage @@ -264,18 +320,18 @@ default settings. ```javascript { - cache: 0, // Set cache time (in seconds), 0 for no cache - live: true, // Enable live support through WebSockets - root: './', // Root location on the filesystem to serve resources - secret: 'node-ldp', // Express Session secret key - cert: false, // Path to the ssl cert - key: false, // Path to the ssl key - mount: '/', // Where to mount Linked Data Platform - webid: false, // Enable WebID+TLS authentication - suffixAcl: '.acl', // Suffix for acl files - corsProxy: false, // Where to mount the CORS proxy - errorHandler: false, // function(err, req, res, next) to have a custom error handler - errorPages: false // specify a path where the error pages are + cache: 0, // Set cache time (in seconds), 0 for no cache + live: true, // Enable live support through WebSockets + root: './', // Root location on the filesystem to serve resources + secret: 'node-ldp', // Express Session secret key + cert: false, // Path to the ssl cert + key: false, // Path to the ssl key + mount: '/', // Where to mount Linked Data Platform + webid: false, // Enable WebID+TLS authentication + suffixAcl: '.acl', // Suffix for acl files + corsProxy: false, // Where to mount the CORS proxy + errorHandler: false, // function(err, req, res, next) to have a custom error handler + errorPages: false // specify a path where the error pages are } ``` @@ -330,7 +386,8 @@ In order to really get a feel for the Solid platform, and to test out `solid`, you will need the following: 1. A WebID profile and browser certificate from one of the Solid-compliant - identity providers, such as [solid.community](https://solid.community). + identity providers, such as [solidcommunity.net](bourgeoa + community.net). 2. A server-side SSL certificate for `solid` to use (see the section below on creating a self-signed certificate for testing). @@ -401,17 +458,18 @@ it. It is currently adviceable to remove it or set it inactive rather than set a large quota, because the current implementation will impair write performance if there is a lot of data. -## Contribute to Solid +## Get help and contribute Solid is only possible because of a large community of [contributors](https://github.com/solid/node-solid-server/blob/master/CONTRIBUTORS.md). A heartfelt thank you to everyone for all of your efforts! -You can help us too: +You can receive or provide help too: -- [Join us in Gitter](https://gitter.im/solid/chat) to help with development or to hang out with us :) +- [Join us in Gitter](https://gitter.im/solid/chat) to chat about Solid or to hang out with us :) +- [NSS Gitter channel](https://gitter.im/solid/node-solid-server) for specific (installation) advice about this code base - [Create a new issue](https://github.com/solid/node-solid-server/issues/new) to report bugs - [Fix an issue](https://github.com/solid/node-solid-server/issues) -- Reach out to Jackson at jacksonm@inrupt.com to become more involved in maintaining Node Solid Server +- Reach out to @bourgeoa at alain.bourgeois10@gmail.com to become more involved in maintaining Node Solid Server Have a look at [CONTRIBUTING.md](https://github.com/solid/node-solid-server/blob/master/CONTRIBUTING.md). diff --git a/bin/config.json b/bin/config.json new file mode 100644 index 000000000..8af1db8cd --- /dev/null +++ b/bin/config.json @@ -0,0 +1,18 @@ +{ + "root": "/Users/imyshor/Projects/solid/solidos/workspaces/node-solid-server/bin/data", + "port": "8443", + "serverUri": "https://localhost:8443", + "webid": false, + "mount": "/", + "configPath": "./config", + "configFile": "./config.json", + "dbPath": "./.db", + "sslKey": "../", + "sslCert": "../", + "multiuser": false, + "server": { + "name": "localhost", + "description": "", + "logo": "" + } +} \ No newline at end of file diff --git a/bin/lib/cli-utils.js b/bin/lib/cli-utils.js deleted file mode 100644 index 47ee29d45..000000000 --- a/bin/lib/cli-utils.js +++ /dev/null @@ -1,85 +0,0 @@ -const fs = require('fs-extra') -const { red, cyan, bold } = require('colorette') -const { URL } = require('url') -const LDP = require('../../lib/ldp') -const AccountManager = require('../../lib/models/account-manager') -const SolidHost = require('../../lib/models/solid-host') - -module.exports.getAccountManager = getAccountManager -module.exports.loadAccounts = loadAccounts -module.exports.loadConfig = loadConfig -module.exports.loadUsernames = loadUsernames - -/** - * Returns an instance of AccountManager - * - * @param {Object} config - * @param {Object} [options] - * @returns {AccountManager} - */ -function getAccountManager (config, options = {}) { - const ldp = options.ldp || new LDP(config) - const host = options.host || SolidHost.from({ port: config.port, serverUri: config.serverUri }) - return AccountManager.from({ - host, - store: ldp, - multiuser: config.multiuser - }) -} - -function loadConfig (program, options) { - let argv = { - ...options, - version: program.version() - } - let configFile = argv['configFile'] || './config.json' - - try { - const file = fs.readFileSync(configFile) - - // Use flags with priority over config file - const config = JSON.parse(file) - argv = { ...config, ...argv } - } catch (err) { - // If config file was specified, but it doesn't exist, stop with error message - if (typeof argv['configFile'] !== 'undefined') { - if (!fs.existsSync(configFile)) { - console.log(red(bold('ERR')), 'Config file ' + configFile + ' doesn\'t exist.') - process.exit(1) - } - } - - // If the file exists, but parsing failed, stop with error message - if (fs.existsSync(configFile)) { - console.log(red(bold('ERR')), 'config file ' + configFile + ' couldn\'t be parsed: ' + err) - process.exit(1) - } - - // Legacy behavior - if config file does not exist, start with default - // values, but an info message to create a config file. - console.log(cyan(bold('TIP')), 'create a config.json: `$ solid init`') - } - - return argv -} - -/** - * - * @param root - * @param [serverUri] If not set, hostname must be set - * @param [hostname] If not set, serverUri must be set - * @returns {*} - */ -function loadAccounts ({ root, serverUri, hostname }) { - const files = fs.readdirSync(root) - hostname = hostname || new URL(serverUri).hostname - const isUserDirectory = new RegExp(`.${hostname}$`) - return files - .filter(file => isUserDirectory.test(file)) -} - -function loadUsernames ({ root, serverUri }) { - const hostname = new URL(serverUri).hostname - return loadAccounts({ root, hostname }) - .map(userDirectory => userDirectory.substr(0, userDirectory.length - hostname.length - 1)) -} diff --git a/bin/lib/cli-utils.mjs b/bin/lib/cli-utils.mjs new file mode 100644 index 000000000..8946e0c5c --- /dev/null +++ b/bin/lib/cli-utils.mjs @@ -0,0 +1,54 @@ +import fs from 'fs-extra' +import { red, cyan, bold } from 'colorette' +import { URL } from 'url' +import LDP from '../../lib/ldp.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' + +export function getAccountManager (config, options = {}) { + const ldp = options.ldp || new LDP(config) + const host = options.host || SolidHost.from({ port: config.port, serverUri: config.serverUri }) + return AccountManager.from({ + host, + store: ldp, + multiuser: config.multiuser + }) +} + +export function loadConfig (program, options) { + let argv = { + ...options, + version: program.version() + } + const configFile = argv.configFile || './config.json' + try { + const file = fs.readFileSync(configFile) + const config = JSON.parse(file) + argv = { ...config, ...argv } + } catch (err) { + if (typeof argv.configFile !== 'undefined') { + if (!fs.existsSync(configFile)) { + console.log(red(bold('ERR')), 'Config file ' + configFile + " doesn't exist.") + process.exit(1) + } + } + if (fs.existsSync(configFile)) { + console.log(red(bold('ERR')), 'config file ' + configFile + " couldn't be parsed: " + err) + process.exit(1) + } + console.log(cyan(bold('TIP')), 'create a config.json: `$ solid init`') + } + return argv +} + +export function loadAccounts ({ root, serverUri, hostname }) { + const files = fs.readdirSync(root) + hostname = hostname || new URL(serverUri).hostname + const isUserDirectory = new RegExp(`.${hostname}$`) + return files.filter(file => isUserDirectory.test(file)) +} + +export function loadUsernames ({ root, serverUri }) { + const hostname = new URL(serverUri).hostname + return loadAccounts({ root, hostname }).map(userDirectory => userDirectory.substr(0, userDirectory.length - hostname.length - 1)) +} diff --git a/bin/lib/cli.js b/bin/lib/cli.js deleted file mode 100644 index 9131896d8..000000000 --- a/bin/lib/cli.js +++ /dev/null @@ -1,39 +0,0 @@ -const program = require('commander') -const loadInit = require('./init') -const loadStart = require('./start') -const loadInvalidUsernames = require('./invalidUsernames') -const loadMigrateLegacyResources = require('./migrateLegacyResources') -const loadUpdateIndex = require('./updateIndex') -const { spawnSync } = require('child_process') -const path = require('path') - -module.exports = function startCli (server) { - program.version(getVersion()) - - loadInit(program) - loadStart(program, server) - loadInvalidUsernames(program) - loadMigrateLegacyResources(program) - loadUpdateIndex(program) - - program.parse(process.argv) - if (program.args.length === 0) program.help() -} - -function getVersion () { - try { - // Obtain version from git - const options = { cwd: __dirname, encoding: 'utf8' } - const { stdout } = spawnSync('git', ['describe', '--tags'], options) - const version = stdout.trim() - if (version === '') { - throw new Error('No git version here') - } - return version - } catch (e) { - // Obtain version from package.json - const { version } = require(path.join(__dirname, '../../package.json')) - return version - } -} - diff --git a/bin/lib/cli.mjs b/bin/lib/cli.mjs new file mode 100644 index 000000000..24cb62e4e --- /dev/null +++ b/bin/lib/cli.mjs @@ -0,0 +1,44 @@ +import { Command } from 'commander' +import loadInit from './init.mjs' +import loadStart from './start.mjs' +import loadInvalidUsernames from './invalidUsernames.mjs' +import loadMigrateLegacyResources from './migrateLegacyResources.mjs' +import loadUpdateIndex from './updateIndex.mjs' +import { spawnSync } from 'child_process' +import path from 'path' +import fs from 'fs' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default function startCli (server) { + const program = new Command() + program.version(getVersion()) + + loadInit(program) + loadStart(program, server) + loadInvalidUsernames(program) + loadMigrateLegacyResources(program) + loadUpdateIndex(program) + + program.parse(process.argv) + if (program.args.length === 0) program.help() +} + +function getVersion () { + try { + const options = { cwd: __dirname, encoding: 'utf8' } + const { stdout } = spawnSync('git', ['describe', '--tags'], options) + const { stdout: gitStatusStdout } = spawnSync('git', ['status'], options) + const version = stdout.trim() + if (version === '' || gitStatusStdout.match('Not currently on any branch')) { + throw new Error('No git version here') + } + return version + } catch (e) { + const pkgPath = path.join(__dirname, '../../package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + return pkg.version + } +} diff --git a/bin/lib/init.js b/bin/lib/init.mjs similarity index 83% rename from bin/lib/init.js rename to bin/lib/init.mjs index fa0deabb0..06074699e 100644 --- a/bin/lib/init.js +++ b/bin/lib/init.mjs @@ -1,94 +1,93 @@ -const inquirer = require('inquirer') -const fs = require('fs') -const options = require('./options') -const camelize = require('camelize') - -let questions = options - .map((option) => { - if (!option.type) { - if (option.flag) { - option.type = 'confirm' - } else { - option.type = 'input' - } - } - - option.message = option.question || option.help - return option - }) - -module.exports = function (program) { - program - .command('init') - .option('--advanced', 'Ask for all the settings') - .description('create solid server configurations') - .action((opts) => { - // Filter out advanced commands - if (!opts.advanced) { - questions = questions.filter((option) => option.prompt) - } - - // Prompt to the user - inquirer.prompt(questions) - .then((answers) => { - manipulateEmailSection(answers) - manipulateServerSection(answers) - cleanupAnswers(answers) - - // write config file - const config = JSON.stringify(camelize(answers), null, ' ') - const configPath = process.cwd() + '/config.json' - - fs.writeFile(configPath, config, (err) => { - if (err) { - return console.log('failed to write config.json') - } - console.log('config created on', configPath) - }) - }) - .catch((err) => { - console.log('Error:', err) - }) - }) -} - -function cleanupAnswers (answers) { - // clean answers - Object.keys(answers).forEach((answer) => { - if (answer.startsWith('use')) { - delete answers[answer] - } - }) -} - -function manipulateEmailSection (answers) { - // setting email - if (answers.useEmail) { - answers.email = { - host: answers['email-host'], - port: answers['email-port'], - secure: true, - auth: { - user: answers['email-auth-user'], - pass: answers['email-auth-pass'] - } - } - delete answers['email-host'] - delete answers['email-port'] - delete answers['email-auth-user'] - delete answers['email-auth-pass'] - } -} - -function manipulateServerSection (answers) { - answers.server = { - name: answers['server-info-name'], - description: answers['server-info-description'], - logo: answers['server-info-logo'] - } - Object.keys(answers).forEach((answer) => { - if (answer.startsWith('server-info-')) { - delete answers[answer] - } - }) -} +import inquirer from 'inquirer' +import fs from 'fs' +import options from './options.mjs' +import camelize from 'camelize' + +const questions = options + .map((option) => { + if (!option.type) { + if (option.flag) { + option.type = 'confirm' + } else { + option.type = 'input' + } + } + + option.message = option.question || option.help + return option + }) + +export default function (program) { + program + .command('init') + .option('--advanced', 'Ask for all the settings') + .description('create solid server configurations') + .action((opts) => { + // Filter out advanced commands + let filtered = questions + if (!opts.advanced) { + filtered = filtered.filter((option) => option.prompt) + } + + // Prompt to the user + inquirer.prompt(filtered) + .then((answers) => { + manipulateEmailSection(answers) + manipulateServerSection(answers) + cleanupAnswers(answers) + + // write config file + const config = JSON.stringify(camelize(answers), null, ' ') + const configPath = process.cwd() + '/config.json' + + fs.writeFile(configPath, config, (err) => { + if (err) { + return console.log('failed to write config.json') + } + console.log('config created on', configPath) + }) + }) + .catch((err) => { + console.log('Error:', err) + }) + }) +} + +function cleanupAnswers (answers) { + Object.keys(answers).forEach((answer) => { + if (answer.startsWith('use')) { + delete answers[answer] + } + }) +} + +function manipulateEmailSection (answers) { + if (answers.useEmail) { + answers.email = { + host: answers['email-host'], + port: answers['email-port'], + secure: true, + auth: { + user: answers['email-auth-user'], + pass: answers['email-auth-pass'] + } + } + delete answers['email-host'] + delete answers['email-port'] + delete answers['email-auth-user'] + delete answers['email-auth-pass'] + } +} + +function manipulateServerSection (answers) { + answers.server = { + name: answers['server-info-name'], + description: answers['server-info-description'], + logo: answers['server-info-logo'] + } + Object.keys(answers).forEach((answer) => { + if (answer.startsWith('server-info-')) { + delete answers[answer] + } + }) +} diff --git a/bin/lib/invalidUsernames.js b/bin/lib/invalidUsernames.mjs similarity index 80% rename from bin/lib/invalidUsernames.js rename to bin/lib/invalidUsernames.mjs index 1d6cd340e..6ad4f4bdd 100644 --- a/bin/lib/invalidUsernames.js +++ b/bin/lib/invalidUsernames.mjs @@ -1,148 +1,136 @@ -const fs = require('fs-extra') -const Handlebars = require('handlebars') -const path = require('path') - -const { getAccountManager, loadConfig, loadUsernames } = require('./cli-utils') -const { isValidUsername } = require('../../lib/common/user-utils') -const blacklistService = require('../../lib/services/blacklist-service') -const { initConfigDir, initTemplateDirs } = require('../../lib/server-config') -const { fromServerConfig } = require('../../lib/models/oidc-manager') - -const EmailService = require('../../lib/services/email-service') -const SolidHost = require('../../lib/models/solid-host') - -module.exports = function (program) { - program - .command('invalidusernames') - .option('--notify', 'Will notify users with usernames that are invalid') - .option('--delete', 'Will delete users with usernames that are invalid') - .description('Manage usernames that are invalid') - .action(async (options) => { - const config = loadConfig(program, options) - if (!config.multiuser) { - return console.error('You are running a single user server, no need to check for invalid usernames') - } - - const invalidUsernames = getInvalidUsernames(config) - const host = SolidHost.from({ port: config.port, serverUri: config.serverUri }) - const accountManager = getAccountManager(config, { host }) - - if (options.notify) { - return notifyUsers(invalidUsernames, accountManager, config) - } - - if (options.delete) { - return deleteUsers(invalidUsernames, accountManager, config, host) - } - - listUsernames(invalidUsernames) - }) -} - -function backupIndexFile (username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail) { - const userDirectory = accountManager.accountDirFor(username) - const currentIndex = path.join(userDirectory, 'index.html') - const currentIndexExists = fs.existsSync(currentIndex) - const backupIndex = path.join(userDirectory, 'index.backup.html') - const backupIndexExists = fs.existsSync(backupIndex) - if (currentIndexExists && !backupIndexExists) { - fs.renameSync(currentIndex, backupIndex) - createNewIndexAcl(userDirectory) - createNewIndex(username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) - console.info(`index.html updated for user ${username}`) - } -} - -function createNewIndex (username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) { - const newIndexSource = invalidUsernameTemplate({ - username, - dateOfRemoval, - supportEmail - }) - fs.writeFileSync(currentIndex, newIndexSource, 'utf-8') -} - -function createNewIndexAcl (userDirectory) { - const currentIndexAcl = path.join(userDirectory, 'index.html.acl') - const backupIndexAcl = path.join(userDirectory, 'index.backup.html.acl') - const currentIndexSource = fs.readFileSync(currentIndexAcl, 'utf-8') - const backupIndexSource = currentIndexSource.replace(/index.html/g, 'index.backup.html') - fs.writeFileSync(backupIndexAcl, backupIndexSource, 'utf-8') -} - -async function deleteUsers (usernames, accountManager, config, host) { - const oidcManager = fromServerConfig({ - ...config, - host - }) - const deletingUsers = usernames - .map(async username => { - try { - const user = accountManager.userAccountFrom({ username }) - await oidcManager.users.deleteUser(user) - } catch (error) { - if (error.message !== 'No email given') { - // 'No email given' is an expected error that we want to ignore - throw error - } - } - const userDirectory = accountManager.accountDirFor(username) - await fs.remove(userDirectory) - }) - await Promise.all(deletingUsers) - console.info(`Deleted ${deletingUsers.length} users succeeded`) -} - -function getInvalidUsernames (config) { - const usernames = loadUsernames(config) - return usernames.filter(username => !isValidUsername(username) || !blacklistService.validate(username)) -} - -function listUsernames (usernames) { - if (usernames.length === 0) { - return console.info('No invalid usernames was found') - } - console.info(`${usernames.length} invalid usernames were found:${usernames.map(username => `\n- ${username}`)}`) -} - -async function notifyUsers (usernames, accountManager, config) { - const twoWeeksFromNow = Date.now() + 14 * 24 * 60 * 60 * 1000 - const dateOfRemoval = (new Date(twoWeeksFromNow)).toLocaleDateString() - const { supportEmail } = config - - updateIndexFiles(usernames, accountManager, dateOfRemoval, supportEmail) - await sendEmails(config, usernames, accountManager, dateOfRemoval, supportEmail) -} - -async function sendEmails (config, usernames, accountManager, dateOfRemoval, supportEmail) { - if (config.email && config.email.host) { - const configPath = initConfigDir(config) - const templates = initTemplateDirs(configPath) - const users = await Promise.all(await usernames.map(async username => { - const emailAddress = await accountManager.loadAccountRecoveryEmail({ username }) - const accountUri = accountManager.accountUriFor(username) - return { username, emailAddress, accountUri } - })) - const emailService = new EmailService(templates.email, config.email) - const sendingEmails = users - .filter(user => !!user.emailAddress) - .map(user => emailService.sendWithTemplate('invalid-username', { - to: user.emailAddress, - accountUri: user.accountUri, - dateOfRemoval, - supportEmail - })) - const emailsSent = await Promise.all(sendingEmails) - console.info(`${emailsSent.length} emails sent to users with invalid usernames`) - return - } - console.info('You have not configured an email service.') - console.info('Please set it up to send users email about their accounts') -} - -function updateIndexFiles (usernames, accountManager, dateOfRemoval, supportEmail) { - const invalidUsernameFilePath = path.join(process.cwd(), 'default-views', 'account', 'invalid-username.hbs') - const source = fs.readFileSync(invalidUsernameFilePath, 'utf-8') - const invalidUsernameTemplate = Handlebars.compile(source) - usernames.forEach(username => backupIndexFile(username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail)) -} +import fs from 'fs-extra' +import Handlebars from 'handlebars' +import path from 'path' +import { getAccountManager, loadConfig, loadUsernames } from './cli-utils.mjs' +import { isValidUsername } from '../../lib/common/user-utils.mjs' +import blacklistService from '../../lib/services/blacklist-service.mjs' +import { initConfigDir, initTemplateDirs } from '../../lib/server-config.mjs' +import { fromServerConfig } from '../../lib/models/oidc-manager.mjs' +import EmailService from '../../lib/services/email-service.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' + +export default function (program) { + program + .command('invalidusernames') + .option('--notify', 'Will notify users with usernames that are invalid') + .option('--delete', 'Will delete users with usernames that are invalid') + .description('Manage usernames that are invalid') + .action(async (options) => { + const config = loadConfig(program, options) + if (!config.multiuser) { + return console.error('You are running a single user server, no need to check for invalid usernames') + } + const invalidUsernames = getInvalidUsernames(config) + const host = SolidHost.from({ port: config.port, serverUri: config.serverUri }) + const accountManager = getAccountManager(config, { host }) + if (options.notify) { + return notifyUsers(invalidUsernames, accountManager, config) + } + if (options.delete) { + return deleteUsers(invalidUsernames, accountManager, config, host) + } + listUsernames(invalidUsernames) + }) +} + +function backupIndexFile (username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail) { + const userDirectory = accountManager.accountDirFor(username) + const currentIndex = path.join(userDirectory, 'index.html') + const currentIndexExists = fs.existsSync(currentIndex) + const backupIndex = path.join(userDirectory, 'index.backup.html') + const backupIndexExists = fs.existsSync(backupIndex) + if (currentIndexExists && !backupIndexExists) { + fs.renameSync(currentIndex, backupIndex) + createNewIndexAcl(userDirectory) + createNewIndex(username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) + console.info(`index.html updated for user ${username}`) + } +} + +function createNewIndex (username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) { + const newIndexSource = invalidUsernameTemplate({ + username, + dateOfRemoval, + supportEmail + }) + fs.writeFileSync(currentIndex, newIndexSource, 'utf-8') +} + +function createNewIndexAcl (userDirectory) { + const currentIndexAcl = path.join(userDirectory, 'index.html.acl') + const backupIndexAcl = path.join(userDirectory, 'index.backup.html.acl') + const currentIndexSource = fs.readFileSync(currentIndexAcl, 'utf-8') + const backupIndexSource = currentIndexSource.replace(/index.html/g, 'index.backup.html') + fs.writeFileSync(backupIndexAcl, backupIndexSource, 'utf-8') +} + +async function deleteUsers (usernames, accountManager, config, host) { + const oidcManager = fromServerConfig({ ...config, host }) + const deletingUsers = usernames.map(async username => { + try { + const user = accountManager.userAccountFrom({ username }) + await oidcManager.users.deleteUser(user) + } catch (error) { + if (error.message !== 'No email given') { + throw error + } + } + const userDirectory = accountManager.accountDirFor(username) + await fs.remove(userDirectory) + }) + await Promise.all(deletingUsers) + console.info(`Deleted ${deletingUsers.length} users succeeded`) +} + +function getInvalidUsernames (config) { + const usernames = loadUsernames(config) + return usernames.filter(username => !isValidUsername(username) || !blacklistService.validate(username)) +} + +function listUsernames (usernames) { + if (usernames.length === 0) { + return console.info('No invalid usernames was found') + } + console.info(`${usernames.length} invalid usernames were found:${usernames.map(username => `\n- ${username}`)}`) +} + +async function notifyUsers (usernames, accountManager, config) { + const twoWeeksFromNow = Date.now() + 14 * 24 * 60 * 60 * 1000 + const dateOfRemoval = (new Date(twoWeeksFromNow)).toLocaleDateString() + const { supportEmail } = config + updateIndexFiles(usernames, accountManager, dateOfRemoval, supportEmail) + await sendEmails(config, usernames, accountManager, dateOfRemoval, supportEmail) +} + +async function sendEmails (config, usernames, accountManager, dateOfRemoval, supportEmail) { + if (config.email && config.email.host) { + const configPath = initConfigDir(config) + const templates = initTemplateDirs(configPath) + const users = await Promise.all(await usernames.map(async username => { + const emailAddress = await accountManager.loadAccountRecoveryEmail({ username }) + const accountUri = accountManager.accountUriFor(username) + return { username, emailAddress, accountUri } + })) + const emailService = new EmailService(templates.email, config.email) + const sendingEmails = users + .filter(user => !!user.emailAddress) + .map(user => emailService.sendWithTemplate('invalid-username.mjs', { + to: user.emailAddress, + accountUri: user.accountUri, + dateOfRemoval, + supportEmail + })) + const emailsSent = await Promise.all(sendingEmails) + console.info(`${emailsSent.length} emails sent to users with invalid usernames`) + return + } + console.info('You have not configured an email service.') + console.info('Please set it up to send users email about their accounts') +} + +function updateIndexFiles (usernames, accountManager, dateOfRemoval, supportEmail) { + const invalidUsernameFilePath = path.join(process.cwd(), 'default-views', 'account', 'invalid-username.hbs') + const source = fs.readFileSync(invalidUsernameFilePath, 'utf-8') + const invalidUsernameTemplate = Handlebars.compile(source) + usernames.forEach(username => backupIndexFile(username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail)) +} diff --git a/bin/lib/migrateLegacyResources.js b/bin/lib/migrateLegacyResources.mjs similarity index 77% rename from bin/lib/migrateLegacyResources.js rename to bin/lib/migrateLegacyResources.mjs index 82895fb3c..d015b2080 100644 --- a/bin/lib/migrateLegacyResources.js +++ b/bin/lib/migrateLegacyResources.mjs @@ -1,69 +1,64 @@ -const fs = require('fs') -const Path = require('path') -const promisify = require('util').promisify -const readdir = promisify(fs.readdir) -const lstat = promisify(fs.lstat) -const rename = promisify(fs.rename) - -/* Converts the old (pre-5.0.0) extensionless files to $-based files _with_ extensions - * to make them work in the new resource mapper (post-5.0.0). - * By default, all extensionless files (that used to be interpreted as Turtle) will now receive a '$.ttl' suffix. */ -/* https://www.w3.org/DesignIssues/HTTPFilenameMapping.html */ - -module.exports = function (program) { - program - .command('migrate-legacy-resources') - .option('-p, --path ', 'Path to the data folder, defaults to \'data/\'') - .option('-s, --suffix ', 'The suffix to add to extensionless files, defaults to \'$.ttl\'') - .option('-v, --verbose', 'Path to the data folder') - .description('Migrate the data folder from node-solid-server 4 to node-solid-server 5') - .action(async (opts) => { - const verbose = opts.verbose - const suffix = opts.suffix || '$.ttl' - let paths = opts.path ? [ opts.path ] : [ 'data', 'config/templates' ] - paths = paths.map(path => path.startsWith(Path.sep) ? path : Path.join(process.cwd(), path)) - try { - for (const path of paths) { - if (verbose) { - console.log(`Migrating files in ${path}`) - } - await migrate(path, suffix, verbose) - } - } catch (err) { - console.error(err) - } - }) -} - -async function migrate (path, suffix, verbose) { - const files = await readdir(path) - for (const file of files) { - const fullFilePath = Path.join(path, file) - const stat = await lstat(fullFilePath) - if (stat.isFile()) { - if (shouldMigrateFile(file)) { - const newFullFilePath = getNewFileName(fullFilePath, suffix) - if (verbose) { - console.log(`${fullFilePath}\n => ${newFullFilePath}`) - } - await rename(fullFilePath, newFullFilePath) - } - } else { - if (shouldMigrateFolder(file)) { - await migrate(fullFilePath, suffix, verbose) - } - } - } -} - -function getNewFileName (fullFilePath, suffix) { - return fullFilePath + suffix -} - -function shouldMigrateFile (filename) { - return filename.indexOf('.') < 0 -} - -function shouldMigrateFolder (foldername) { - return foldername[0] !== '.' -} +import fs from 'fs' +import Path from 'path' +import { promisify } from 'util' +const readdir = promisify(fs.readdir) +const lstat = promisify(fs.lstat) +const rename = promisify(fs.rename) + +export default function (program) { + program + .command('migrate-legacy-resources') + .option('-p, --path ', 'Path to the data folder, defaults to \'data/\'') + .option('-s, --suffix ', 'The suffix to add to extensionless files, defaults to \'$.ttl\'') + .option('-v, --verbose', 'Path to the data folder') + .description('Migrate the data folder from node-solid-server 4 to node-solid-server 5') + .action(async (opts) => { + const verbose = opts.verbose + const suffix = opts.suffix || '$.ttl' + let paths = opts.path ? [opts.path] : ['data', 'config/templates'] + paths = paths.map(path => path.startsWith(Path.sep) ? path : Path.join(process.cwd(), path)) + try { + for (const path of paths) { + if (verbose) { + console.log(`Migrating files in ${path}`) + } + await migrate(path, suffix, verbose) + } + } catch (err) { + console.error(err) + } + }) +} + +async function migrate (path, suffix, verbose) { + const files = await readdir(path) + for (const file of files) { + const fullFilePath = Path.join(path, file) + const stat = await lstat(fullFilePath) + if (stat.isFile()) { + if (shouldMigrateFile(file)) { + const newFullFilePath = getNewFileName(fullFilePath, suffix) + if (verbose) { + console.log(`${fullFilePath}\n => ${newFullFilePath}`) + } + await rename(fullFilePath, newFullFilePath) + } + } else { + if (shouldMigrateFolder(file)) { + await migrate(fullFilePath, suffix, verbose) + } + } + } +} + +function getNewFileName (fullFilePath, suffix) { + return fullFilePath + suffix +} + +function shouldMigrateFile (filename) { + return filename.indexOf('.') < 0 +} + +function shouldMigrateFolder (foldername) { + return foldername[0] !== '.' +} diff --git a/bin/lib/options.js b/bin/lib/options.mjs similarity index 81% rename from bin/lib/options.js rename to bin/lib/options.mjs index d5cd4fc85..6c335b442 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.mjs @@ -1,399 +1,379 @@ -const fs = require('fs') -const path = require('path') -const validUrl = require('valid-url') -const { URL } = require('url') -const { isEmail } = require('validator') - -module.exports = [ - // { - // abbr: 'v', - // flag: true, - // help: 'Print the logs to console\n' - // }, - { - name: 'root', - help: "Root folder to serve (default: './data')", - question: 'Path to the folder you want to serve. Default is', - default: './data', - prompt: true, - filter: (value) => path.resolve(value) - }, - { - name: 'port', - help: 'SSL port to use', - question: 'SSL port to run on. Default is', - default: '8443', - prompt: true - }, - { - name: 'server-uri', - question: 'Solid server uri (with protocol, hostname and port)', - help: "Solid server uri (default: 'https://localhost:8443')", - default: 'https://localhost:8443', - validate: validUri, - prompt: true - }, - { - name: 'webid', - help: 'Enable WebID authentication and access control (uses HTTPS)', - flag: true, - default: true, - question: 'Enable WebID authentication', - prompt: true - }, - { - name: 'mount', - help: "Serve on a specific URL path (default: '/')", - question: 'Serve Solid on URL path', - default: '/', - prompt: true - }, - { - name: 'config-path', - question: 'Path to the config directory (for example: /etc/solid-server)', - default: './config', - prompt: true - }, - { - name: 'config-file', - question: 'Path to the config file (for example: ./config.json)', - default: './config.json', - prompt: true - }, - { - name: 'db-path', - question: 'Path to the server metadata db directory (for users/apps etc)', - default: './.db', - prompt: true - }, - { - name: 'auth', - help: 'Pick an authentication strategy for WebID: `tls` or `oidc`', - question: 'Select authentication strategy', - type: 'list', - choices: [ - 'WebID-OpenID Connect' - ], - prompt: false, - default: 'WebID-OpenID Connect', - filter: (value) => { - if (value === 'WebID-OpenID Connect') return 'oidc' - }, - when: (answers) => { - return answers.webid - } - }, - { - name: 'use-owner', - question: 'Do you already have a WebID?', - type: 'confirm', - default: false, - hide: true - }, - { - name: 'owner', - help: 'Set the owner of the storage (overwrites the root ACL file)', - question: 'Your webid (to overwrite the root ACL with)', - prompt: false, - validate: function (value) { - if (value === '' || !value.startsWith('http')) { - return 'Enter a valid Webid' - } - return true - }, - when: function (answers) { - return answers['use-owner'] - } - }, - { - name: 'ssl-key', - help: 'Path to the SSL private key in PEM format', - validate: validPath, - prompt: true - }, - { - name: 'ssl-cert', - help: 'Path to the SSL certificate key in PEM format', - validate: validPath, - prompt: true - }, - { - name: 'no-reject-unauthorized', - help: 'Accept self-signed certificates', - flag: true, - default: false, - prompt: false - }, - { - name: 'multiuser', - help: 'Enable multi-user mode', - question: 'Enable multi-user mode', - flag: true, - default: false, - prompt: true - }, - { - name: 'idp', - help: 'Obsolete; use --multiuser', - prompt: false - }, - { - name: 'no-live', - help: 'Disable live support through WebSockets', - flag: true, - default: false - }, - // { - // full: 'default-app', - // help: 'URI to use as a default app for resources (default: https://linkeddata.github.io/warp/#/list/)' - // }, - { - name: 'use-cors-proxy', - help: 'Do you want to have a CORS proxy endpoint?', - flag: true, - default: false, - hide: true - }, - { - name: 'proxy', - help: 'Obsolete; use --corsProxy', - prompt: false - }, - { - name: 'cors-proxy', - help: 'Serve the CORS proxy on this path', - when: function (answers) { - return answers['use-cors-proxy'] - }, - default: '/proxy', - prompt: true - }, - { - name: 'auth-proxy', - help: 'Object with path/server pairs to reverse proxy', - default: {}, - prompt: false, - hide: true - }, - { - name: 'suppress-data-browser', - help: 'Suppress provision of a data browser', - flag: true, - prompt: false, - default: false, - hide: false - }, - { - name: 'data-browser-path', - help: 'An HTML file which is sent to allow users to browse the data (eg using mashlib.js)', - question: 'Path of data viewer page (defaults to using mashlib)', - validate: validPath, - default: 'default', - prompt: false - }, - { - name: 'suffix-acl', - full: 'suffix-acl', - help: "Suffix for acl files (default: '.acl')", - default: '.acl', - prompt: false - }, - { - name: 'suffix-meta', - full: 'suffix-meta', - help: "Suffix for metadata files (default: '.meta')", - default: '.meta', - prompt: false - }, - { - name: 'secret', - help: 'Secret used to sign the session ID cookie (e.g. "your secret phrase")', - question: 'Session secret for cookie', - default: 'random', - prompt: false, - filter: function (value) { - if (value === '' || value === 'random') { - return - } - return value - } - }, - // { - // full: 'no-error-pages', - // flag: true, - // help: 'Disable custom error pages (use Node.js default pages instead)' - // }, - { - name: 'error-pages', - help: 'Folder from which to look for custom error pages files (files must be named .html -- eg. 500.html)', - validate: validPath, - prompt: false - }, - { - name: 'force-user', - help: 'Force a WebID to always be logged in (useful when offline)' - }, - { - name: 'strict-origin', - help: 'Enforce same origin policy in the ACL', - flag: true, - default: false, - prompt: false - }, - { - name: 'use-email', - help: 'Do you want to set up an email service?', - flag: true, - prompt: true, - default: false - }, - { - name: 'email-host', - help: 'Host of your email service', - prompt: true, - default: 'smtp.gmail.com', - when: (answers) => { - return answers['use-email'] - } - }, - { - name: 'email-port', - help: 'Port of your email service', - prompt: true, - default: '465', - when: (answers) => { - return answers['use-email'] - } - }, - { - name: 'email-auth-user', - help: 'User of your email service', - prompt: true, - when: (answers) => { - return answers['use-email'] - }, - validate: (value) => { - if (!value) { - return 'You must enter this information' - } - return true - } - }, - { - name: 'email-auth-pass', - help: 'Password of your email service', - type: 'password', - prompt: true, - when: (answers) => { - return answers['use-email'] - } - }, - { - name: 'use-api-apps', - help: 'Do you want to load your default apps on /api/apps?', - flag: true, - prompt: false, - default: true - }, - { - name: 'api-apps', - help: 'Path to the folder to mount on /api/apps', - prompt: true, - validate: validPath, - when: (answers) => { - return answers['use-api-apps'] - } - }, - { // copied from name: 'owner' - name: 'redirect-http-from', - help: 'HTTP port or \',\'-separated ports to redirect to the solid server port (e.g. "80,8080").', - prompt: false, - validate: function (value) { - if (!value.match(/^[0-9]+(,[0-9]+)*$/)) { - return 'direct-port(s) must be a comma-separated list of integers.' - } - let list = value.split(/,/).map(v => parseInt(v)) - let bad = list.find(v => { return v < 1 || v > 65535 }) - if (bad.length) { - return 'redirect-http-from port(s) ' + bad + ' out of range' - } - return true - } - }, - { - // This property is packaged into an object for the server property in config.json - name: 'server-info-name', // All properties with prefix server-info- will be removed from the config - help: 'A name for your server (not required, but will be presented on your server\'s frontpage)', - prompt: true, - default: answers => new URL(answers['server-uri']).hostname - }, - { - // This property is packaged into an object for the server property in config.json - name: 'server-info-description', // All properties with prefix server-info- will be removed from the config - help: 'A description of your server (not required)', - prompt: true - }, - { - // This property is packaged into an object for the server property in config.json - name: 'server-info-logo', // All properties with prefix server-info- will be removed from the config - help: 'A logo that represents you, your brand, or your server (not required)', - prompt: true - }, - { - name: 'enforce-toc', - help: 'Do you want to enforce Terms & Conditions for your service?', - flag: true, - prompt: true, - default: false, - when: answers => answers.multiuser - }, - { - name: 'toc-uri', - help: 'URI to your Terms & Conditions', - prompt: true, - validate: validUri, - when: answers => answers['enforce-toc'] - }, - { - name: 'disable-password-checks', - help: 'Do you want to disable password strength checking?', - flag: true, - prompt: true, - default: false, - when: answers => answers.multiuser - }, - { - name: 'support-email', - help: 'The support email you provide for your users (not required)', - prompt: true, - validate: (value) => { - if (value && !isEmail(value)) { - return 'Must be a valid email' - } - return true - }, - when: answers => answers.multiuser - } -] - -function validPath (value) { - if (value === 'default') { - return Promise.resolve(true) - } - if (!value) { - return Promise.resolve('You must enter a valid path') - } - return new Promise((resolve) => { - fs.stat(value, function (err) { - if (err) return resolve('Nothing found at this path') - return resolve(true) - }) - }) -} - -function validUri (value) { - if (!validUrl.isUri(value)) { - return 'Enter a valid uri (with protocol)' - } - return true -} +import fs from 'fs' +import path from 'path' +import validUrl from 'valid-url' +import { URL } from 'url' +import validator from 'validator' +const { isEmail } = validator + +const options = [ + { + name: 'root', + help: "Root folder to serve (default: './data')", + question: 'Path to the folder you want to serve. Default is', + default: './data', + prompt: true, + filter: (value) => path.resolve(value) + }, + { + name: 'port', + help: 'SSL port to use', + question: 'SSL port to run on. Default is', + default: '8443', + prompt: true + }, + { + name: 'server-uri', + question: 'Solid server uri (with protocol, hostname and port)', + help: "Solid server uri (default: 'https://localhost:8443')", + default: 'https://localhost:8443', + validate: validUri, + prompt: true + }, + { + name: 'webid', + help: 'Enable WebID authentication and access control (uses HTTPS)', + flag: true, + default: true, + question: 'Enable WebID authentication', + prompt: true + }, + { + name: 'mount', + help: "Serve on a specific URL path (default: '/')", + question: 'Serve Solid on URL path', + default: '/', + prompt: true + }, + { + name: 'config-path', + question: 'Path to the config directory (for example: ./config)', + default: './config', + prompt: true + }, + { + name: 'config-file', + question: 'Path to the config file (for example: ./config.json)', + default: './config.json', + prompt: true + }, + { + name: 'db-path', + question: 'Path to the server metadata db directory (for users/apps etc)', + default: './.db', + prompt: true + }, + { + name: 'auth', + help: 'Pick an authentication strategy for WebID: `tls` or `oidc`', + question: 'Select authentication strategy', + type: 'list', + choices: [ + 'WebID-OpenID Connect' + ], + prompt: false, + default: 'WebID-OpenID Connect', + filter: (value) => { + if (value === 'WebID-OpenID Connect') return 'oidc' + }, + when: (answers) => answers.webid + }, + { + name: 'use-owner', + question: 'Do you already have a WebID?', + type: 'confirm', + default: false, + hide: true + }, + { + name: 'owner', + help: 'Set the owner of the storage (overwrites the root ACL file)', + question: 'Your webid (to overwrite the root ACL with)', + prompt: false, + validate: function (value) { + if (value === '' || !value.startsWith('http')) { + return 'Enter a valid Webid' + } + return true + }, + when: function (answers) { + return answers['use-owner'] + } + }, + { + name: 'ssl-key', + help: 'Path to the SSL private key in PEM format', + validate: validPath, + prompt: true + }, + { + name: 'ssl-cert', + help: 'Path to the SSL certificate key in PEM format', + validate: validPath, + prompt: true + }, + { + name: 'no-reject-unauthorized', + help: 'Accept self-signed certificates', + flag: true, + default: false, + prompt: false + }, + { + name: 'multiuser', + help: 'Enable multi-user mode', + question: 'Enable multi-user mode', + flag: true, + default: false, + prompt: true + }, + { + name: 'idp', + help: 'Obsolete; use --multiuser', + prompt: false + }, + { + name: 'no-live', + help: 'Disable live support through WebSockets', + flag: true, + default: false + }, + { + name: 'no-prep', + help: 'Disable Per Resource Events', + flag: true, + default: false + }, + { + name: 'use-cors-proxy', + help: 'Do you want to have a CORS proxy endpoint?', + flag: true, + default: false, + hide: true + }, + { + name: 'proxy', + help: 'Obsolete; use --corsProxy', + prompt: false + }, + { + name: 'cors-proxy', + help: 'Serve the CORS proxy on this path', + when: function (answers) { + return answers['use-cors-proxy'] + }, + default: '/proxy', + prompt: true + }, + { + name: 'auth-proxy', + help: 'Object with path/server pairs to reverse proxy', + default: {}, + prompt: false, + hide: true + }, + { + name: 'suppress-data-browser', + help: 'Suppress provision of a data browser', + flag: true, + prompt: false, + default: false, + hide: false + }, + { + name: 'data-browser-path', + help: 'An HTML file which is sent to allow users to browse the data (eg using mashlib.js)', + question: 'Path of data viewer page (defaults to using mashlib)', + validate: validPath, + default: 'default', + prompt: false + }, + { + name: 'suffix-acl', + full: 'suffix-acl', + help: "Suffix for acl files (default: '.acl')", + default: '.acl', + prompt: false + }, + { + name: 'suffix-meta', + full: 'suffix-meta', + help: "Suffix for metadata files (default: '.meta')", + default: '.meta', + prompt: false + }, + { + name: 'secret', + help: 'Secret used to sign the session ID cookie (e.g. "your secret phrase")', + question: 'Session secret for cookie', + default: 'random', + prompt: false, + filter: function (value) { + if (value === '' || value === 'random') { + return + } + return value + } + }, + { + name: 'error-pages', + help: 'Folder from which to look for custom error pages files (files must be named .html -- eg. 500.html)', + validate: validPath, + prompt: false + }, + { + name: 'force-user', + help: 'Force a WebID to always be logged in (useful when offline)' + }, + { + name: 'strict-origin', + help: 'Enforce same origin policy in the ACL', + flag: true, + default: false, + prompt: false + }, + { + name: 'use-email', + help: 'Do you want to set up an email service?', + flag: true, + prompt: true, + default: false + }, + { + name: 'email-host', + help: 'Host of your email service', + prompt: true, + default: 'smtp.gmail.com', + when: (answers) => answers['use-email'] + }, + { + name: 'email-port', + help: 'Port of your email service', + prompt: true, + default: '465', + when: (answers) => answers['use-email'] + }, + { + name: 'email-auth-user', + help: 'User of your email service', + prompt: true, + when: (answers) => answers['use-email'], + validate: (value) => { + if (!value) { + return 'You must enter this information' + } + return true + } + }, + { + name: 'email-auth-pass', + help: 'Password of your email service', + type: 'password', + prompt: true, + when: (answers) => answers['use-email'] + }, + { + name: 'use-api-apps', + help: 'Do you want to load your default apps on /api/apps?', + flag: true, + prompt: false, + default: true + }, + { + name: 'api-apps', + help: 'Path to the folder to mount on /api/apps', + prompt: true, + validate: validPath, + when: (answers) => answers['use-api-apps'] + }, + { + name: 'redirect-http-from', + help: 'HTTP port or comma-separated ports to redirect to the solid server port (e.g. "80,8080").', + prompt: false, + validate: function (value) { + if (!value.match(/^[0-9]+(,[0-9]+)*$/)) { + return 'direct-port(s) must be a comma-separated list of integers.' + } + const list = value.split(/,/).map(v => parseInt(v)) + const bad = list.find(v => { return v < 1 || v > 65535 }) + if (bad && bad.length) { + return 'redirect-http-from port(s) ' + bad + ' out of range' + } + return true + } + }, + { + name: 'server-info-name', + help: 'A name for your server (not required, but will be presented on your server\'s frontpage)', + prompt: true, + default: answers => new URL(answers['server-uri']).hostname + }, + { + name: 'server-info-description', + help: 'A description of your server (not required)', + prompt: true + }, + { + name: 'server-info-logo', + help: 'A logo that represents you, your brand, or your server (not required)', + prompt: true + }, + { + name: 'enforce-toc', + help: 'Do you want to enforce Terms & Conditions for your service?', + flag: true, + prompt: true, + default: false, + when: answers => answers.multiuser + }, + { + name: 'toc-uri', + help: 'URI to your Terms & Conditions', + prompt: true, + validate: validUri, + when: answers => answers['enforce-toc'] + }, + { + name: 'disable-password-checks', + help: 'Do you want to disable password strength checking?', + flag: true, + prompt: true, + default: false, + when: answers => answers.multiuser + }, + { + name: 'support-email', + help: 'The support email you provide for your users (not required)', + prompt: true, + validate: (value) => { + if (value && !isEmail(value)) { + return 'Must be a valid email' + } + return true + }, + when: answers => answers.multiuser + } +] + +function validPath (value) { + if (value === 'default') { + return Promise.resolve(true) + } + if (!value) { + return Promise.resolve('You must enter a valid path') + } + return new Promise((resolve) => { + fs.stat(value, function (err) { + if (err) return resolve('Nothing found at this path') + return resolve(true) + }) + }) +} + +function validUri (value) { + if (!validUrl.isUri(value)) { + return 'Enter a valid uri (with protocol)' + } + return true +} + +export default options diff --git a/bin/lib/start.js b/bin/lib/start.mjs similarity index 75% rename from bin/lib/start.js rename to bin/lib/start.mjs index eb543ecf4..9ef770c9a 100644 --- a/bin/lib/start.js +++ b/bin/lib/start.mjs @@ -1,144 +1,124 @@ -'use strict' - -const options = require('./options') -const fs = require('fs') -const path = require('path') -const { loadConfig } = require('./cli-utils') -const { red, bold } = require('colorette') - -module.exports = function (program, server) { - const start = program - .command('start') - .description('run the Solid server') - - options - .filter((option) => !option.hide) - .forEach((option) => { - const configName = option.name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) - const snakeCaseName = configName.replace(/([A-Z])/g, '_$1') - const envName = `SOLID_${snakeCaseName.toUpperCase()}` - - let name = '--' + option.name - if (!option.flag) { - name += ' [value]' - } - - if (process.env[envName]) { - const raw = process.env[envName] - const envValue = /^(true|false)$/.test(raw) ? raw === 'true' : raw - - start.option(name, option.help, envValue) - } else { - start.option(name, option.help) - } - }) - - start.option('-q, --quiet', 'Do not print the logs to console') - - start.action(async (options) => { - const config = loadConfig(program, options) - bin(config, server) - }) -} - -function bin (argv, server) { - if (!argv.email) { - argv.email = { - host: argv['emailHost'], - port: argv['emailPort'], - secure: true, - auth: { - user: argv['emailAuthUser'], - pass: argv['emailAuthPass'] - } - } - delete argv['emailHost'] - delete argv['emailPort'] - delete argv['emailAuthUser'] - delete argv['emailAuthPass'] - } - - // Set up --no-* - argv.live = !argv.noLive - - // Set up debug environment - if (!argv.quiet) { - require('debug').enable('solid:*') - } - - // Set up port - argv.port = argv.port || 3456 - - // Multiuser with no webid is not allowed - - // Webid to be default in command line - if (argv.webid !== false) { - argv.webid = true - } - - if (!argv.webid && argv.multiuser) { - throw new Error('Server cannot operate as multiuser without webids') - } - - // Signal handling (e.g. CTRL+C) - if (process.platform !== 'win32') { - // Signal handlers don't work on Windows. - process.on('SIGINT', function () { - console.log('\nSolid stopped.') - process.exit() - }) - } - - // Overwrite root .acl if owner is specified - if (argv.owner) { - let rootPath = path.resolve(argv.root || process.cwd()) - if (!(rootPath.endsWith('/'))) { - rootPath += '/' - } - rootPath += (argv.suffixAcl || '.acl') - - const defaultAcl = `@prefix n0: . - @prefix n2: . - - <#owner> - a n0:Authorization; - n0:accessTo <./>; - n0:agent <${argv.owner}>; - n0:default <./>; - n0:mode n0:Control, n0:Read, n0:Write. - <#everyone> - a n0:Authorization; - n0: n2:Agent; - n0:accessTo <./>; - n0:default <./>; - n0:mode n0:Read.` - - fs.writeFileSync(rootPath, defaultAcl) - } - - // // Finally starting solid - const solid = require('../../') - let app - try { - app = solid.createServer(argv, server) - } catch (e) { - if (e.code === 'EACCES') { - if (e.syscall === 'mkdir') { - console.log(red(bold('ERROR')), `You need permissions to create '${e.path}' folder`) - } else { - console.log(red(bold('ERROR')), 'You need root privileges to start on this port') - } - return 1 - } - if (e.code === 'EADDRINUSE') { - console.log(red(bold('ERROR')), 'The port ' + argv.port + ' is already in use') - return 1 - } - console.log(red(bold('ERROR')), e.message) - return 1 - } - app.listen(argv.port, function () { - console.log(`Solid server (${argv.version}) running on \u001b[4mhttps://localhost:${argv.port}/\u001b[0m`) - console.log('Press +c to stop') - }) -} +import options from './options.mjs' +import fs from 'fs' +import path from 'path' +import { loadConfig } from './cli-utils.mjs' +import { red, bold } from 'colorette' + +export default function (program, server) { + const start = program + .command('start') + .description('run the Solid server') + + options + .filter((option) => !option.hide) + .forEach((option) => { + const configName = option.name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) + const snakeCaseName = configName.replace(/([A-Z])/g, '_$1') + const envName = `SOLID_${snakeCaseName.toUpperCase()}` + let name = '--' + option.name + if (!option.flag) { + name += ' [value]' + } + if (process.env[envName]) { + const raw = process.env[envName] + const envValue = /^(true|false)$/.test(raw) ? raw === 'true' : raw + start.option(name, option.help, envValue) + } else { + start.option(name, option.help) + } + }) + + start.option('-q, --quiet', 'Do not print the logs to console') + + start.action(async (options) => { + const config = loadConfig(program, options) + await bin(config, server) + }) +} + +async function bin (argv, server) { + if (!argv.email) { + argv.email = { + host: argv.emailHost, + port: argv.emailPort, + secure: true, + auth: { + user: argv.emailAuthUser, + pass: argv.emailAuthPass + } + } + delete argv.emailHost + delete argv.emailPort + delete argv.emailAuthUser + delete argv.emailAuthPass + } + if (!argv.tokenTypesSupported) { + argv.tokenTypesSupported = ['legacyPop', 'dpop'] + } + argv.live = !argv.noLive + if (!argv.quiet) { + const debug = await import('debug') + debug.default.enable('solid:*') + } + argv.port = argv.port || 3456 + if (argv.webid !== false) { + argv.webid = true + } + if (!argv.webid && argv.multiuser) { + throw new Error('Server cannot operate as multiuser without webids') + } + if (process.platform !== 'win32') { + process.on('SIGINT', function () { + console.log('\nSolid stopped.') + process.exit() + }) + } + if (argv.owner) { + let rootPath = path.resolve(argv.root || process.cwd()) + if (!(rootPath.endsWith('/'))) { + rootPath += '/' + } + rootPath += (argv.suffixAcl || '.acl') + const defaultAcl = `@prefix n0: . + @prefix n2: . + + <#owner> + a n0:Authorization; + n0:accessTo <./>; + n0:agent <${argv.owner}>; + n0:default <./>; + n0:mode n0:Control, n0:Read, n0:Write. + <#everyone> + a n0:Authorization; + n0: n2:Agent; + n0:accessTo <./>; + n0:default <./>; + n0:mode n0:Read.` + fs.writeFileSync(rootPath, defaultAcl) + } + const solid = (await import('../../index.mjs')).default + let app + try { + app = solid.createServer(argv, server) + } catch (e) { + if (e.code === 'EACCES') { + if (e.syscall === 'mkdir') { + console.log(red(bold('ERROR')), `You need permissions to create '${e.path}' folder`) + } else { + console.log(red(bold('ERROR')), 'You need root privileges to start on this port') + } + return 1 + } + if (e.code === 'EADDRINUSE') { + console.log(red(bold('ERROR')), 'The port ' + argv.port + ' is already in use') + return 1 + } + console.log(red(bold('ERROR')), e.message) + return 1 + } + app.listen(argv.port, function () { + console.log('ESM Solid server') + console.log(`Solid server (${argv.version}) running on \u001b[4mhttps://localhost:${argv.port}/\u001b[0m`) + console.log('Press +c to stop') + }) +} diff --git a/bin/lib/updateIndex.js b/bin/lib/updateIndex.mjs similarity index 73% rename from bin/lib/updateIndex.js rename to bin/lib/updateIndex.mjs index 8412f7210..30413242f 100644 --- a/bin/lib/updateIndex.js +++ b/bin/lib/updateIndex.mjs @@ -1,56 +1,55 @@ -const fs = require('fs') -const path = require('path') -const cheerio = require('cheerio') -const LDP = require('../../lib/ldp') -const { URL } = require('url') -const debug = require('../../lib/debug') -const { readFile } = require('../../lib/common/fs-utils') - -const { compileTemplate, writeTemplate } = require('../../lib/common/template-utils') -const { loadConfig, loadAccounts } = require('./cli-utils') -const { getName, getWebId } = require('../../lib/common/user-utils') -const { initConfigDir, initTemplateDirs } = require('../../lib/server-config') - -module.exports = function (program) { - program - .command('updateindex') - .description('Update index.html in root of all PODs that haven\'t been marked otherwise') - .action(async (options) => { - const config = loadConfig(program, options) - const configPath = initConfigDir(config) - const templates = initTemplateDirs(configPath) - const indexTemplatePath = path.join(templates.account, 'index.html') - const indexTemplate = await compileTemplate(indexTemplatePath) - const ldp = new LDP(config) - const accounts = loadAccounts(config) - const usersProcessed = accounts.map(async account => { - const accountDirectory = path.join(config.root, account) - const indexFilePath = path.join(accountDirectory, '/index.html') - if (!isUpdateAllowed(indexFilePath)) { - return - } - const accountUrl = getAccountUrl(account, config) - try { - const webId = await getWebId(accountDirectory, accountUrl, ldp.suffixMeta, (filePath) => readFile(filePath)) - const name = await getName(webId, ldp.fetchGraph) - writeTemplate(indexFilePath, indexTemplate, { name, webId }) - } catch (err) { - debug.errors(`Failed to create new index for ${account}: ${JSON.stringify(err, null, 2)}`) - } - }) - await Promise.all(usersProcessed) - debug.accounts(`Processed ${usersProcessed.length} users`) - }) -} - -function getAccountUrl (name, config) { - const serverUrl = new URL(config.serverUri) - return `${serverUrl.protocol}//${name}.${serverUrl.host}/` -} - -function isUpdateAllowed (indexFilePath) { - const indexSource = fs.readFileSync(indexFilePath, 'utf-8') - const $ = cheerio.load(indexSource) - const allowAutomaticUpdateValue = $('meta[name="solid-allow-automatic-updates"]').prop('content') - return !allowAutomaticUpdateValue || allowAutomaticUpdateValue === 'true' -} +import fs from 'fs' +import path from 'path' +import * as cheerio from 'cheerio' +import LDP from '../../lib/ldp.mjs' +import { URL } from 'url' +import debug from '../../lib/debug.mjs' +import { readFile } from '../../lib/common/fs-utils.mjs' +import { compileTemplate, writeTemplate } from '../../lib/common/template-utils.mjs' +import { loadConfig, loadAccounts } from './cli-utils.mjs' +import { getName, getWebId } from '../../lib/common/user-utils.mjs' +import { initConfigDir, initTemplateDirs } from '../../lib/server-config.mjs' + +export default function (program) { + program + .command('updateindex.mjs') + .description('Update index.html in root of all PODs that haven\'t been marked otherwise') + .action(async (options) => { + const config = loadConfig(program, options) + const configPath = initConfigDir(config) + const templates = initTemplateDirs(configPath) + const indexTemplatePath = path.join(templates.account, 'index.html') + const indexTemplate = await compileTemplate(indexTemplatePath) + const ldp = new LDP(config) + const accounts = loadAccounts(config) + const usersProcessed = accounts.map(async account => { + const accountDirectory = path.join(config.root, account) + const indexFilePath = path.join(accountDirectory, '/index.html') + if (!isUpdateAllowed(indexFilePath)) { + return + } + const accountUrl = getAccountUrl(account, config) + try { + const webId = await getWebId(accountDirectory, accountUrl, ldp.suffixMeta, (filePath) => readFile(filePath)) + const name = await getName(webId, ldp.fetchGraph) + writeTemplate(indexFilePath, indexTemplate, { name, webId }) + } catch (err) { + debug.errors(`Failed to create new index for ${account}: ${JSON.stringify(err, null, 2)}`) + } + }) + await Promise.all(usersProcessed) + debug.accounts(`Processed ${usersProcessed.length} users`) + }) +} + +function getAccountUrl (name, config) { + const serverUrl = new URL(config.serverUri) + return `${serverUrl.protocol}//${name}.${serverUrl.host}/` +} + +function isUpdateAllowed (indexFilePath) { + const indexSource = fs.readFileSync(indexFilePath, 'utf-8') + const $ = cheerio.load(indexSource) + const allowAutomaticUpdateValue = $('meta[name="solid-allow-automatic-updates"]').prop('content') + return !allowAutomaticUpdateValue || allowAutomaticUpdateValue === 'true' +} diff --git a/bin/solid b/bin/solid index 059baef66..5c705088a 100755 --- a/bin/solid +++ b/bin/solid @@ -1,3 +1,3 @@ #!/usr/bin/env node -const startCli = require('./lib/cli') +import startCli from './lib/cli.mjs' startCli() diff --git a/bin/solid.js b/bin/solid.js deleted file mode 100755 index 059baef66..000000000 --- a/bin/solid.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node -const startCli = require('./lib/cli') -startCli() diff --git a/common/css/bootstrap.min.css b/common/css/bootstrap.min.css new file mode 100644 index 000000000..5b96335ff --- /dev/null +++ b/common/css/bootstrap.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap v3.4.1 (https://getbootstrap.com/) + * Copyright 2011-2019 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;-moz-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:"Glyphicons Halflings";src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format("embedded-opentype"),url(../fonts/glyphicons-halflings-regular.woff2) format("woff2"),url(../fonts/glyphicons-halflings-regular.woff) format("woff"),url(../fonts/glyphicons-halflings-regular.ttf) format("truetype"),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format("svg")}.glyphicon{position:relative;top:1px;display:inline-block;font-family:"Glyphicons Halflings";font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none;margin-left:-5px}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:"\2014 \00A0"}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:""}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:"\00A0 \2014"}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.row-no-gutters{margin-right:0;margin-left:0}.row-no-gutters [class*=col-]{padding-right:0;padding-left:0}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:none;-moz-appearance:none;appearance:none}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s,-webkit-box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control::-ms-expand{background-color:transparent;border:0}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],.input-group-sm input[type=time],input[type=date].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm,input[type=time].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],.input-group-lg input[type=time],input[type=date].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg,input[type=time].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;margin-bottom:0;font-weight:400;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;padding:6px 12px;font-size:14px;line-height:1.42857143;border-radius:4px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);opacity:.65;-webkit-box-shadow:none;box-shadow:none}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;background-image:none;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;background-image:none;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;background-image:none;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;background-image:none;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;background-image:none;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;background-image:none;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group .form-control:focus{z-index:3}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1);-webkit-overflow-scrolling:touch}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-right:15px;margin-top:8px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-right:-15px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);margin-top:8px;margin-bottom:8px}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0%;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{padding:0;cursor:pointer;background:0 0;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%);-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out,-o-transform .3s ease-out}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5);outline:0}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:400;line-height:1.42857143;line-break:auto;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;font-size:12px;filter:alpha(opacity=0);opacity:0}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:400;line-height:1.42857143;line-break:auto;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;font-size:14px;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover>.arrow{border-width:11px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out,-o-transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);left:0}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);left:0}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);left:0}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:rgba(0,0,0,0);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;outline:0;filter:alpha(opacity=90);opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:"\2039"}.carousel-control .icon-next:before{content:"\203a"}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/common/css/solid.css b/common/css/solid.css index 9a7dea5a9..77de81172 100644 --- a/common/css/solid.css +++ b/common/css/solid.css @@ -1,3 +1,90 @@ + .index-page { + background-color: #f8f8f8; + font-size: 1em; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; + } + + .index-page dt { + font-weight: bold; + } + + .index-page dd { + margin: 0; + } + + .index-page .header { + box-shadow: 0px 1px 4px rgba(0,0.0,0.2) !important; + -webkit-box-shadow: 0px 1px 4px rgba(0,0.0,0.2) !important; + text-align: center !important; + color: #7C4DFF; + padding: 10px; + display: inline-flex; + width: 99%; + } + + .index-page .header-left { + text-align: left; + margin-top: 0.67em; + width: 19%; + } + + .index-page .header-center { + margin: auto; + } + + @media screen and (max-width: 1000px) { + .header-right { + display: block !important; + } + } + + .header-right { + display: inline-flex; + margin-top: 0.67em; + min-width: 19%; + } + + .index-page .logo-img { + width: 50px; + } + + .index-page .title { + color: #7C4DFF; + font-size: 2em; + line-height: 1em; + } + + .index-page .container { + position: relative; + display: flex; + flex-direction: column; + flex-wrap: row wrap; + margin: auto; + width: 50%; + line-height: 1.6; + } + + .index-page .content { + display: block; + } + + .index-page .webId { + text-align: center; + -webkit-box-shadow: 0px 1px 4px #7C4DFF !important; + } + + .index-page .logo { + color: #f8f8f8; + } + + .index-page .register-button { + padding: 1em; + border-radius:0.5em; + font-size: 100%; + background-color: #efe; + margin-right: 1em; + } + .panel-login-tls, .panel-already-registered{ text-align: center; diff --git a/common/fonts/glyphicons-halflings-regular.eot b/common/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 000000000..b93a4953f Binary files /dev/null and b/common/fonts/glyphicons-halflings-regular.eot differ diff --git a/common/fonts/glyphicons-halflings-regular.svg b/common/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 000000000..94fb5490a --- /dev/null +++ b/common/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/fonts/glyphicons-halflings-regular.ttf b/common/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 000000000..1413fc609 Binary files /dev/null and b/common/fonts/glyphicons-halflings-regular.ttf differ diff --git a/common/fonts/glyphicons-halflings-regular.woff b/common/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 000000000..9e612858f Binary files /dev/null and b/common/fonts/glyphicons-halflings-regular.woff differ diff --git a/common/fonts/glyphicons-halflings-regular.woff2 b/common/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 000000000..64539b54c Binary files /dev/null and b/common/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/common/img/solid-emblem.svg b/common/img/solid-emblem.svg new file mode 100644 index 000000000..a9b20e4ff --- /dev/null +++ b/common/img/solid-emblem.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/common/js/auth-buttons.js b/common/js/auth-buttons.mjs similarity index 55% rename from common/js/auth-buttons.js rename to common/js/auth-buttons.mjs index 49c28840e..8823aa21d 100644 --- a/common/js/auth-buttons.js +++ b/common/js/auth-buttons.mjs @@ -1,49 +1,57 @@ -/* Provide functionality for authentication buttons */ - -(({ auth }) => { - // Wire up DOM elements - const [loginButton, logoutButton, registerButton, accountSettings] = - ['login', 'logout', 'register', 'account-settings'].map(id => - document.getElementById(id) || document.createElement('a')) - loginButton.addEventListener('click', login) - logoutButton.addEventListener('click', logout) - registerButton.addEventListener('click', register) - - // Track authentication status and update UI - auth.trackSession(session => { - const loggedIn = !!session - const isOwner = loggedIn && new URL(session.webId).origin === location.origin - loginButton.classList.toggle('hidden', loggedIn) - logoutButton.classList.toggle('hidden', !loggedIn) - registerButton.classList.toggle('hidden', loggedIn) - accountSettings.classList.toggle('hidden', !isOwner) - }) - - // Log the user in on the client and the server - async function login () { - const session = await auth.popupLogin() - if (session) { - // Make authenticated request to the server to establish a session cookie - const {status} = await auth.fetch(location, { method: 'HEAD' }) - if (status === 401) { - alert(`Invalid login.\n\nDid you set ${session.idp} as your OIDC provider in your profile ${session.webId}?`) - await auth.logout() - } - // Now that we have a cookie, reload to display the authenticated page - location.reload() - } - } - - // Log the user out from the client and the server - async function logout () { - await auth.logout() - location.reload() - } - - // Redirect to the registration page - function register () { - const registration = new URL('/register', location) - registration.searchParams.set('returnToUrl', location) - location.href = registration - } -})(solid) +// ESM version of auth-buttons.js +// global: location, alert, solid + +((auth) => { + // Wire up DOM elements + const [ + loginButton, + logoutButton, + registerButton, + accountSettings, + loggedInContainer, + profileLink + ] = [ + 'login', + 'logout', + 'register', + 'account-settings', + 'loggedIn', + 'profileLink' + ].map(id => document.getElementById(id) || document.createElement('a')) + loginButton.addEventListener('click', login) + logoutButton.addEventListener('click', logout) + registerButton.addEventListener('click', register) + + // Track authentication status and update UI + auth.trackSession(session => { + const loggedIn = !!session + const isOwner = loggedIn && new URL(session.webId).origin === location.origin + loginButton.classList.toggle('hidden', loggedIn) + logoutButton.classList.toggle('hidden', !loggedIn) + registerButton.classList.toggle('hidden', loggedIn) + accountSettings.classList.toggle('hidden', !isOwner) + loggedInContainer.classList.toggle('hidden', !loggedIn) + if (session) { + profileLink.href = session.webId + profileLink.innerText = session.webId + } + }) + + // Log the user in on the client and the server + async function login () { + alert(`login from this page is no more possible.\n\nYou must ask the pod owner to modify this page or remove it.`) + // Deprecated code omitted + } + + // Log the user out from the client and the server + async function logout () { + await auth.logout() + location.reload() + } + + // Redirect to the registration page + function register () { + const registration = new URL('/register', location) + location.href = registration + } +})(solid) diff --git a/common/js/index-buttons.mjs b/common/js/index-buttons.mjs new file mode 100644 index 000000000..8b75dd99b --- /dev/null +++ b/common/js/index-buttons.mjs @@ -0,0 +1,44 @@ +// ESM version of index-buttons.js +/* global SolidLogic */ +'use strict' +const keyname = 'SolidServerRootRedirectLink' +/* function register () { + alert(2) + window.location.href = '/register' +} */ +document.addEventListener('DOMContentLoaded', async function () { + const authn = SolidLogic.authn + const authSession = SolidLogic.authSession + + if (!authn.currentUser()) await authn.checkUser() + const user = authn.currentUser() + + // IF LOGGED IN: SET SolidServerRootRedirectLink. LOGOUT + if (user) { + window.localStorage.setItem(keyname, user.uri) + await authSession.logout() + } else { + const webId = window.localStorage.getItem(keyname) + // IF NOT LOGGED IN AND COOKIE EXISTS: REMOVE COOKIE, HIDE WELCOME, SHOW LINK TO PROFILE + if (webId) { + window.localStorage.removeItem(keyname) + document.getElementById('loggedIn').style.display = 'block' + document.getElementById('loggedIn').innerHTML = `

Your WebID is : ${webId}.

Visit your profile to log into your Pod.

` + + // IF NOT LOGGED IN AND COOKIE DOES NOT EXIST + // SHOW WELCOME, SHOW LOGIN BUTTON + // HIDE LOGIN BUTTON, ADD REGISTER BUTTON + } else { + const loginArea = document.getElementById('loginStatusArea') + const html = `` + const span = document.createElement('span') + span.innerHTML = html + loginArea.appendChild(span) + loginArea.appendChild(UI.login.loginStatusBox(document, null, {})) + const logInButton = loginArea.querySelectorAll('input')[1] + logInButton.value = 'Log in to see your WebID' + const signUpButton = loginArea.querySelectorAll('input')[2] + signUpButton.style.display = 'none' + } + } +}) diff --git a/common/js/solid.js b/common/js/solid.js index 850b0610a..b186273db 100644 --- a/common/js/solid.js +++ b/common/js/solid.js @@ -267,8 +267,8 @@ // Add the errors in the stack to the DOM this.errors.map((error) => { - let text = document.createTextNode(error) - let paragraph = document.createElement('p') + const text = document.createTextNode(error) + const paragraph = document.createElement('p') paragraph.appendChild(text) this.passwordHelpText.appendChild(paragraph) }) @@ -419,15 +419,15 @@ * @returns {string[]} */ PasswordValidator.prototype.tokenize = function () { - let tokenArray = [] - for (let i in arguments) { + const tokenArray = [] + for (const i in arguments) { tokenArray.push(arguments[i]) } return tokenArray.join(' ').split(' ') } PasswordValidator.prototype.sha1 = function (str) { - let buffer = new TextEncoder('utf-8').encode(str) + const buffer = new TextEncoder('utf-8').encode(str) return crypto.subtle.digest('SHA-1', buffer).then((hash) => { return this.hex(hash) @@ -435,13 +435,13 @@ } PasswordValidator.prototype.hex = function (buffer) { - let hexCodes = [] - let view = new DataView(buffer) + const hexCodes = [] + const view = new DataView(buffer) for (let i = 0; i < view.byteLength; i += 4) { - let value = view.getUint32(i) - let stringValue = value.toString(16) + const value = view.getUint32(i) + const stringValue = value.toString(16) const padding = '00000000' - let paddedValue = (padding + stringValue).slice(-padding.length) + const paddedValue = (padding + stringValue).slice(-padding.length) hexCodes.push(paddedValue) } return hexCodes.join('') diff --git a/common/js/solid.mjs b/common/js/solid.mjs new file mode 100644 index 000000000..e2660bf43 --- /dev/null +++ b/common/js/solid.mjs @@ -0,0 +1,456 @@ +// ESM version of solid.js +// global: owaspPasswordStrengthTest, TextEncoder, crypto, fetch + +(function () { + 'use strict' + + const PasswordValidator = function (passwordField, repeatedPasswordField) { + if ( + passwordField === null || passwordField === undefined || + repeatedPasswordField === null || repeatedPasswordField === undefined + ) { + return + } + + this.passwordField = passwordField + this.repeatedPasswordField = repeatedPasswordField + + this.fetchDomNodes() + this.bindEvents() + + this.currentStrengthLevel = 0 + this.errors = [] + } + + const FEEDBACK_SUCCESS = 'success' + const FEEDBACK_WARNING = 'warning' + const FEEDBACK_ERROR = 'error' + + const ICON_SUCCESS = 'glyphicon-ok' + const ICON_WARNING = 'glyphicon-warning-sign' + const ICON_ERROR = 'glyphicon-remove' + + const VALIDATION_SUCCESS = 'has-success' + const VALIDATION_WARNING = 'has-warning' + const VALIDATION_ERROR = 'has-error' + + const STRENGTH_PROGRESS_0 = 'progress-bar-danger level-0' + const STRENGTH_PROGRESS_1 = 'progress-bar-danger level-1' + const STRENGTH_PROGRESS_2 = 'progress-bar-warning level-2' + const STRENGTH_PROGRESS_3 = 'progress-bar-success level-3' + const STRENGTH_PROGRESS_4 = 'progress-bar-success level-4' + + /** + * Prefetch all dom nodes at initialisation in order to gain time at execution since DOM manipulations + * are really time consuming + */ + PasswordValidator.prototype.fetchDomNodes = function () { + this.form = this.passwordField.closest('form') + + this.disablePasswordChecks = this.passwordField.classList.contains('disable-password-checks') + + this.passwordGroup = this.passwordField.closest('.form-group') + this.passwordFeedback = this.passwordGroup.querySelector('.form-control-feedback') + this.passwordStrengthMeter = this.passwordGroup.querySelector('.progress-bar') + this.passwordHelpText = this.passwordGroup.querySelector('.help-block') + + this.repeatedPasswordGroup = this.repeatedPasswordField.closest('.form-group') + this.repeatedPasswordFeedback = this.repeatedPasswordGroup.querySelector('.form-control-feedback') + } + + PasswordValidator.prototype.bindEvents = function () { + this.passwordField.addEventListener('focus', this.resetPasswordFeedback.bind(this)) + this.passwordField.addEventListener('keyup', this.instantFeedbackForPassword.bind(this)) + this.repeatedPasswordField.addEventListener('keyup', this.validateRepeatedPassword.bind(this)) + this.passwordField.addEventListener('blur', this.validatePassword.bind(this)) + } + + /** + * Events Listeners + */ + + PasswordValidator.prototype.resetPasswordFeedback = function () { + this.errors = [] + this.resetValidation(this.passwordGroup) + this.resetFeedbackIcon(this.passwordFeedback) + if (!this.disablePasswordChecks) { + this.displayPasswordErrors() + this.instantFeedbackForPassword() + } + } + + /** + * Validate password on the fly to provide the user a visual strength meter + */ + PasswordValidator.prototype.instantFeedbackForPassword = function () { + const passwordStrength = this.getPasswordStrength(this.passwordField.value) + const strengthLevel = this.getStrengthLevel(passwordStrength) + + if (this.currentStrengthLevel === strengthLevel) { + return + } + + this.currentStrengthLevel = strengthLevel + + this.updateStrengthMeter() + + if (this.repeatedPasswordField.value !== '') { + this.updateRepeatedPasswordFeedback() + } + } + + /** + * Validate password and display the error(s) message(s) + */ + PasswordValidator.prototype.validatePassword = function () { + this.errors = [] + const password = this.passwordField.value + + if (!this.disablePasswordChecks) { + const passwordStrength = this.getPasswordStrength(password) + this.currentStrengthLevel = this.getStrengthLevel(passwordStrength) + + if (passwordStrength.errors) { + this.addPasswordError(passwordStrength.errors) + } + + this.checkLeakedPassword(password).then(this.handleLeakedPasswordResponse.bind(this)) + } + + this.setPasswordFeedback() + } + + /** + * Validate the repeated password upon typing + */ + PasswordValidator.prototype.validateRepeatedPassword = function () { + this.updateRepeatedPasswordFeedback() + } + + /** + * User Feedback manipulators + */ + + /** + * Update the strength meter based on OWASP feedback + */ + PasswordValidator.prototype.updateStrengthMeter = function () { + this.resetStrengthMeter() + + this.passwordStrengthMeter.classList.add.apply( + this.passwordStrengthMeter.classList, + this.tokenize(this.getStrengthLevelProgressClass()) + ) + } + + PasswordValidator.prototype.setPasswordFeedback = function () { + const feedback = this.getFeedbackFromLevel() + this.updateStrengthMeter() + this.displayPasswordErrors() + this.setFeedbackForField(feedback, this.passwordField) + } + + /** + * Update the repeated password feedback icon and color + */ + PasswordValidator.prototype.updateRepeatedPasswordFeedback = function () { + const feedback = this.checkPasswordFieldsEquality() ? FEEDBACK_SUCCESS : FEEDBACK_ERROR + this.setFeedbackForField(feedback, this.repeatedPasswordField) + } + + /** + * Display the given feedback on the field + * @param {string} feedback success|error|warning + * @param {HTMLElement} field + */ + PasswordValidator.prototype.setFeedbackForField = function (feedback, field) { + const formGroup = this.getFormGroupElementForField(field) + const visualFeedback = this.getFeedbackElementForField(field) + + this.resetValidation(formGroup) + this.resetFeedbackIcon(visualFeedback) + + visualFeedback.classList.remove('hidden') + + visualFeedback.classList + .add + .apply( + visualFeedback.classList, + this.tokenize(this.getFeedbackIconClass(feedback)) + ) + + formGroup.classList + .add + .apply( + formGroup.classList, + this.tokenize(this.getValidationClass(feedback)) + ) + } + + /** + * Password Strength Helpers + */ + + /** + * Get OWASP feedback on the given password. Returns false if the password is empty + * @param password + * @returns {object|false} + */ + PasswordValidator.prototype.getPasswordStrength = function (password) { + if (password === '') { + return false + } + return owaspPasswordStrengthTest.test(password) + } + + /** + * Get the password strength level based on password strength feedback object given by OWASP + * @param passwordStrength + * @returns {number} + */ + PasswordValidator.prototype.getStrengthLevel = function (passwordStrength) { + if (passwordStrength === false) { + return 0 + } + if (passwordStrength.requiredTestErrors.length !== 0) { + return 1 + } + + if (passwordStrength.strong === false) { + return 2 + } + + if (passwordStrength.isPassphrase === false || passwordStrength.optionalTestErrors.length !== 0) { + return 3 + } + + return 4 + } + + PasswordValidator.prototype.LEVEL_TO_FEEDBACK_MAP = [ + FEEDBACK_ERROR, + FEEDBACK_ERROR, + FEEDBACK_WARNING, + FEEDBACK_SUCCESS, + FEEDBACK_SUCCESS + ] + + /** + * @returns {string} + */ + PasswordValidator.prototype.getFeedbackFromLevel = function () { + return this.LEVEL_TO_FEEDBACK_MAP[this.currentStrengthLevel] + } + + PasswordValidator.prototype.LEVEL_TO_PROGRESS_MAP = [ + STRENGTH_PROGRESS_0, + STRENGTH_PROGRESS_1, + STRENGTH_PROGRESS_2, + STRENGTH_PROGRESS_3, + STRENGTH_PROGRESS_4 + ] + + /** + * Get the CSS class for the meter based on the current level + */ + PasswordValidator.prototype.getStrengthLevelProgressClass = function () { + return this.LEVEL_TO_PROGRESS_MAP[this.currentStrengthLevel] + } + + PasswordValidator.prototype.addPasswordError = function (error) { + this.errors.push(...(Array.isArray(error) ? error : [error])) + } + + PasswordValidator.prototype.displayPasswordErrors = function () { + // Erase the error list content + while (this.passwordHelpText.firstChild) { + this.passwordHelpText.removeChild(this.passwordHelpText.firstChild) + } + + // Add the errors in the stack to the DOM + this.errors.map((error) => { + const text = document.createTextNode(error) + const paragraph = document.createElement('p') + paragraph.appendChild(text) + this.passwordHelpText.appendChild(paragraph) + }) + } + + PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP = [] + PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP[FEEDBACK_SUCCESS] = ICON_SUCCESS + PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP[FEEDBACK_WARNING] = ICON_WARNING + PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP[FEEDBACK_ERROR] = ICON_ERROR + + /** + * @param success|error|warning feedback + */ + PasswordValidator.prototype.getFeedbackIconClass = function (feedback) { + return this.FEEDBACK_TO_ICON_MAP[feedback] + } + + PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP = [] + PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP[FEEDBACK_SUCCESS] = VALIDATION_SUCCESS + PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP[FEEDBACK_WARNING] = VALIDATION_WARNING + PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP[FEEDBACK_ERROR] = VALIDATION_ERROR + + /** + * @param success|error|warning feedback + */ + PasswordValidator.prototype.getValidationClass = function (feedback) { + return this.FEEDBACK_TO_VALIDATION_MAP[feedback] + } + + /** + * Validators + */ + + /** + * Check if both password fields are equal + * @returns {boolean} + */ + PasswordValidator.prototype.checkPasswordFieldsEquality = function () { + return this.passwordField.value === this.repeatedPasswordField.value + } + + /** + * Check if the password is leaked + * @param password + */ + PasswordValidator.prototype.checkLeakedPassword = function (password) { + const url = 'https://api.pwnedpasswords.com/range/' + + return new Promise(function (resolve, reject) { + this.sha1(password).then((digest) => { + const preFix = digest.slice(0, 5) + let suffix = digest.slice(5, digest.length) + suffix = suffix.toUpperCase() + + return fetch(url + preFix) + .then(function (response) { + return response.text() + }) + .then(function (data) { + resolve(data.indexOf(suffix) > -1) + }) + .catch(function (err) { + reject(err) + }) + }) + }.bind(this)) + } + + PasswordValidator.prototype.handleLeakedPasswordResponse = function (hasPasswordLeaked) { + if (hasPasswordLeaked === true) { + this.currentStrengthLevel-- + this.addPasswordError('This password was exposed in a data breach. Please use a more secure alternative one!') + } + + this.setPasswordFeedback() + } + + /** + * CSS Classes reseters + */ + + PasswordValidator.prototype.resetValidation = function (el) { + const tokenizedClasses = this.tokenize( + VALIDATION_ERROR, + VALIDATION_WARNING, + VALIDATION_SUCCESS + ) + + el.classList.remove.apply( + el.classList, + tokenizedClasses + ) + } + + PasswordValidator.prototype.resetFeedbackIcon = function (el) { + const tokenizedClasses = this.tokenize( + ICON_ERROR, + ICON_WARNING, + ICON_SUCCESS + ) + + el.classList.remove.apply( + el.classList, + tokenizedClasses + ) + } + + PasswordValidator.prototype.resetStrengthMeter = function () { + const tokenizedClasses = this.tokenize( + STRENGTH_PROGRESS_1, + STRENGTH_PROGRESS_2, + STRENGTH_PROGRESS_3, + STRENGTH_PROGRESS_4 + ) + + this.passwordStrengthMeter.classList.remove.apply( + this.passwordStrengthMeter.classList, + tokenizedClasses + ) + } + + /** + * Helpers + */ + + PasswordValidator.prototype.getFormGroupElementForField = function (field) { + if (field === this.passwordField) { + return this.passwordGroup + } + + if (field === this.repeatedPasswordField) { + return this.repeatedPasswordGroup + } + } + + PasswordValidator.prototype.getFeedbackElementForField = function (field) { + if (field === this.passwordField) { + return this.passwordFeedback + } + + if (field === this.repeatedPasswordField) { + return this.repeatedPasswordFeedback + } + } + + /** + * Returns an array of strings ready to be applied on classList.add or classList.remove + * @returns {string[]} + */ + PasswordValidator.prototype.tokenize = function () { + const tokenArray = [] + for (const i in arguments) { + tokenArray.push(arguments[i]) + } + return tokenArray.join(' ').split(' ') + } + + PasswordValidator.prototype.sha1 = function (str) { + const buffer = new TextEncoder('utf-8').encode(str) + + return crypto.subtle.digest('SHA-1', buffer).then((hash) => { + return this.hex(hash) + }) + } + + PasswordValidator.prototype.hex = function (buffer) { + const hexCodes = [] + const view = new DataView(buffer) + for (let i = 0; i < view.byteLength; i += 4) { + const value = view.getUint32(i) + const stringValue = value.toString(16) + const padding = '00000000' + const paddedValue = (padding + stringValue).slice(-padding.length) + hexCodes.push(paddedValue) + } + return hexCodes.join('') + } + + new PasswordValidator( + document.getElementById('password'), + document.getElementById('repeat_password') + ) +})() diff --git a/config/defaults.js b/config/defaults.js deleted file mode 100644 index 92b4ef70e..000000000 --- a/config/defaults.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict' - -module.exports = { - 'auth': 'oidc', - 'localAuth': { - 'tls': true, - 'password': true - }, - 'configPath': './config', - 'dbPath': './.db', - 'port': 8443, - 'serverUri': 'https://localhost:8443', - 'webid': true, - 'strictOrigin': true, - 'trustedOrigins': [], - 'dataBrowserPath': 'default' - - // For use in Enterprises to configure a HTTP proxy for all outbound HTTP requests from the SOLID server (we use - // https://www.npmjs.com/package/global-tunnel-ng). - // "httpProxy": { - // "tunnel": "neither", - // "host": "proxy.example.com", - // "port": 12345 - // } -} diff --git a/config/defaults.mjs b/config/defaults.mjs new file mode 100644 index 000000000..62c9b448e --- /dev/null +++ b/config/defaults.mjs @@ -0,0 +1,22 @@ +export default { + auth: 'oidc', + localAuth: { + tls: true, + password: true + }, + configPath: './config', + dbPath: './.db', + port: 8443, + serverUri: 'https://localhost:8443', + webid: true, + strictOrigin: true, + trustedOrigins: [], + dataBrowserPath: 'default' + // For use in Enterprises to configure a HTTP proxy for all outbound HTTP requests from the SOLID server (we use + // https://www.npmjs.com/package/global-tunnel-ng). + // "httpProxy": { + // "tunnel": "neither", + // "host": "proxy.example.com", + // "port": 12345 + // } +} diff --git a/default-templates/emails/delete-account.mjs b/default-templates/emails/delete-account.mjs new file mode 100644 index 000000000..c8c98d915 --- /dev/null +++ b/default-templates/emails/delete-account.mjs @@ -0,0 +1,31 @@ +export function render (data) { + return { + subject: 'Delete Solid-account request', + + /** + * Text version + */ + text: `Hi, + +We received a request to delete your Solid account, ${data.webId} + +To delete your account, click on the following link: + +${data.deleteUrl} + +If you did not mean to delete your account, ignore this email.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to delete your Solid account, ${data.webId}

+ +

To delete your account, click on the following link:

+ +

${data.deleteUrl}

+ +

If you did not mean to delete your account, ignore this email.

` + } +} diff --git a/default-templates/emails/invalid-username.mjs b/default-templates/emails/invalid-username.mjs new file mode 100644 index 000000000..7f0351d77 --- /dev/null +++ b/default-templates/emails/invalid-username.mjs @@ -0,0 +1,27 @@ +export function render (data) { + return { + subject: `Invalid username for account ${data.accountUri}`, + + /** + * Text version + */ + text: `Hi, + +We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy. + +This account has been set to be deleted at ${data.dateOfRemoval}. + +${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move your account.` : ''}`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.

+ +

This account has been set to be deleted at ${data.dateOfRemoval}.

+ +${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''}` + } +} diff --git a/default-templates/emails/reset-password.mjs b/default-templates/emails/reset-password.mjs new file mode 100644 index 000000000..8c76e240e --- /dev/null +++ b/default-templates/emails/reset-password.mjs @@ -0,0 +1,31 @@ +export function render (data) { + return { + subject: 'Account password reset', + + /** + * Text version + */ + text: `Hi, + +We received a request to reset your password for your Solid account, ${data.webId} + +To reset your password, click on the following link: + +${data.resetUrl} + +If you did not mean to reset your password, ignore this email, your password will not change.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to reset your password for your Solid account, ${data.webId}

+ +

To reset your password, click on the following link:

+ +

${data.resetUrl}

+ +

If you did not mean to reset your password, ignore this email, your password will not change.

` + } +} diff --git a/default-templates/emails/welcome.mjs b/default-templates/emails/welcome.mjs new file mode 100644 index 000000000..eec8581e0 --- /dev/null +++ b/default-templates/emails/welcome.mjs @@ -0,0 +1,23 @@ +export function render (data) { + return { + subject: 'Welcome to Solid', + + /** + * Text version of the Welcome email + */ + text: `Welcome to Solid! + +Your account has been created. + +Your Web Id: ${data.webid}`, + + /** + * HTML version of the Welcome email + */ + html: `

Welcome to Solid!

+ +

Your account has been created.

+ +

Your Web Id: ${data.webid}

` + } +} diff --git a/default-templates/new-account/.well-known/.acl b/default-templates/new-account/.well-known/.acl index 9e13201e2..6e9f5133d 100644 --- a/default-templates/new-account/.well-known/.acl +++ b/default-templates/new-account/.well-known/.acl @@ -7,7 +7,7 @@ a acl:Authorization; acl:agent <{{webId}}>; acl:accessTo <./>; - acl:defaultForNew <./>; + acl:default <./>; acl:mode acl:Read, acl:Write, acl:Control. # The public has read permissions @@ -15,5 +15,5 @@ a acl:Authorization; acl:agentClass foaf:Agent; acl:accessTo <./>; - acl:defaultForNew <./>; + acl:default <./>; acl:mode acl:Read. diff --git a/default-templates/new-account/index.html b/default-templates/new-account/index.html deleted file mode 100644 index 7fe96bfa7..000000000 --- a/default-templates/new-account/index.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - {{#if name}}{{name}} – {{/if}}Solid Home - - - - -
- - -

- - This is a public homepage of {{#if name}}{{name}}, whose WebID is{{else}}a user with WebID{{/if}} - - {{webId}}. -

- -
-

Data

- -
- -
-

Apps

- -
- - -
- - - - - diff --git a/default-templates/new-account/private/.acl b/default-templates/new-account/private/.acl index 1fae7fb55..914efcf9f 100644 --- a/default-templates/new-account/private/.acl +++ b/default-templates/new-account/private/.acl @@ -6,5 +6,5 @@ a acl:Authorization; acl:agent <{{webId}}>; acl:accessTo <./>; - acl:defaultForNew <./>; + acl:default <./>; acl:mode acl:Read, acl:Write, acl:Control. diff --git a/default-templates/new-account/profile/.acl b/default-templates/new-account/profile/.acl index a3cfaedc4..1fb254129 100644 --- a/default-templates/new-account/profile/.acl +++ b/default-templates/new-account/profile/.acl @@ -7,7 +7,7 @@ a acl:Authorization; acl:agent <{{webId}}>; acl:accessTo <./>; - acl:defaultForNew <./>; + acl:default <./>; acl:mode acl:Read, acl:Write, acl:Control. # The public has read permissions @@ -15,5 +15,5 @@ a acl:Authorization; acl:agentClass foaf:Agent; acl:accessTo <./>; - acl:defaultForNew <./>; + acl:default <./>; acl:mode acl:Read. diff --git a/default-templates/new-account/profile/card$.ttl b/default-templates/new-account/profile/card$.ttl index 063bc61cf..e16d1771d 100644 --- a/default-templates/new-account/profile/card$.ttl +++ b/default-templates/new-account/profile/card$.ttl @@ -17,6 +17,7 @@ solid:account ; # link to the account uri pim:storage ; # root storage + solid:oidcIssuer <{{idp}}> ; # identity provider ldp:inbox ; diff --git a/default-templates/new-account/settings/serverSide.ttl.inactive b/default-templates/new-account/settings/serverSide.ttl.inactive index 46037ed3e..3cad13211 100644 --- a/default-templates/new-account/settings/serverSide.ttl.inactive +++ b/default-templates/new-account/settings/serverSide.ttl.inactive @@ -1,7 +1,6 @@ @prefix dct: . @prefix pim: . @prefix solid: . -@prefix unit: . <> a pim:ConfigurationFile; diff --git a/default-templates/server/index.html b/default-templates/server/index.html index 30c3b1609..85158e1e3 100644 --- a/default-templates/server/index.html +++ b/default-templates/server/index.html @@ -1,55 +1,54 @@ - - - - - Welcome to Solid - + + + - - -
- + + + + diff --git a/test/resources/config/views/auth/login-tls.hbs b/test/resources/config/views/auth/login-tls.hbs new file mode 100644 index 000000000..3c934b45a --- /dev/null +++ b/test/resources/config/views/auth/login-tls.hbs @@ -0,0 +1,11 @@ + diff --git a/test/resources/config/views/auth/login-username-password.hbs b/test/resources/config/views/auth/login-username-password.hbs new file mode 100644 index 000000000..3e6f3bb84 --- /dev/null +++ b/test/resources/config/views/auth/login-username-password.hbs @@ -0,0 +1,28 @@ +
+
+ +
+
diff --git a/test/resources/config/views/auth/login.hbs b/test/resources/config/views/auth/login.hbs new file mode 100644 index 000000000..37c89e2ec --- /dev/null +++ b/test/resources/config/views/auth/login.hbs @@ -0,0 +1,55 @@ + + + + + + Login + + + + + + +
+ + + + {{> shared/error}} + +
+
+ {{#if enablePassword}} +

Login

+ {{> auth/login-username-password}} + {{/if}} +
+ {{> shared/create-account }} +
+
+ +
+ {{#if enableTls}} + {{> auth/login-tls}} + {{/if}} +
+ {{> shared/create-account }} +
+
+
+
+ + + + + diff --git a/test/resources/config/views/auth/no-permission.hbs b/test/resources/config/views/auth/no-permission.hbs new file mode 100644 index 000000000..18e719de7 --- /dev/null +++ b/test/resources/config/views/auth/no-permission.hbs @@ -0,0 +1,29 @@ + + + + + + No permission + + + + +
+ +
+

+ You are currently logged in as {{webId}}, + but do not have permission to access {{currentUrl}}. +

+

+ +

+
+
+
+ + + + diff --git a/test/resources/config/views/auth/password-changed.hbs b/test/resources/config/views/auth/password-changed.hbs new file mode 100644 index 000000000..bf513858f --- /dev/null +++ b/test/resources/config/views/auth/password-changed.hbs @@ -0,0 +1,27 @@ + + + + + + Password Changed + + + + +
+ + +
+

Your password has been changed.

+
+ +

+ + Log in + +

+
+ + diff --git a/test/resources/config/views/auth/reset-link-sent.hbs b/test/resources/config/views/auth/reset-link-sent.hbs new file mode 100644 index 000000000..6241c443d --- /dev/null +++ b/test/resources/config/views/auth/reset-link-sent.hbs @@ -0,0 +1,21 @@ + + + + + + Reset Link Sent + + + + +
+ + +
+

A Reset Password link has been sent to the associated email account.

+
+
+ + diff --git a/test/resources/config/views/auth/reset-password.hbs b/test/resources/config/views/auth/reset-password.hbs new file mode 100644 index 000000000..24d9c61e3 --- /dev/null +++ b/test/resources/config/views/auth/reset-password.hbs @@ -0,0 +1,52 @@ + + + + + + Reset Password + + + + +
+ + + +
+
+
+ {{> shared/error}} + +
+ {{#if multiuser}} +

Please enter your account name. A password reset link will be + emailed to the address you provided during account registration.

+ + + + {{else}} +

A password reset link will be + emailed to the address you provided during account registration.

+ {{/if}} + +
+ + + +
+
+
+ +
+
+ New to Solid? Create an + account +
+
+ +
+ + diff --git a/test/resources/config/views/auth/sharing.hbs b/test/resources/config/views/auth/sharing.hbs new file mode 100644 index 000000000..c2c4e409d --- /dev/null +++ b/test/resources/config/views/auth/sharing.hbs @@ -0,0 +1,49 @@ + + + + + + {{title}} + + + + + +
+

Authorize {{app_origin}} to access your Pod?

+

Solid allows you to precisely choose what other people and apps can read and write in a Pod. This version of the authorization user interface (node-solid-server V5.1) only supports the toggle of global access permissions to all of the data in your Pod.

+

If you don’t want to set these permissions at a global level, uncheck all of the boxes below, then click authorize. This will add the application origin to your authorization list, without granting it permission to any of your data yet. You will then need to manage those permissions yourself by setting them explicitly in the places you want this application to access.

+
+
+
+

By clicking Authorize, any app from {{app_origin}} will be able to:

+
+
+ + + +
+ + + +
+ + + +
+ + + +
+
+ + + + {{> auth/auth-hidden-fields}} +
+
+
+

This server (node-solid-server V5.1) only implements a limited subset of OpenID Connect, and doesn’t yet support token issuance for applications. OIDC Token Issuance and fine-grained management through this authorization user interface is currently in the development backlog for node-solid-server

+
+ + diff --git a/test/resources/config/views/shared/create-account.hbs b/test/resources/config/views/shared/create-account.hbs new file mode 100644 index 000000000..1cc0bd810 --- /dev/null +++ b/test/resources/config/views/shared/create-account.hbs @@ -0,0 +1,8 @@ +
+
+ New to Solid? + + Create an account + +
+
diff --git a/test/resources/config/views/shared/error.hbs b/test/resources/config/views/shared/error.hbs new file mode 100644 index 000000000..8aedd23e0 --- /dev/null +++ b/test/resources/config/views/shared/error.hbs @@ -0,0 +1,5 @@ +{{#if error}} +
+

{{error}}

+
+{{/if}} diff --git a/test/resources/favicon.ico b/test/resources/favicon.ico new file mode 100644 index 000000000..764acb205 Binary files /dev/null and b/test/resources/favicon.ico differ diff --git a/test/resources/favicon.ico.acl b/test/resources/favicon.ico.acl new file mode 100644 index 000000000..e76838bb8 --- /dev/null +++ b/test/resources/favicon.ico.acl @@ -0,0 +1,15 @@ +# ACL for the default favicon.ico resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/resources/ldpatch-example-final.ttl b/test/resources/ldpatch-example-final.ttl index 158304e42..9c3f55140 100644 --- a/test/resources/ldpatch-example-final.ttl +++ b/test/resources/ldpatch-example-final.ttl @@ -1,11 +1,11 @@ @prefix schema: . -@prefix profile: . +@prefix pro: . @prefix ex: . a schema:Person ; schema:alternateName "TimBL" ; - profile:first_name "Timothy" ; - profile:last_name "Berners-Lee" ; + pro:first_name "Timothy" ; + pro:last_name "Berners-Lee" ; schema:workLocation [ schema:name "W3C/MIT" ] ; schema:performerIn _:b1, _:b2 ; ex:preferredLanguages ( "en" "fr-CH" ). diff --git a/test/resources/patch/.well-known/.acl b/test/resources/patch/.well-known/.acl new file mode 100644 index 000000000..6cacb3779 --- /dev/null +++ b/test/resources/patch/.well-known/.acl @@ -0,0 +1,15 @@ +# ACL for the default .well-known/ resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/resources/patch/favicon.ico b/test/resources/patch/favicon.ico new file mode 100644 index 000000000..764acb205 Binary files /dev/null and b/test/resources/patch/favicon.ico differ diff --git a/test/resources/patch/favicon.ico.acl b/test/resources/patch/favicon.ico.acl new file mode 100644 index 000000000..e76838bb8 --- /dev/null +++ b/test/resources/patch/favicon.ico.acl @@ -0,0 +1,15 @@ +# ACL for the default favicon.ico resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/resources/patch/robots.txt b/test/resources/patch/robots.txt new file mode 100644 index 000000000..8c27a0227 --- /dev/null +++ b/test/resources/patch/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +# Allow all crawling (subject to ACLs as usual, of course) +Disallow: diff --git a/test/resources/patch/robots.txt.acl b/test/resources/patch/robots.txt.acl new file mode 100644 index 000000000..1eaabc201 --- /dev/null +++ b/test/resources/patch/robots.txt.acl @@ -0,0 +1,15 @@ +# ACL for the default robots.txt resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/resources/robots.txt b/test/resources/robots.txt new file mode 100644 index 000000000..8c27a0227 --- /dev/null +++ b/test/resources/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +# Allow all crawling (subject to ACLs as usual, of course) +Disallow: diff --git a/test/resources/robots.txt.acl b/test/resources/robots.txt.acl new file mode 100644 index 000000000..1eaabc201 --- /dev/null +++ b/test/resources/robots.txt.acl @@ -0,0 +1,15 @@ +# ACL for the default robots.txt resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/resources/sampleContainer/example.ttl.old b/test/resources/sampleContainer/example.ttl.old new file mode 100644 index 000000000..0d931ee5f --- /dev/null +++ b/test/resources/sampleContainer/example.ttl.old @@ -0,0 +1 @@ +<#current> <#temp> 123 . \ No newline at end of file diff --git a/test/surface/docker/cookie/.dockerignore b/test/surface/docker/cookie/.dockerignore new file mode 100644 index 000000000..8921eb9d0 --- /dev/null +++ b/test/surface/docker/cookie/.dockerignore @@ -0,0 +1 @@ +app/node_modules diff --git a/test/surface/docker/cookie/Dockerfile b/test/surface/docker/cookie/Dockerfile new file mode 100644 index 000000000..a5dcb069a --- /dev/null +++ b/test/surface/docker/cookie/Dockerfile @@ -0,0 +1,6 @@ +FROM node +ADD app /app +WORKDIR /app +RUN npm install +ENV NODE_TLS_REJECT_UNAUTHORIZED 0 +CMD node index.js diff --git a/test/surface/docker/cookie/app/index.js b/test/surface/docker/cookie/app/index.js new file mode 100644 index 000000000..bd338c8be --- /dev/null +++ b/test/surface/docker/cookie/app/index.js @@ -0,0 +1,29 @@ +const fetch = require('node-fetch') + +const SERVER_ROOT = process.env.SERVER_ROOT || 'https://server' +const LOGIN_URL = `${SERVER_ROOT}/login/password` +const USERNAME = process.env.USERNAME || 'alice' +const PASSWORD = process.env.PASSWORD || '123' + +async function getCookie () { + const result = await fetch(LOGIN_URL, { + body: [ + `username=${USERNAME}`, + `password=${PASSWORD}` + ].join('&'), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + method: 'POST', + redirect: 'manual' + }) + return result.headers.get('set-cookie') +} + +async function run () { + const cookie = await getCookie() + console.log(cookie) +} + +// ... +run() diff --git a/test/surface/docker/cookie/app/package-lock.json b/test/surface/docker/cookie/app/package-lock.json new file mode 100644 index 000000000..4d199e01f --- /dev/null +++ b/test/surface/docker/cookie/app/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "get-nss-cookie", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + } + } +} diff --git a/test/surface/docker/cookie/app/package.json b/test/surface/docker/cookie/app/package.json new file mode 100644 index 000000000..0af7f4e26 --- /dev/null +++ b/test/surface/docker/cookie/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "get-nss-cookie", + "version": "1.0.0", + "description": "Get a cookie from a node-solid-server instance", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "node-fetch": "^2.6.1" + } +} diff --git a/test/surface/docker/server/.db/oidc/op/clients/_key_7e5c0fede7682892e36b2ef3ecda05a6.json b/test/surface/docker/server/.db/oidc/op/clients/_key_7e5c0fede7682892e36b2ef3ecda05a6.json new file mode 100644 index 000000000..c31a76805 --- /dev/null +++ b/test/surface/docker/server/.db/oidc/op/clients/_key_7e5c0fede7682892e36b2ef3ecda05a6.json @@ -0,0 +1 @@ +{"redirect_uris":["https://server/api/oidc/rp/https%3A%2F%2Ffd.xuwubk.eu.org%3A443%2Fhttps%2Fserver"],"client_id":"7e5c0fede7682892e36b2ef3ecda05a6","client_secret":"d634791ff779ce90d378d714282e1374","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://server","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://server/goodbye"]} \ No newline at end of file diff --git a/test/surface/docker/server/.db/oidc/op/clients/_key_coolApp1.json b/test/surface/docker/server/.db/oidc/op/clients/_key_coolApp1.json new file mode 100644 index 000000000..be28eb170 --- /dev/null +++ b/test/surface/docker/server/.db/oidc/op/clients/_key_coolApp1.json @@ -0,0 +1 @@ +{"redirect_uris":["http://localhost:3001/redirect"],"client_id":"coolApp1","client_secret":"9ae94c0a2f86a02a5dfa8d0a522f8176","response_types":["code"],"grant_types":["authorization_code"],"application_type":"web","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic"} diff --git a/test/surface/docker/server/.db/oidc/op/clients/_key_coolApp2.json b/test/surface/docker/server/.db/oidc/op/clients/_key_coolApp2.json new file mode 100644 index 000000000..933174251 --- /dev/null +++ b/test/surface/docker/server/.db/oidc/op/clients/_key_coolApp2.json @@ -0,0 +1 @@ +{"redirect_uris":["http://localhost:3002/redirect"],"client_id":"coolApp","client_secret":"9ae94c0a2f86a02a5dfa8d0a522f8176","response_types":["code"],"grant_types":["authorization_code"],"application_type":"web","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic"} diff --git a/test/surface/docker/server/.db/oidc/rp/clients/_key_https%3A%2F%2Ffd.xuwubk.eu.org%3A443%2Fhttps%2Fserver.json b/test/surface/docker/server/.db/oidc/rp/clients/_key_https%3A%2F%2Ffd.xuwubk.eu.org%3A443%2Fhttps%2Fserver.json new file mode 100644 index 000000000..21234de07 --- /dev/null +++ b/test/surface/docker/server/.db/oidc/rp/clients/_key_https%3A%2F%2Ffd.xuwubk.eu.org%3A443%2Fhttps%2Fserver.json @@ -0,0 +1 @@ +{"provider":{"url":"https://server","configuration":{"issuer":"https://server","jwks_uri":"https://server/jwks","response_types_supported":["code","code token","code id_token","id_token code","id_token","id_token token","code id_token token","none"],"token_types_supported":["legacyPop","dpop"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":"client_secret_basic","token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://server/session","end_session_endpoint":"https://server/logout","authorization_endpoint":"https://server/authorize","token_endpoint":"https://server/token","userinfo_endpoint":"https://server/userinfo","registration_endpoint":"https://server/register","keys":{"descriptor":{"id_token":{"signing":{"RS256":{"alg":"RS256","modulusLength":2048},"RS384":{"alg":"RS384","modulusLength":2048},"RS512":{"alg":"RS512","modulusLength":2048}},"encryption":{}},"token":{"signing":{"RS256":{"alg":"RS256","modulusLength":2048},"RS384":{"alg":"RS384","modulusLength":2048},"RS512":{"alg":"RS512","modulusLength":2048}},"encryption":{}},"userinfo":{"encryption":{}},"register":{"signing":{"RS256":{"alg":"RS256","modulusLength":2048}}}},"jwks":{"keys":[{"kid":"z1rytTgbnLU","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"u0_S_wb5BPOPSH63j_PuXu_GVAlrytrlXFYMX0UgyT_fGiC46bL-XaQ9IihL1OWb03_kBBfj8IKJ6cantiURxpXHSC0e25kuqBTgYzdpnxIWtdatVJMYj9jXLaKMUZqMkuQSUknaD7v6uW5jPwKD0JWmPSWok3LXFIzbKrXQ5KJmB1IOFi5j9qPEZ5Ia16IoyVnSpmvsiVAYAMT8lk1c6jxzDvp9lSIKOTkTeQxwBzH3l3WuaPqMr5gvzazKoMDV7RAJlbZEO56HFici_ch7Zuq2QMRrjwv7vuvRRoOy1lnowVmldav1V4BbUHzuHEZ4p92mafT7Qgg6wQRzwtnjMw","e":"AQAB"},{"kid":"moNrbB9qhmE","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"3BO_8L0G6fRRQ1JZyG_DrJjjNPvn-B9aKsTv24Dt1jwxNQFyZwb5s7kn3dyxKq12IubebwGMxkA8rrHmvIFe9HOThm6_rDo3NIMGM0OVL1PSxlnlzNXEcdkJknhO03YqwUWZc-VkoVD1CSznX-0fE0BcTupCfrN0pgjgnx8j5GZijLePKh6d2v_6PSR2qKa0MaMgwAL9ilniSwbIpIVjgPI9UloP4rYBjUVTTQi2scmafeZFfN_jID44L7skMPXHde16eEPwy2lxdVgxBno_y1d42Rf6wkr3NMnSQaemm0HTTxT4hpDeQTvioKvZHvu5gNm8ujm9Qo2tXbiDtT_hcQ","e":"AQAB"},{"kid":"YN4vTwmLAgI","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"wjddayoLnWlgEZB6WrM3UiKMRPknK70SW9cqgKLxSDP1p06qg9ECDolh3PkPiEGkNbbg4SzKu8RPTRcgBkc9xWvPf22ee6ArPscH3Y8977TPvOXY5xFGfHMvWxdC95FSdHcYEHCPdH-ww3DqirmRU3yGgjpVaCIdFsud9uz-UnssfUPAcs5cmtfLTH6OLMKtrU3X5A1TNi6pFu2HGZ4NPPAnu5mr3s_fDxZ7UZLLP8NiRujHiDT8mMKF_ts10oiUERtd2UZOkO36zT3TdsNYoAg_s53E5NOUzYPFrr_PgN-hTi449NIE5M9bDlN73EudGvQT9QM5359M58qSQZ9Alw","e":"AQAB"},{"kid":"33Ip-C3EnfA","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"vrNq6KwFvD3eyHOSA--bsFMC-Ix3X_keuy-c-Ci_UulEDXtfQLa1Ckj0SmJPwfP7sk40b58_7X2e_vlIcZ3dVTodH01YH3Yt8XU2bd6Tey_hDyRAUjXKGqrREmFJcR15XcfgA-Jm715iSUrIaiuC3jUcItMf8xWrlsd-Kc3iVdQZ3NA0kF5iseEpahTLxkFDV8V2gttzYDky1DDBT-mwBEG5E33pQ7rHalX9UdwwYm5BddEwv2HZunt2B9k4T24ISOpZYts-x0GHtrgOKppZfKaQi4Rchof-M1nMI0v5aK64fjKhsV_pev8F2TwXkSlzrv2_yn51rKowYWvztE4hhw","e":"AQAB"},{"kid":"HS3QjC5Zj2s","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"qWkmj-s2mhQDYJe8KrVaZPXyZyFxBAQTpv802h6HmLVa1PGdUE-jqaDNnnnWdcxhhJnXGVWjsDVY4bjYlRjHvzcwZSFGies-sotSt0AGgzL4Fc4VZKE00RStO78bbZHhQmS1H9GQ8S979IbKD5YDSrQqMdCjCAzwQBZftWzX3xAXg-Uy1kwOuRTlS6J9DQwBY-9kU0J8gh13kl6b_IaanRXHEC_fvmcVHuwZ4Si2jE48XmX47P7OmxkJB54W9EmDlajHjMbMg81Dn3Fw06pV2Gqrx-rpvyFz4SxC9u0FI23ibIAiYnTSqLRkmmskBd_c2OyOQ31eT0n-reuuOxWjWw","e":"AQAB"},{"kid":"joRjAmfDKgU","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"zdzcMHQprra3JSdIkrgT0Nptrs1KtjyFNnT5o0roPyurvMpxA5cdxZkwu3rHegd3Fijv6jdhuBs3BCTZlk5MV6Jg2l8dzq34cnx4ZLcSS3YPVmR_odeiM8migULQ42ocdo5WF_eXoHw0wL887s07Icjat8S7Xq3gRaP97STo12fIwfNktY4MwlhLUsOAq_5XOA46GhjQJie9t1zkFLg5v_VNkVPbPTY8aIftV6e9nSunW2N6lvp21ig_Qq4YPbm5K1JCGvJVuTx9lSRiO7lnZh6Q8bX5IS_PZ5X2_MbEpgoMa9A1fL8claRTBpMs6EVk6xe8H0Go01UtfSHZP42ALw","e":"AQAB"},{"kid":"bBLLTwm4xP4","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"0PxqonoU5XK1nzoniCIaUiW8gzA2kT-GczhEjrrR86KyhJy7nE5TF142sb-k-b7RyJAUALHr4kN37MZcRwfZsm1Hsqh_SewY-0cZrbfvmtjA21GlRSa30DI-TGwp3S99-CyofD1aMGkNyasu38pLrRo61CL_dykD12JpXaQJM7t8KLkSDlzjT0w5c0qHGDTwLFk-U99VdYWCDpAGoL8Sc6Z4end7crXomscX3N2UP7SjuvGFQJGHU1v7ZEIUXLpX_Rbr8eXV3nW_kRYjsR8s-IJjMzuAma5oD1MNCxh3CfcHbdtGd3lDgij05w9B3yDDC3U4azWIIyflAm8tNbWnOQ","e":"AQAB"}]},"id_token":{"signing":{"RS256":{"privateJwk":{"kid":"J6XZibraWb0","kty":"RSA","alg":"RS256","key_ops":["sign"],"ext":true,"n":"u0_S_wb5BPOPSH63j_PuXu_GVAlrytrlXFYMX0UgyT_fGiC46bL-XaQ9IihL1OWb03_kBBfj8IKJ6cantiURxpXHSC0e25kuqBTgYzdpnxIWtdatVJMYj9jXLaKMUZqMkuQSUknaD7v6uW5jPwKD0JWmPSWok3LXFIzbKrXQ5KJmB1IOFi5j9qPEZ5Ia16IoyVnSpmvsiVAYAMT8lk1c6jxzDvp9lSIKOTkTeQxwBzH3l3WuaPqMr5gvzazKoMDV7RAJlbZEO56HFici_ch7Zuq2QMRrjwv7vuvRRoOy1lnowVmldav1V4BbUHzuHEZ4p92mafT7Qgg6wQRzwtnjMw","e":"AQAB","d":"HtPaZAKfxULYmBPS3ixFVPada4NJbE9uZC39R0i3Rqfubfxosn21A3BeZ1q-cEGBzeNEga_04cZ6MDFztLSRIl_QNn7Gj1m8pgkZjPq3tWhiuHamfD7hUftcHrSb52pKLHrA0S46Z1CGyTHzv5EIZLHcdD_YaLwDzewIvhTCVn5jTYkIW6A93hBU2_-AZFC0hwDxZ0Y4q6KCW2WYTsYVfwLdAfCj94W7HvbiDK4DNIi0lojfmmoe91PTUq-y9doUHG12pV5gOU4itG8CgnXsZ5hvPY8_YbQTyDTcYG0Vuxv_rF9mee0bgFqj7DApeygbPPMdThY7oWFw3bEDcBqZsQ","p":"9EgRwbgq7Xy3ygFjAcD44ZSvXDxa-_Qf6-UZ26HMknnpu4P9JrGsYQiCyghG-tXsjwcfgumn9jNitIyo5hWzewUpTb2TzBzTRpSPRAdgjKa0ukr-LQXQCEZNniQ_vQ-z5Vw1D7w3ruMbYQPWgJwbE4AcDhAjr6ZzcoKJdvS7O9c","q":"xEwhin0LQn2Sei1_W9WOJ0QbGMDA3Glw97Rgj-GZ5TuTm0ejuGLHSs42wuBejD_YoWb02vWfK4DXkoyPvqBLKRlTmh8fuccD7f3fq0gK01M_7s-xVepqe5r4YHvN_vr3NsuOLuAOa6K4hDl4fFO_lXQgGYhlQ0MrKG_NsvblCAU","dp":"5Exqk_KL5BHYLmlnX186-pAb54bcvXYlUzB1Hyey6f4YLFCpib0pTjJHEYv00j6V7AILOC0o9VaG3BhNTWNgrwte07HmbC9QYTk8P6bpW-n9I9IshGVXTDRwG-jizM3dIfEwAfm0zLShhOSyVtYOFAZ5scbxoxpb8NwAnvUP-1c","dq":"JInjSGsEOIk1RcbISSjUQBzeSlo-zAhYfdM2kjG1OsU_MY1BPWYtoJAIA3hOQR71TP3kIAnOagOenOnLK_mcY_cR58NZXXRdF-TEyJYtZa6-XM6OPObYkU-EYjlJW-gNMkbrnXAZXxG39OzZr5LLO5-VBgushbuwAePlzyVD9p0","qi":"2QH6Tctc1vU3KE68RffhQhu_U06o_-BYYBlrBANfP_mQrvrIgc1ngEq9kJ3P6N0e8kMfUZKdf4MRvdtCJQWGGqb8cHoB_jvNniWivrFLkaQEsEhgcCDdELG7I-0TM4DcH12C7EUxkFQnAbQXNSrhWCtZVR92-Gh6CKvmjGbtcps"},"publicJwk":{"kid":"z1rytTgbnLU","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"u0_S_wb5BPOPSH63j_PuXu_GVAlrytrlXFYMX0UgyT_fGiC46bL-XaQ9IihL1OWb03_kBBfj8IKJ6cantiURxpXHSC0e25kuqBTgYzdpnxIWtdatVJMYj9jXLaKMUZqMkuQSUknaD7v6uW5jPwKD0JWmPSWok3LXFIzbKrXQ5KJmB1IOFi5j9qPEZ5Ia16IoyVnSpmvsiVAYAMT8lk1c6jxzDvp9lSIKOTkTeQxwBzH3l3WuaPqMr5gvzazKoMDV7RAJlbZEO56HFici_ch7Zuq2QMRrjwv7vuvRRoOy1lnowVmldav1V4BbUHzuHEZ4p92mafT7Qgg6wQRzwtnjMw","e":"AQAB"}},"RS384":{"privateJwk":{"kid":"NkM_9AhWXeo","kty":"RSA","alg":"RS384","key_ops":["sign"],"ext":true,"n":"3BO_8L0G6fRRQ1JZyG_DrJjjNPvn-B9aKsTv24Dt1jwxNQFyZwb5s7kn3dyxKq12IubebwGMxkA8rrHmvIFe9HOThm6_rDo3NIMGM0OVL1PSxlnlzNXEcdkJknhO03YqwUWZc-VkoVD1CSznX-0fE0BcTupCfrN0pgjgnx8j5GZijLePKh6d2v_6PSR2qKa0MaMgwAL9ilniSwbIpIVjgPI9UloP4rYBjUVTTQi2scmafeZFfN_jID44L7skMPXHde16eEPwy2lxdVgxBno_y1d42Rf6wkr3NMnSQaemm0HTTxT4hpDeQTvioKvZHvu5gNm8ujm9Qo2tXbiDtT_hcQ","e":"AQAB","d":"JfyCtNrrxpYVMLmWJbKk47XAAfU5JOrEdX1oqUqnwsLA-5U0WfQqRYtABluBeQxXx85xtldeJRoRX1X1rbPm3-rTG_EhxGiH_theyZpwtaqSRwpdT-3V6pC7xjxd3sIWvyV0RzzhEbcXlG3bwgL7yibhx_1HXPc8uyvtGYoWd-WiQRKU5htfD4Ke5ZV4ptGp9ZPYbGtlzDDoYj_h41BQjZjrLT3dGpJ-Tbc05q8SknvgmnI36Nmoj5-N6gfoyMSpyrdlPiGgjJuIueajhVD-xC2h_WrLyCVjJxRmhYj5m3AU9-nSxgKFn0d4xifsBDBRV5KNNRxuED_DL1MoQ08sAQ","p":"9UUCy-bC-JqWxSDQ_wACDOV6ieLgHcPkynZ9B3bWsRcfZaA_5hw0GHf1V3Z32HB-uSTDlR8LJEUj1SmlY9BKJz4QNOamg6EZ5Ejtmcsp3nbzfa3X9ssx71SP-WqsQturbGf7jDssLfPDhBuik32WOJzc6OKJ2sYcpVX7ceD7T3k","q":"5bSWNjZRByH1AvTCTtN_s86P1MaZly9QDlgnK-x0rKrJOsUWq5N2H2R8dkYALvd15kY-J5tZ9sUxndP7-qjI5cESI2guSysfomn8kp2zgFpD-jHXKY5tJI-rmT-ja51U2pd3U1BXwI5ZxWhqQ4vA_Oj4Axp0yqnAnTu8iSxzS7k","dp":"irbaCwna0RFVHe4eZWnSvwp2EE_Q4uSsm9kBg1wxtfxW1HoaSYE_8Wq-xhWJWE7fTMS_HLJu8bdsuZ0RHe9qUOFn9xNPr3hIlXStdGKTrm7l7PmJ_9kRx8KynqQ3AqUMQYZZnQjGRsLrm-apPvMzJ5eH1Opyftm2z8deKxzL5Tk","dq":"Esh8i_xPDeVB8rbu-KEkzSAz9LM0tf8hbbrZoSbZt5DTmaGqI-eP_isqXkWFGFIV6vmNdZGnfp1LXFuMPEf2_YqXIBwRxQXGtXIzPA33MqSu8FOJA5Xo6NdysbpZc6BO4v0FveNQ-abqQlEyd0mDQ2sNdLuCF1xgKrtKxHDFEMk","qi":"21tg8FGwTK3y-ABRAzaDX3NN0uHQkGZLwniin-Js_EviQQqKKPbHRojUJ2kRnp3ONzj49J7FVxh9lyBqYNYlPVRHDWuLV38J5_MSFjgCEBeFuf_rVftUuiu8Df3WHQtQhO5wUjkEgUHiYT7bXX_tmAZerU61Qq9ksb89hnFDKA0"},"publicJwk":{"kid":"moNrbB9qhmE","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"3BO_8L0G6fRRQ1JZyG_DrJjjNPvn-B9aKsTv24Dt1jwxNQFyZwb5s7kn3dyxKq12IubebwGMxkA8rrHmvIFe9HOThm6_rDo3NIMGM0OVL1PSxlnlzNXEcdkJknhO03YqwUWZc-VkoVD1CSznX-0fE0BcTupCfrN0pgjgnx8j5GZijLePKh6d2v_6PSR2qKa0MaMgwAL9ilniSwbIpIVjgPI9UloP4rYBjUVTTQi2scmafeZFfN_jID44L7skMPXHde16eEPwy2lxdVgxBno_y1d42Rf6wkr3NMnSQaemm0HTTxT4hpDeQTvioKvZHvu5gNm8ujm9Qo2tXbiDtT_hcQ","e":"AQAB"}},"RS512":{"privateJwk":{"kid":"pqVIRK5Vnr4","kty":"RSA","alg":"RS512","key_ops":["sign"],"ext":true,"n":"wjddayoLnWlgEZB6WrM3UiKMRPknK70SW9cqgKLxSDP1p06qg9ECDolh3PkPiEGkNbbg4SzKu8RPTRcgBkc9xWvPf22ee6ArPscH3Y8977TPvOXY5xFGfHMvWxdC95FSdHcYEHCPdH-ww3DqirmRU3yGgjpVaCIdFsud9uz-UnssfUPAcs5cmtfLTH6OLMKtrU3X5A1TNi6pFu2HGZ4NPPAnu5mr3s_fDxZ7UZLLP8NiRujHiDT8mMKF_ts10oiUERtd2UZOkO36zT3TdsNYoAg_s53E5NOUzYPFrr_PgN-hTi449NIE5M9bDlN73EudGvQT9QM5359M58qSQZ9Alw","e":"AQAB","d":"qqLSJRFv2wF2Mnhpx76l4DgSXZc6XydjNeW5pgODUhi0wCZRkjbUQ8lYfjkk-GYTzE29Dpm4FXjI48ZpkJqPHyE10ZOSJrP2ytU7h-IOXMjTq_eVto5rC3R4KuQpJjI766-nKOp8X7LArzZKG1Am6t8BnvF5kGBIhnqncweN-xBKwM1QL_FXGMpk25fC5WUnLXlGCNILPM-yJ8gs35UGPWEyZ-JN-pWzncBKDMIIODLP2SaOkQ2fF43WfYtChk6wXta3ofBCS3acZUJ9lPgQjh8W-SThw4Lz2gjBAIzm05bgU4zOsIGtS-B4xDc-ofU5Ng3QfWZIWqGhFhLOaxquIQ","p":"-4MNqugGNGjz3MbuV2NWYJL9Zwhu7ecwv6mBsLg-B-ifIAFMW48KTSMnehzGd-FW1uDSl7W1i3MiUyVscl9yIzTfTHFtPidHr1HJXvtB2hlqOg-pWAtlf6RW1Clog3USYbzq_hUrjsFdjr1OL01fzCZxRJ6Vhs2-YOPOm-FzdFM","q":"xa6TaPF6NtbiHEVg2CKuNLoi-C84gt7eZzvCEaM4ep8i2KGLKHQtS0S2JnoMaihvrJ6OzIAoQ6-beg8dWSXdRXXteD9Rmkr8bFRskGfY5UWVO11e9LeMY6wv73hMAcvBERHDsPiU6yHWCxvLFFsLxxaW4G51yIx9ea8Q-xkDOi0","dp":"Zu5ieJBOgcJ2EuOjBUaVQh7F8BXGeDyGedngRreQQ2JTRSDi5BGtMJZzgIkoPEWPaY9HAGmQK8rpwEdvk3s2Vew8eqdtAuPGdZyuId4IPD7sd2iTcIHxfwR9uIRdznbqF-_d6DA3zEucg188ESXET-NntaKFJU8sW7C_jJH-0xs","dq":"IITocQIQZccRqS7skIGcAMaCDCvQcw7wrTDSaw5bsyhMljB08PGzpccm48t-EVSXkmD_ArsuZHwV6o1j-Y9WCCAvMXHRHb2qKrP0rAi5UHYS55IjlcRADwF7XTx_3GfFWeZ_N7Sc4tVNHcSzsqSLmnOn3EGvlI8v8P2QoI3rzv0","qi":"F7hwWxK-2mJcMyJb2aObNw3kj3Bm1VE1m0l9gUsAbdbEybUNYW-06mev0EcrLEi5yxobRNNsYKd_UyTPpjTKWYW0tBocswKHVywTDKXhZvtqkpm02LCbvWgd9QiWWbfop_Xl4KpcrDeelPD9e4Jij7ZgRz10nYx6Vb0etT-bzEg"},"publicJwk":{"kid":"YN4vTwmLAgI","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"wjddayoLnWlgEZB6WrM3UiKMRPknK70SW9cqgKLxSDP1p06qg9ECDolh3PkPiEGkNbbg4SzKu8RPTRcgBkc9xWvPf22ee6ArPscH3Y8977TPvOXY5xFGfHMvWxdC95FSdHcYEHCPdH-ww3DqirmRU3yGgjpVaCIdFsud9uz-UnssfUPAcs5cmtfLTH6OLMKtrU3X5A1TNi6pFu2HGZ4NPPAnu5mr3s_fDxZ7UZLLP8NiRujHiDT8mMKF_ts10oiUERtd2UZOkO36zT3TdsNYoAg_s53E5NOUzYPFrr_PgN-hTi449NIE5M9bDlN73EudGvQT9QM5359M58qSQZ9Alw","e":"AQAB"}}},"encryption":{}},"token":{"signing":{"RS256":{"privateJwk":{"kid":"pPEXJVD0PkU","kty":"RSA","alg":"RS256","key_ops":["sign"],"ext":true,"n":"vrNq6KwFvD3eyHOSA--bsFMC-Ix3X_keuy-c-Ci_UulEDXtfQLa1Ckj0SmJPwfP7sk40b58_7X2e_vlIcZ3dVTodH01YH3Yt8XU2bd6Tey_hDyRAUjXKGqrREmFJcR15XcfgA-Jm715iSUrIaiuC3jUcItMf8xWrlsd-Kc3iVdQZ3NA0kF5iseEpahTLxkFDV8V2gttzYDky1DDBT-mwBEG5E33pQ7rHalX9UdwwYm5BddEwv2HZunt2B9k4T24ISOpZYts-x0GHtrgOKppZfKaQi4Rchof-M1nMI0v5aK64fjKhsV_pev8F2TwXkSlzrv2_yn51rKowYWvztE4hhw","e":"AQAB","d":"UpVmPd6JGUz91nbeC-BO3twEFFjYNXKv0UY8Rud2e1RTSTddN6wn2I1hZXkPqyGapUviv1gKmjFlkmun6LQBrq_c_rpC6FUIbmFhMdFKsvU4FJORW0i5_jRtF_WTlW27Klatd2ErTIvmKnE9O2UeZlY_mgEt-9otlb1MsJPdaWcSGzk8vkcQ5QK0tyU9-pMTBhXPiDBuSDvLd50VSyiiOhPhhoyf6MOTxZKRn2JGQYFm0peFYWOWQqzN6lZDqj-8Ns9dJVnrnV1k4dON6oSKUSfSlfL0DSX3TBOdqAN32oMLFed1lsDC5IngRB4bz43nH-zHeRBPoFstQqTPC8nl-Q","p":"6nNRymrhjD05emzO35RsDvkBvBwHOgoGolhVDHxClAI8nOQq4Hs1NGoH2Bcc6c-XrYv0bPpOLb5Zrphg80n_iWSCbu-GJqetMkX_hADe_3cP2wH-Q75yBGAP3jdNDV7FMu8UNDT0-5Z1a2WRRkMfkqEL_IetTXZyIFtY_JbrcgM","q":"0Dqogwc-slIFd9wWp_WIaTDID62JvZe8rJPrP7UVkPlw2-dTDFe9JHCzruLNS9NFoWqs8wbsi7MT0dD5xX16DidiVTArAbSukjBXH-6zN4y3xSvPFm0psuimMtbC5PL3R_Fu6OW5c92oQWVwbAes24bVODCykpkbu94dROGAXS0","dp":"h7GE9j3UGyHYYZYWSesSe7v9GA201Q-2dUHgv6AvvJBq2ndopZJJ4fM4ZQZDksO2yxhEuMpKc0lHiLji7Ay5HKESqPhy0W6c3IsM7y694mfbwmst6bGRNh0PMhMZwpJGWktriyfXXsHfZfBcG6l_3ZLeaCNy13GlbjrQjykeTlc","dq":"Fi_Z2rQ_6vIwYPgTdewEj-jBuGHuRkRPtze_njcmSB879kJsp-kFX0ee7ah-5XBqf-uE6BQ5yhzXpMiWhIN9KNrrcFZEjtb4rtz1u5JMiEO3JQwo8aPKCKUfaFVXyNtWm9TO04P-wajBj85GL0yWD4ILYARYUqR7vwXIqpk4ezU","qi":"IOGtBU9PZbzwIxHi03Ppcecbo8wCYg7_Egf_nHWPmCCogGI4ZKtCYWZqaIagvu60wygGR5l6pB24e-ZM3VT5lnOivejIums10ule7UbjGyAOpp4kJBvZM1oSZeEkgZwR5oTLrVLNbx9tddcZWLZwSNIXP3Q_exuBO7MrF9FBL7g"},"publicJwk":{"kid":"33Ip-C3EnfA","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"vrNq6KwFvD3eyHOSA--bsFMC-Ix3X_keuy-c-Ci_UulEDXtfQLa1Ckj0SmJPwfP7sk40b58_7X2e_vlIcZ3dVTodH01YH3Yt8XU2bd6Tey_hDyRAUjXKGqrREmFJcR15XcfgA-Jm715iSUrIaiuC3jUcItMf8xWrlsd-Kc3iVdQZ3NA0kF5iseEpahTLxkFDV8V2gttzYDky1DDBT-mwBEG5E33pQ7rHalX9UdwwYm5BddEwv2HZunt2B9k4T24ISOpZYts-x0GHtrgOKppZfKaQi4Rchof-M1nMI0v5aK64fjKhsV_pev8F2TwXkSlzrv2_yn51rKowYWvztE4hhw","e":"AQAB"}},"RS384":{"privateJwk":{"kid":"LXOsGRThYQE","kty":"RSA","alg":"RS384","key_ops":["sign"],"ext":true,"n":"qWkmj-s2mhQDYJe8KrVaZPXyZyFxBAQTpv802h6HmLVa1PGdUE-jqaDNnnnWdcxhhJnXGVWjsDVY4bjYlRjHvzcwZSFGies-sotSt0AGgzL4Fc4VZKE00RStO78bbZHhQmS1H9GQ8S979IbKD5YDSrQqMdCjCAzwQBZftWzX3xAXg-Uy1kwOuRTlS6J9DQwBY-9kU0J8gh13kl6b_IaanRXHEC_fvmcVHuwZ4Si2jE48XmX47P7OmxkJB54W9EmDlajHjMbMg81Dn3Fw06pV2Gqrx-rpvyFz4SxC9u0FI23ibIAiYnTSqLRkmmskBd_c2OyOQ31eT0n-reuuOxWjWw","e":"AQAB","d":"NWMiuOYKHgJCkjUfImJmazyquF9sizRxsQXp9Pb8Cl7UkhjWV5HRZMp9If0JXbQb4zrL83rui8A0E2Y6MrpNcHAG-0fCQAJ3jrKjTYaKxtvQHKcGTOEUkPMwKIzwKtZ3I4IzJiTXxXoOWSAFG2ZOAUPHrE0wo3_YUon6fWUgnnWjRnBNlEgM1IaPOUC9bwYRxPFveQ3doQ0VCB3gXqGb-wI2OalfIrB4PQgIUG56soixSi0_vYMmTxR3IWKVzhbEdlJDW4NSeQoO70p8o9dNuDBgDm8Rbs37Omdl5r5j4MI5UwuFpQKgzRl2J2ApnE_TJHqs5YANBTOP_oI2jBuNSQ","p":"3PskT4hzRLwKSdmz-P_7UTQhUpOlJPMnGVtIuymV6Mt-B7eI767mgH0cBKpb2peI0aA-OTW8npta0lO4L24yuuszZvrJ7co0V8z8_EG6LhIP8pVjz1s4SGGT285Yi_m-cE3p3tvS-eCU1au8NMvdW9Z-CYQuUwXIuzyPC52MdB0","q":"xEHh5XdNIT6CMA32z-nFAVBT4j5giN4yWQs7Z7c2UuNNohH3fCBqElbEsx_VlXnw_RVP8qaszTfSdwlBbcssW3Mnc7Yck82oo4QZWc34HlxWFaTSvaZ36ej_RIllYDi-jfsTi_6dgsMDXkhosMXyfvkQKTGINGldsuzahWh4a9c","dp":"nuwOpRQgseH6FDp48C5Ic5HmFRkRv40PJXE80T2LDiyqqqoX1SgXpXhUWhaakI5CW1--4C4BRJ-9pV2ILLQ3z62u_fSGnHi7RBmsJ06tssxSo8dETK_xvjxOtdmkXKZzixi9hQTaqdIVt8UWSXID9DRB2F4zYonaXq2iwlu_0xE","dq":"q_WKB_QrWbiae56QpoYO_uKyTScYkHQYK1sjFvI6IBBYAmy5q0H_jsgGG2kGTK2G0UnuPg96k2mY-IHcmWYPHKXeaI2lpn_phjSFveExyPBg4SAFIvUMVqC3oga9E71EgcT_0Ics3dkfR2osiM84dbanSWDEFiBIYDEWGxR5hws","qi":"wjnOTmkl2loaPjZnHmMW9ghTkj3pixx_Y0ryNhdscKB8-JtErzgPlgyDiUzPUZhddTcCp6cDjkOJU2FPtPLvZdwJmznYdLbg21rkm4-TLomyNPe36jKK49YSUigzpTL5poNQPi_3u_tEIVdU2ygQJj1wt4n7fY1So-gOrwN0vtw"},"publicJwk":{"kid":"HS3QjC5Zj2s","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"qWkmj-s2mhQDYJe8KrVaZPXyZyFxBAQTpv802h6HmLVa1PGdUE-jqaDNnnnWdcxhhJnXGVWjsDVY4bjYlRjHvzcwZSFGies-sotSt0AGgzL4Fc4VZKE00RStO78bbZHhQmS1H9GQ8S979IbKD5YDSrQqMdCjCAzwQBZftWzX3xAXg-Uy1kwOuRTlS6J9DQwBY-9kU0J8gh13kl6b_IaanRXHEC_fvmcVHuwZ4Si2jE48XmX47P7OmxkJB54W9EmDlajHjMbMg81Dn3Fw06pV2Gqrx-rpvyFz4SxC9u0FI23ibIAiYnTSqLRkmmskBd_c2OyOQ31eT0n-reuuOxWjWw","e":"AQAB"}},"RS512":{"privateJwk":{"kid":"odIgAwvdxPo","kty":"RSA","alg":"RS512","key_ops":["sign"],"ext":true,"n":"zdzcMHQprra3JSdIkrgT0Nptrs1KtjyFNnT5o0roPyurvMpxA5cdxZkwu3rHegd3Fijv6jdhuBs3BCTZlk5MV6Jg2l8dzq34cnx4ZLcSS3YPVmR_odeiM8migULQ42ocdo5WF_eXoHw0wL887s07Icjat8S7Xq3gRaP97STo12fIwfNktY4MwlhLUsOAq_5XOA46GhjQJie9t1zkFLg5v_VNkVPbPTY8aIftV6e9nSunW2N6lvp21ig_Qq4YPbm5K1JCGvJVuTx9lSRiO7lnZh6Q8bX5IS_PZ5X2_MbEpgoMa9A1fL8claRTBpMs6EVk6xe8H0Go01UtfSHZP42ALw","e":"AQAB","d":"JgyJQgdmYN1yklJlboDJYNPOa_2TroUXFg6eyVih_nMC5f0A0GoQ3aHHyQ8TaXGRyC2-0ip0TEPzcjehY8-K-rOPdS0ZZEbxYa-xzOOtZPcoNI6UrIQffbTp7Tw9QZsuMZdzQDDJ_KaYVXvDNlwGbuMh0c9x1jkK97QszbUBuDYolh2SWFnlqwaEHaq6ojT9LdMxz-vTWsB0L0nshMCwcVof_9QbM0VdvmieH8oWbpVcdMcUuiL5JyXE3-0MoOzn5hZTmD6lIxKlQhjfknfPmzy178vW1jN_c2_xfL7LUikJNLHs7L4eDNNXON2EYRI3fnq90w2ako_Gb2BWXojtIQ","p":"7GNogDYzTik3JOc5MZdaZ22Ik5lO7aLhAYCUuIeI1MRT3Z56QxrIJZcaJ8Revr5puT6uGAST8TMGdYK73nmrh3i5lsgtbBRpE3hr1aSWW2WZAloGkD_p4UjfcwoPIYfZ5HHBBCpLipMeQQIaGIwWh2QXogwxJvUjbnINnEaAG58","q":"3vEgeLbOSRJk8iwTwponYkz_WXdOlNT8UpcRTIci5mMyqjeIxqoO4POS_8qx4Wp1sqMh8P3PuPhOEGMa0B2sv1xyt_3o6uAnixDnbp1dCbKHhNFYJ2JFHP9MjK7kL9oxA9JYXiJDqcmEJWoXn7_nQBVFivULcWab522BooE8UXE","dp":"6K1Ao1K2VW0ENnLSPRn5hmyuTnpM0mPMjin-OVRPPv6wfigLuBmYgEenGxWTHLVYY1prCoEXDgdniCtSuL2SNRqDswL-kq_UbbHOktAan4P1g_cRFtOSZonQR-_SzPJnaxD9KBhpmtEMqnhjL15UnpfLG1pc1zfb1E63TuemGYk","dq":"JDjZJcPN0GGEtTQkIcIaFAaellKkGdphBKo2zVBHg1cqrC1Js719nV71y0mLjSxW4ydOJHGYhl0RkegIOzgXESBcIzjF1yOESTv93iMDMsgm7JV21S9KO2PfdBwWRxAUVqKeaOz4QWXUap_KJtJvKCJMoj8eFNavDfLk1RpaSoE","qi":"UZx9wq3fNIbyUF5rLuLenAEEYYY-PKbH5a9bGs0uzzpmhXAn7jrtO2cFhyXEanBXBI8lSjeG7mzgxVU_hV3j10Epyl8P9qjDS0DmsKbn-xRZbMh7X3aqNHT5NxV_Q8-Tn8voHeZVkG7ZY5EHswvbknR3No3uHT2AFvydktD9Res"},"publicJwk":{"kid":"joRjAmfDKgU","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"zdzcMHQprra3JSdIkrgT0Nptrs1KtjyFNnT5o0roPyurvMpxA5cdxZkwu3rHegd3Fijv6jdhuBs3BCTZlk5MV6Jg2l8dzq34cnx4ZLcSS3YPVmR_odeiM8migULQ42ocdo5WF_eXoHw0wL887s07Icjat8S7Xq3gRaP97STo12fIwfNktY4MwlhLUsOAq_5XOA46GhjQJie9t1zkFLg5v_VNkVPbPTY8aIftV6e9nSunW2N6lvp21ig_Qq4YPbm5K1JCGvJVuTx9lSRiO7lnZh6Q8bX5IS_PZ5X2_MbEpgoMa9A1fL8claRTBpMs6EVk6xe8H0Go01UtfSHZP42ALw","e":"AQAB"}}},"encryption":{}},"userinfo":{"encryption":{}},"register":{"signing":{"RS256":{"privateJwk":{"kid":"Wm24pG52IPs","kty":"RSA","alg":"RS256","key_ops":["sign"],"ext":true,"n":"0PxqonoU5XK1nzoniCIaUiW8gzA2kT-GczhEjrrR86KyhJy7nE5TF142sb-k-b7RyJAUALHr4kN37MZcRwfZsm1Hsqh_SewY-0cZrbfvmtjA21GlRSa30DI-TGwp3S99-CyofD1aMGkNyasu38pLrRo61CL_dykD12JpXaQJM7t8KLkSDlzjT0w5c0qHGDTwLFk-U99VdYWCDpAGoL8Sc6Z4end7crXomscX3N2UP7SjuvGFQJGHU1v7ZEIUXLpX_Rbr8eXV3nW_kRYjsR8s-IJjMzuAma5oD1MNCxh3CfcHbdtGd3lDgij05w9B3yDDC3U4azWIIyflAm8tNbWnOQ","e":"AQAB","d":"cebRG6LcFr4xXQouF5U2sUUd_IZfh0SPO-cT_pK18Urgb0SZQDS0Ns1DlBc2jGPDJMPaExLl1FkfWK44BwKxVP0YkbgiQCDs8K8swLC6Z7PxUNer8weKMW_g4nglTQcgag20-pnZuP7Y4-xnzNMN8deU7p_winqRPGfHs6C-3zE387_NBdQ8MwOW6bBFnZgXOcmFG7dxUvWNdwrk6bQ5nnSV-jJ6P_RT0BXP2ouTzlsz4Ycg4RDNWC9QacgXj8F3mDxUw_BKv2oNWSZhuR4N13R3VIvDKEXwEiRELraOXsBoMlZt-NvEd_D4ZH8sDm5zl_Itf6S5Oucss5EWoFdErQ","p":"_OuV9rvduRhoqB0zSPlWMEJ6IHMrV0zE2_1MIbwAU1k8w9Qp5GcXYMt1e5s6tKZbxh_CrwnJusH2LGb0Cx5q-JNZbzJia_HYhjjY-2MTtDWxgrUrO7JGxbHaby8U1ivJlPO1mhyt82YuByCem2wvZKv4IEq0CPIsXp7o-UMoU08","q":"04fgf21ZjyVwTO6TYYqHTkQ2zm3j3lH8SGAdVBm1aaVAacURZ7JdIf-2ozo1Q-GDG0fWU_G4lJuR97gacfgv-Kk_e7p7TYt7ApvC-0j3CBWHWDB3Fv-qJFxOxBEwPEwt13v4Y7xrIsl0oVIi3o43jjBRFdlg35HDdb3WePhk2vc","dp":"Z4jZHuPQ5BCF5yvs7paDHcZY0CfVOiuG-rc6DyUyzOve4Btd-s3o2Arx0OO-qGzhbL1bqOPM3NLBv3N1u4d8Kr3HAqoReDbMeEWVLXNlgYPpYqRfSlS0fAFOde1EDlhmcL9DPA85dkYB2ZEU3HLxA7kSHcX25SKd3y4WGNPREik","dq":"FP4fIYZQpQwqIPhsV_nPg8zxQ3tUafPo_aXMQ1Rp1Jo50kVkfM4OwBkInxpfvuTahhKTCrGqh9UIn3T96uGeoSbqzfSr1_5HrvKWXynWmk7Ip8_ngbjNwd4HUx4Bk3pb8k6zT_KbD1C-6mOkYkHq8YmKAokYPBfTNhQo_Mhp-fE","qi":"Uic1k9TN6gDxiRMoFMYJjHNHFb-N-sD7AfmrcLRf5FW6M8a6tV1jPJTnRi3pNoTCYuVPfVIusqmyeL5QZ0Rp6tpBB77GCACWGjFHSwNdVL3rYo3yIHpjPbyOzGYl5kr3g95zaKs_kGbnSPgvH8HVSXX1h-BJWGUsmvOewNSSjb8"},"publicJwk":{"kid":"bBLLTwm4xP4","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"0PxqonoU5XK1nzoniCIaUiW8gzA2kT-GczhEjrrR86KyhJy7nE5TF142sb-k-b7RyJAUALHr4kN37MZcRwfZsm1Hsqh_SewY-0cZrbfvmtjA21GlRSa30DI-TGwp3S99-CyofD1aMGkNyasu38pLrRo61CL_dykD12JpXaQJM7t8KLkSDlzjT0w5c0qHGDTwLFk-U99VdYWCDpAGoL8Sc6Z4end7crXomscX3N2UP7SjuvGFQJGHU1v7ZEIUXLpX_Rbr8eXV3nW_kRYjsR8s-IJjMzuAma5oD1MNCxh3CfcHbdtGd3lDgij05w9B3yDDC3U4azWIIyflAm8tNbWnOQ","e":"AQAB"}}}},"jwkSet":"{\"keys\":[{\"kid\":\"z1rytTgbnLU\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"key_ops\":[\"verify\"],\"ext\":true,\"n\":\"u0_S_wb5BPOPSH63j_PuXu_GVAlrytrlXFYMX0UgyT_fGiC46bL-XaQ9IihL1OWb03_kBBfj8IKJ6cantiURxpXHSC0e25kuqBTgYzdpnxIWtdatVJMYj9jXLaKMUZqMkuQSUknaD7v6uW5jPwKD0JWmPSWok3LXFIzbKrXQ5KJmB1IOFi5j9qPEZ5Ia16IoyVnSpmvsiVAYAMT8lk1c6jxzDvp9lSIKOTkTeQxwBzH3l3WuaPqMr5gvzazKoMDV7RAJlbZEO56HFici_ch7Zuq2QMRrjwv7vuvRRoOy1lnowVmldav1V4BbUHzuHEZ4p92mafT7Qgg6wQRzwtnjMw\",\"e\":\"AQAB\"},{\"kid\":\"moNrbB9qhmE\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"key_ops\":[\"verify\"],\"ext\":true,\"n\":\"3BO_8L0G6fRRQ1JZyG_DrJjjNPvn-B9aKsTv24Dt1jwxNQFyZwb5s7kn3dyxKq12IubebwGMxkA8rrHmvIFe9HOThm6_rDo3NIMGM0OVL1PSxlnlzNXEcdkJknhO03YqwUWZc-VkoVD1CSznX-0fE0BcTupCfrN0pgjgnx8j5GZijLePKh6d2v_6PSR2qKa0MaMgwAL9ilniSwbIpIVjgPI9UloP4rYBjUVTTQi2scmafeZFfN_jID44L7skMPXHde16eEPwy2lxdVgxBno_y1d42Rf6wkr3NMnSQaemm0HTTxT4hpDeQTvioKvZHvu5gNm8ujm9Qo2tXbiDtT_hcQ\",\"e\":\"AQAB\"},{\"kid\":\"YN4vTwmLAgI\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"key_ops\":[\"verify\"],\"ext\":true,\"n\":\"wjddayoLnWlgEZB6WrM3UiKMRPknK70SW9cqgKLxSDP1p06qg9ECDolh3PkPiEGkNbbg4SzKu8RPTRcgBkc9xWvPf22ee6ArPscH3Y8977TPvOXY5xFGfHMvWxdC95FSdHcYEHCPdH-ww3DqirmRU3yGgjpVaCIdFsud9uz-UnssfUPAcs5cmtfLTH6OLMKtrU3X5A1TNi6pFu2HGZ4NPPAnu5mr3s_fDxZ7UZLLP8NiRujHiDT8mMKF_ts10oiUERtd2UZOkO36zT3TdsNYoAg_s53E5NOUzYPFrr_PgN-hTi449NIE5M9bDlN73EudGvQT9QM5359M58qSQZ9Alw\",\"e\":\"AQAB\"},{\"kid\":\"33Ip-C3EnfA\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"key_ops\":[\"verify\"],\"ext\":true,\"n\":\"vrNq6KwFvD3eyHOSA--bsFMC-Ix3X_keuy-c-Ci_UulEDXtfQLa1Ckj0SmJPwfP7sk40b58_7X2e_vlIcZ3dVTodH01YH3Yt8XU2bd6Tey_hDyRAUjXKGqrREmFJcR15XcfgA-Jm715iSUrIaiuC3jUcItMf8xWrlsd-Kc3iVdQZ3NA0kF5iseEpahTLxkFDV8V2gttzYDky1DDBT-mwBEG5E33pQ7rHalX9UdwwYm5BddEwv2HZunt2B9k4T24ISOpZYts-x0GHtrgOKppZfKaQi4Rchof-M1nMI0v5aK64fjKhsV_pev8F2TwXkSlzrv2_yn51rKowYWvztE4hhw\",\"e\":\"AQAB\"},{\"kid\":\"HS3QjC5Zj2s\",\"kty\":\"RSA\",\"alg\":\"RS384\",\"key_ops\":[\"verify\"],\"ext\":true,\"n\":\"qWkmj-s2mhQDYJe8KrVaZPXyZyFxBAQTpv802h6HmLVa1PGdUE-jqaDNnnnWdcxhhJnXGVWjsDVY4bjYlRjHvzcwZSFGies-sotSt0AGgzL4Fc4VZKE00RStO78bbZHhQmS1H9GQ8S979IbKD5YDSrQqMdCjCAzwQBZftWzX3xAXg-Uy1kwOuRTlS6J9DQwBY-9kU0J8gh13kl6b_IaanRXHEC_fvmcVHuwZ4Si2jE48XmX47P7OmxkJB54W9EmDlajHjMbMg81Dn3Fw06pV2Gqrx-rpvyFz4SxC9u0FI23ibIAiYnTSqLRkmmskBd_c2OyOQ31eT0n-reuuOxWjWw\",\"e\":\"AQAB\"},{\"kid\":\"joRjAmfDKgU\",\"kty\":\"RSA\",\"alg\":\"RS512\",\"key_ops\":[\"verify\"],\"ext\":true,\"n\":\"zdzcMHQprra3JSdIkrgT0Nptrs1KtjyFNnT5o0roPyurvMpxA5cdxZkwu3rHegd3Fijv6jdhuBs3BCTZlk5MV6Jg2l8dzq34cnx4ZLcSS3YPVmR_odeiM8migULQ42ocdo5WF_eXoHw0wL887s07Icjat8S7Xq3gRaP97STo12fIwfNktY4MwlhLUsOAq_5XOA46GhjQJie9t1zkFLg5v_VNkVPbPTY8aIftV6e9nSunW2N6lvp21ig_Qq4YPbm5K1JCGvJVuTx9lSRiO7lnZh6Q8bX5IS_PZ5X2_MbEpgoMa9A1fL8claRTBpMs6EVk6xe8H0Go01UtfSHZP42ALw\",\"e\":\"AQAB\"},{\"kid\":\"bBLLTwm4xP4\",\"kty\":\"RSA\",\"alg\":\"RS256\",\"key_ops\":[\"verify\"],\"ext\":true,\"n\":\"0PxqonoU5XK1nzoniCIaUiW8gzA2kT-GczhEjrrR86KyhJy7nE5TF142sb-k-b7RyJAUALHr4kN37MZcRwfZsm1Hsqh_SewY-0cZrbfvmtjA21GlRSa30DI-TGwp3S99-CyofD1aMGkNyasu38pLrRo61CL_dykD12JpXaQJM7t8KLkSDlzjT0w5c0qHGDTwLFk-U99VdYWCDpAGoL8Sc6Z4end7crXomscX3N2UP7SjuvGFQJGHU1v7ZEIUXLpX_Rbr8eXV3nW_kRYjsR8s-IJjMzuAma5oD1MNCxh3CfcHbdtGd3lDgij05w9B3yDDC3U4azWIIyflAm8tNbWnOQ\",\"e\":\"AQAB\"}]}"}},"jwks":{"keys":[{"kid":"z1rytTgbnLU","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"u0_S_wb5BPOPSH63j_PuXu_GVAlrytrlXFYMX0UgyT_fGiC46bL-XaQ9IihL1OWb03_kBBfj8IKJ6cantiURxpXHSC0e25kuqBTgYzdpnxIWtdatVJMYj9jXLaKMUZqMkuQSUknaD7v6uW5jPwKD0JWmPSWok3LXFIzbKrXQ5KJmB1IOFi5j9qPEZ5Ia16IoyVnSpmvsiVAYAMT8lk1c6jxzDvp9lSIKOTkTeQxwBzH3l3WuaPqMr5gvzazKoMDV7RAJlbZEO56HFici_ch7Zuq2QMRrjwv7vuvRRoOy1lnowVmldav1V4BbUHzuHEZ4p92mafT7Qgg6wQRzwtnjMw","e":"AQAB"},{"kid":"moNrbB9qhmE","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"3BO_8L0G6fRRQ1JZyG_DrJjjNPvn-B9aKsTv24Dt1jwxNQFyZwb5s7kn3dyxKq12IubebwGMxkA8rrHmvIFe9HOThm6_rDo3NIMGM0OVL1PSxlnlzNXEcdkJknhO03YqwUWZc-VkoVD1CSznX-0fE0BcTupCfrN0pgjgnx8j5GZijLePKh6d2v_6PSR2qKa0MaMgwAL9ilniSwbIpIVjgPI9UloP4rYBjUVTTQi2scmafeZFfN_jID44L7skMPXHde16eEPwy2lxdVgxBno_y1d42Rf6wkr3NMnSQaemm0HTTxT4hpDeQTvioKvZHvu5gNm8ujm9Qo2tXbiDtT_hcQ","e":"AQAB"},{"kid":"YN4vTwmLAgI","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"wjddayoLnWlgEZB6WrM3UiKMRPknK70SW9cqgKLxSDP1p06qg9ECDolh3PkPiEGkNbbg4SzKu8RPTRcgBkc9xWvPf22ee6ArPscH3Y8977TPvOXY5xFGfHMvWxdC95FSdHcYEHCPdH-ww3DqirmRU3yGgjpVaCIdFsud9uz-UnssfUPAcs5cmtfLTH6OLMKtrU3X5A1TNi6pFu2HGZ4NPPAnu5mr3s_fDxZ7UZLLP8NiRujHiDT8mMKF_ts10oiUERtd2UZOkO36zT3TdsNYoAg_s53E5NOUzYPFrr_PgN-hTi449NIE5M9bDlN73EudGvQT9QM5359M58qSQZ9Alw","e":"AQAB"},{"kid":"33Ip-C3EnfA","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"vrNq6KwFvD3eyHOSA--bsFMC-Ix3X_keuy-c-Ci_UulEDXtfQLa1Ckj0SmJPwfP7sk40b58_7X2e_vlIcZ3dVTodH01YH3Yt8XU2bd6Tey_hDyRAUjXKGqrREmFJcR15XcfgA-Jm715iSUrIaiuC3jUcItMf8xWrlsd-Kc3iVdQZ3NA0kF5iseEpahTLxkFDV8V2gttzYDky1DDBT-mwBEG5E33pQ7rHalX9UdwwYm5BddEwv2HZunt2B9k4T24ISOpZYts-x0GHtrgOKppZfKaQi4Rchof-M1nMI0v5aK64fjKhsV_pev8F2TwXkSlzrv2_yn51rKowYWvztE4hhw","e":"AQAB"},{"kid":"HS3QjC5Zj2s","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"qWkmj-s2mhQDYJe8KrVaZPXyZyFxBAQTpv802h6HmLVa1PGdUE-jqaDNnnnWdcxhhJnXGVWjsDVY4bjYlRjHvzcwZSFGies-sotSt0AGgzL4Fc4VZKE00RStO78bbZHhQmS1H9GQ8S979IbKD5YDSrQqMdCjCAzwQBZftWzX3xAXg-Uy1kwOuRTlS6J9DQwBY-9kU0J8gh13kl6b_IaanRXHEC_fvmcVHuwZ4Si2jE48XmX47P7OmxkJB54W9EmDlajHjMbMg81Dn3Fw06pV2Gqrx-rpvyFz4SxC9u0FI23ibIAiYnTSqLRkmmskBd_c2OyOQ31eT0n-reuuOxWjWw","e":"AQAB"},{"kid":"joRjAmfDKgU","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"zdzcMHQprra3JSdIkrgT0Nptrs1KtjyFNnT5o0roPyurvMpxA5cdxZkwu3rHegd3Fijv6jdhuBs3BCTZlk5MV6Jg2l8dzq34cnx4ZLcSS3YPVmR_odeiM8migULQ42ocdo5WF_eXoHw0wL887s07Icjat8S7Xq3gRaP97STo12fIwfNktY4MwlhLUsOAq_5XOA46GhjQJie9t1zkFLg5v_VNkVPbPTY8aIftV6e9nSunW2N6lvp21ig_Qq4YPbm5K1JCGvJVuTx9lSRiO7lnZh6Q8bX5IS_PZ5X2_MbEpgoMa9A1fL8claRTBpMs6EVk6xe8H0Go01UtfSHZP42ALw","e":"AQAB"},{"kid":"bBLLTwm4xP4","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"0PxqonoU5XK1nzoniCIaUiW8gzA2kT-GczhEjrrR86KyhJy7nE5TF142sb-k-b7RyJAUALHr4kN37MZcRwfZsm1Hsqh_SewY-0cZrbfvmtjA21GlRSa30DI-TGwp3S99-CyofD1aMGkNyasu38pLrRo61CL_dykD12JpXaQJM7t8KLkSDlzjT0w5c0qHGDTwLFk-U99VdYWCDpAGoL8Sc6Z4end7crXomscX3N2UP7SjuvGFQJGHU1v7ZEIUXLpX_Rbr8eXV3nW_kRYjsR8s-IJjMzuAma5oD1MNCxh3CfcHbdtGd3lDgij05w9B3yDDC3U4azWIIyflAm8tNbWnOQ","e":"AQAB"}]}},"defaults":{"popToken":false,"authenticate":{"response_type":"id_token token","display":"page","scope":["openid"]}},"registration":{"redirect_uris":["https://server/api/oidc/rp/https%3A%2F%2Ffd.xuwubk.eu.org%3A443%2Fhttps%2Fserver"],"client_id":"7e5c0fede7682892e36b2ef3ecda05a6","client_secret":"d634791ff779ce90d378d714282e1374","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://server","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://server/goodbye"],"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3NlcnZlciIsImF1ZCI6IjdlNWMwZmVkZTc2ODI4OTJlMzZiMmVmM2VjZGEwNWE2Iiwic3ViIjoiN2U1YzBmZWRlNzY4Mjg5MmUzNmIyZWYzZWNkYTA1YTYifQ.V2PYz-gf4Dq0xlUXXcCUFRLt8OgT_efjM7lmgnYP3X8JhjmM1p0yysItSx3sfplPmlUSZ2VAo8qu3zW0iJmlQBhYrRUwFJexDpMHxqh-zj_z2AE3zOVWn3SlDJmveWN4JkmsgwAuSJw8EEAG8zjwgprgAe95crEh-dr0p5LDkl_ipm0ArAWP0KysIunLR33fWwkYt8cUZ6LdhalI95gQBbGye4Y_B8Yvxn9QbpQ_EqqhvxUfgZZGuxGxlQlnsTD2c4OSTICrgtfqOrcYYmbRoqk7ZE6CsWbnc6cCH_TavWCumswulJgIRAPrGGh3kvgVheTK6cZl5R2hmeyG6umKEQ","registration_client_uri":"https://server/register/7e5c0fede7682892e36b2ef3ecda05a6","client_id_issued_at":1593518892,"client_secret_expires_at":0}} \ No newline at end of file diff --git a/test/surface/docker/server/.db/oidc/users/users-by-email/_key_alice%40pdsinterop.org.json b/test/surface/docker/server/.db/oidc/users/users-by-email/_key_alice%40pdsinterop.org.json new file mode 100644 index 000000000..1f513a2b3 --- /dev/null +++ b/test/surface/docker/server/.db/oidc/users/users-by-email/_key_alice%40pdsinterop.org.json @@ -0,0 +1 @@ +{"id":"server/profile/card#me"} diff --git a/test/surface/docker/server/.db/oidc/users/users/_key_server%2Fprofile%2Fcard%23me.json b/test/surface/docker/server/.db/oidc/users/users/_key_server%2Fprofile%2Fcard%23me.json new file mode 100644 index 000000000..917bb7c0c --- /dev/null +++ b/test/surface/docker/server/.db/oidc/users/users/_key_server%2Fprofile%2Fcard%23me.json @@ -0,0 +1 @@ +{"username":"alice","webId":"https://server/profile/card#me","name":"Alice","email":"alice@pdsinterop.org","externalWebId":"","hashedPassword":"$2a$10$NFqVQzFzHLpI25bf2/B74OGmodqEKZJjeDNGX13137jZ6Zr6nWuby"} diff --git a/test/surface/docker/server/.db/oidc/users/users/_key_thirdparty%2Fprofile%2Fcard%23me.json b/test/surface/docker/server/.db/oidc/users/users/_key_thirdparty%2Fprofile%2Fcard%23me.json new file mode 100644 index 000000000..9c1464110 --- /dev/null +++ b/test/surface/docker/server/.db/oidc/users/users/_key_thirdparty%2Fprofile%2Fcard%23me.json @@ -0,0 +1 @@ +{"username":"alice","webId":"https://thirdparty/profile/card#me","name":"Alice","email":"alice@pdsinterop.org","externalWebId":"","hashedPassword":"$2a$10$NFqVQzFzHLpI25bf2/B74OGmodqEKZJjeDNGX13137jZ6Zr6nWuby"} diff --git a/test/surface/docker/server/.dockerignore b/test/surface/docker/server/.dockerignore new file mode 100644 index 000000000..94143827e --- /dev/null +++ b/test/surface/docker/server/.dockerignore @@ -0,0 +1 @@ +Dockerfile diff --git a/test/surface/docker/server/Dockerfile b/test/surface/docker/server/Dockerfile new file mode 100644 index 000000000..293be8ac3 --- /dev/null +++ b/test/surface/docker/server/Dockerfile @@ -0,0 +1,19 @@ +FROM node:latest +ARG BRANCH=main +ARG REPO=nodeSolidServer/node-solid-server +RUN echo Testing branch ${BRANCH} of NSS +RUN git clone https://github.com/${REPO} +WORKDIR node-solid-server +RUN git checkout ${BRANCH} +RUN git status +RUN npm install +RUN openssl req -new -x509 -days 365 -nodes \ + -out ./server.cert \ + -keyout ./server.key \ + -subj "/C=RO/ST=Bucharest/L=Bucharest/O=IT/CN=www.example.ro" +EXPOSE 443 +ADD config.json . +ADD config ./config +ADD data ./data +ADD .db ./.db +CMD ./bin/solid-test start diff --git a/test/surface/docker/server/config.json b/test/surface/docker/server/config.json new file mode 100644 index 000000000..5a405646e --- /dev/null +++ b/test/surface/docker/server/config.json @@ -0,0 +1,22 @@ +{ + "root": "./data", + "port": "443", + "serverUri": "https://server", + "webid": true, + "mount": "/", + "configPath": "./config", + "configFile": "./config.json", + "dbPath": "./.db", + "sslKey": "./server.key", + "sslCert": "./server.cert", + "multiuser": false, + "server": { + "name": "server", + "description": "", + "logo": "" + }, + "enforceToc": true, + "disablePasswordChecks": false, + "tocUri": "https://your-toc", + "supportEmail": "Your support email address" +} diff --git a/test/surface/docker/server/config/defaults.js b/test/surface/docker/server/config/defaults.js new file mode 100644 index 000000000..d53408c90 --- /dev/null +++ b/test/surface/docker/server/config/defaults.js @@ -0,0 +1,10 @@ +module.exports = { + auth: 'oidc', + localAuth: { + tls: true, + password: true + }, + strictOrigin: true, + trustedOrigins: [], + dataBrowserPath: 'default' +} diff --git a/test/surface/docker/server/config/templates/emails/delete-account.js b/test/surface/docker/server/config/templates/emails/delete-account.js new file mode 100644 index 000000000..9ef228651 --- /dev/null +++ b/test/surface/docker/server/config/templates/emails/delete-account.js @@ -0,0 +1,49 @@ +'use strict' + +/** + * Returns a partial Email object (minus the `to` and `from` properties), + * suitable for sending with Nodemailer. + * + * Used to send a Delete Account email, upon user request + * + * @param data {Object} + * + * @param data.deleteUrl {string} + * @param data.webId {string} + * + * @return {Object} + */ +function render (data) { + return { + subject: 'Delete Solid-account request', + + /** + * Text version + */ + text: `Hi, + +We received a request to delete your Solid account, ${data.webId} + +To delete your account, click on the following link: + +${data.deleteUrl} + +If you did not mean to delete your account, ignore this email.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to delete your Solid account, ${data.webId}

+ +

To delete your account, click on the following link:

+ +

${data.deleteUrl}

+ +

If you did not mean to delete your account, ignore this email.

+` + } +} + +module.exports.render = render diff --git a/test/surface/docker/server/config/templates/emails/invalid-username.js b/test/surface/docker/server/config/templates/emails/invalid-username.js new file mode 100644 index 000000000..8a7497fc5 --- /dev/null +++ b/test/surface/docker/server/config/templates/emails/invalid-username.js @@ -0,0 +1,30 @@ +module.exports.render = render + +function render (data) { + return { + subject: `Invalid username for account ${data.accountUri}`, + + /** + * Text version + */ + text: `Hi, + +We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy. + +This account has been set to be deleted at ${data.dateOfRemoval}. + +${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move your account.` : ''}`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.

+ +

This account has been set to be deleted at ${data.dateOfRemoval}.

+ +${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''} +` + } +} diff --git a/test/surface/docker/server/config/templates/emails/reset-password.js b/test/surface/docker/server/config/templates/emails/reset-password.js new file mode 100644 index 000000000..fb18972cc --- /dev/null +++ b/test/surface/docker/server/config/templates/emails/reset-password.js @@ -0,0 +1,49 @@ +'use strict' + +/** + * Returns a partial Email object (minus the `to` and `from` properties), + * suitable for sending with Nodemailer. + * + * Used to send a Reset Password email, upon user request + * + * @param data {Object} + * + * @param data.resetUrl {string} + * @param data.webId {string} + * + * @return {Object} + */ +function render (data) { + return { + subject: 'Account password reset', + + /** + * Text version + */ + text: `Hi, + +We received a request to reset your password for your Solid account, ${data.webId} + +To reset your password, click on the following link: + +${data.resetUrl} + +If you did not mean to reset your password, ignore this email, your password will not change.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to reset your password for your Solid account, ${data.webId}

+ +

To reset your password, click on the following link:

+ +

${data.resetUrl}

+ +

If you did not mean to reset your password, ignore this email, your password will not change.

+` + } +} + +module.exports.render = render diff --git a/test/surface/docker/server/config/templates/emails/welcome.js b/test/surface/docker/server/config/templates/emails/welcome.js new file mode 100644 index 000000000..bce554462 --- /dev/null +++ b/test/surface/docker/server/config/templates/emails/welcome.js @@ -0,0 +1,39 @@ +'use strict' + +/** + * Returns a partial Email object (minus the `to` and `from` properties), + * suitable for sending with Nodemailer. + * + * Used to send a Welcome email after a new user account has been created. + * + * @param data {Object} + * + * @param data.webid {string} + * + * @return {Object} + */ +function render (data) { + return { + subject: 'Welcome to Solid', + + /** + * Text version of the Welcome email + */ + text: `Welcome to Solid! + +Your account has been created. + +Your Web Id: ${data.webid}`, + + /** + * HTML version of the Welcome email + */ + html: `

Welcome to Solid!

+ +

Your account has been created.

+ +

Your Web Id: ${data.webid}

` + } +} + +module.exports.render = render diff --git a/test/surface/docker/server/config/templates/new-account/.acl b/test/surface/docker/server/config/templates/new-account/.acl new file mode 100644 index 000000000..9f2213c84 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/.acl @@ -0,0 +1,26 @@ +# Root ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +# The homepage is readable by the public +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo ; + acl:mode acl:Read. + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + # Optional owner email, to be used for account recovery: + {{#if email}}acl:agent ;{{/if}} + # Set the access to the root storage folder itself + acl:accessTo ; + # All resources will inherit this authorization, by default + acl:default ; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. diff --git a/test/surface/docker/server/config/templates/new-account/.meta b/test/surface/docker/server/config/templates/new-account/.meta new file mode 100644 index 000000000..591051f43 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/.meta @@ -0,0 +1,5 @@ +# Root Meta resource for the user account +# Used to discover the account's WebID URI, given the account URI +<{{webId}}> + + . diff --git a/test/surface/docker/server/config/templates/new-account/.meta.acl b/test/surface/docker/server/config/templates/new-account/.meta.acl new file mode 100644 index 000000000..c297ce822 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/.meta.acl @@ -0,0 +1,25 @@ +# ACL resource for the Root Meta +# Should be public-readable (since the root meta is used for WebID discovery) + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + <{{webId}}>; + + acl:accessTo ; + + acl:mode + acl:Read, acl:Write, acl:Control. + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/new-account/.well-known/.acl b/test/surface/docker/server/config/templates/new-account/.well-known/.acl new file mode 100644 index 000000000..9e13201e2 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/.well-known/.acl @@ -0,0 +1,19 @@ +# ACL resource for the well-known folder +@prefix acl: . +@prefix foaf: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./>; + acl:defaultForNew <./>; + acl:mode acl:Read, acl:Write, acl:Control. + +# The public has read permissions +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:defaultForNew <./>; + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/new-account/favicon.ico b/test/surface/docker/server/config/templates/new-account/favicon.ico new file mode 100644 index 000000000..764acb205 Binary files /dev/null and b/test/surface/docker/server/config/templates/new-account/favicon.ico differ diff --git a/test/surface/docker/server/config/templates/new-account/favicon.ico.acl b/test/surface/docker/server/config/templates/new-account/favicon.ico.acl new file mode 100644 index 000000000..01e11d075 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/favicon.ico.acl @@ -0,0 +1,26 @@ +# ACL for the default favicon.ico resource +# Individual users will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + <{{webId}}>; + + acl:accessTo ; + + acl:mode + acl:Read, acl:Write, acl:Control. + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/new-account/inbox/.acl b/test/surface/docker/server/config/templates/new-account/inbox/.acl new file mode 100644 index 000000000..17b8e4bb7 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/inbox/.acl @@ -0,0 +1,26 @@ +# ACL resource for the profile Inbox + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + <{{webId}}>; + + acl:accessTo <./>; + acl:default <./>; + + acl:mode + acl:Read, acl:Write, acl:Control. + +# Public-appendable but NOT public-readable +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo <./>; + + acl:mode acl:Append. diff --git a/test/surface/docker/server/config/templates/new-account/private/.acl b/test/surface/docker/server/config/templates/new-account/private/.acl new file mode 100644 index 000000000..914efcf9f --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/private/.acl @@ -0,0 +1,10 @@ +# ACL resource for the private folder +@prefix acl: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/test/surface/docker/server/config/templates/new-account/profile/.acl b/test/surface/docker/server/config/templates/new-account/profile/.acl new file mode 100644 index 000000000..1fb254129 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/profile/.acl @@ -0,0 +1,19 @@ +# ACL resource for the profile folder +@prefix acl: . +@prefix foaf: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. + +# The public has read permissions +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/new-account/profile/card$.ttl b/test/surface/docker/server/config/templates/new-account/profile/card$.ttl new file mode 100644 index 000000000..e16d1771d --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/profile/card$.ttl @@ -0,0 +1,26 @@ +@prefix solid: . +@prefix foaf: . +@prefix pim: . +@prefix schema: . +@prefix ldp: . + +<> + a foaf:PersonalProfileDocument ; + foaf:maker <{{webId}}> ; + foaf:primaryTopic <{{webId}}> . + +<{{webId}}> + a foaf:Person ; + a schema:Person ; + + foaf:name "{{name}}" ; + + solid:account ; # link to the account uri + pim:storage ; # root storage + solid:oidcIssuer <{{idp}}> ; # identity provider + + ldp:inbox ; + + pim:preferencesFile ; # private settings/preferences + solid:publicTypeIndex ; + solid:privateTypeIndex . diff --git a/test/surface/docker/server/config/templates/new-account/public/.acl b/test/surface/docker/server/config/templates/new-account/public/.acl new file mode 100644 index 000000000..210555a83 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/public/.acl @@ -0,0 +1,19 @@ +# ACL resource for the public folder +@prefix acl: . +@prefix foaf: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. + +# The public has read permissions +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/new-account/robots.txt b/test/surface/docker/server/config/templates/new-account/robots.txt new file mode 100644 index 000000000..8c27a0227 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +# Allow all crawling (subject to ACLs as usual, of course) +Disallow: diff --git a/test/surface/docker/server/config/templates/new-account/robots.txt.acl b/test/surface/docker/server/config/templates/new-account/robots.txt.acl new file mode 100644 index 000000000..2326c86c2 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/robots.txt.acl @@ -0,0 +1,26 @@ +# ACL for the default robots.txt resource +# Individual users will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + <{{webId}}>; + + acl:accessTo ; + + acl:mode + acl:Read, acl:Write, acl:Control. + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/new-account/settings/.acl b/test/surface/docker/server/config/templates/new-account/settings/.acl new file mode 100644 index 000000000..921e65570 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/settings/.acl @@ -0,0 +1,20 @@ +# ACL resource for the /settings/ container +@prefix acl: . + +<#owner> + a acl:Authorization; + + acl:agent + <{{webId}}>; + + # Set the access to the root storage folder itself + acl:accessTo <./>; + + # All settings resources will be private, by default, unless overridden + acl:default <./>; + + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. + +# Private, no public access modes diff --git a/test/surface/docker/server/config/templates/new-account/settings/prefs.ttl b/test/surface/docker/server/config/templates/new-account/settings/prefs.ttl new file mode 100644 index 000000000..72ef47b88 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/settings/prefs.ttl @@ -0,0 +1,15 @@ +@prefix dct: . +@prefix pim: . +@prefix foaf: . +@prefix solid: . + +<> + a pim:ConfigurationFile; + + dct:title "Preferences file" . + +{{#if email}}<{{webId}}> foaf:mbox .{{/if}} + +<{{webId}}> + solid:publicTypeIndex ; + solid:privateTypeIndex . diff --git a/test/surface/docker/server/config/templates/new-account/settings/privateTypeIndex.ttl b/test/surface/docker/server/config/templates/new-account/settings/privateTypeIndex.ttl new file mode 100644 index 000000000..b6fee77e6 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/settings/privateTypeIndex.ttl @@ -0,0 +1,4 @@ +@prefix solid: . +<> + a solid:TypeIndex ; + a solid:UnlistedDocument. diff --git a/test/surface/docker/server/config/templates/new-account/settings/publicTypeIndex.ttl b/test/surface/docker/server/config/templates/new-account/settings/publicTypeIndex.ttl new file mode 100644 index 000000000..433486252 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/settings/publicTypeIndex.ttl @@ -0,0 +1,4 @@ +@prefix solid: . +<> + a solid:TypeIndex ; + a solid:ListedDocument. diff --git a/test/surface/docker/server/config/templates/new-account/settings/publicTypeIndex.ttl.acl b/test/surface/docker/server/config/templates/new-account/settings/publicTypeIndex.ttl.acl new file mode 100644 index 000000000..6a1901462 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/settings/publicTypeIndex.ttl.acl @@ -0,0 +1,25 @@ +# ACL resource for the Public Type Index + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + <{{webId}}>; + + acl:accessTo <./publicTypeIndex.ttl>; + + acl:mode + acl:Read, acl:Write, acl:Control. + +# Public-readable +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo <./publicTypeIndex.ttl>; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/new-account/settings/serverSide.ttl.acl b/test/surface/docker/server/config/templates/new-account/settings/serverSide.ttl.acl new file mode 100644 index 000000000..fdcc53288 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/settings/serverSide.ttl.acl @@ -0,0 +1,13 @@ +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + <{{webId}}>; + + acl:accessTo <./serverSide.ttl>; + + acl:mode acl:Read . + diff --git a/test/surface/docker/server/config/templates/new-account/settings/serverSide.ttl.inactive b/test/surface/docker/server/config/templates/new-account/settings/serverSide.ttl.inactive new file mode 100644 index 000000000..3cad13211 --- /dev/null +++ b/test/surface/docker/server/config/templates/new-account/settings/serverSide.ttl.inactive @@ -0,0 +1,12 @@ +@prefix dct: . +@prefix pim: . +@prefix solid: . + +<> + a pim:ConfigurationFile; + + dct:description "Administrative settings for the POD that the user can only read." . + + + solid:storageQuota "25000000" . + diff --git a/test/surface/docker/server/config/templates/server/.acl b/test/surface/docker/server/config/templates/server/.acl new file mode 100644 index 000000000..05a9842d9 --- /dev/null +++ b/test/surface/docker/server/config/templates/server/.acl @@ -0,0 +1,10 @@ +# Root ACL resource for the root +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; # everyone + acl:accessTo ; + acl:default ; + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/server/.well-known/.acl b/test/surface/docker/server/config/templates/server/.well-known/.acl new file mode 100644 index 000000000..6cacb3779 --- /dev/null +++ b/test/surface/docker/server/config/templates/server/.well-known/.acl @@ -0,0 +1,15 @@ +# ACL for the default .well-known/ resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/server/favicon.ico b/test/surface/docker/server/config/templates/server/favicon.ico new file mode 100644 index 000000000..764acb205 Binary files /dev/null and b/test/surface/docker/server/config/templates/server/favicon.ico differ diff --git a/test/surface/docker/server/config/templates/server/favicon.ico.acl b/test/surface/docker/server/config/templates/server/favicon.ico.acl new file mode 100644 index 000000000..e76838bb8 --- /dev/null +++ b/test/surface/docker/server/config/templates/server/favicon.ico.acl @@ -0,0 +1,15 @@ +# ACL for the default favicon.ico resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/templates/server/index.html b/test/surface/docker/server/config/templates/server/index.html new file mode 100644 index 000000000..37df7b336 --- /dev/null +++ b/test/surface/docker/server/config/templates/server/index.html @@ -0,0 +1,55 @@ + + + + + + Welcome to Solid + + + + +
+ + +

+ This is a prototype implementation of a Solid server. + + It is a fully functional server, but there are no security or stability guarantees. + + If you have not already done so, please create an account. +

+ + + +
+ {{#if serverLogo}} + + {{/if}} +

Server info

+
+
Name
+
{{serverName}}
+ {{#if serverDescription}} +
Description
+
{{serverDescription}}
+ {{/if}} +
Details
+
Running on Solid {{serverVersion}}
+
+
+
+ + + + diff --git a/test/surface/docker/server/config/templates/server/robots.txt b/test/surface/docker/server/config/templates/server/robots.txt new file mode 100644 index 000000000..8c27a0227 --- /dev/null +++ b/test/surface/docker/server/config/templates/server/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +# Allow all crawling (subject to ACLs as usual, of course) +Disallow: diff --git a/test/surface/docker/server/config/templates/server/robots.txt.acl b/test/surface/docker/server/config/templates/server/robots.txt.acl new file mode 100644 index 000000000..1eaabc201 --- /dev/null +++ b/test/surface/docker/server/config/templates/server/robots.txt.acl @@ -0,0 +1,15 @@ +# ACL for the default robots.txt resource +# Server operators will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/config/usernames-blacklist.json b/test/surface/docker/server/config/usernames-blacklist.json new file mode 100644 index 000000000..c1b469a1e --- /dev/null +++ b/test/surface/docker/server/config/usernames-blacklist.json @@ -0,0 +1,4 @@ +{ + "useTheBigUsernameBlacklist": true, + "customBlacklistedUsernames": [] +} diff --git a/test/surface/docker/server/config/views/account/account-deleted.hbs b/test/surface/docker/server/config/views/account/account-deleted.hbs new file mode 100644 index 000000000..29c76b30f --- /dev/null +++ b/test/surface/docker/server/config/views/account/account-deleted.hbs @@ -0,0 +1,17 @@ + + + + + + Account Deleted + + + +
+

Account Deleted

+
+
+

Your account has been deleted.

+
+ + diff --git a/test/surface/docker/server/config/views/account/delete-confirm.hbs b/test/surface/docker/server/config/views/account/delete-confirm.hbs new file mode 100644 index 000000000..f72654041 --- /dev/null +++ b/test/surface/docker/server/config/views/account/delete-confirm.hbs @@ -0,0 +1,51 @@ + + + + + + Delete Account + + + +
+

Delete Account

+
+
+
+ {{#if error}} +
+
+
+

{{error}}

+
+
+
+ {{/if}} + + {{#if validToken}} +

Beware that this is an irreversible action. All your data that is stored in the POD will be deleted.

+ +
+
+
+ +
+
+ + +
+ {{else}} +
+
+
+
+ Token not valid +
+
+
+
+ {{/if}} +
+
+ + diff --git a/test/surface/docker/server/config/views/account/delete-link-sent.hbs b/test/surface/docker/server/config/views/account/delete-link-sent.hbs new file mode 100644 index 000000000..d6d2dd722 --- /dev/null +++ b/test/surface/docker/server/config/views/account/delete-link-sent.hbs @@ -0,0 +1,17 @@ + + + + + + Delete Account Link Sent + + + +
+

Confirm account deletion

+
+
+

A link to confirm the deletion of this account has been sent to your email.

+
+ + diff --git a/test/surface/docker/server/config/views/account/delete.hbs b/test/surface/docker/server/config/views/account/delete.hbs new file mode 100644 index 000000000..55ac940b2 --- /dev/null +++ b/test/surface/docker/server/config/views/account/delete.hbs @@ -0,0 +1,51 @@ + + + + + + Delete Account + + + + +
+

Delete Account

+
+
+
+
+ {{#if error}} +
+
+

{{error}}

+
+
+ {{/if}} +
+
+ {{#if multiuser}} +

Please enter your account name. A delete account link will be + emailed to the address you provided during account registration.

+ + + + {{else}} +

A delete account link will be + emailed to the address you provided during account registration.

+ {{/if}} +
+
+
+ +
+
+
+ +
+
+
+
+
+ + diff --git a/test/surface/docker/server/config/views/account/invalid-username.hbs b/test/surface/docker/server/config/views/account/invalid-username.hbs new file mode 100644 index 000000000..2ed52b424 --- /dev/null +++ b/test/surface/docker/server/config/views/account/invalid-username.hbs @@ -0,0 +1,22 @@ + + + + + + Invalid username + + + +
+

Invalid username

+
+
+

We're sorry to inform you that this account's username ({{username}}) is not allowed after changes to username policy.

+

This account has been set to be deleted at {{dateOfRemoval}}.

+ {{#if supportEmail}} +

Please contact {{supportEmail}} if you want to move your account.

+ {{/if}} +

If you had an email address connected to this account, you should have received an email about this.

+
+ + diff --git a/test/surface/docker/server/config/views/account/register-disabled.hbs b/test/surface/docker/server/config/views/account/register-disabled.hbs new file mode 100644 index 000000000..7cf4d97af --- /dev/null +++ b/test/surface/docker/server/config/views/account/register-disabled.hbs @@ -0,0 +1,6 @@ +
+

+ Registering a new account is disabled for the WebID-TLS authentication method. + Please restart the server using another mode. +

+
diff --git a/test/surface/docker/server/config/views/account/register-form.hbs b/test/surface/docker/server/config/views/account/register-form.hbs new file mode 100644 index 000000000..aae348e78 --- /dev/null +++ b/test/surface/docker/server/config/views/account/register-form.hbs @@ -0,0 +1,147 @@ +
+
+
+
+
+ {{> shared/error}} + +
+ + + + {{#if multiuser}} +

Your username should be a lower-case word with only + letters a-z and numbers 0-9 and without periods.

+

Your public Solid POD URL will be: + https://alice.

+

Your public Solid WebID will be: + https://alice./profile/card#me

+ +

Your POD URL is like the homepage for your Solid + pod. By default, it is readable by the public, but you can + always change that if you like by changing the access + control.

+ +

Your Solid WebID is your globally unique name + that you can use to identify and authenticate yourself with + other PODs across the world.

+ {{/if}} + +
+ +
+ + + +
+
+
+
+
+ + +
+ + + +
+ + +
+ + +
+ +
+ + + Your email will only be used for account recovery +
+ +
+ +
+ + + + {{#if enforceToc}} + {{#if tocUri}} +
+ +
+ {{/if}} + {{/if}} + + + + + + {{> auth/auth-hidden-fields}} + +
+
+
+
+ +
+
+
+

Already have an account?

+

+ + Please Log In + +

+
+
+
+
+ + + + + + + diff --git a/test/surface/docker/server/config/views/account/register.hbs b/test/surface/docker/server/config/views/account/register.hbs new file mode 100644 index 000000000..f003871b1 --- /dev/null +++ b/test/surface/docker/server/config/views/account/register.hbs @@ -0,0 +1,24 @@ + + + + + + Register + + + + +
+ + + + {{#if registerDisabled}} + {{> account/register-disabled}} + {{else}} + {{> account/register-form}} + {{/if}} +
+ + diff --git a/test/surface/docker/server/config/views/auth/auth-hidden-fields.hbs b/test/surface/docker/server/config/views/auth/auth-hidden-fields.hbs new file mode 100644 index 000000000..35d9fd316 --- /dev/null +++ b/test/surface/docker/server/config/views/auth/auth-hidden-fields.hbs @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/surface/docker/server/config/views/auth/change-password.hbs b/test/surface/docker/server/config/views/auth/change-password.hbs new file mode 100644 index 000000000..07f7ffa2e --- /dev/null +++ b/test/surface/docker/server/config/views/auth/change-password.hbs @@ -0,0 +1,58 @@ + + + + + + Change Password + + + + +
+ + + + {{#if validToken}} +
+ {{> shared/error}} + + +
+ + + +
+
+
+
+
+ + +
+ + + +
+ + + + + +
+ + + + + + {{else}} + + + Email password reset link + + + {{/if}} +
+ + diff --git a/test/surface/docker/server/config/views/auth/goodbye.hbs b/test/surface/docker/server/config/views/auth/goodbye.hbs new file mode 100644 index 000000000..0a96d5b35 --- /dev/null +++ b/test/surface/docker/server/config/views/auth/goodbye.hbs @@ -0,0 +1,23 @@ + + + + + + Logged Out + + + + +
+
+

Logout

+
+ +
+

You have successfully logged out.

+
+ + Login Again +
+ + diff --git a/test/surface/docker/server/config/views/auth/login-required.hbs b/test/surface/docker/server/config/views/auth/login-required.hbs new file mode 100644 index 000000000..467a3a655 --- /dev/null +++ b/test/surface/docker/server/config/views/auth/login-required.hbs @@ -0,0 +1,34 @@ + + + + + + Log in + + + + +
+ + +
+

+ The resource you are trying to access + ({{currentUrl}}) + requires you to log in. +

+
+ +
+ + + + + diff --git a/test/surface/docker/server/config/views/auth/login-tls.hbs b/test/surface/docker/server/config/views/auth/login-tls.hbs new file mode 100644 index 000000000..3c934b45a --- /dev/null +++ b/test/surface/docker/server/config/views/auth/login-tls.hbs @@ -0,0 +1,11 @@ + diff --git a/test/surface/docker/server/config/views/auth/login-username-password.hbs b/test/surface/docker/server/config/views/auth/login-username-password.hbs new file mode 100644 index 000000000..3e6f3bb84 --- /dev/null +++ b/test/surface/docker/server/config/views/auth/login-username-password.hbs @@ -0,0 +1,28 @@ +
+
+ +
+
diff --git a/test/surface/docker/server/config/views/auth/login.hbs b/test/surface/docker/server/config/views/auth/login.hbs new file mode 100644 index 000000000..37c89e2ec --- /dev/null +++ b/test/surface/docker/server/config/views/auth/login.hbs @@ -0,0 +1,55 @@ + + + + + + Login + + + + + + +
+ + + + {{> shared/error}} + +
+
+ {{#if enablePassword}} +

Login

+ {{> auth/login-username-password}} + {{/if}} +
+ {{> shared/create-account }} +
+
+ +
+ {{#if enableTls}} + {{> auth/login-tls}} + {{/if}} +
+ {{> shared/create-account }} +
+
+
+
+ + + + + diff --git a/test/surface/docker/server/config/views/auth/no-permission.hbs b/test/surface/docker/server/config/views/auth/no-permission.hbs new file mode 100644 index 000000000..18e719de7 --- /dev/null +++ b/test/surface/docker/server/config/views/auth/no-permission.hbs @@ -0,0 +1,29 @@ + + + + + + No permission + + + + +
+ +
+

+ You are currently logged in as {{webId}}, + but do not have permission to access {{currentUrl}}. +

+

+ +

+
+
+ + + + + diff --git a/test/surface/docker/server/config/views/auth/password-changed.hbs b/test/surface/docker/server/config/views/auth/password-changed.hbs new file mode 100644 index 000000000..bf513858f --- /dev/null +++ b/test/surface/docker/server/config/views/auth/password-changed.hbs @@ -0,0 +1,27 @@ + + + + + + Password Changed + + + + +
+ + +
+

Your password has been changed.

+
+ +

+ + Log in + +

+
+ + diff --git a/test/surface/docker/server/config/views/auth/reset-link-sent.hbs b/test/surface/docker/server/config/views/auth/reset-link-sent.hbs new file mode 100644 index 000000000..1059f963a --- /dev/null +++ b/test/surface/docker/server/config/views/auth/reset-link-sent.hbs @@ -0,0 +1,21 @@ + + + + + + Reset Link Sent + + + + +
+ + +
+

A Reset Password link has been sent to your email.

+
+
+ + diff --git a/test/surface/docker/server/config/views/auth/reset-password.hbs b/test/surface/docker/server/config/views/auth/reset-password.hbs new file mode 100644 index 000000000..24d9c61e3 --- /dev/null +++ b/test/surface/docker/server/config/views/auth/reset-password.hbs @@ -0,0 +1,52 @@ + + + + + + Reset Password + + + + +
+ + + +
+
+
+ {{> shared/error}} + +
+ {{#if multiuser}} +

Please enter your account name. A password reset link will be + emailed to the address you provided during account registration.

+ + + + {{else}} +

A password reset link will be + emailed to the address you provided during account registration.

+ {{/if}} + +
+ + + +
+
+
+ +
+
+ New to Solid? Create an + account +
+
+ +
+ + diff --git a/test/surface/docker/server/config/views/auth/sharing.hbs b/test/surface/docker/server/config/views/auth/sharing.hbs new file mode 100644 index 000000000..c2c4e409d --- /dev/null +++ b/test/surface/docker/server/config/views/auth/sharing.hbs @@ -0,0 +1,49 @@ + + + + + + {{title}} + + + + + +
+

Authorize {{app_origin}} to access your Pod?

+

Solid allows you to precisely choose what other people and apps can read and write in a Pod. This version of the authorization user interface (node-solid-server V5.1) only supports the toggle of global access permissions to all of the data in your Pod.

+

If you don’t want to set these permissions at a global level, uncheck all of the boxes below, then click authorize. This will add the application origin to your authorization list, without granting it permission to any of your data yet. You will then need to manage those permissions yourself by setting them explicitly in the places you want this application to access.

+
+
+
+

By clicking Authorize, any app from {{app_origin}} will be able to:

+
+
+ + + +
+ + + +
+ + + +
+ + + +
+
+ + + + {{> auth/auth-hidden-fields}} +
+
+
+

This server (node-solid-server V5.1) only implements a limited subset of OpenID Connect, and doesn’t yet support token issuance for applications. OIDC Token Issuance and fine-grained management through this authorization user interface is currently in the development backlog for node-solid-server

+
+ + diff --git a/test/surface/docker/server/config/views/shared/create-account.hbs b/test/surface/docker/server/config/views/shared/create-account.hbs new file mode 100644 index 000000000..1cc0bd810 --- /dev/null +++ b/test/surface/docker/server/config/views/shared/create-account.hbs @@ -0,0 +1,8 @@ +
+
+ New to Solid? + + Create an account + +
+
diff --git a/test/surface/docker/server/config/views/shared/error.hbs b/test/surface/docker/server/config/views/shared/error.hbs new file mode 100644 index 000000000..8aedd23e0 --- /dev/null +++ b/test/surface/docker/server/config/views/shared/error.hbs @@ -0,0 +1,5 @@ +{{#if error}} +
+

{{error}}

+
+{{/if}} diff --git a/test/surface/docker/server/data/.acl b/test/surface/docker/server/data/.acl new file mode 100644 index 000000000..dc4c048ea --- /dev/null +++ b/test/surface/docker/server/data/.acl @@ -0,0 +1,26 @@ +# Root ACL resource for the user account +@prefix acl: . +@prefix foaf: . + +# The homepage is readable by the public +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo ; + acl:mode acl:Read. + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent ; + # Optional owner email, to be used for account recovery: + acl:agent ; + # Set the access to the root storage folder itself + acl:accessTo ; + # All resources will inherit this authorization, by default + acl:default ; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. diff --git a/test/surface/docker/server/data/.meta b/test/surface/docker/server/data/.meta new file mode 100644 index 000000000..6ef91f731 --- /dev/null +++ b/test/surface/docker/server/data/.meta @@ -0,0 +1,5 @@ +# Root Meta resource for the user account +# Used to discover the account's WebID URI, given the account URI + + + . diff --git a/test/surface/docker/server/data/.meta.acl b/test/surface/docker/server/data/.meta.acl new file mode 100644 index 000000000..c20a61ab6 --- /dev/null +++ b/test/surface/docker/server/data/.meta.acl @@ -0,0 +1,25 @@ +# ACL resource for the Root Meta +# Should be public-readable (since the root meta is used for WebID discovery) + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + ; + + acl:accessTo ; + + acl:mode + acl:Read, acl:Write, acl:Control. + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/data/.well-known/.acl b/test/surface/docker/server/data/.well-known/.acl new file mode 100644 index 000000000..0607a6069 --- /dev/null +++ b/test/surface/docker/server/data/.well-known/.acl @@ -0,0 +1,19 @@ +# ACL resource for the well-known folder +@prefix acl: . +@prefix foaf: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent ; + acl:accessTo <./>; + acl:defaultForNew <./>; + acl:mode acl:Read, acl:Write, acl:Control. + +# The public has read permissions +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:defaultForNew <./>; + acl:mode acl:Read. diff --git a/test/surface/docker/server/data/favicon.ico b/test/surface/docker/server/data/favicon.ico new file mode 100644 index 000000000..764acb205 Binary files /dev/null and b/test/surface/docker/server/data/favicon.ico differ diff --git a/test/surface/docker/server/data/favicon.ico.acl b/test/surface/docker/server/data/favicon.ico.acl new file mode 100644 index 000000000..96d2ff46d --- /dev/null +++ b/test/surface/docker/server/data/favicon.ico.acl @@ -0,0 +1,26 @@ +# ACL for the default favicon.ico resource +# Individual users will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + ; + + acl:accessTo ; + + acl:mode + acl:Read, acl:Write, acl:Control. + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/data/inbox/.acl b/test/surface/docker/server/data/inbox/.acl new file mode 100644 index 000000000..5f805e987 --- /dev/null +++ b/test/surface/docker/server/data/inbox/.acl @@ -0,0 +1,26 @@ +# ACL resource for the profile Inbox + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + ; + + acl:accessTo <./>; + acl:default <./>; + + acl:mode + acl:Read, acl:Write, acl:Control. + +# Public-appendable but NOT public-readable +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo <./>; + + acl:mode acl:Append. diff --git a/test/surface/docker/server/data/index.html b/test/surface/docker/server/data/index.html new file mode 100644 index 000000000..2bee2230b --- /dev/null +++ b/test/surface/docker/server/data/index.html @@ -0,0 +1,48 @@ + + + + + + Welcome to Solid + + + + +
+ + +

+ This is a prototype implementation of a Solid server. + + It is a fully functional server, but there are no security or stability guarantees. + + If you have not already done so, please create an account. +

+ + + +
+

Server info

+
+
Name
+
server
+
Details
+
Running on Solid 5.3.0
+
+
+
+ + + + diff --git a/test/surface/docker/server/data/private/.acl b/test/surface/docker/server/data/private/.acl new file mode 100644 index 000000000..4bee153fe --- /dev/null +++ b/test/surface/docker/server/data/private/.acl @@ -0,0 +1,10 @@ +# ACL resource for the private folder +@prefix acl: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent ; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/test/surface/docker/server/data/profile/.acl b/test/surface/docker/server/data/profile/.acl new file mode 100644 index 000000000..bb1375b96 --- /dev/null +++ b/test/surface/docker/server/data/profile/.acl @@ -0,0 +1,19 @@ +# ACL resource for the profile folder +@prefix acl: . +@prefix foaf: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent ; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. + +# The public has read permissions +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read. diff --git a/test/surface/docker/server/data/profile/card$.ttl b/test/surface/docker/server/data/profile/card$.ttl new file mode 100644 index 000000000..48bba3f8d --- /dev/null +++ b/test/surface/docker/server/data/profile/card$.ttl @@ -0,0 +1,31 @@ +@prefix : <#>. +@prefix solid: . +@prefix pro: <./>. +@prefix n0: . +@prefix schem: . +@prefix n1: . +@prefix ldp: . +@prefix inbox: . +@prefix sp: . +@prefix ser: . + +pro:card a n0:PersonalProfileDocument; n0:maker :me; n0:primaryTopic :me. + +:me + a schem:Person, n0:Person; + n1:trustedApp + [ + n1:mode n1:Append, n1:Control, n1:Read, n1:Write; + n1:origin + ], + [ + n1:mode n1:Append, n1:Control, n1:Read, n1:Write; + n1:origin + ]; + ldp:inbox inbox:; + sp:preferencesFile ; + sp:storage ser:; + solid:account ser:; + solid:privateTypeIndex ; + solid:publicTypeIndex ; + n0:name "Alice". diff --git a/test/surface/docker/server/data/public/.acl b/test/surface/docker/server/data/public/.acl new file mode 100644 index 000000000..500481ee4 --- /dev/null +++ b/test/surface/docker/server/data/public/.acl @@ -0,0 +1,19 @@ +# ACL resource for the public folder +@prefix acl: . +@prefix foaf: . + +# The owner has all permissions +<#owner> + a acl:Authorization; + acl:agent ; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read, acl:Write, acl:Control. + +# The public has read permissions +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:default <./>; + acl:mode acl:Read. diff --git a/test/surface/docker/server/data/robots.txt b/test/surface/docker/server/data/robots.txt new file mode 100644 index 000000000..8c27a0227 --- /dev/null +++ b/test/surface/docker/server/data/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +# Allow all crawling (subject to ACLs as usual, of course) +Disallow: diff --git a/test/surface/docker/server/data/robots.txt.acl b/test/surface/docker/server/data/robots.txt.acl new file mode 100644 index 000000000..69d91694c --- /dev/null +++ b/test/surface/docker/server/data/robots.txt.acl @@ -0,0 +1,26 @@ +# ACL for the default robots.txt resource +# Individual users will be able to override it as they wish +# Public-readable + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + ; + + acl:accessTo ; + + acl:mode + acl:Read, acl:Write, acl:Control. + +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo ; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/data/settings/.acl b/test/surface/docker/server/data/settings/.acl new file mode 100644 index 000000000..c3132df3a --- /dev/null +++ b/test/surface/docker/server/data/settings/.acl @@ -0,0 +1,20 @@ +# ACL resource for the /settings/ container +@prefix acl: . + +<#owner> + a acl:Authorization; + + acl:agent + ; + + # Set the access to the root storage folder itself + acl:accessTo <./>; + + # All settings resources will be private, by default, unless overridden + acl:default <./>; + + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. + +# Private, no public access modes diff --git a/test/surface/docker/server/data/settings/prefs.ttl b/test/surface/docker/server/data/settings/prefs.ttl new file mode 100644 index 000000000..8ae50e579 --- /dev/null +++ b/test/surface/docker/server/data/settings/prefs.ttl @@ -0,0 +1,15 @@ +@prefix dct: . +@prefix pim: . +@prefix foaf: . +@prefix solid: . + +<> + a pim:ConfigurationFile; + + dct:title "Preferences file" . + + foaf:mbox . + + + solid:publicTypeIndex ; + solid:privateTypeIndex . diff --git a/test/surface/docker/server/data/settings/privateTypeIndex.ttl b/test/surface/docker/server/data/settings/privateTypeIndex.ttl new file mode 100644 index 000000000..b6fee77e6 --- /dev/null +++ b/test/surface/docker/server/data/settings/privateTypeIndex.ttl @@ -0,0 +1,4 @@ +@prefix solid: . +<> + a solid:TypeIndex ; + a solid:UnlistedDocument. diff --git a/test/surface/docker/server/data/settings/publicTypeIndex.ttl b/test/surface/docker/server/data/settings/publicTypeIndex.ttl new file mode 100644 index 000000000..433486252 --- /dev/null +++ b/test/surface/docker/server/data/settings/publicTypeIndex.ttl @@ -0,0 +1,4 @@ +@prefix solid: . +<> + a solid:TypeIndex ; + a solid:ListedDocument. diff --git a/test/surface/docker/server/data/settings/publicTypeIndex.ttl.acl b/test/surface/docker/server/data/settings/publicTypeIndex.ttl.acl new file mode 100644 index 000000000..cdf2e676f --- /dev/null +++ b/test/surface/docker/server/data/settings/publicTypeIndex.ttl.acl @@ -0,0 +1,25 @@ +# ACL resource for the Public Type Index + +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + ; + + acl:accessTo <./publicTypeIndex.ttl>; + + acl:mode + acl:Read, acl:Write, acl:Control. + +# Public-readable +<#public> + a acl:Authorization; + + acl:agentClass foaf:Agent; # everyone + + acl:accessTo <./publicTypeIndex.ttl>; + + acl:mode acl:Read. diff --git a/test/surface/docker/server/data/settings/serverSide.ttl b/test/surface/docker/server/data/settings/serverSide.ttl new file mode 100644 index 000000000..e69de29bb diff --git a/test/surface/docker/server/data/settings/serverSide.ttl.acl b/test/surface/docker/server/data/settings/serverSide.ttl.acl new file mode 100644 index 000000000..f890cea5e --- /dev/null +++ b/test/surface/docker/server/data/settings/serverSide.ttl.acl @@ -0,0 +1,13 @@ +@prefix acl: . +@prefix foaf: . + +<#owner> + a acl:Authorization; + + acl:agent + ; + + acl:accessTo <./serverSide.ttl>; + + acl:mode acl:Read . + diff --git a/test/surface/docker/server/data/settings/serverSide.ttl.inactive b/test/surface/docker/server/data/settings/serverSide.ttl.inactive new file mode 100644 index 000000000..3cad13211 --- /dev/null +++ b/test/surface/docker/server/data/settings/serverSide.ttl.inactive @@ -0,0 +1,12 @@ +@prefix dct: . +@prefix pim: . +@prefix solid: . + +<> + a pim:ConfigurationFile; + + dct:description "Administrative settings for the POD that the user can only read." . + + + solid:storageQuota "25000000" . + diff --git a/test/surface/docker/server/env.list b/test/surface/docker/server/env.list new file mode 100644 index 000000000..eff72df49 --- /dev/null +++ b/test/surface/docker/server/env.list @@ -0,0 +1,4 @@ +ALICE_WEBID=https://server/profile/card#me +SERVER_ROOT=https://server +USERNAME=alice +PASSWORD=123 diff --git a/test/surface/docker/solid-crud/Dockerfile b/test/surface/docker/solid-crud/Dockerfile new file mode 100644 index 000000000..c6027fb88 --- /dev/null +++ b/test/surface/docker/solid-crud/Dockerfile @@ -0,0 +1,4 @@ +FROM solidtestsuite/solid-crud-tests +RUN git fetch origin +RUN git checkout nss-skips +RUN git pull diff --git a/test/surface/docker/web-access-control/Dockerfile b/test/surface/docker/web-access-control/Dockerfile new file mode 100644 index 000000000..e211ca91c --- /dev/null +++ b/test/surface/docker/web-access-control/Dockerfile @@ -0,0 +1 @@ +FROM solidtestsuite/web-access-control-tests diff --git a/test/surface/docker/webid-provider/Dockerfile b/test/surface/docker/webid-provider/Dockerfile new file mode 100644 index 000000000..574de85f2 --- /dev/null +++ b/test/surface/docker/webid-provider/Dockerfile @@ -0,0 +1 @@ +FROM solidtestsuite/webid-provider-tests diff --git a/test/surface/run-solid-test-suite.sh b/test/surface/run-solid-test-suite.sh new file mode 100644 index 000000000..1e385dc5f --- /dev/null +++ b/test/surface/run-solid-test-suite.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -e + +function setup { + echo Branch name: $1 + echo Repoitory: $2 + docker network create testnet + docker build -t server --build-arg BRANCH=$1 --build-arg REPO=$2 test/surface/docker/server + docker build -t cookie test/surface/docker/cookie + docker run -d --env-file test/surface/server-env.list --name server --network=testnet -v `pwd`:/travis -w /node-solid-server server /travis/bin/solid-test start --config-file /node-solid-server/config.json + docker run -d --env-file test/surface/thirdparty-env.list --name thirdparty --network=testnet -v `pwd`/test/surface:/surface server /node-solid-server/bin/solid-test start --config-file /surface/thirdparty-config.json +} +function teardown { + docker stop `docker ps --filter network=testnet -q` + docker rm `docker ps --filter network=testnet -qa` + docker network remove testnet +} + +function waitForNss { + docker pull solidtestsuite/webid-provider-tests + until docker run --rm --network=testnet solidtestsuite/webid-provider-tests curl -kI https://$1 2> /dev/null > /dev/null + do + echo Waiting for $1 to start, this can take up to a minute ... + docker ps -a + docker logs $1 + sleep 1 + done + + docker logs $1 + echo Getting cookie for $1... + export COOKIE_$1="`docker run --cap-add=SYS_ADMIN --network=testnet --env-file test/surface/$1-env.list cookie`" +} + +function runTests { + docker pull solidtestsuite/$1:$2 + + echo "Running $1 against server with cookie $COOKIE_server" + docker run --rm --network=testnet \ + --env COOKIE="$COOKIE_server" \ + --env COOKIE_ALICE="$COOKIE_server" \ + --env COOKIE_BOB="$COOKIE_thirdparty" \ + --env-file test/surface/$1-env.list solidtestsuite/$1:$2 +} + +function runTestsFromGit { + docker build https://github.com/solid-contrib/$1.git#$2 -t $1 + + echo "Running web-access-control-tests against server with cookie $COOKIE_server" + docker run --rm --network=testnet \ + --env COOKIE="$COOKIE_server" \ + --env COOKIE_ALICE="$COOKIE_server" \ + --env COOKIE_BOB="$COOKIE_thirdparty" \ + --env-file test/surface/$1-env.list $1 +} + +# ... +teardown || true +setup $1 $2 +waitForNss server +runTests webid-provider-tests v2.0.3 +runTestsFromGit solid-crud-tests v6.0.0-issue#1743 +waitForNss thirdparty +# runTests web-access-control-tests v7.1.0 +runTestsFromGit web-access-control-tests patchAppendNewDocument +teardown + +# To debug, e.g. running web-access-control-tests jest interactively, +# comment out `teardown` and uncomment this instead: +# env +# docker run -it --network=testnet \ +# --env COOKIE="$COOKIE_server" \ +# --env COOKIE_ALICE="$COOKIE_server" \ +# --env COOKIE_BOB="$COOKIE_thirdparty" \ +# --env-file test/surface/web-access-control-tests-env.list \ +# solidtestsuite/web-access-control-tests:latest /bin/bash diff --git a/test/surface/server-env.list b/test/surface/server-env.list new file mode 100644 index 000000000..283447a7e --- /dev/null +++ b/test/surface/server-env.list @@ -0,0 +1,5 @@ +ALICE_WEBID=https://server/profile/card#me +SERVER_ROOT=https://server +USERNAME=alice +PASSWORD=123 +ACL_CACHE_TIME=5 \ No newline at end of file diff --git a/test/surface/solid-crud-tests-env.list b/test/surface/solid-crud-tests-env.list new file mode 100644 index 000000000..d95843af2 --- /dev/null +++ b/test/surface/solid-crud-tests-env.list @@ -0,0 +1,5 @@ +ALICE_WEBID=https://server/profile/card#me +SERVER_ROOT=https://server +USERNAME=alice +PASSWORD=123 +SKIP_CONC=1 diff --git a/test/surface/thirdparty-config.json b/test/surface/thirdparty-config.json new file mode 100644 index 000000000..1662a2f4e --- /dev/null +++ b/test/surface/thirdparty-config.json @@ -0,0 +1,23 @@ +{ + "root": "./data", + "port": "443", + "serverUri": "https://thirdparty", + "webid": true, + "mount": "/", + "configPath": "./config", + "configFile": "./config.json", + "dbPath": "./.db", + "sslKey": "./server.key", + "sslCert": "./server.cert", + "multiuser": false, + "server": { + "name": "server", + "description": "", + "logo": "" + }, + "enforceToc": true, + "disablePasswordChecks": false, + "tocUri": "https://your-toc", + "supportEmail": "Your support email address" +} + diff --git a/test/surface/thirdparty-env.list b/test/surface/thirdparty-env.list new file mode 100644 index 000000000..9a3e30a3b --- /dev/null +++ b/test/surface/thirdparty-env.list @@ -0,0 +1,6 @@ +ALICE_WEBID=https://thirdparty/profile/card#me +SERVER_ROOT=https://thirdparty +USERNAME=alice +PASSWORD=123 +ACL_CACHE_TIME=5 + diff --git a/test/surface/web-access-control-tests-env.list b/test/surface/web-access-control-tests-env.list new file mode 100644 index 000000000..2f850ff7e --- /dev/null +++ b/test/surface/web-access-control-tests-env.list @@ -0,0 +1,6 @@ +WEBID_ALICE=https://server/profile/card#me +OIDC_ISSUER_ALICE=https://server +STORAGE_ROOT_ALICE=https://server/ +WEBID_BOB=https://thirdparty/profile/card#me +OIDC_ISSUER_BOB=https://thirdparty +STORAGE_ROOT_BOB=https://thirdparty/ \ No newline at end of file diff --git a/test/surface/webid-provider-tests-env.list b/test/surface/webid-provider-tests-env.list new file mode 100644 index 000000000..eff72df49 --- /dev/null +++ b/test/surface/webid-provider-tests-env.list @@ -0,0 +1,4 @@ +ALICE_WEBID=https://server/profile/card#me +SERVER_ROOT=https://server +USERNAME=alice +PASSWORD=123 diff --git a/test/test-helpers.mjs b/test/test-helpers.mjs new file mode 100644 index 000000000..f9359241e --- /dev/null +++ b/test/test-helpers.mjs @@ -0,0 +1,63 @@ +// ESM Test Configuration +import { performance as perf } from 'perf_hooks' +export const testConfig = { + timeout: 10000, + slow: 2000, + nodeOptions: '--experimental-loader=esmock' +} + +// Utility to create test servers with ESM modules +export async function createTestServer (options = {}) { + const { default: createApp } = await import('../index.mjs') + + const defaultOptions = { + port: 0, // Random port + serverUri: 'https://localhost', + webid: true, + multiuser: false, + ...options + } + + const app = createApp(defaultOptions) + return app +} + +// Utility to test ESM import functionality +export async function testESMImport (modulePath) { + try { + const module = await import(modulePath) + return { + success: true, + module, + hasDefault: 'default' in module, + namedExports: Object.keys(module).filter(key => key !== 'default') + } + } catch (error) { + return { + success: false, + error: error.message + } + } +} + +// Performance measurement utilities +export class PerformanceTimer { + constructor () { + this.startTime = null + this.endTime = null + } + + start () { + this.startTime = perf.now() + return this + } + + end () { + this.endTime = perf.now() + return this.duration + } + + get duration () { + return this.endTime - this.startTime + } +} diff --git a/test/unit/account-manager-test.js b/test/unit/account-manager-test.mjs similarity index 60% rename from test/unit/account-manager-test.js rename to test/unit/account-manager-test.mjs index 3d0ac3188..d7b7e758e 100644 --- a/test/unit/account-manager-test.js +++ b/test/unit/account-manager-test.mjs @@ -1,600 +1,610 @@ -'use strict' - -const path = require('path') -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.use(require('dirty-chai')) -chai.should() - -const rdf = require('rdflib') -const ns = require('solid-namespace')(rdf) -const LDP = require('../../lib/ldp') -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') -const UserAccount = require('../../lib/models/user-account') -const TokenService = require('../../lib/services/token-service') -const WebIdTlsCertificate = require('../../lib/models/webid-tls-certificate') -const ResourceMapper = require('../../lib/resource-mapper') - -const testAccountsDir = path.join(__dirname, '../resources/accounts') - -var host - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) -}) - -describe('AccountManager', () => { - describe('from()', () => { - it('should init with passed in options', () => { - let config = { - host, - authMethod: 'oidc', - multiuser: true, - store: {}, - emailService: {}, - tokenService: {} - } - - let mgr = AccountManager.from(config) - expect(mgr.host).to.equal(config.host) - expect(mgr.authMethod).to.equal(config.authMethod) - expect(mgr.multiuser).to.equal(config.multiuser) - expect(mgr.store).to.equal(config.store) - expect(mgr.emailService).to.equal(config.emailService) - expect(mgr.tokenService).to.equal(config.tokenService) - }) - - it('should error if no host param is passed in', () => { - expect(() => { AccountManager.from() }) - .to.throw(/AccountManager requires a host instance/) - }) - }) - - describe('accountUriFor', () => { - it('should compose account uri for an account in multi user mode', () => { - let options = { - multiuser: true, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - let mgr = AccountManager.from(options) - - let webId = mgr.accountUriFor('alice') - expect(webId).to.equal('https://alice.localhost') - }) - - it('should compose account uri for an account in single user mode', () => { - let options = { - multiuser: false, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - let mgr = AccountManager.from(options) - - let webId = mgr.accountUriFor('alice') - expect(webId).to.equal('https://localhost') - }) - }) - - describe('accountWebIdFor()', () => { - it('should compose a web id uri for an account in multi user mode', () => { - let options = { - multiuser: true, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - let mgr = AccountManager.from(options) - let webId = mgr.accountWebIdFor('alice') - expect(webId).to.equal('https://alice.localhost/profile/card#me') - }) - - it('should compose a web id uri for an account in single user mode', () => { - let options = { - multiuser: false, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - let mgr = AccountManager.from(options) - let webId = mgr.accountWebIdFor('alice') - expect(webId).to.equal('https://localhost/profile/card#me') - }) - }) - - describe('accountDirFor()', () => { - it('should match the solid root dir config, in single user mode', () => { - let multiuser = false - let resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - let store = new LDP({ multiuser, resourceMapper }) - let options = { multiuser, store, host } - let accountManager = AccountManager.from(options) - - let accountDir = accountManager.accountDirFor('alice') - expect(accountDir).to.equal(store.resourceMapper._rootPath) - }) - - it('should compose the account dir in multi user mode', () => { - let multiuser = true - let resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - let store = new LDP({ multiuser, resourceMapper }) - let host = SolidHost.from({ serverUri: 'https://localhost' }) - let options = { multiuser, store, host } - let accountManager = AccountManager.from(options) - - let accountDir = accountManager.accountDirFor('alice') - expect(accountDir).to.equal(testAccountsDir + '/alice.localhost') - }) - }) - - describe('userAccountFrom()', () => { - describe('in multi user mode', () => { - let multiuser = true - let options, accountManager - - beforeEach(() => { - options = { host, multiuser } - accountManager = AccountManager.from(options) - }) - - it('should throw an error if no username is passed', () => { - expect(() => { - accountManager.userAccountFrom({}) - }).to.throw(/Username or web id is required/) - }) - - it('should init webId from param if no username is passed', () => { - let userData = { webId: 'https://example.com' } - let newAccount = accountManager.userAccountFrom(userData) - expect(newAccount.webId).to.equal(userData.webId) - }) - - it('should derive the local account id from username, for external webid', () => { - let userData = { - externalWebId: 'https://alice.external.com/profile#me', - username: 'user1' - } - - let newAccount = accountManager.userAccountFrom(userData) - - expect(newAccount.username).to.equal('user1') - expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') - expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') - expect(newAccount.localAccountId).to.equal('user1.example.com/profile/card#me') - }) - - it('should use the external web id as username if no username given', () => { - let userData = { - externalWebId: 'https://alice.external.com/profile#me' - } - - let newAccount = accountManager.userAccountFrom(userData) - - expect(newAccount.username).to.equal('https://alice.external.com/profile#me') - expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') - expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') - }) - }) - - describe('in single user mode', () => { - let multiuser = false - let options, accountManager - - beforeEach(() => { - options = { host, multiuser } - accountManager = AccountManager.from(options) - }) - - it('should not throw an error if no username is passed', () => { - expect(() => { - accountManager.userAccountFrom({}) - }).to.not.throw(Error) - }) - }) - }) - - describe('addCertKeyToProfile()', () => { - let accountManager, certificate, userAccount, profileGraph - - beforeEach(() => { - let options = { host } - accountManager = AccountManager.from(options) - userAccount = accountManager.userAccountFrom({ username: 'alice' }) - certificate = WebIdTlsCertificate.fromSpkacPost('1234', userAccount, host) - profileGraph = {} - }) - - it('should fetch the profile graph', () => { - accountManager.getProfileGraphFor = sinon.stub().returns(Promise.resolve()) - accountManager.addCertKeyToGraph = sinon.stub() - accountManager.saveProfileGraph = sinon.stub() - - return accountManager.addCertKeyToProfile(certificate, userAccount) - .then(() => { - expect(accountManager.getProfileGraphFor).to - .have.been.calledWith(userAccount) - }) - }) - - it('should add the cert key to the account graph', () => { - accountManager.getProfileGraphFor = sinon.stub() - .returns(Promise.resolve(profileGraph)) - accountManager.addCertKeyToGraph = sinon.stub() - accountManager.saveProfileGraph = sinon.stub() - - return accountManager.addCertKeyToProfile(certificate, userAccount) - .then(() => { - expect(accountManager.addCertKeyToGraph).to - .have.been.calledWith(certificate, profileGraph) - expect(accountManager.addCertKeyToGraph).to - .have.been.calledAfter(accountManager.getProfileGraphFor) - }) - }) - - it('should save the modified graph to the profile doc', () => { - accountManager.getProfileGraphFor = sinon.stub() - .returns(Promise.resolve(profileGraph)) - accountManager.addCertKeyToGraph = sinon.stub() - .returns(Promise.resolve(profileGraph)) - accountManager.saveProfileGraph = sinon.stub() - - return accountManager.addCertKeyToProfile(certificate, userAccount) - .then(() => { - expect(accountManager.saveProfileGraph).to - .have.been.calledWith(profileGraph, userAccount) - expect(accountManager.saveProfileGraph).to - .have.been.calledAfter(accountManager.addCertKeyToGraph) - }) - }) - }) - - describe('getProfileGraphFor()', () => { - it('should throw an error if webId is missing', (done) => { - let emptyUserData = {} - let userAccount = UserAccount.from(emptyUserData) - let options = { host, multiuser: true } - let accountManager = AccountManager.from(options) - - accountManager.getProfileGraphFor(userAccount) - .catch(error => { - expect(error.message).to - .equal('Cannot fetch profile graph, missing WebId URI') - done() - }) - }) - - it('should fetch the profile graph via LDP store', () => { - let store = { - getGraph: sinon.stub().returns(Promise.resolve()) - } - let webId = 'https://alice.example.com/#me' - let profileHostUri = 'https://alice.example.com/' - - let userData = { webId } - let userAccount = UserAccount.from(userData) - let options = { host, multiuser: true, store } - let accountManager = AccountManager.from(options) - - expect(userAccount.webId).to.equal(webId) - - return accountManager.getProfileGraphFor(userAccount) - .then(() => { - expect(store.getGraph).to.have.been.calledWith(profileHostUri) - }) - }) - }) - - describe('saveProfileGraph()', () => { - it('should save the profile graph via the LDP store', () => { - let store = { - putGraph: sinon.stub().returns(Promise.resolve()) - } - let webId = 'https://alice.example.com/#me' - let profileHostUri = 'https://alice.example.com/' - - let userData = { webId } - let userAccount = UserAccount.from(userData) - let options = { host, multiuser: true, store } - let accountManager = AccountManager.from(options) - let profileGraph = rdf.graph() - - return accountManager.saveProfileGraph(profileGraph, userAccount) - .then(() => { - expect(store.putGraph).to.have.been.calledWith(profileGraph, profileHostUri) - }) - }) - }) - - describe('rootAclFor()', () => { - it('should return the server root .acl in single user mode', () => { - let resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - rootPath: process.cwd(), - includeHost: false - }) - let store = new LDP({ suffixAcl: '.acl', multiuser: false, resourceMapper }) - let options = { host, multiuser: false, store } - let accountManager = AccountManager.from(options) - - let userAccount = UserAccount.from({ username: 'alice' }) - - let rootAclUri = accountManager.rootAclFor(userAccount) - - expect(rootAclUri).to.equal('https://example.com/.acl') - }) - - it('should return the profile root .acl in multi user mode', () => { - let resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - rootPath: process.cwd(), - includeHost: true - }) - let store = new LDP({ suffixAcl: '.acl', multiuser: true, resourceMapper }) - let options = { host, multiuser: true, store } - let accountManager = AccountManager.from(options) - - let userAccount = UserAccount.from({ username: 'alice' }) - - let rootAclUri = accountManager.rootAclFor(userAccount) - - expect(rootAclUri).to.equal('https://alice.example.com/.acl') - }) - }) - - describe('loadAccountRecoveryEmail()', () => { - it('parses and returns the agent mailto from the root acl', () => { - let userAccount = UserAccount.from({ username: 'alice' }) - - let rootAclGraph = rdf.graph() - rootAclGraph.add( - rdf.namedNode('https://alice.example.com/.acl#owner'), - ns.acl('agent'), - rdf.namedNode('mailto:alice@example.com') - ) - - let store = { - suffixAcl: '.acl', - getGraph: sinon.stub().resolves(rootAclGraph) - } - - let options = { host, multiuser: true, store } - let accountManager = AccountManager.from(options) - - return accountManager.loadAccountRecoveryEmail(userAccount) - .then(recoveryEmail => { - expect(recoveryEmail).to.equal('alice@example.com') - }) - }) - - it('should return undefined when agent mailto is missing', () => { - let userAccount = UserAccount.from({ username: 'alice' }) - - let emptyGraph = rdf.graph() - - let store = { - suffixAcl: '.acl', - getGraph: sinon.stub().resolves(emptyGraph) - } - - let options = { host, multiuser: true, store } - let accountManager = AccountManager.from(options) - - return accountManager.loadAccountRecoveryEmail(userAccount) - .then(recoveryEmail => { - expect(recoveryEmail).to.be.undefined() - }) - }) - }) - - describe('passwordResetUrl()', () => { - it('should return a token reset validation url', () => { - let tokenService = new TokenService() - let options = { host, multiuser: true, tokenService } - - let accountManager = AccountManager.from(options) - - let returnToUrl = 'https://example.com/resource' - let token = '123' - - let resetUrl = accountManager.passwordResetUrl(token, returnToUrl) - - let expectedUri = 'https://example.com/account/password/change?' + - 'token=123&returnToUrl=' + returnToUrl - - expect(resetUrl).to.equal(expectedUri) - }) - }) - - describe('generateDeleteToken()', () => { - it('should generate and store an expiring delete token', () => { - let tokenService = new TokenService() - let options = { host, tokenService } - - let accountManager = AccountManager.from(options) - - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId - } - - let token = accountManager.generateDeleteToken(userAccount) - - let tokenValue = accountManager.tokenService.verify('delete-account', token) - - expect(tokenValue.webId).to.equal(aliceWebId) - expect(tokenValue).to.have.property('exp') - }) - }) - - describe('generateResetToken()', () => { - it('should generate and store an expiring reset token', () => { - let tokenService = new TokenService() - let options = { host, tokenService } - - let accountManager = AccountManager.from(options) - - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId - } - - let token = accountManager.generateResetToken(userAccount) - - let tokenValue = accountManager.tokenService.verify('reset-password', token) - - expect(tokenValue.webId).to.equal(aliceWebId) - expect(tokenValue).to.have.property('exp') - }) - }) - - describe('sendPasswordResetEmail()', () => { - it('should compose and send a password reset email', () => { - let resetToken = '1234' - let tokenService = { - generate: sinon.stub().returns(resetToken) - } - - let emailService = { - sendWithTemplate: sinon.stub().resolves() - } - - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - let returnToUrl = 'https://example.com/resource' - - let options = { host, tokenService, emailService } - let accountManager = AccountManager.from(options) - - accountManager.passwordResetUrl = sinon.stub().returns('reset url') - - let expectedEmailData = { - to: 'alice@example.com', - webId: aliceWebId, - resetUrl: 'reset url' - } - - return accountManager.sendPasswordResetEmail(userAccount, returnToUrl) - .then(() => { - expect(accountManager.passwordResetUrl) - .to.have.been.calledWith(resetToken, returnToUrl) - expect(emailService.sendWithTemplate) - .to.have.been.calledWith('reset-password', expectedEmailData) - }) - }) - - it('should reject if no email service is set up', done => { - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - let returnToUrl = 'https://example.com/resource' - let options = { host } - let accountManager = AccountManager.from(options) - - accountManager.sendPasswordResetEmail(userAccount, returnToUrl) - .catch(error => { - expect(error.message).to.equal('Email service is not set up') - done() - }) - }) - - it('should reject if no user email is provided', done => { - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId - } - let returnToUrl = 'https://example.com/resource' - let emailService = {} - let options = { host, emailService } - - let accountManager = AccountManager.from(options) - - accountManager.sendPasswordResetEmail(userAccount, returnToUrl) - .catch(error => { - expect(error.message).to.equal('Account recovery email has not been provided') - done() - }) - }) - }) - - describe('sendDeleteAccountEmail()', () => { - it('should compose and send a delete account email', () => { - let deleteToken = '1234' - let tokenService = { - generate: sinon.stub().returns(deleteToken) - } - - let emailService = { - sendWithTemplate: sinon.stub().resolves() - } - - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - - let options = { host, tokenService, emailService } - let accountManager = AccountManager.from(options) - - accountManager.getAccountDeleteUrl = sinon.stub().returns('delete account url') - - let expectedEmailData = { - to: 'alice@example.com', - webId: aliceWebId, - deleteUrl: 'delete account url' - } - - return accountManager.sendDeleteAccountEmail(userAccount) - .then(() => { - expect(accountManager.getAccountDeleteUrl) - .to.have.been.calledWith(deleteToken) - expect(emailService.sendWithTemplate) - .to.have.been.calledWith('delete-account', expectedEmailData) - }) - }) - - it('should reject if no email service is set up', done => { - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - let options = { host } - let accountManager = AccountManager.from(options) - - accountManager.sendDeleteAccountEmail(userAccount) - .catch(error => { - expect(error.message).to.equal('Email service is not set up') - done() - }) - }) - - it('should reject if no user email is provided', done => { - let aliceWebId = 'https://alice.example.com/#me' - let userAccount = { - webId: aliceWebId - } - let emailService = {} - let options = { host, emailService } - - let accountManager = AccountManager.from(options) - - accountManager.sendDeleteAccountEmail(userAccount) - .catch(error => { - expect(error.message).to.equal('Account recovery email has not been provided') - done() - }) - }) - }) -}) +import { describe, it, beforeEach } from 'mocha' +import { fileURLToPath } from 'url' +import path from 'path' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +// Import CommonJS modules that haven't been converted yet +import rdf from 'rdflib' +import vocab from 'solid-namespace' + +// Import ESM modules (assuming they exist or will be created) +import LDP from '../../lib/ldp.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import UserAccount from '../../lib/models/user-account.mjs' +import TokenService from '../../lib/services/token-service.mjs' +import WebIdTlsCertificate from '../../lib/models/webid-tls-certificate.mjs' +import ResourceMapper from '../../lib/resource-mapper.mjs' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() +const ns = vocab(rdf) + +const testAccountsDir = path.join(__dirname, '../resources/accounts') + +let host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +describe('AccountManager', () => { + describe('from()', () => { + it('should init with passed in options', () => { + const config = { + host, + authMethod: 'oidc', + multiuser: true, + store: {}, + emailService: {}, + tokenService: {} + } + + const mgr = AccountManager.from(config) + expect(mgr.host).to.equal(config.host) + expect(mgr.authMethod).to.equal(config.authMethod) + expect(mgr.multiuser).to.equal(config.multiuser) + expect(mgr.store).to.equal(config.store) + expect(mgr.emailService).to.equal(config.emailService) + expect(mgr.tokenService).to.equal(config.tokenService) + }) + + it('should error if no host param is passed in', () => { + expect(() => { AccountManager.from() }) + .to.throw(/AccountManager requires a host instance/) + }) + }) + + describe('accountUriFor', () => { + it('should compose account uri for an account in multi user mode', () => { + const options = { + multiuser: true, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + + const webId = mgr.accountUriFor('alice') + expect(webId).to.equal('https://alice.localhost') + }) + + it('should compose account uri for an account in single user mode', () => { + const options = { + multiuser: false, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + + const webId = mgr.accountUriFor('alice') + expect(webId).to.equal('https://localhost') + }) + }) + + describe('accountWebIdFor()', () => { + it('should compose a web id uri for an account in multi user mode', () => { + const options = { + multiuser: true, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + const webId = mgr.accountWebIdFor('alice') + expect(webId).to.equal('https://alice.localhost/profile/card#me') + }) + + it('should compose a web id uri for an account in single user mode', () => { + const options = { + multiuser: false, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + const webId = mgr.accountWebIdFor('alice') + expect(webId).to.equal('https://localhost/profile/card#me') + }) + }) + + describe('accountDirFor()', () => { + it('should match the solid root dir config, in single user mode', () => { + const multiuser = false + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ multiuser, resourceMapper }) + const options = { multiuser, store, host } + const accountManager = AccountManager.from(options) + + const accountDir = accountManager.accountDirFor('alice') + expect(accountDir).to.equal(store.resourceMapper._rootPath) + }) + + it('should compose the account dir in multi user mode', () => { + const multiuser = true + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ multiuser, resourceMapper }) + const host = SolidHost.from({ serverUri: 'https://localhost' }) + const options = { multiuser, store, host } + const accountManager = AccountManager.from(options) + + const accountDir = accountManager.accountDirFor('alice') + const expectedPath = path.join(testAccountsDir, 'alice.localhost') + expect(path.normalize(accountDir)).to.equal(path.normalize(expectedPath)) + }) + }) + + describe('userAccountFrom()', () => { + describe('in multi user mode', () => { + const multiuser = true + let options, accountManager + + beforeEach(() => { + options = { host, multiuser } + accountManager = AccountManager.from(options) + }) + + it('should throw an error if no username is passed', () => { + expect(() => { + accountManager.userAccountFrom({}) + }).to.throw(/Username or web id is required/) + }) + + it('should init webId from param if no username is passed', () => { + const userData = { webId: 'https://example.com' } + const newAccount = accountManager.userAccountFrom(userData) + expect(newAccount.webId).to.equal(userData.webId) + }) + + it('should derive the local account id from username, for external webid', () => { + const userData = { + externalWebId: 'https://alice.external.com/profile#me', + username: 'user1' + } + + const newAccount = accountManager.userAccountFrom(userData) + + expect(newAccount.username).to.equal('user1') + expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.localAccountId).to.equal('user1.example.com/profile/card#me') + }) + + it('should use the external web id as username if no username given', () => { + const userData = { + externalWebId: 'https://alice.external.com/profile#me' + } + + const newAccount = accountManager.userAccountFrom(userData) + + expect(newAccount.username).to.equal('https://alice.external.com/profile#me') + expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') + }) + }) + + describe('in single user mode', () => { + const multiuser = false + let options, accountManager + + beforeEach(() => { + options = { host, multiuser } + accountManager = AccountManager.from(options) + }) + + it('should not throw an error if no username is passed', () => { + expect(() => { + accountManager.userAccountFrom({}) + }).to.not.throw(Error) + }) + }) + }) + + describe('addCertKeyToProfile()', () => { + let accountManager, certificate, userAccount, profileGraph + + beforeEach(() => { + const options = { host } + accountManager = AccountManager.from(options) + userAccount = accountManager.userAccountFrom({ username: 'alice' }) + certificate = WebIdTlsCertificate.fromSpkacPost('1234', userAccount, host) + profileGraph = {} + }) + + it('should fetch the profile graph', () => { + accountManager.getProfileGraphFor = sinon.stub().returns(Promise.resolve()) + accountManager.addCertKeyToGraph = sinon.stub() + accountManager.saveProfileGraph = sinon.stub() + + return accountManager.addCertKeyToProfile(certificate, userAccount) + .then(() => { + expect(accountManager.getProfileGraphFor).to + .have.been.calledWith(userAccount) + }) + }) + + it('should add the cert key to the account graph', () => { + accountManager.getProfileGraphFor = sinon.stub() + .returns(Promise.resolve(profileGraph)) + accountManager.addCertKeyToGraph = sinon.stub() + accountManager.saveProfileGraph = sinon.stub() + + return accountManager.addCertKeyToProfile(certificate, userAccount) + .then(() => { + expect(accountManager.addCertKeyToGraph).to + .have.been.calledWith(certificate, profileGraph) + expect(accountManager.addCertKeyToGraph).to + .have.been.calledAfter(accountManager.getProfileGraphFor) + }) + }) + + it('should save the modified graph to the profile doc', () => { + accountManager.getProfileGraphFor = sinon.stub() + .returns(Promise.resolve(profileGraph)) + accountManager.addCertKeyToGraph = sinon.stub() + .returns(Promise.resolve(profileGraph)) + accountManager.saveProfileGraph = sinon.stub() + + return accountManager.addCertKeyToProfile(certificate, userAccount) + .then(() => { + expect(accountManager.saveProfileGraph).to + .have.been.calledWith(profileGraph, userAccount) + expect(accountManager.saveProfileGraph).to + .have.been.calledAfter(accountManager.addCertKeyToGraph) + }) + }) + }) + + describe('getProfileGraphFor()', () => { + it('should throw an error if webId is missing', (done) => { + const emptyUserData = {} + const userAccount = UserAccount.from(emptyUserData) + const options = { host, multiuser: true } + const accountManager = AccountManager.from(options) + + accountManager.getProfileGraphFor(userAccount) + .catch(error => { + expect(error.message).to + .equal('Cannot fetch profile graph, missing WebId URI') + done() + }) + }) + + it('should fetch the profile graph via LDP store', () => { + const store = { + getGraph: sinon.stub().returns(Promise.resolve()) + } + const webId = 'https://alice.example.com/#me' + const profileHostUri = 'https://alice.example.com/' + + const userData = { webId } + const userAccount = UserAccount.from(userData) + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + expect(userAccount.webId).to.equal(webId) + + return accountManager.getProfileGraphFor(userAccount) + .then(() => { + expect(store.getGraph).to.have.been.calledWith(profileHostUri) + }) + }) + }) + + describe('saveProfileGraph()', () => { + it('should save the profile graph via the LDP store', () => { + const store = { + putGraph: sinon.stub().returns(Promise.resolve()) + } + const webId = 'https://alice.example.com/#me' + const profileHostUri = 'https://alice.example.com/' + + const userData = { webId } + const userAccount = UserAccount.from(userData) + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + const profileGraph = rdf.graph() + + return accountManager.saveProfileGraph(profileGraph, userAccount) + .then(() => { + expect(store.putGraph).to.have.been.calledWith(profileGraph, profileHostUri) + }) + }) + }) + + describe('rootAclFor()', () => { + it('should return the server root .acl in single user mode', () => { + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath: process.cwd(), + includeHost: false + }) + const store = new LDP({ suffixAcl: '.acl', multiuser: false, resourceMapper }) + const options = { host, multiuser: false, store } + const accountManager = AccountManager.from(options) + + const userAccount = UserAccount.from({ username: 'alice' }) + + const rootAclUri = accountManager.rootAclFor(userAccount) + + expect(rootAclUri).to.equal('https://example.com/.acl') + }) + + it('should return the profile root .acl in multi user mode', () => { + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath: process.cwd(), + includeHost: true + }) + const store = new LDP({ suffixAcl: '.acl', multiuser: true, resourceMapper }) + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + const userAccount = UserAccount.from({ username: 'alice' }) + + const rootAclUri = accountManager.rootAclFor(userAccount) + + expect(rootAclUri).to.equal('https://alice.example.com/.acl') + }) + }) + + describe('loadAccountRecoveryEmail()', () => { + it('parses and returns the agent mailto from the root acl', () => { + const userAccount = UserAccount.from({ username: 'alice' }) + + const rootAclGraph = rdf.graph() + rootAclGraph.add( + rdf.namedNode('https://alice.example.com/.acl#owner'), + ns.acl('agent'), + rdf.namedNode('mailto:alice@example.com') + ) + + const store = { + suffixAcl: '.acl', + getGraph: sinon.stub().resolves(rootAclGraph) + } + + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + expect(recoveryEmail).to.equal('alice@example.com') + }) + }) + + it('should return undefined when agent mailto is missing', () => { + const userAccount = UserAccount.from({ username: 'alice' }) + + const emptyGraph = rdf.graph() + + const store = { + suffixAcl: '.acl', + getGraph: sinon.stub().resolves(emptyGraph) + } + + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + expect(recoveryEmail).to.be.undefined() + }) + }) + }) + + describe('passwordResetUrl()', () => { + it('should return a token reset validation url', () => { + const tokenService = new TokenService() + const options = { host, multiuser: true, tokenService } + + const accountManager = AccountManager.from(options) + + const returnToUrl = 'https://example.com/resource' + const token = '123' + + const resetUrl = accountManager.passwordResetUrl(token, returnToUrl) + + const expectedUri = 'https://example.com/account/password/change?' + + 'token=123&returnToUrl=' + returnToUrl + + expect(resetUrl).to.equal(expectedUri) + }) + }) + + describe('generateDeleteToken()', () => { + it('should generate and store an expiring delete token', () => { + const tokenService = new TokenService() + const options = { host, tokenService } + + const accountManager = AccountManager.from(options) + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + + const token = accountManager.generateDeleteToken(userAccount) + + const tokenValue = accountManager.tokenService.verify('delete-account.mjs', token) + + expect(tokenValue.webId).to.equal(aliceWebId) + expect(tokenValue).to.have.property('exp') + }) + }) + + describe('generateResetToken()', () => { + it('should generate and store an expiring reset token', () => { + const tokenService = new TokenService() + const options = { host, tokenService } + + const accountManager = AccountManager.from(options) + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + + const token = accountManager.generateResetToken(userAccount) + + const tokenValue = accountManager.tokenService.verify('reset-password', token) + + expect(tokenValue.webId).to.equal(aliceWebId) + expect(tokenValue).to.have.property('exp') + }) + }) + + describe('sendPasswordResetEmail()', () => { + it('should compose and send a password reset email', () => { + const resetToken = '1234' + const tokenService = { + generate: sinon.stub().returns(resetToken) + } + + const emailService = { + sendWithTemplate: sinon.stub().resolves() + } + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + const returnToUrl = 'https://example.com/resource' + + const options = { host, tokenService, emailService } + const accountManager = AccountManager.from(options) + + accountManager.passwordResetUrl = sinon.stub().returns('reset url') + + const expectedEmailData = { + to: 'alice@example.com', + webId: aliceWebId, + resetUrl: 'reset url' + } + + return accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .then(() => { + expect(accountManager.passwordResetUrl) + .to.have.been.calledWith(resetToken, returnToUrl) + expect(emailService.sendWithTemplate) + .to.have.been.calledWith('reset-password.mjs', expectedEmailData) + }) + }) + + it('should reject if no email service is set up', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + const returnToUrl = 'https://example.com/resource' + const options = { host } + const accountManager = AccountManager.from(options) + + accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .catch(error => { + expect(error.message).to.equal('Email service is not set up') + done() + }) + }) + + it('should reject if no user email is provided', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + const returnToUrl = 'https://example.com/resource' + const emailService = {} + const options = { host, emailService } + + const accountManager = AccountManager.from(options) + + accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .catch(error => { + expect(error.message).to.equal('Account recovery email has not been provided') + done() + }) + }) + }) + + describe('sendDeleteAccountEmail()', () => { + it('should compose and send a delete account email', () => { + const deleteToken = '1234' + const tokenService = { + generate: sinon.stub().returns(deleteToken) + } + + const emailService = { + sendWithTemplate: sinon.stub().resolves() + } + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + + const options = { host, tokenService, emailService } + const accountManager = AccountManager.from(options) + + accountManager.getAccountDeleteUrl = sinon.stub().returns('delete account url') + + const expectedEmailData = { + to: 'alice@example.com', + webId: aliceWebId, + deleteUrl: 'delete account url' + } + + return accountManager.sendDeleteAccountEmail(userAccount) + .then(() => { + expect(accountManager.getAccountDeleteUrl) + .to.have.been.calledWith(deleteToken) + expect(emailService.sendWithTemplate) + .to.have.been.calledWith('delete-account.mjs', expectedEmailData) + }) + }) + + it('should reject if no email service is set up', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + const options = { host } + const accountManager = AccountManager.from(options) + + accountManager.sendDeleteAccountEmail(userAccount) + .catch(error => { + expect(error.message).to.equal('Email service is not set up') + done() + }) + }) + + it('should reject if no user email is provided', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + const emailService = {} + const options = { host, emailService } + + const accountManager = AccountManager.from(options) + + accountManager.sendDeleteAccountEmail(userAccount) + .catch(error => { + expect(error.message).to.equal('Account recovery email has not been provided') + done() + }) + }) + }) +}) diff --git a/test/unit/account-template-test.js b/test/unit/account-template-test.mjs similarity index 75% rename from test/unit/account-template-test.js rename to test/unit/account-template-test.mjs index eb1d653b0..d30a46cd2 100644 --- a/test/unit/account-template-test.js +++ b/test/unit/account-template-test.mjs @@ -1,59 +1,58 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() - -const AccountTemplate = require('../../lib/models/account-template') -const UserAccount = require('../../lib/models/user-account') - -describe('AccountTemplate', () => { - describe('isTemplate()', () => { - let template = new AccountTemplate() - - it('should recognize rdf files as templates', () => { - expect(template.isTemplate('./file.ttl')).to.be.true - expect(template.isTemplate('./file.rdf')).to.be.true - expect(template.isTemplate('./file.html')).to.be.true - expect(template.isTemplate('./file.jsonld')).to.be.true - }) - - it('should recognize files with template extensions as templates', () => { - expect(template.isTemplate('./.acl')).to.be.true - expect(template.isTemplate('./.meta')).to.be.true - expect(template.isTemplate('./file.json')).to.be.true - expect(template.isTemplate('./file.acl')).to.be.true - expect(template.isTemplate('./file.meta')).to.be.true - expect(template.isTemplate('./file.hbs')).to.be.true - expect(template.isTemplate('./file.handlebars')).to.be.true - }) - - it('should recognize reserved files with no extensions as templates', () => { - expect(template.isTemplate('./card')).to.be.true - }) - - it('should recognize arbitrary binary files as non-templates', () => { - expect(template.isTemplate('./favicon.ico')).to.be.false - expect(template.isTemplate('./file')).to.be.false - }) - }) - - describe('templateSubstitutionsFor()', () => { - it('should init', () => { - let userOptions = { - username: 'alice', - webId: 'https://alice.example.com/profile/card#me', - name: 'Alice Q.', - email: 'alice@example.com' - } - let userAccount = UserAccount.from(userOptions) - - let substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) - expect(substitutions.name).to.equal('Alice Q.') - expect(substitutions.email).to.equal('alice@example.com') - expect(substitutions.webId).to.equal('https://alice.example.com/profile/card#me') - }) - }) -}) +import chai from 'chai' +import sinonChai from 'sinon-chai' + +import AccountTemplate from '../../lib/models/account-template.mjs' +import UserAccount from '../../lib/models/user-account.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.should() + +describe('AccountTemplate', () => { + describe('isTemplate()', () => { + const template = new AccountTemplate() + + it('should recognize rdf files as templates', () => { + expect(template.isTemplate('./file.ttl')).to.be.true + expect(template.isTemplate('./file.rdf')).to.be.true + expect(template.isTemplate('./file.html')).to.be.true + expect(template.isTemplate('./file.jsonld')).to.be.true + }) + + it('should recognize files with template extensions as templates', () => { + expect(template.isTemplate('./.acl')).to.be.true + expect(template.isTemplate('./.meta')).to.be.true + expect(template.isTemplate('./file.json')).to.be.true + expect(template.isTemplate('./file.acl')).to.be.true + expect(template.isTemplate('./file.meta')).to.be.true + expect(template.isTemplate('./file.hbs')).to.be.true + expect(template.isTemplate('./file.handlebars')).to.be.true + }) + + it('should recognize reserved files with no extensions as templates', () => { + expect(template.isTemplate('./card')).to.be.true + }) + + it('should recognize arbitrary binary files as non-templates', () => { + expect(template.isTemplate('./favicon.ico')).to.be.false + expect(template.isTemplate('./file')).to.be.false + }) + }) + + describe('templateSubstitutionsFor()', () => { + it('should init', () => { + const userOptions = { + username: 'alice', + webId: 'https://alice.example.com/profile/card#me', + name: 'Alice Q.', + email: 'alice@example.com' + } + const userAccount = UserAccount.from(userOptions) + + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + expect(substitutions.name).to.equal('Alice Q.') + expect(substitutions.email).to.equal('alice@example.com') + expect(substitutions.webId).to.equal('/profile/card#me') + }) + }) +}) diff --git a/test/unit/acl-checker-test.js b/test/unit/acl-checker-test.mjs similarity index 86% rename from test/unit/acl-checker-test.js rename to test/unit/acl-checker-test.mjs index 81571f802..808776648 100644 --- a/test/unit/acl-checker-test.js +++ b/test/unit/acl-checker-test.mjs @@ -1,48 +1,51 @@ -'use strict' -const ACLChecker = require('../../lib/acl-checker') -const chai = require('chai') -const { expect } = chai -chai.use(require('chai-as-promised')) - -const options = { fetch: (url, callback) => {} } - -describe('ACLChecker unit test', () => { - describe('getPossibleACLs', () => { - it('returns all possible ACLs of the root', () => { - const aclChecker = new ACLChecker('http://ex.org/', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/.acl' - ]) - }) - - it('returns all possible ACLs of a regular file', () => { - const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/abc/def/ghi.acl', - 'http://ex.org/abc/def/.acl', - 'http://ex.org/abc/.acl', - 'http://ex.org/.acl' - ]) - }) - - it('returns all possible ACLs of an ACL file', () => { - const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi.acl', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/abc/def/ghi.acl', - 'http://ex.org/abc/def/.acl', - 'http://ex.org/abc/.acl', - 'http://ex.org/.acl' - ]) - }) - - it('returns all possible ACLs of a directory', () => { - const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi/', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/abc/def/ghi/.acl', - 'http://ex.org/abc/def/.acl', - 'http://ex.org/abc/.acl', - 'http://ex.org/.acl' - ]) - }) - }) -}) +import { describe, it } from 'mocha' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' + +import ACLChecker from '../../lib/acl-checker.mjs' + +const { expect } = chai +chai.use(chaiAsPromised) + +const options = { fetch: (url, callback) => {} } + +describe('ACLChecker unit test', () => { + describe('getPossibleACLs', () => { + it('returns all possible ACLs of the root', () => { + const aclChecker = new ACLChecker('http://ex.org/', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of a regular file', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of an ACL file', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi.acl', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of a directory', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi/', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi/.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + }) +}) diff --git a/test/unit/add-cert-request-test.js b/test/unit/add-cert-request-test.mjs similarity index 50% rename from test/unit/add-cert-request-test.js rename to test/unit/add-cert-request-test.mjs index f6e7f2ce4..5e71a529e 100644 --- a/test/unit/add-cert-request-test.js +++ b/test/unit/add-cert-request-test.mjs @@ -1,117 +1,119 @@ -'use strict' - -const fs = require('fs-extra') -const path = require('path') -const rdf = require('rdflib') -const ns = require('solid-namespace')(rdf) -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() -const HttpMocks = require('node-mocks-http') - -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') -const AddCertificateRequest = require('../../lib/requests/add-cert-request') -const WebIdTlsCertificate = require('../../lib/models/webid-tls-certificate') - -const exampleSpkac = fs.readFileSync( - path.join(__dirname, '../resources/example_spkac.cnf'), 'utf8' -) - -var host - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) -}) - -describe('AddCertificateRequest', () => { - describe('fromParams()', () => { - it('should throw a 401 error if session.userId is missing', () => { - let multiuser = true - let options = { host, multiuser, authMethod: 'oidc' } - let accountManager = AccountManager.from(options) - - let req = { - body: { spkac: '123', webid: 'https://alice.example.com/#me' }, - session: {} - } - let res = HttpMocks.createResponse() - - try { - AddCertificateRequest.fromParams(req, res, accountManager) - } catch (error) { - expect(error.status).to.equal(401) - } - }) - }) - - describe('createRequest()', () => { - let multiuser = true - - it('should call certificate.generateCertificate()', () => { - let options = { host, multiuser, authMethod: 'oidc' } - let accountManager = AccountManager.from(options) - - let req = { - body: { spkac: '123', webid: 'https://alice.example.com/#me' }, - session: { - userId: 'https://alice.example.com/#me' - } - } - let res = HttpMocks.createResponse() - - let request = AddCertificateRequest.fromParams(req, res, accountManager) - let certificate = request.certificate - - accountManager.addCertKeyToProfile = sinon.stub() - request.sendResponse = sinon.stub() - let certSpy = sinon.stub(certificate, 'generateCertificate').returns(Promise.resolve()) - - return AddCertificateRequest.addCertificate(request) - .then(() => { - expect(certSpy).to.have.been.called - }) - }) - }) - - describe('accountManager.addCertKeyToGraph()', () => { - let multiuser = true - - it('should add certificate data to a graph', () => { - let options = { host, multiuser, authMethod: 'oidc' } - let accountManager = AccountManager.from(options) - - let userData = { username: 'alice' } - let userAccount = accountManager.userAccountFrom(userData) - - let certificate = WebIdTlsCertificate.fromSpkacPost( - decodeURIComponent(exampleSpkac), - userAccount, - host) - - let graph = rdf.graph() - - return certificate.generateCertificate() - .then(() => { - return accountManager.addCertKeyToGraph(certificate, graph) - }) - .then(graph => { - let webId = rdf.namedNode(certificate.webId) - let key = rdf.namedNode(certificate.keyUri) - - expect(graph.anyStatementMatching(webId, ns.cert('key'), key)) - .to.exist - expect(graph.anyStatementMatching(key, ns.rdf('type'), ns.cert('RSAPublicKey'))) - .to.exist - expect(graph.anyStatementMatching(key, ns.cert('modulus'))) - .to.exist - expect(graph.anyStatementMatching(key, ns.cert('exponent'))) - .to.exist - }) - }) - }) -}) - +import { fileURLToPath } from 'url' +import fs from 'fs-extra' +import path from 'path' +import rdf from 'rdflib' +import solidNamespace from 'solid-namespace' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' + +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import AddCertificateRequest from '../../lib/requests/add-cert-request.mjs' +import WebIdTlsCertificate from '../../lib/models/webid-tls-certificate.mjs' + +const { expect } = chai +const ns = solidNamespace(rdf) +chai.use(sinonChai) +chai.should() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const exampleSpkac = fs.readFileSync( + path.join(__dirname, '../resources/example_spkac.cnf'), 'utf8' +) + +let host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +describe('AddCertificateRequest', () => { + describe('fromParams()', () => { + it('should throw a 401 error if session.userId is missing', () => { + const multiuser = true + const options = { host, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const req = { + body: { spkac: '123', webid: 'https://alice.example.com/#me' }, + session: {} + } + const res = HttpMocks.createResponse() + + try { + AddCertificateRequest.fromParams(req, res, accountManager) + } catch (error) { + expect(error.status).to.equal(401) + } + }) + }) + + describe('createRequest()', () => { + const multiuser = true + + it('should call certificate.generateCertificate()', () => { + const options = { host, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const req = { + body: { spkac: '123', webid: 'https://alice.example.com/#me' }, + session: { + userId: 'https://alice.example.com/#me' + } + } + const res = HttpMocks.createResponse() + + const request = AddCertificateRequest.fromParams(req, res, accountManager) + const certificate = request.certificate + + accountManager.addCertKeyToProfile = sinon.stub() + request.sendResponse = sinon.stub() + const certSpy = sinon.stub(certificate, 'generateCertificate').returns(Promise.resolve()) + + return AddCertificateRequest.addCertificate(request) + .then(() => { + expect(certSpy).to.have.been.called + }) + }) + }) + + describe('accountManager.addCertKeyToGraph()', () => { + const multiuser = true + + it('should add certificate data to a graph', () => { + const options = { host, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const userData = { username: 'alice' } + const userAccount = accountManager.userAccountFrom(userData) + + const certificate = WebIdTlsCertificate.fromSpkacPost( + decodeURIComponent(exampleSpkac), + userAccount, + host) + + const graph = rdf.graph() + + return certificate.generateCertificate() + .then(() => { + return accountManager.addCertKeyToGraph(certificate, graph) + }) + .then(graph => { + const webId = rdf.namedNode(certificate.webId) + const key = rdf.namedNode(certificate.keyUri) + + expect(graph.anyStatementMatching(webId, ns.cert('key'), key)) + .to.exist + expect(graph.anyStatementMatching(key, ns.rdf('type'), ns.cert('RSAPublicKey'))) + .to.exist + expect(graph.anyStatementMatching(key, ns.cert('modulus'))) + .to.exist + expect(graph.anyStatementMatching(key, ns.cert('exponent'))) + .to.exist + }) + }) + }) +}) diff --git a/test/unit/auth-handlers-test.js b/test/unit/auth-handlers-test.mjs similarity index 83% rename from test/unit/auth-handlers-test.js rename to test/unit/auth-handlers-test.mjs index 84c640983..97c6c5ce1 100644 --- a/test/unit/auth-handlers-test.js +++ b/test/unit/auth-handlers-test.mjs @@ -1,103 +1,108 @@ -'use strict' -const chai = require('chai') -const sinon = require('sinon') -const { expect } = chai -chai.use(require('sinon-chai')) -chai.use(require('dirty-chai')) -chai.should() - -const Auth = require('../../lib/api/authn') - -describe('OIDC Handler', () => { - describe('setAuthenticateHeader()', () => { - let res, req - - beforeEach(() => { - req = { - app: { - locals: { host: { serverUri: 'https://example.com' } } - }, - get: sinon.stub() - } - res = { set: sinon.stub() } - }) - - it('should set the WWW-Authenticate header with error params', () => { - let error = { - error: 'invalid_token', - error_description: 'Invalid token', - error_uri: 'https://example.com/errors/token' - } - - Auth.oidc.setAuthenticateHeader(req, res, error) - - expect(res.set).to.be.calledWith( - 'WWW-Authenticate', - 'Bearer realm="https://example.com", scope="openid webid", error="invalid_token", error_description="Invalid token", error_uri="https://example.com/errors/token"' - ) - }) - - it('should set WWW-Authenticate with no error_description if none given', () => { - let error = {} - - Auth.oidc.setAuthenticateHeader(req, res, error) - - expect(res.set).to.be.calledWith( - 'WWW-Authenticate', - 'Bearer realm="https://example.com", scope="openid webid"' - ) - }) - }) - - describe('isEmptyToken()', () => { - let req - - beforeEach(() => { - req = { get: sinon.stub() } - }) - - it('should be true for empty access token', () => { - req.get.withArgs('Authorization').returns('Bearer ') - - expect(Auth.oidc.isEmptyToken(req)).to.be.true() - - req.get.withArgs('Authorization').returns('Bearer') - - expect(Auth.oidc.isEmptyToken(req)).to.be.true() - }) - - it('should be false when access token is present', () => { - req.get.withArgs('Authorization').returns('Bearer token123') - - expect(Auth.oidc.isEmptyToken(req)).to.be.false() - }) - - it('should be false when no authorization header is present', () => { - expect(Auth.oidc.isEmptyToken(req)).to.be.false() - }) - }) -}) - -describe('WebID-TLS Handler', () => { - describe('setAuthenticateHeader()', () => { - let res, req - - beforeEach(() => { - req = { - app: { - locals: { host: { serverUri: 'https://example.com' } } - } - } - res = { set: sinon.stub() } - }) - - it('should set the WWW-Authenticate header', () => { - Auth.tls.setAuthenticateHeader(req, res) - - expect(res.set).to.be.calledWith( - 'WWW-Authenticate', - 'WebID-TLS realm="https://example.com"' - ) - }) - }) -}) +import { describe, it, beforeEach } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +// Import CommonJS modules +// const Auth = require('../../lib/api/authn') +import * as Auth from '../../lib/api/authn/index.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +describe('OIDC Handler', () => { + describe('setAuthenticateHeader()', () => { + let res, req + + beforeEach(() => { + req = { + app: { + locals: { host: { serverUri: 'https://example.com' } } + }, + get: sinon.stub() + } + res = { set: sinon.stub() } + }) + + it('should set the WWW-Authenticate header with error params', () => { + const error = { + error: 'invalid_token', + error_description: 'Invalid token', + error_uri: 'https://example.com/errors/token' + } + + Auth.oidc.setAuthenticateHeader(req, res, error) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'Bearer realm="https://example.com", scope="openid webid", error="invalid_token", error_description="Invalid token", error_uri="https://example.com/errors/token"' + ) + }) + + it('should set WWW-Authenticate with no error_description if none given', () => { + const error = {} + + Auth.oidc.setAuthenticateHeader(req, res, error) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'Bearer realm="https://example.com", scope="openid webid"' + ) + }) + }) + + describe('isEmptyToken()', () => { + let req + + beforeEach(() => { + req = { get: sinon.stub() } + }) + + it('should be true for empty access token', () => { + req.get.withArgs('Authorization').returns('Bearer ') + + expect(Auth.oidc.isEmptyToken(req)).to.be.true() + + req.get.withArgs('Authorization').returns('Bearer') + + expect(Auth.oidc.isEmptyToken(req)).to.be.true() + }) + + it('should be false when access token is present', () => { + req.get.withArgs('Authorization').returns('Bearer token123') + + expect(Auth.oidc.isEmptyToken(req)).to.be.false() + }) + + it('should be false when no authorization header is present', () => { + expect(Auth.oidc.isEmptyToken(req)).to.be.false() + }) + }) +}) + +describe('WebID-TLS Handler', () => { + describe('setAuthenticateHeader()', () => { + let res, req + + beforeEach(() => { + req = { + app: { + locals: { host: { serverUri: 'https://example.com' } } + } + } + res = { set: sinon.stub() } + }) + + it('should set the WWW-Authenticate header', () => { + Auth.tls.setAuthenticateHeader(req, res) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'WebID-TLS realm="https://example.com"' + ) + }) + }) +}) diff --git a/test/unit/auth-proxy-test.js b/test/unit/auth-proxy-test.mjs similarity index 93% rename from test/unit/auth-proxy-test.js rename to test/unit/auth-proxy-test.mjs index baf5a7b79..4ad852e2e 100644 --- a/test/unit/auth-proxy-test.js +++ b/test/unit/auth-proxy-test.mjs @@ -1,221 +1,224 @@ -const authProxy = require('../../lib/handlers/auth-proxy') -const nock = require('nock') -const express = require('express') -const request = require('supertest') -const { expect } = require('chai') - -const HOST = 'solid.org' -const USER = 'https://ruben.verborgh.org/profile/#me' - -describe('Auth Proxy', () => { - describe('An auth proxy with 2 destinations', () => { - let loggedIn = true - - let app - before(() => { - // Set up test back-end servers - nock('http://server-a.org').persist() - .get(/./).reply(200, addRequestDetails('a')) - nock('https://server-b.org').persist() - .get(/./).reply(200, addRequestDetails('b')) - - // Set up proxy server - app = express() - app.use((req, res, next) => { - if (loggedIn) { - req.session = { userId: USER } - } - next() - }) - authProxy(app, { - '/server/a': 'http://server-a.org', - '/server/b': 'https://server-b.org/foo/bar' - }) - }) - - after(() => { - // Release back-end servers - nock.cleanAll() - }) - - describe('responding to /server/a', () => { - let response - before(() => { - return request(app).get('/server/a') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-a.org/', () => { - const { server, path } = response.body - expect(server).to.equal('a') - expect(path).to.equal('/') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-a.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/a/my/path?query=string', () => { - let response - before(() => { - return request(app).get('/server/a/my/path?query=string') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-a.org/my/path?query=string', () => { - const { server, path } = response.body - expect(server).to.equal('a') - expect(path).to.equal('/my/path?query=string') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-a.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/b', () => { - let response - before(() => { - return request(app).get('/server/b') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-b.org/foo/bar', () => { - const { server, path } = response.body - expect(server).to.equal('b') - expect(path).to.equal('/foo/bar') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-b.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/b/my/path?query=string', () => { - let response - before(() => { - return request(app).get('/server/b/my/path?query=string') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-b.org/foo/bar/my/path?query=string', () => { - const { server, path } = response.body - expect(server).to.equal('b') - expect(path).to.equal('/foo/bar/my/path?query=string') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-b.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/a without a logged-in user', () => { - let response - before(() => { - loggedIn = false - return request(app).get('/server/a') - .set('Host', HOST) - .then(res => { response = res }) - }) - after(() => { - loggedIn = true - }) - - it('proxies to http://server-a.org/', () => { - const { server, path } = response.body - expect(server).to.equal('a') - expect(path).to.equal('/') - }) - - it('does not set the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.not.have.property('user') - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-a.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - }) -}) - -function addRequestDetails (server) { - return function (path) { - return { server, path, headers: this.req.headers } - } -} +import express from 'express' +import request from 'supertest' +import nock from 'nock' +import chai from 'chai' + +import authProxy from '../../lib/handlers/auth-proxy.mjs' + +const { expect } = chai + +const HOST = 'solid.org' +const USER = 'https://ruben.verborgh.org/profile/#me' + +describe('Auth Proxy', () => { + describe('An auth proxy with 2 destinations', () => { + let loggedIn = true + + let app + before(() => { + // Set up test back-end servers + nock('http://server-a.org').persist() + .get(/./).reply(200, addRequestDetails('a')) + nock('https://server-b.org').persist() + .get(/./).reply(200, addRequestDetails('b')) + + // Set up proxy server + app = express() + app.use((req, res, next) => { + if (loggedIn) { + req.session = { userId: USER } + } + next() + }) + authProxy(app, { + '/server/a': 'http://server-a.org', + '/server/b': 'https://server-b.org/foo/bar' + }) + }) + + after(() => { + // Release back-end servers + nock.cleanAll() + }) + + describe('responding to /server/a', () => { + let response + before(() => { + return request(app).get('/server/a') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-a.org/', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/a/my/path?query=string', () => { + let response + before(() => { + return request(app).get('/server/a/my/path?query=string') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-a.org/my/path?query=string', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/my/path?query=string') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/b', () => { + let response + before(() => { + return request(app).get('/server/b') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-b.org/foo/bar', () => { + const { server, path } = response.body + expect(server).to.equal('b') + expect(path).to.equal('/foo/bar') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-b.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/b/my/path?query=string', () => { + let response + before(() => { + return request(app).get('/server/b/my/path?query=string') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-b.org/foo/bar/my/path?query=string', () => { + const { server, path } = response.body + expect(server).to.equal('b') + expect(path).to.equal('/foo/bar/my/path?query=string') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-b.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/a without a logged-in user', () => { + let response + before(() => { + loggedIn = false + return request(app).get('/server/a') + .set('Host', HOST) + .then(res => { response = res }) + }) + after(() => { + loggedIn = true + }) + + it('proxies to http://server-a.org/', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/') + }) + + it('does not set the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.not.have.property('user') + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + }) +}) + +function addRequestDetails (server) { + return function (path) { + return { server, path, headers: this.req.headers } + } +} diff --git a/test/unit/auth-request-test.js b/test/unit/auth-request-test.js deleted file mode 100644 index d4ae46da5..000000000 --- a/test/unit/auth-request-test.js +++ /dev/null @@ -1,100 +0,0 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -// const sinon = require('sinon') -chai.use(require('sinon-chai')) -chai.use(require('dirty-chai')) -chai.should() -// const HttpMocks = require('node-mocks-http') -const url = require('url') - -const AuthRequest = require('../../lib/requests/auth-request') -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') -const UserAccount = require('../../lib/models/user-account') - -describe('AuthRequest', () => { - function testAuthQueryParams () { - let body = {} - body['response_type'] = 'code' - body['scope'] = 'openid' - body['client_id'] = 'client1' - body['redirect_uri'] = 'https://redirect.example.com/' - body['state'] = '1234' - body['nonce'] = '5678' - body['display'] = 'page' - - return body - } - - const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) - const accountManager = AccountManager.from({ host }) - - describe('extractAuthParams()', () => { - it('should initialize the auth url query object from params', () => { - let body = testAuthQueryParams() - body['other_key'] = 'whatever' - let req = { body, method: 'POST' } - - let extracted = AuthRequest.extractAuthParams(req) - - for (let param of AuthRequest.AUTH_QUERY_PARAMS) { - expect(extracted[param]).to.equal(body[param]) - } - - // make sure *only* the listed params were copied - expect(extracted['other_key']).to.not.exist() - }) - - it('should return empty params with no request body present', () => { - let req = { method: 'POST' } - - expect(AuthRequest.extractAuthParams(req)).to.eql({}) - }) - }) - - describe('authorizeUrl()', () => { - it('should return an /authorize url', () => { - let request = new AuthRequest({ accountManager }) - - let authUrl = request.authorizeUrl() - - expect(authUrl.startsWith('https://localhost:8443/authorize')).to.be.true() - }) - - it('should pass through relevant auth query params from request body', () => { - let body = testAuthQueryParams() - let req = { body, method: 'POST' } - - let request = new AuthRequest({ accountManager }) - request.authQueryParams = AuthRequest.extractAuthParams(req) - - let authUrl = request.authorizeUrl() - - let parseQueryString = true - let parsedUrl = url.parse(authUrl, parseQueryString) - - for (let param in body) { - expect(body[param]).to.equal(parsedUrl.query[param]) - } - }) - }) - - describe('initUserSession()', () => { - it('should initialize the request session', () => { - let webId = 'https://alice.example.com/#me' - let alice = UserAccount.from({ username: 'alice', webId }) - let session = {} - - let request = new AuthRequest({ session }) - - request.initUserSession(alice) - - expect(request.session.userId).to.equal(webId) - let subject = request.session.subject - expect(subject['_id']).to.equal(webId) - }) - }) -}) - diff --git a/test/unit/auth-request-test.mjs b/test/unit/auth-request-test.mjs new file mode 100644 index 000000000..0659b5e6d --- /dev/null +++ b/test/unit/auth-request-test.mjs @@ -0,0 +1,96 @@ +import chai from 'chai' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +import AuthRequest from '../../lib/requests/auth-request.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import UserAccount from '../../lib/models/user-account.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +describe('AuthRequest', () => { + function testAuthQueryParams () { + const body = {} + body.response_type = 'code' + body.scope = 'openid' + body.client_id = 'client1' + body.redirect_uri = 'https://redirect.example.com/' + body.state = '1234' + body.nonce = '5678' + body.display = 'page' + + return body + } + + const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) + const accountManager = AccountManager.from({ host }) + + describe('extractAuthParams()', () => { + it('should initialize the auth url query object from params', () => { + const body = testAuthQueryParams() + body.other_key = 'whatever' + const req = { body, method: 'POST' } + + const extracted = AuthRequest.extractAuthParams(req) + + for (const param of AuthRequest.AUTH_QUERY_PARAMS) { + expect(extracted[param]).to.equal(body[param]) + } + + // make sure *only* the listed params were copied + expect(extracted.other_key).to.not.exist() + }) + + it('should return empty params with no request body present', () => { + const req = { method: 'POST' } + + expect(AuthRequest.extractAuthParams(req)).to.eql({}) + }) + }) + + describe('authorizeUrl()', () => { + it('should return an /authorize url', () => { + const request = new AuthRequest({ accountManager }) + + const authUrl = request.authorizeUrl() + + expect(authUrl.startsWith('https://localhost:8443/authorize')).to.be.true() + }) + + it('should pass through relevant auth query params from request body', () => { + const body = testAuthQueryParams() + const req = { body, method: 'POST' } + + const request = new AuthRequest({ accountManager }) + request.authQueryParams = AuthRequest.extractAuthParams(req) + + const authUrl = request.authorizeUrl() + + const parsedUrl = new URL(authUrl) + + for (const param in body) { + expect(body[param]).to.equal(parsedUrl.searchParams.get(param)) + } + }) + }) + + describe('initUserSession()', () => { + it('should initialize the request session', () => { + const webId = 'https://alice.example.com/#me' + const alice = UserAccount.from({ username: 'alice', webId }) + const session = {} + + const request = new AuthRequest({ session }) + + request.initUserSession(alice) + + expect(request.session.userId).to.equal(webId) + const subject = request.session.subject + expect(subject._id).to.equal(webId) + }) + }) +}) diff --git a/test/unit/authenticator-test.js b/test/unit/authenticator-test.mjs similarity index 66% rename from test/unit/authenticator-test.js rename to test/unit/authenticator-test.mjs index 83197675c..6cc27f542 100644 --- a/test/unit/authenticator-test.js +++ b/test/unit/authenticator-test.mjs @@ -1,34 +1,34 @@ -'use strict' -const chai = require('chai') -const { expect } = chai -chai.use(require('chai-as-promised')) -chai.should() - -const { Authenticator } = require('../../lib/models/authenticator') - -describe('Authenticator', () => { - describe('constructor()', () => { - it('should initialize the accountManager property', () => { - let accountManager = {} - let auth = new Authenticator({ accountManager }) - - expect(auth.accountManager).to.equal(accountManager) - }) - }) - - describe('fromParams()', () => { - it('should throw an abstract method error', () => { - expect(() => Authenticator.fromParams()) - .to.throw(/Must override method/) - }) - }) - - describe('findValidUser()', () => { - it('should throw an abstract method error', () => { - let auth = new Authenticator({}) - - expect(() => auth.findValidUser()) - .to.throw(/Must override method/) - }) - }) -}) +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { Authenticator } from '../../lib/models/authenticator.mjs' + +const { expect } = chai +chai.use(chaiAsPromised) +chai.should() + +describe('Authenticator', () => { + describe('constructor()', () => { + it('should initialize the accountManager property', () => { + const accountManager = {} + const auth = new Authenticator({ accountManager }) + + expect(auth.accountManager).to.equal(accountManager) + }) + }) + + describe('fromParams()', () => { + it('should throw an abstract method error', () => { + expect(() => Authenticator.fromParams()) + .to.throw(/Must override method/) + }) + }) + + describe('findValidUser()', () => { + it('should throw an abstract method error', () => { + const auth = new Authenticator({}) + + expect(() => auth.findValidUser()) + .to.throw(/Must override method/) + }) + }) +}) diff --git a/test/unit/blacklist-service-test.js b/test/unit/blacklist-service-test.mjs similarity index 82% rename from test/unit/blacklist-service-test.js rename to test/unit/blacklist-service-test.mjs index a2db974ca..934585d27 100644 --- a/test/unit/blacklist-service-test.js +++ b/test/unit/blacklist-service-test.mjs @@ -1,49 +1,49 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect - -const blacklist = require('the-big-username-blacklist').list -const blacklistService = require('../../lib/services/blacklist-service') - -describe('BlacklistService', () => { - afterEach(() => blacklistService.reset()) - - describe('addWord', () => { - it('allows adding words', () => { - const numberOfBlacklistedWords = blacklistService.list.length - blacklistService.addWord('foo') - expect(blacklistService.list.length).to.equal(numberOfBlacklistedWords + 1) - }) - }) - - describe('reset', () => { - it('will reset list of blacklisted words', () => { - blacklistService.addWord('foo') - blacklistService.reset() - expect(blacklistService.list.length).to.equal(blacklist.length) - }) - - it('can configure service via reset', () => { - blacklistService.reset({ - useTheBigUsernameBlacklist: false, - customBlacklistedUsernames: ['foo'] - }) - expect(blacklistService.list.length).to.equal(1) - expect(blacklistService.validate('admin')).to.equal(true) - }) - - it('is a singleton', () => { - const instanceA = blacklistService - blacklistService.reset({ customBlacklistedUsernames: ['foo'] }) - expect(instanceA.validate('foo')).to.equal(blacklistService.validate('foo')) - }) - }) - - describe('validate', () => { - it('validates given a default list of blacklisted usernames', () => { - const validWords = blacklist.reduce((memo, word) => memo + (blacklistService.validate(word) ? 1 : 0), 0) - expect(validWords).to.equal(0) - }) - }) +import chai from 'chai' + +import theBigUsernameBlacklistPkg from 'the-big-username-blacklist' +import blacklistService from '../../lib/services/blacklist-service.mjs' + +const { expect } = chai +const blacklist = theBigUsernameBlacklistPkg.list + +describe('BlacklistService', () => { + afterEach(() => blacklistService.reset()) + + describe('addWord', () => { + it('allows adding words', () => { + const numberOfBlacklistedWords = blacklistService.list.length + blacklistService.addWord('foo') + expect(blacklistService.list.length).to.equal(numberOfBlacklistedWords + 1) + }) + }) + + describe('reset', () => { + it('will reset list of blacklisted words', () => { + blacklistService.addWord('foo') + blacklistService.reset() + expect(blacklistService.list.length).to.equal(blacklist.length) + }) + + it('can configure service via reset', () => { + blacklistService.reset({ + useTheBigUsernameBlacklist: false, + customBlacklistedUsernames: ['foo'] + }) + expect(blacklistService.list.length).to.equal(1) + expect(blacklistService.validate('admin')).to.equal(true) + }) + + it('is a singleton', () => { + const instanceA = blacklistService + blacklistService.reset({ customBlacklistedUsernames: ['foo'] }) + expect(instanceA.validate('foo')).to.equal(blacklistService.validate('foo')) + }) + }) + + describe('validate', () => { + it('validates given a default list of blacklisted usernames', () => { + const validWords = blacklist.reduce((memo, word) => memo + (blacklistService.validate(word) ? 1 : 0), 0) + expect(validWords).to.equal(0) + }) + }) }) diff --git a/test/unit/create-account-request-test.js b/test/unit/create-account-request-test.mjs similarity index 65% rename from test/unit/create-account-request-test.js rename to test/unit/create-account-request-test.mjs index 892219b37..ba6a71e2a 100644 --- a/test/unit/create-account-request-test.js +++ b/test/unit/create-account-request-test.mjs @@ -1,305 +1,306 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() -const HttpMocks = require('node-mocks-http') -const blacklist = require('the-big-username-blacklist') - -const LDP = require('../../lib/ldp') -const AccountManager = require('../../lib/models/account-manager') -const SolidHost = require('../../lib/models/solid-host') -const defaults = require('../../config/defaults') -const { CreateAccountRequest } = require('../../lib/requests/create-account-request') -const blacklistService = require('../../lib/services/blacklist-service') - -describe('CreateAccountRequest', () => { - let host, store, accountManager - let session, res - - beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - accountManager = AccountManager.from({ host, store }) - - session = {} - res = HttpMocks.createResponse() - }) - - describe('constructor()', () => { - it('should create an instance with the given config', () => { - let aliceData = { username: 'alice' } - let userAccount = accountManager.userAccountFrom(aliceData) - - let options = { accountManager, userAccount, session, response: res } - let request = new CreateAccountRequest(options) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount).to.equal(userAccount) - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - }) - }) - - describe('fromParams()', () => { - it('should create subclass depending on authMethod', () => { - let request, aliceData, req - - aliceData = { username: 'alice' } - req = HttpMocks.createRequest({ - app: { locals: { accountManager } }, body: aliceData, session - }) - req.app.locals.authMethod = 'tls' - - request = CreateAccountRequest.fromParams(req, res, accountManager) - expect(request).to.respondTo('generateTlsCertificate') - - aliceData = { username: 'alice', password: '12345' } - req = HttpMocks.createRequest({ - app: { locals: { accountManager, oidc: {} } }, body: aliceData, session - }) - req.app.locals.authMethod = 'oidc' - request = CreateAccountRequest.fromParams(req, res, accountManager) - expect(request).to.not.respondTo('generateTlsCertificate') - }) - }) - - describe('createAccount()', () => { - it('should return a 400 error if account already exists', done => { - let accountManager = AccountManager.from({ host }) - let locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } - let aliceData = { - username: 'alice', password: '1234' - } - let req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) - - let request = CreateAccountRequest.fromParams(req, res) - - accountManager.accountExists = sinon.stub().returns(Promise.resolve(true)) - - request.createAccount() - .catch(err => { - expect(err.status).to.equal(400) - done() - }) - }) - - it('should return a 400 error if a username is invalid', () => { - let accountManager = AccountManager.from({ host }) - let locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } - - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - - const invalidUsernames = [ - '-', - '-a', - 'a-', - '9-', - 'alice--bob', - 'alice bob', - 'alice.bob' - ] - - let invalidUsernamesCount = 0 - - const requests = invalidUsernames.map((username) => { - let aliceData = { - username: username, password: '1234' - } - - let req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) - let request = CreateAccountRequest.fromParams(req, res) - - return request.createAccount() - .then(() => { - throw new Error('should not happen') - }) - .catch(err => { - invalidUsernamesCount++ - expect(err.message).to.match(/Invalid username/) - expect(err.status).to.equal(400) - }) - }) - - return Promise.all(requests) - .then(() => { - expect(invalidUsernamesCount).to.eq(invalidUsernames.length) - }) - }) - - describe('Blacklisted usernames', () => { - const invalidUsernames = [...blacklist.list, 'foo'] - - before(() => { - const accountManager = AccountManager.from({ host }) - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - blacklistService.addWord('foo') - }) - - after(() => blacklistService.reset()) - - it('should return a 400 error if a username is blacklisted', async () => { - const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } - - let invalidUsernamesCount = 0 - - const requests = invalidUsernames.map((username) => { - let req = HttpMocks.createRequest({ - app: { locals }, - body: { username, password: '1234' } - }) - let request = CreateAccountRequest.fromParams(req, res) - - return request.createAccount() - .then(() => { - throw new Error('should not happen') - }) - .catch(err => { - invalidUsernamesCount++ - expect(err.message).to.match(/Invalid username/) - expect(err.status).to.equal(400) - }) - }) - - await Promise.all(requests) - expect(invalidUsernamesCount).to.eq(invalidUsernames.length) - }) - }) - }) -}) - -describe('CreateOidcAccountRequest', () => { - let authMethod = 'oidc' - let host, store - let session, res - - beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - session = {} - res = HttpMocks.createResponse() - }) - - describe('fromParams()', () => { - it('should create an instance with the given config', () => { - let accountManager = AccountManager.from({ host, store }) - let aliceData = { username: 'alice', password: '123' } - - let userStore = {} - let req = HttpMocks.createRequest({ - app: { - locals: { authMethod, oidc: { users: userStore }, accountManager } - }, - body: aliceData, - session - }) - - let request = CreateAccountRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount.username).to.equal('alice') - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - expect(request.password).to.equal(aliceData.password) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('saveCredentialsFor()', () => { - it('should create a new user in the user store', () => { - let accountManager = AccountManager.from({ host, store }) - let password = '12345' - let aliceData = { username: 'alice', password } - let userStore = { - createUser: (userAccount, password) => { return Promise.resolve() } - } - let createUserSpy = sinon.spy(userStore, 'createUser') - let req = HttpMocks.createRequest({ - app: { locals: { authMethod, oidc: { users: userStore }, accountManager } }, - body: aliceData, - session - }) - - let request = CreateAccountRequest.fromParams(req, res) - let userAccount = request.userAccount - - return request.saveCredentialsFor(userAccount) - .then(() => { - expect(createUserSpy).to.have.been.calledWith(userAccount, password) - }) - }) - }) - - describe('sendResponse()', () => { - it('should respond with a 302 Redirect', () => { - let accountManager = AccountManager.from({ host, store }) - let aliceData = { username: 'alice', password: '12345' } - let req = HttpMocks.createRequest({ - app: { locals: { authMethod, oidc: {}, accountManager } }, - body: aliceData, - session - }) - let alice = accountManager.userAccountFrom(aliceData) - - let request = CreateAccountRequest.fromParams(req, res) - - let result = request.sendResponse(alice) - expect(request.response.statusCode).to.equal(302) - expect(result.username).to.equal('alice') - }) - }) -}) - -describe('CreateTlsAccountRequest', () => { - let authMethod = 'tls' - let host, store - let session, res - - beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - session = {} - res = HttpMocks.createResponse() - }) - - describe('fromParams()', () => { - it('should create an instance with the given config', () => { - let accountManager = AccountManager.from({ host, store }) - let aliceData = { username: 'alice' } - let req = HttpMocks.createRequest({ - app: { locals: { authMethod, accountManager } }, body: aliceData, session - }) - - let request = CreateAccountRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount.username).to.equal('alice') - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - expect(request.spkac).to.equal(aliceData.spkac) - }) - }) - - describe('saveCredentialsFor()', () => { - it('should call generateTlsCertificate()', () => { - let accountManager = AccountManager.from({ host, store }) - let aliceData = { username: 'alice' } - let req = HttpMocks.createRequest({ - app: { locals: { authMethod, accountManager } }, body: aliceData, session - }) - - let request = CreateAccountRequest.fromParams(req, res) - let userAccount = accountManager.userAccountFrom(aliceData) - - let generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') - - return request.saveCredentialsFor(userAccount) - .then(() => { - expect(generateTlsCertificate).to.have.been.calledWith(userAccount) - }) - }) - }) +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import HttpMocks from 'node-mocks-http' +import theBigUsernameBlacklistPkg from 'the-big-username-blacklist' + +import LDP from '../../lib/ldp.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import defaults from '../../config/defaults.mjs' +import { CreateAccountRequest } from '../../lib/requests/create-account-request.mjs' +import blacklistService from '../../lib/services/blacklist-service.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.should() +const blacklist = theBigUsernameBlacklistPkg + +describe('CreateAccountRequest', () => { + let host, store, accountManager + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + accountManager = AccountManager.from({ host, store }) + + session = {} + res = HttpMocks.createResponse() + }) + + describe('constructor()', () => { + it('should create an instance with the given config', () => { + const aliceData = { username: 'alice' } + const userAccount = accountManager.userAccountFrom(aliceData) + + const options = { accountManager, userAccount, session, response: res } + const request = new CreateAccountRequest(options) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount).to.equal(userAccount) + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + }) + }) + + describe('fromParams()', () => { + it('should create subclass depending on authMethod', () => { + let request, aliceData, req + + aliceData = { username: 'alice' } + req = HttpMocks.createRequest({ + app: { locals: { accountManager } }, body: aliceData, session + }) + req.app.locals.authMethod = 'tls' + + request = CreateAccountRequest.fromParams(req, res, accountManager) + expect(request).to.respondTo('generateTlsCertificate') + + aliceData = { username: 'alice', password: '12345' } + req = HttpMocks.createRequest({ + app: { locals: { accountManager, oidc: {} } }, body: aliceData, session + }) + req.app.locals.authMethod = 'oidc' + request = CreateAccountRequest.fromParams(req, res, accountManager) + expect(request).to.not.respondTo('generateTlsCertificate') + }) + }) + + describe('createAccount()', () => { + it('should return a 400 error if account already exists', done => { + const accountManager = AccountManager.from({ host }) + const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } + const aliceData = { + username: 'alice', password: '1234' + } + const req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) + + const request = CreateAccountRequest.fromParams(req, res) + + accountManager.accountExists = sinon.stub().returns(Promise.resolve(true)) + + request.createAccount() + .catch(err => { + expect(err.status).to.equal(400) + done() + }) + }) + + it('should return a 400 error if a username is invalid', () => { + const accountManager = AccountManager.from({ host }) + const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } + + accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) + + const invalidUsernames = [ + '-', + '-a', + 'a-', + '9-', + 'alice--bob', + 'alice bob', + 'alice.bob' + ] + + let invalidUsernamesCount = 0 + + const requests = invalidUsernames.map((username) => { + const aliceData = { + username: username, password: '1234' + } + + const req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) + const request = CreateAccountRequest.fromParams(req, res) + + return request.createAccount() + .then(() => { + throw new Error('should not happen') + }) + .catch(err => { + invalidUsernamesCount++ + expect(err.message).to.match(/Invalid username/) + expect(err.status).to.equal(400) + }) + }) + + return Promise.all(requests) + .then(() => { + expect(invalidUsernamesCount).to.eq(invalidUsernames.length) + }) + }) + + describe('Blacklisted usernames', () => { + const invalidUsernames = [...blacklist.list, 'foo'] + + before(() => { + const accountManager = AccountManager.from({ host }) + accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) + blacklistService.addWord('foo') + }) + + after(() => blacklistService.reset()) + + it('should return a 400 error if a username is blacklisted', async () => { + const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } + + let invalidUsernamesCount = 0 + + const requests = invalidUsernames.map((username) => { + const req = HttpMocks.createRequest({ + app: { locals }, + body: { username, password: '1234' } + }) + const request = CreateAccountRequest.fromParams(req, res) + + return request.createAccount() + .then(() => { + throw new Error('should not happen') + }) + .catch(err => { + invalidUsernamesCount++ + expect(err.message).to.match(/Invalid username/) + expect(err.status).to.equal(400) + }) + }) + + await Promise.all(requests) + expect(invalidUsernamesCount).to.eq(invalidUsernames.length) + }) + }) + }) +}) + +describe('CreateOidcAccountRequest', () => { + const authMethod = 'oidc' + let host, store + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + session = {} + res = HttpMocks.createResponse() + }) + + describe('fromParams()', () => { + it('should create an instance with the given config', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice', password: '123' } + + const userStore = {} + const req = HttpMocks.createRequest({ + app: { + locals: { authMethod, oidc: { users: userStore }, accountManager } + }, + body: aliceData, + session + }) + + const request = CreateAccountRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount.username).to.equal('alice') + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + expect(request.password).to.equal(aliceData.password) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('saveCredentialsFor()', () => { + it('should create a new user in the user store', () => { + const accountManager = AccountManager.from({ host, store }) + const password = '12345' + const aliceData = { username: 'alice', password } + const userStore = { + createUser: (userAccount, password) => { return Promise.resolve() } + } + const createUserSpy = sinon.spy(userStore, 'createUser') + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, oidc: { users: userStore }, accountManager } }, + body: aliceData, + session + }) + + const request = CreateAccountRequest.fromParams(req, res) + const userAccount = request.userAccount + + return request.saveCredentialsFor(userAccount) + .then(() => { + expect(createUserSpy).to.have.been.calledWith(userAccount, password) + }) + }) + }) + + describe('sendResponse()', () => { + it('should respond with a 302 Redirect', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice', password: '12345' } + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, oidc: {}, accountManager } }, + body: aliceData, + session + }) + const alice = accountManager.userAccountFrom(aliceData) + + const request = CreateAccountRequest.fromParams(req, res) + + const result = request.sendResponse(alice) + expect(request.response.statusCode).to.equal(302) + expect(result.username).to.equal('alice') + }) + }) +}) + +describe('CreateTlsAccountRequest', () => { + const authMethod = 'tls' + let host, store + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + session = {} + res = HttpMocks.createResponse() + }) + + describe('fromParams()', () => { + it('should create an instance with the given config', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice' } + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, accountManager } }, body: aliceData, session + }) + + const request = CreateAccountRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount.username).to.equal('alice') + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + expect(request.spkac).to.equal(aliceData.spkac) + }) + }) + + describe('saveCredentialsFor()', () => { + it('should call generateTlsCertificate()', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice' } + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, accountManager } }, body: aliceData, session + }) + + const request = CreateAccountRequest.fromParams(req, res) + const userAccount = accountManager.userAccountFrom(aliceData) + + const generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') + + return request.saveCredentialsFor(userAccount) + .then(() => { + expect(generateTlsCertificate).to.have.been.calledWith(userAccount) + }) + }) + }) }) diff --git a/test/unit/delete-account-confirm-request-test.js b/test/unit/delete-account-confirm-request-test.mjs similarity index 89% rename from test/unit/delete-account-confirm-request-test.js rename to test/unit/delete-account-confirm-request-test.mjs index dbaa56aba..5878f27bc 100644 --- a/test/unit/delete-account-confirm-request-test.js +++ b/test/unit/delete-account-confirm-request-test.mjs @@ -1,232 +1,234 @@ -'use strict' - -const chai = require('chai') -const sinon = require('sinon') -const expect = chai.expect -const dirtyChai = require('dirty-chai') -chai.use(dirtyChai) -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() - -const HttpMocks = require('node-mocks-http') - -const DeleteAccountConfirmRequest = require('../../lib/requests/delete-account-confirm-request') -const SolidHost = require('../../lib/models/solid-host') - -describe('DeleteAccountConfirmRequest', () => { - sinon.spy(DeleteAccountConfirmRequest.prototype, 'error') - - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - const res = HttpMocks.createResponse() - - const accountManager = {} - const userStore = {} - - const options = { - accountManager, - userStore, - response: res, - token: '12345' - } - - const request = new DeleteAccountConfirmRequest(options) - - expect(request.response).to.equal(res) - expect(request.token).to.equal(options.token) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - const token = '12345' - const accountManager = {} - const userStore = {} - - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - const res = HttpMocks.createResponse() - - const request = DeleteAccountConfirmRequest.fromParams(req, res) - - expect(request.response).to.equal(res) - expect(request.token).to.equal(token) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('get()', () => { - const token = '12345' - const userStore = {} - const res = HttpMocks.createResponse() - sinon.spy(res, 'render') - - it('should create an instance and render a delete account form', () => { - const accountManager = { - validateDeleteToken: sinon.stub().resolves(true) - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - - return DeleteAccountConfirmRequest.get(req, res) - .then(() => { - expect(accountManager.validateDeleteToken) - .to.have.been.called() - expect(res.render).to.have.been.calledWith('account/delete-confirm', - { token, validToken: true }) - }) - }) - - it('should display an error message on an invalid token', () => { - const accountManager = { - validateDeleteToken: sinon.stub().throws() - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - - return DeleteAccountConfirmRequest.get(req, res) - .then(() => { - expect(DeleteAccountConfirmRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(DeleteAccountConfirmRequest, 'handlePost') - - const token = '12345' - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const alice = { - webId: 'https://alice.example.com/#me' - } - const storedToken = { webId: alice.webId } - const accountManager = { - host, - userAccountFrom: sinon.stub().resolves(alice), - validateDeleteToken: sinon.stub().resolves(storedToken) - } - - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - - const req = { - app: { locals: { accountManager, oidc: { users: {} } } }, - body: { token } - } - const res = HttpMocks.createResponse() - - return DeleteAccountConfirmRequest.post(req, res) - .then(() => { - expect(DeleteAccountConfirmRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('handlePost()', () => { - it('should display error message if validation error encountered', () => { - const token = '12345' - const userStore = {} - const res = HttpMocks.createResponse() - const accountManager = { - validateResetToken: sinon.stub().throws() - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - - const request = DeleteAccountConfirmRequest.fromParams(req, res) - - return DeleteAccountConfirmRequest.handlePost(request) - .then(() => { - expect(DeleteAccountConfirmRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('validateToken()', () => { - it('should return false if no token is present', () => { - const accountManager = { - validateDeleteToken: sinon.stub() - } - const request = new DeleteAccountConfirmRequest({ accountManager, token: null }) - - return request.validateToken() - .then(result => { - expect(result).to.be.false() - expect(accountManager.validateDeleteToken).to.not.have.been.called() - }) - }) - }) - - describe('error()', () => { - it('should invoke renderForm() with the error', () => { - const request = new DeleteAccountConfirmRequest({}) - request.renderForm = sinon.stub() - const error = new Error('error message') - - request.error(error) - - expect(request.renderForm).to.have.been.calledWith(error) - }) - }) - - describe('deleteAccount()', () => { - it('should remove user from userStore and remove directories', () => { - const webId = 'https://alice.example.com/#me' - const user = { webId, id: webId } - const accountManager = { - userAccountFrom: sinon.stub().returns(user), - accountDirFor: sinon.stub().returns('/some/path/to/data/for/alice.example.com/') - } - const userStore = { - deleteUser: sinon.stub().resolves() - } - - const options = { - accountManager, userStore, newPassword: 'swordfish' - } - const request = new DeleteAccountConfirmRequest(options) - const tokenContents = { webId } - - return request.deleteAccount(tokenContents) - .then(() => { - expect(accountManager.userAccountFrom).to.have.been.calledWith(tokenContents) - expect(accountManager.accountDirFor).to.have.been.calledWith(user.username) - expect(userStore.deleteUser).to.have.been.calledWith(user) - }) - }) - }) - - describe('renderForm()', () => { - it('should set response status to error status, if error exists', () => { - const token = '12345' - const response = HttpMocks.createResponse() - sinon.spy(response, 'render') - - const options = { token, response } - - const request = new DeleteAccountConfirmRequest(options) - - const error = new Error('error message') - - request.renderForm(error) - - expect(response.render).to.have.been.calledWith('account/delete-confirm', - { validToken: false, token, error: 'error message' }) - }) - }) +// import { createRequire } from 'module' +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' + +// const require = createRequire(import.meta.url) +// const DeleteAccountConfirmRequest = require('../../lib/requests/delete-account-confirm-request') +// const SolidHost = require('../../lib/models/solid-host') +import DeleteAccountConfirmRequest from '../../lib/requests/delete-account-confirm-request.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('DeleteAccountConfirmRequest', () => { + sinon.spy(DeleteAccountConfirmRequest.prototype, 'error') + + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const accountManager = {} + const userStore = {} + + const options = { + accountManager, + userStore, + response: res, + token: '12345' + } + + const request = new DeleteAccountConfirmRequest(options) + + expect(request.response).to.equal(res) + expect(request.token).to.equal(options.token) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const token = '12345' + const accountManager = {} + const userStore = {} + + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + const res = HttpMocks.createResponse() + + const request = DeleteAccountConfirmRequest.fromParams(req, res) + + expect(request.response).to.equal(res) + expect(request.token).to.equal(token) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('get()', () => { + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + sinon.spy(res, 'render') + + it('should create an instance and render a delete account form', () => { + const accountManager = { + validateDeleteToken: sinon.stub().resolves(true) + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + + return DeleteAccountConfirmRequest.get(req, res) + .then(() => { + expect(accountManager.validateDeleteToken) + .to.have.been.called() + expect(res.render).to.have.been.calledWith('account/delete-confirm', + { token, validToken: true }) + }) + }) + + it('should display an error message on an invalid token', () => { + const accountManager = { + validateDeleteToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + + return DeleteAccountConfirmRequest.get(req, res) + .then(() => { + expect(DeleteAccountConfirmRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(DeleteAccountConfirmRequest, 'handlePost') + + const token = '12345' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const alice = { + webId: 'https://alice.example.com/#me' + } + const storedToken = { webId: alice.webId } + const accountManager = { + host, + userAccountFrom: sinon.stub().resolves(alice), + validateDeleteToken: sinon.stub().resolves(storedToken) + } + + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + + const req = { + app: { locals: { accountManager, oidc: { users: {} } } }, + body: { token } + } + const res = HttpMocks.createResponse() + + return DeleteAccountConfirmRequest.post(req, res) + .then(() => { + expect(DeleteAccountConfirmRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('handlePost()', () => { + it('should display error message if validation error encountered', () => { + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + const accountManager = { + validateResetToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + + const request = DeleteAccountConfirmRequest.fromParams(req, res) + + return DeleteAccountConfirmRequest.handlePost(request) + .then(() => { + expect(DeleteAccountConfirmRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('validateToken()', () => { + it('should return false if no token is present', () => { + const accountManager = { + validateDeleteToken: sinon.stub() + } + const request = new DeleteAccountConfirmRequest({ accountManager, token: null }) + + return request.validateToken() + .then(result => { + expect(result).to.be.false() + expect(accountManager.validateDeleteToken).to.not.have.been.called() + }) + }) + }) + + describe('error()', () => { + it('should invoke renderForm() with the error', () => { + const request = new DeleteAccountConfirmRequest({}) + request.renderForm = sinon.stub() + const error = new Error('error message') + + request.error(error) + + expect(request.renderForm).to.have.been.calledWith(error) + }) + }) + + describe('deleteAccount()', () => { + it('should remove user from userStore and remove directories', () => { + const webId = 'https://alice.example.com/#me' + const user = { webId, id: webId } + const accountManager = { + userAccountFrom: sinon.stub().returns(user), + accountDirFor: sinon.stub().returns('/some/path/to/data/for/alice.example.com/') + } + const userStore = { + deleteUser: sinon.stub().resolves() + } + + const options = { + accountManager, userStore, newPassword: 'swordfish' + } + const request = new DeleteAccountConfirmRequest(options) + const tokenContents = { webId } + + return request.deleteAccount(tokenContents) + .then(() => { + expect(accountManager.userAccountFrom).to.have.been.calledWith(tokenContents) + expect(accountManager.accountDirFor).to.have.been.calledWith(user.username) + expect(userStore.deleteUser).to.have.been.calledWith(user) + }) + }) + }) + + describe('renderForm()', () => { + it('should set response status to error status, if error exists', () => { + const token = '12345' + const response = HttpMocks.createResponse() + sinon.spy(response, 'render') + + const options = { token, response } + + const request = new DeleteAccountConfirmRequest(options) + + const error = new Error('error message') + + request.renderForm(error) + + expect(response.render).to.have.been.calledWith('account/delete-confirm', + { validToken: false, token, error: 'error message' }) + }) + }) }) diff --git a/test/unit/delete-account-request-test.js b/test/unit/delete-account-request-test.mjs similarity index 90% rename from test/unit/delete-account-request-test.js rename to test/unit/delete-account-request-test.mjs index 943e50ede..5b244a888 100644 --- a/test/unit/delete-account-request-test.js +++ b/test/unit/delete-account-request-test.mjs @@ -1,181 +1,180 @@ -'use strict' - -const chai = require('chai') -const sinon = require('sinon') -const expect = chai.expect -const dirtyChai = require('dirty-chai') -chai.use(dirtyChai) -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() - -const HttpMocks = require('node-mocks-http') - -const DeleteAccountRequest = require('../../lib/requests/delete-account-request') -const AccountManager = require('../../lib/models/account-manager') -const SolidHost = require('../../lib/models/solid-host') - -describe('DeleteAccountRequest', () => { - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - const res = HttpMocks.createResponse() - - const options = { - response: res, - username: 'alice' - } - - const request = new DeleteAccountRequest(options) - - expect(request.response).to.equal(res) - expect(request.username).to.equal(options.username) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - const username = 'alice' - const accountManager = {} - - const req = { - app: { locals: { accountManager } }, - body: { username } - } - const res = HttpMocks.createResponse() - - const request = DeleteAccountRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.username).to.equal(username) - expect(request.response).to.equal(res) - }) - }) - - describe('get()', () => { - it('should create an instance and render a delete account form', () => { - const username = 'alice' - const accountManager = { multiuser: true } - - const req = { - app: { locals: { accountManager } }, - body: { username } - } - const res = HttpMocks.createResponse() - res.render = sinon.stub() - - DeleteAccountRequest.get(req, res) - - expect(res.render).to.have.been.calledWith('account/delete', - { error: undefined, multiuser: true }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(DeleteAccountRequest, 'handlePost') - - const username = 'alice' - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { - suffixAcl: '.acl' - } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendDeleteLink = sinon.stub().resolves() - - const req = { - app: { locals: { accountManager } }, - body: { username } - } - const res = HttpMocks.createResponse() - - DeleteAccountRequest.post(req, res) - .then(() => { - expect(DeleteAccountRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('validate()', () => { - it('should throw an error if username is missing in multi-user mode', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const accountManager = AccountManager.from({ host, multiuser: true }) - - const request = new DeleteAccountRequest({ accountManager }) - - expect(() => request.validate()).to.throw(/Username required/) - }) - - it('should not throw an error if username is missing in single user mode', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const accountManager = AccountManager.from({ host, multiuser: false }) - - const request = new DeleteAccountRequest({ accountManager }) - - expect(() => request.validate()).to.not.throw() - }) - }) - - describe('handlePost()', () => { - it('should handle the post request', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendDeleteAccountEmail = sinon.stub().resolves() - accountManager.accountExists = sinon.stub().resolves(true) - - const username = 'alice' - const response = HttpMocks.createResponse() - response.render = sinon.stub() - - const options = { accountManager, username, response } - const request = new DeleteAccountRequest(options) - - sinon.spy(request, 'error') - - return DeleteAccountRequest.handlePost(request) - .then(() => { - expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() - expect(response.render).to.have.been.calledWith('account/delete-link-sent') - expect(request.error).to.not.have.been.called() - }) - }) - }) - - describe('loadUser()', () => { - it('should return a UserAccount instance based on username', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - const username = 'alice' - - const options = { accountManager, username } - const request = new DeleteAccountRequest(options) - - return request.loadUser() - .then(account => { - expect(account.webId).to.equal('https://alice.example.com/profile/card#me') - }) - }) - - it('should throw an error if the user does not exist', done => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(false) - const username = 'alice' - - const options = { accountManager, username } - const request = new DeleteAccountRequest(options) - - request.loadUser() - .catch(error => { - expect(error.message).to.equal('Account not found for that username') - done() - }) - }) - }) +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' + +import HttpMocks from 'node-mocks-http' + +import DeleteAccountRequest from '../../lib/requests/delete-account-request.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('DeleteAccountRequest', () => { + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const options = { + response: res, + username: 'alice' + } + + const request = new DeleteAccountRequest(options) + + expect(request.response).to.equal(res) + expect(request.username).to.equal(options.username) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const username = 'alice' + const accountManager = {} + + const req = { + app: { locals: { accountManager } }, + body: { username } + } + const res = HttpMocks.createResponse() + + const request = DeleteAccountRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.username).to.equal(username) + expect(request.response).to.equal(res) + }) + }) + + describe('get()', () => { + it('should create an instance and render a delete account form', () => { + const username = 'alice' + const accountManager = { multiuser: true } + + const req = { + app: { locals: { accountManager } }, + body: { username } + } + const res = HttpMocks.createResponse() + res.render = sinon.stub() + + DeleteAccountRequest.get(req, res) + + expect(res.render).to.have.been.calledWith('account/delete', + { error: undefined, multiuser: true }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(DeleteAccountRequest, 'handlePost') + + const username = 'alice' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { + suffixAcl: '.acl' + } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendDeleteLink = sinon.stub().resolves() + + const req = { + app: { locals: { accountManager } }, + body: { username } + } + const res = HttpMocks.createResponse() + + DeleteAccountRequest.post(req, res) + .then(() => { + expect(DeleteAccountRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('validate()', () => { + it('should throw an error if username is missing in multi-user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: true }) + + const request = new DeleteAccountRequest({ accountManager }) + + expect(() => request.validate()).to.throw(/Username required/) + }) + + it('should not throw an error if username is missing in single user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: false }) + + const request = new DeleteAccountRequest({ accountManager }) + + expect(() => request.validate()).to.not.throw() + }) + }) + + describe('handlePost()', () => { + it('should handle the post request', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendDeleteAccountEmail = sinon.stub().resolves() + accountManager.accountExists = sinon.stub().resolves(true) + + const username = 'alice' + const response = HttpMocks.createResponse() + response.render = sinon.stub() + + const options = { accountManager, username, response } + const request = new DeleteAccountRequest(options) + + sinon.spy(request, 'error') + + return DeleteAccountRequest.handlePost(request) + .then(() => { + expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() + expect(response.render).to.have.been.calledWith('account/delete-link-sent') + expect(request.error).to.not.have.been.called() + }) + }) + }) + + describe('loadUser()', () => { + it('should return a UserAccount instance based on username', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + const username = 'alice' + + const options = { accountManager, username } + const request = new DeleteAccountRequest(options) + + return request.loadUser() + .then(account => { + expect(account.webId).to.equal('https://alice.example.com/profile/card#me') + }) + }) + + it('should throw an error if the user does not exist', done => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(false) + const username = 'alice' + + const options = { accountManager, username } + const request = new DeleteAccountRequest(options) + + request.loadUser() + .catch(error => { + expect(error.message).to.equal('Account not found for that username') + done() + }) + }) + }) }) diff --git a/test/unit/email-service-test.js b/test/unit/email-service-test.js deleted file mode 100644 index 27f08da26..000000000 --- a/test/unit/email-service-test.js +++ /dev/null @@ -1,156 +0,0 @@ -const EmailService = require('../../lib/services/email-service') -const path = require('path') -const sinon = require('sinon') -const chai = require('chai') -const expect = chai.expect -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() - -const templatePath = path.join(__dirname, '../../default-templates/emails') - -describe('Email Service', function () { - describe('EmailService constructor', () => { - it('should set up a nodemailer instance', () => { - let templatePath = '../../config/email-templates' - let config = { - host: 'smtp.gmail.com', - auth: { - user: 'alice@gmail.com', - pass: '12345' - } - } - - let emailService = new EmailService(templatePath, config) - expect(emailService.mailer.options.host).to.equal('smtp.gmail.com') - expect(emailService.mailer).to.respondTo('sendMail') - - expect(emailService.templatePath).to.equal(templatePath) - }) - - it('should init a sender address if explicitly passed in', () => { - let sender = 'Solid Server ' - let config = { host: 'smtp.gmail.com', auth: {}, sender } - - let emailService = new EmailService(templatePath, config) - expect(emailService.sender).to.equal(sender) - }) - - it('should construct a default sender if not passed in', () => { - let config = { host: 'databox.me', auth: {} } - - let emailService = new EmailService(templatePath, config) - - expect(emailService.sender).to.equal('no-reply@databox.me') - }) - }) - - describe('sendMail()', () => { - it('passes through the sendMail call to the initialized mailer', () => { - let sendMail = sinon.stub().returns(Promise.resolve()) - let config = { host: 'databox.me', auth: {} } - let emailService = new EmailService(templatePath, config) - - emailService.mailer.sendMail = sendMail - - let email = { subject: 'Test' } - - return emailService.sendMail(email) - .then(() => { - expect(sendMail).to.have.been.calledWith(email) - }) - }) - - it('uses the provided from:, if present', () => { - let config = { host: 'databox.me', auth: {} } - let emailService = new EmailService(templatePath, config) - let email = { subject: 'Test', from: 'alice@example.com' } - - emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } - - return emailService.sendMail(email) - .then(email => { - expect(email.from).to.equal('alice@example.com') - }) - }) - - it('uses the default sender if a from: is not provided', () => { - let config = { host: 'databox.me', auth: {}, sender: 'solid@example.com' } - let emailService = new EmailService(templatePath, config) - let email = { subject: 'Test', from: null } - - emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } - - return emailService.sendMail(email) - .then(email => { - expect(email.from).to.equal(config.sender) - }) - }) - }) - - describe('templatePathFor()', () => { - it('should compose filename based on base path and template name', () => { - let config = { host: 'databox.me', auth: {} } - let templatePath = '../../config/email-templates' - let emailService = new EmailService(templatePath, config) - - let templateFile = emailService.templatePathFor('welcome') - - expect(templateFile.endsWith('email-templates/welcome')) - }) - }) - - describe('readTemplate()', () => { - it('should read a template if it exists', () => { - let config = { host: 'databox.me', auth: {} } - let emailService = new EmailService(templatePath, config) - - let template = emailService.readTemplate('welcome') - - expect(template).to.respondTo('render') - }) - - it('should throw an error if a template does not exist', () => { - let config = { host: 'databox.me', auth: {} } - let emailService = new EmailService(templatePath, config) - - expect(() => { emailService.readTemplate('invalid-template') }) - .to.throw(/Cannot find email template/) - }) - }) - - describe('sendWithTemplate()', () => { - it('should reject with error if template does not exist', done => { - let config = { host: 'databox.me', auth: {} } - let emailService = new EmailService(templatePath, config) - - let data = {} - - emailService.sendWithTemplate('invalid-template', data) - .catch(error => { - expect(error.message.startsWith('Cannot find email template')) - .to.be.true - done() - }) - }) - - it('should render an email from template and send it', () => { - let config = { host: 'databox.me', auth: {} } - let emailService = new EmailService(templatePath, config) - - emailService.sendMail = (email) => { return Promise.resolve(email) } - emailService.sendMail = sinon.spy(emailService, 'sendMail') - - let data = { webid: 'https://alice.example.com#me' } - - return emailService.sendWithTemplate('welcome', data) - .then(renderedEmail => { - expect(emailService.sendMail).to.be.called - - expect(renderedEmail.subject).to.exist - expect(renderedEmail.text.endsWith('Your Web Id: https://alice.example.com#me')) - .to.be.true - }) - }) - }) -}) diff --git a/test/unit/email-service-test.mjs b/test/unit/email-service-test.mjs new file mode 100644 index 000000000..b244908e6 --- /dev/null +++ b/test/unit/email-service-test.mjs @@ -0,0 +1,165 @@ +import sinon from 'sinon' +import chai from 'chai' +import sinonChai from 'sinon-chai' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import EmailService from '../../lib/services/email-service.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.should() + +// const require = createRequire(import.meta.url) +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const templatePath = join(__dirname, '../../default-templates/emails') + +describe('Email Service', function () { + describe('EmailService constructor', () => { + it('should set up a nodemailer instance', () => { + const templatePath = '../../config/email-templates' + const config = { + host: 'smtp.gmail.com', + auth: { + user: 'alice@gmail.com', + pass: '12345' + } + } + + const emailService = new EmailService(templatePath, config) + expect(emailService.mailer.options.host).to.equal('smtp.gmail.com') + expect(emailService.mailer).to.respondTo('sendMail') + + expect(emailService.templatePath).to.equal(templatePath) + }) + + it('should init a sender address if explicitly passed in', () => { + const sender = 'Solid Server ' + const config = { host: 'smtp.gmail.com', auth: {}, sender } + + const emailService = new EmailService(templatePath, config) + expect(emailService.sender).to.equal(sender) + }) + + it('should construct a default sender if not passed in', () => { + const config = { host: 'databox.me', auth: {} } + + const emailService = new EmailService(templatePath, config) + + expect(emailService.sender).to.equal('no-reply@databox.me') + }) + }) + + describe('sendMail()', () => { + it('passes through the sendMail call to the initialized mailer', () => { + const sendMail = sinon.stub().returns(Promise.resolve()) + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + emailService.mailer.sendMail = sendMail + + const email = { subject: 'Test' } + + return emailService.sendMail(email) + .then(() => { + expect(sendMail).to.have.been.calledWith(email) + }) + }) + + it('uses the provided from:, if present', () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + const email = { subject: 'Test', from: 'alice@example.com' } + + emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } + + return emailService.sendMail(email) + .then(email => { + expect(email.from).to.equal('alice@example.com') + }) + }) + + it('uses the default sender if a from: is not provided', () => { + const config = { host: 'databox.me', auth: {}, sender: 'solid@example.com' } + const emailService = new EmailService(templatePath, config) + const email = { subject: 'Test', from: null } + + emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } + + return emailService.sendMail(email) + .then(email => { + expect(email.from).to.equal(config.sender) + }) + }) + }) + + describe('templatePathFor()', () => { + it('should compose filename based on base path and template name', () => { + const config = { host: 'databox.me', auth: {} } + const templatePath = '../../config/email-templates' + const emailService = new EmailService(templatePath, config) + + const templateFile = emailService.templatePathFor('welcome') + + expect(templateFile.endsWith('email-templates/welcome')) + }) + }) + + describe('readTemplate()', () => { + it('should read a template if it exists', async () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + const template = await emailService.readTemplate('welcome.js') // support legacy name + + expect(template).to.respondTo('render') + }) + + it('should throw an error if a template does not exist', async () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + try { + await emailService.readTemplate('invalid-template') + throw new Error('Expected readTemplate to throw') + } catch (err) { + expect(err.message).to.match(/Cannot find email template/) + } + }) + }) + + describe('sendWithTemplate()', () => { + it('should reject with error if template does not exist', done => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + const data = {} + + emailService.sendWithTemplate('invalid-template', data) + .catch(error => { + expect(error.message.startsWith('Cannot find email template')) + .to.be.true + done() + }) + }) + + it('should render an email from template and send it', () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + emailService.sendMail = (email) => { return Promise.resolve(email) } + emailService.sendMail = sinon.spy(emailService, 'sendMail') + + const data = { webid: 'https://alice.example.com#me' } + + return emailService.sendWithTemplate('welcome.js', data) + .then(renderedEmail => { + expect(emailService.sendMail).to.be.called + + expect(renderedEmail.subject).to.exist + expect(renderedEmail.text.endsWith('Your Web Id: https://alice.example.com#me')) + .to.be.true + }) + }) + }) +}) diff --git a/test/unit/email-welcome-test.js b/test/unit/email-welcome-test.mjs similarity index 52% rename from test/unit/email-welcome-test.js rename to test/unit/email-welcome-test.mjs index 7317a48c3..bb150b40c 100644 --- a/test/unit/email-welcome-test.js +++ b/test/unit/email-welcome-test.mjs @@ -1,78 +1,80 @@ -'use strict' - -const path = require('path') -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() - -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') -const EmailService = require('../../lib/services/email-service') - -const templatePath = path.join(__dirname, '../../default-templates/emails') - -var host, accountManager, emailService - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - - let emailConfig = { auth: {}, sender: 'solid@example.com' } - emailService = new EmailService(templatePath, emailConfig) - - let mgrConfig = { - host, - emailService, - authMethod: 'oidc', - multiuser: true - } - accountManager = AccountManager.from(mgrConfig) -}) - -describe('Account Creation Welcome Email', () => { - describe('accountManager.sendWelcomeEmail() (unit tests)', () => { - it('should resolve to null if email service not set up', () => { - accountManager.emailService = null - - let userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } - let newUser = accountManager.userAccountFrom(userData) - - return accountManager.sendWelcomeEmail(newUser) - .then(result => { - expect(result).to.be.null - }) - }) - - it('should resolve to null if a new user has no email', () => { - let userData = { name: 'Alice', username: 'alice' } - let newUser = accountManager.userAccountFrom(userData) - - return accountManager.sendWelcomeEmail(newUser) - .then(result => { - expect(result).to.be.null - }) - }) - - it('should send an email using the welcome template', () => { - let sendWithTemplate = sinon - .stub(accountManager.emailService, 'sendWithTemplate') - .returns(Promise.resolve()) - - let userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } - let newUser = accountManager.userAccountFrom(userData) - - let expectedEmailData = { - webid: 'https://alice.example.com/profile/card#me', - to: 'alice@alice.com', - name: 'Alice' - } - - return accountManager.sendWelcomeEmail(newUser) - .then(result => { - expect(sendWithTemplate).to.be.calledWith('welcome', expectedEmailData) - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import EmailService from '../../lib/services/email-service.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.should() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const templatePath = path.join(__dirname, '../../default-templates/emails') + +let host, accountManager, emailService + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + + const emailConfig = { auth: {}, sender: 'solid@example.com' } + emailService = new EmailService(templatePath, emailConfig) + + const mgrConfig = { + host, + emailService, + authMethod: 'oidc', + multiuser: true + } + accountManager = AccountManager.from(mgrConfig) +}) + +describe('Account Creation Welcome Email', () => { + describe('accountManager.sendWelcomeEmail() (unit tests)', () => { + it('should resolve to null if email service not set up', () => { + accountManager.emailService = null + + const userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } + const newUser = accountManager.userAccountFrom(userData) + + return accountManager.sendWelcomeEmail(newUser) + .then(result => { + expect(result).to.be.null + }) + }) + + it('should resolve to null if a new user has no email', () => { + const userData = { name: 'Alice', username: 'alice' } + const newUser = accountManager.userAccountFrom(userData) + + return accountManager.sendWelcomeEmail(newUser) + .then(result => { + expect(result).to.be.null + }) + }) + + it('should send an email using the welcome template', () => { + const sendWithTemplate = sinon + .stub(accountManager.emailService, 'sendWithTemplate') + .returns(Promise.resolve()) + + const userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } + const newUser = accountManager.userAccountFrom(userData) + + const expectedEmailData = { + webid: 'https://alice.example.com/profile/card#me', + to: 'alice@alice.com', + name: 'Alice' + } + + return accountManager.sendWelcomeEmail(newUser) + .then(result => { + expect(sendWithTemplate).to.be.calledWith('welcome.mjs', expectedEmailData) + }) + }) + }) +}) diff --git a/test/unit/error-pages-test.js b/test/unit/error-pages-test.mjs similarity index 66% rename from test/unit/error-pages-test.js rename to test/unit/error-pages-test.mjs index c67fe68e4..4aa199202 100644 --- a/test/unit/error-pages-test.js +++ b/test/unit/error-pages-test.mjs @@ -1,98 +1,100 @@ -'use strict' -const chai = require('chai') -const sinon = require('sinon') -const { expect } = chai -chai.use(require('sinon-chai')) -chai.use(require('dirty-chai')) -chai.should() - -const errorPages = require('../../lib/handlers/error-pages') - -describe('handlers/error-pages', () => { - describe('handler()', () => { - it('should use the custom error handler if available', () => { - let ldp = { errorHandler: sinon.stub() } - let req = { app: { locals: { ldp } } } - let res = { status: sinon.stub(), send: sinon.stub() } - let err = {} - let next = {} - - errorPages.handler(err, req, res, next) - - expect(ldp.errorHandler).to.have.been.calledWith(err, req, res, next) - - expect(res.status).to.not.have.been.called() - expect(res.send).to.not.have.been.called() - }) - - it('defaults to status code 500 if none is specified in the error', () => { - let ldp = { noErrorPages: true } - let req = { app: { locals: { ldp } } } - let res = { status: sinon.stub(), send: sinon.stub(), header: sinon.stub() } - let err = { message: 'Unspecified error' } - let next = {} - - errorPages.handler(err, req, res, next) - - expect(res.status).to.have.been.calledWith(500) - expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') - expect(res.send).to.have.been.calledWith('Unspecified error\n') - }) - }) - - describe('sendErrorResponse()', () => { - it('should send http status code and error message', () => { - let statusCode = 404 - let error = { - message: 'Error description' - } - let res = { - status: sinon.stub(), - header: sinon.stub(), - send: sinon.stub() - } - - errorPages.sendErrorResponse(statusCode, res, error) - - expect(res.status).to.have.been.calledWith(404) - expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') - expect(res.send).to.have.been.calledWith('Error description\n') - }) - }) - - describe('setAuthenticateHeader()', () => { - it('should do nothing for a non-implemented auth method', () => { - let err = {} - let req = { - app: { locals: { authMethod: null } } - } - let res = { - set: sinon.stub() - } - - errorPages.setAuthenticateHeader(req, res, err) - - expect(res.set).to.not.have.been.called() - }) - }) - - describe('sendErrorPage()', () => { - it('falls back the default sendErrorResponse if no page is found', () => { - let statusCode = 400 - let res = { - status: sinon.stub(), - header: sinon.stub(), - send: sinon.stub() - } - let err = { message: 'Error description' } - let ldp = { errorPages: './' } - - return errorPages.sendErrorPage(statusCode, res, err, ldp) - .then(() => { - expect(res.status).to.have.been.calledWith(400) - expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') - expect(res.send).to.have.been.calledWith('Error description\n') - }) - }) - }) -}) +import { describe, it } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' +import * as errorPages from '../../lib/handlers/error-pages.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +describe('handlers/error-pages', () => { + describe('handler()', () => { + it('should use the custom error handler if available', () => { + const ldp = { errorHandler: sinon.stub() } + const req = { app: { locals: { ldp } } } + const res = { status: sinon.stub(), send: sinon.stub() } + const err = {} + const next = {} + + errorPages.handler(err, req, res, next) + + expect(ldp.errorHandler).to.have.been.calledWith(err, req, res, next) + + expect(res.status).to.not.have.been.called() + expect(res.send).to.not.have.been.called() + }) + + it('defaults to status code 500 if none is specified in the error', () => { + const ldp = { noErrorPages: true } + const req = { app: { locals: { ldp } } } + const res = { status: sinon.stub(), send: sinon.stub(), header: sinon.stub() } + const err = { message: 'Unspecified error' } + const next = {} + + errorPages.handler(err, req, res, next) + + expect(res.status).to.have.been.calledWith(500) + expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') + expect(res.send).to.have.been.calledWith('Unspecified error\n') + }) + }) + + describe('sendErrorResponse()', () => { + it('should send http status code and error message', () => { + const statusCode = 404 + const error = { + message: 'Error description' + } + const res = { + status: sinon.stub(), + header: sinon.stub(), + send: sinon.stub() + } + + errorPages.sendErrorResponse(statusCode, res, error) + + expect(res.status).to.have.been.calledWith(404) + expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') + expect(res.send).to.have.been.calledWith('Error description\n') + }) + }) + + describe('setAuthenticateHeader()', () => { + it('should do nothing for a non-implemented auth method', () => { + const err = {} + const req = { + app: { locals: { authMethod: null } } + } + const res = { + set: sinon.stub() + } + + errorPages.setAuthenticateHeader(req, res, err) + + expect(res.set).to.not.have.been.called() + }) + }) + + describe('sendErrorPage()', () => { + it('falls back the default sendErrorResponse if no page is found', () => { + const statusCode = 400 + const res = { + status: sinon.stub(), + header: sinon.stub(), + send: sinon.stub() + } + const err = { message: 'Error description' } + const ldp = { errorPages: './' } + + return errorPages.sendErrorPage(statusCode, res, err, ldp) + .then(() => { + expect(res.status).to.have.been.calledWith(400) + expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') + expect(res.send).to.have.been.calledWith('Error description\n') + }) + }) + }) +}) diff --git a/test/unit/esm-imports.test.mjs b/test/unit/esm-imports.test.mjs new file mode 100644 index 000000000..e5b9da0c7 --- /dev/null +++ b/test/unit/esm-imports.test.mjs @@ -0,0 +1,148 @@ +import { describe, it } from 'mocha' +import { expect } from 'chai' +import { testESMImport, PerformanceTimer } from '../test-helpers.mjs' + +describe('ESM Module Import Tests', function () { + this.timeout(10000) + + describe('Core Utility Modules', () => { + it('should import debug.mjs with named exports', async () => { + const result = await testESMImport('../lib/debug.mjs') + + expect(result.success).to.be.true + expect(result.namedExports).to.include('handlers') + expect(result.namedExports).to.include('ACL') + expect(result.namedExports).to.include('fs') + expect(result.namedExports).to.include('metadata') + }) + + it('should import http-error.mjs with default export', async () => { + const result = await testESMImport('../lib/http-error.mjs') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: HTTPError } = result.module + expect(typeof HTTPError).to.equal('function') + + const error = HTTPError(404, 'Not Found') + expect(error.status).to.equal(404) + expect(error.message).to.equal('Not Found') + }) + + it('should import utils.mjs with named exports', async () => { + const result = await testESMImport('../lib/utils.mjs') + + expect(result.success).to.be.true + expect(result.namedExports).to.include('getContentType') + expect(result.namedExports).to.include('pathBasename') + expect(result.namedExports).to.include('translate') + expect(result.namedExports).to.include('routeResolvedFile') + }) + }) + + describe('Handler Modules', () => { + it('should import all handler modules successfully', async () => { + const handlers = [ + '../lib/handlers/get.mjs', + '../lib/handlers/post.mjs', + '../lib/handlers/put.mjs', + '../lib/handlers/delete.mjs', + '../lib/handlers/copy.mjs', + '../lib/handlers/patch.mjs' + ] + + for (const handler of handlers) { + const result = await testESMImport(handler) + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + expect(typeof result.module.default).to.equal('function') + } + }) + + it('should import allow.mjs and validate permission function', async () => { + const result = await testESMImport('../lib/handlers/allow.mjs') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: allow } = result.module + expect(typeof allow).to.equal('function') + + const readHandler = allow('Read') + expect(typeof readHandler).to.equal('function') + }) + }) + + describe('Infrastructure Modules', () => { + it('should import metadata.mjs with Metadata constructor', async () => { + const result = await testESMImport('../lib/metadata.mjs') + + expect(result.success).to.be.true + expect(result.namedExports).to.include('Metadata') + + const { Metadata } = result.module + const metadata = new Metadata() + expect(metadata.isResource).to.be.false + expect(metadata.isContainer).to.be.false + }) + + it('should import acl-checker.mjs with ACLChecker class', async () => { + const result = await testESMImport('../lib/acl-checker.mjs') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + expect(result.namedExports).to.include('DEFAULT_ACL_SUFFIX') + expect(result.namedExports).to.include('clearAclCache') + + const { default: ACLChecker, DEFAULT_ACL_SUFFIX } = result.module + expect(typeof ACLChecker).to.equal('function') + expect(DEFAULT_ACL_SUFFIX).to.equal('.acl') + }) + + it('should import lock.mjs with withLock function', async () => { + const result = await testESMImport('../lib/lock.mjs') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: withLock } = result.module + expect(typeof withLock).to.equal('function') + }) + }) + + describe('Application Modules', () => { + it('should import ldp-middleware.mjs with router function', async () => { + const result = await testESMImport('../lib/ldp-middleware.mjs') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: LdpMiddleware } = result.module + expect(typeof LdpMiddleware).to.equal('function') + }) + + it('should import main entry point index.mjs', async () => { + const result = await testESMImport('../index.mjs') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + expect(result.namedExports).to.include('createServer') + expect(result.namedExports).to.include('startCli') + }) + }) + + describe('Import Performance', () => { + it('should measure ESM import performance', async () => { + const timer = new PerformanceTimer() + + timer.start() + const result = await testESMImport('../index.mjs') + const duration = timer.end() + + expect(result.success).to.be.true + expect(duration).to.be.lessThan(1000) // Should import in less than 1 second + console.log(`ESM import took ${duration.toFixed(2)}ms`) + }) + }) +}) diff --git a/test/unit/force-user-test.js b/test/unit/force-user-test.mjs similarity index 87% rename from test/unit/force-user-test.js rename to test/unit/force-user-test.mjs index 0ed7d8d7c..d707536c1 100644 --- a/test/unit/force-user-test.js +++ b/test/unit/force-user-test.mjs @@ -1,70 +1,73 @@ -const forceUser = require('../../lib/api/authn/force-user') -const sinon = require('sinon') -const chai = require('chai') -const { expect } = chai -const sinonChai = require('sinon-chai') -chai.use(sinonChai) - -const USER = 'https://ruben.verborgh.org/profile/#me' - -describe('Force User', () => { - describe('a forceUser handler', () => { - let app, handler - before(() => { - app = { use: sinon.stub() } - const argv = { forceUser: USER } - forceUser.initialize(app, argv) - handler = app.use.getCall(0).args[1] - }) - - it('adds a route on /', () => { - expect(app.use).to.have.callCount(1) - expect(app.use).to.have.been.calledWith('/') - }) - - describe('when called', () => { - let request, response - before(done => { - request = { session: {} } - response = { set: sinon.stub() } - handler(request, response, done) - }) - - it('sets session.userId to the user', () => { - expect(request.session).to.have.property('userId', USER) - }) - - it('does not set the User header', () => { - expect(response.set).to.have.callCount(0) - }) - }) - }) - - describe('a forceUser handler for TLS', () => { - let handler - before(() => { - const app = { use: sinon.stub() } - const argv = { forceUser: USER, auth: 'tls' } - forceUser.initialize(app, argv) - handler = app.use.getCall(0).args[1] - }) - - describe('when called', () => { - let request, response - before(done => { - request = { session: {} } - response = { set: sinon.stub() } - handler(request, response, done) - }) - - it('sets session.userId to the user', () => { - expect(request.session).to.have.property('userId', USER) - }) - - it('sets the User header', () => { - expect(response.set).to.have.callCount(1) - expect(response.set).to.have.been.calledWith('User', USER) - }) - }) - }) +import { describe, it, before } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import forceUser from '../../lib/api/authn/force-user.mjs' + +const { expect } = chai +chai.use(sinonChai) + +const USER = 'https://ruben.verborgh.org/profile/#me' + +describe('Force User', () => { + describe('a forceUser handler', () => { + let app, handler + before(() => { + app = { use: sinon.stub() } + const argv = { forceUser: USER } + forceUser.initialize(app, argv) + handler = app.use.getCall(0).args[1] + }) + + it('adds a route on /', () => { + expect(app.use).to.have.callCount(1) + expect(app.use).to.have.been.calledWith('/') + }) + + describe('when called', () => { + let request, response + before(done => { + request = { session: {} } + response = { set: sinon.stub() } + handler(request, response, done) + }) + + it('sets session.userId to the user', () => { + expect(request.session).to.have.property('userId', USER) + }) + + it('does not set the User header', () => { + expect(response.set).to.have.callCount(0) + }) + }) + }) + + describe('a forceUser handler for TLS', () => { + let handler + before(() => { + const app = { use: sinon.stub() } + const argv = { forceUser: USER, auth: 'tls' } + forceUser.initialize(app, argv) + handler = app.use.getCall(0).args[1] + }) + + describe('when called', () => { + let request, response + before(done => { + request = { session: {} } + response = { set: sinon.stub() } + handler(request, response, done) + }) + + it('sets session.userId to the user', () => { + expect(request.session).to.have.property('userId', USER) + }) + + it('sets the User header', () => { + expect(response.set).to.have.callCount(1) + expect(response.set).to.have.been.calledWith('User', USER) + }) + }) + }) }) diff --git a/test/unit/getAvailableUrl-test.mjs b/test/unit/getAvailableUrl-test.mjs new file mode 100644 index 000000000..4b47ac886 --- /dev/null +++ b/test/unit/getAvailableUrl-test.mjs @@ -0,0 +1,30 @@ +import { strict as assert } from 'assert' +import LDP from '../../lib/ldp.mjs' + +export async function testNoExistingResource () { + const rm = { + resolveUrl: (hostname, containerURI) => `https://${hostname}/root${containerURI}/`, + mapUrlToFile: async () => { throw new Error('Not found') } + } + const ldp = new LDP({ resourceMapper: rm }) + const url = await ldp.getAvailableUrl('host.test', '/container', { slug: 'name.txt', extension: '', container: false }) + assert.equal(url, 'https://host.test/root/container/name.txt') +} + +export async function testExistingResourcePrefixes () { + let called = 0 + const rm = { + resolveUrl: (hostname, containerURI) => `https://${hostname}/root${containerURI}/`, + mapUrlToFile: async () => { + called += 1 + // First call indicates file exists (resolve), so return some object + if (called === 1) return { path: '/some/path' } + // Subsequent calls simulate not found + throw new Error('Not found') + } + } + const ldp = new LDP({ resourceMapper: rm }) + const url = await ldp.getAvailableUrl('host.test', '/container', { slug: 'name.txt', extension: '', container: false }) + // Should contain a uuid-prefix before name.txt, i.e. -name.txt + assert.ok(url.endsWith('-name.txt') || url.includes('-name.txt')) +} diff --git a/test/unit/getTrustedOrigins-test.mjs b/test/unit/getTrustedOrigins-test.mjs new file mode 100644 index 000000000..cb7877bb6 --- /dev/null +++ b/test/unit/getTrustedOrigins-test.mjs @@ -0,0 +1,20 @@ +import { describe, it } from 'mocha' +import { assert } from 'chai' +import LDP from '../../lib/ldp.mjs' + +describe('LDP.getTrustedOrigins', () => { + it('includes resourceMapper.resolveUrl(hostname), trustedOrigins and serverUri when multiuser', () => { + const rm = { resolveUrl: (hostname) => `https://${hostname}/` } + const ldp = new LDP({ resourceMapper: rm, trustedOrigins: ['https://trusted.example/'], multiuser: true, serverUri: 'https://server.example/' }) + const res = ldp.getTrustedOrigins({ hostname: 'host.test' }) + assert.includeMembers(res, ['https://host.test/', 'https://trusted.example/', 'https://server.example/']) + }) + + it('omits serverUri when not multiuser', () => { + const rm = { resolveUrl: (hostname) => `https://${hostname}/` } + const ldp = new LDP({ resourceMapper: rm, trustedOrigins: ['https://trusted.example/'], multiuser: false, serverUri: 'https://server.example/' }) + const res = ldp.getTrustedOrigins({ hostname: 'host.test' }) + assert.includeMembers(res, ['https://host.test/', 'https://trusted.example/']) + assert.notInclude(res, 'https://server.example/') + }) +}) diff --git a/test/unit/login-request-test.js b/test/unit/login-request-test.mjs similarity index 55% rename from test/unit/login-request-test.js rename to test/unit/login-request-test.mjs index aa00291c0..306f7bcf3 100644 --- a/test/unit/login-request-test.js +++ b/test/unit/login-request-test.mjs @@ -1,238 +1,246 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -chai.use(require('sinon-chai')) -chai.use(require('dirty-chai')) -chai.should() -const HttpMocks = require('node-mocks-http') - -const AuthRequest = require('../../lib/requests/auth-request') -const { LoginRequest } = require('../../lib/requests/login-request') - -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') - -const mockUserStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: (user, password) => { return Promise.resolve(user) } -} - -const authMethod = 'oidc' -const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) -const accountManager = AccountManager.from({ host, authMethod }) -const localAuth = { password: true, tls: true } - -describe('LoginRequest', () => { - describe('loginPassword()', () => { - let res, req - - beforeEach(() => { - req = { - app: { locals: { oidc: { users: mockUserStore }, localAuth, accountManager } }, - body: { username: 'alice', password: '12345' } - } - res = HttpMocks.createResponse() - }) - - it('should create a LoginRequest instance', () => { - let fromParams = sinon.spy(LoginRequest, 'fromParams') - let loginStub = sinon.stub(LoginRequest, 'login') - .returns(Promise.resolve()) - - return LoginRequest.loginPassword(req, res) - .then(() => { - expect(fromParams).to.have.been.calledWith(req, res) - fromParams.reset() - loginStub.restore() - }) - }) - - it('should invoke login()', () => { - let login = sinon.spy(LoginRequest, 'login') - - return LoginRequest.loginPassword(req, res) - .then(() => { - expect(login).to.have.been.called() - login.reset() - }) - }) - }) - - describe('loginTls()', () => { - let res, req - - beforeEach(() => { - req = { - connection: {}, - app: { locals: { localAuth, accountManager } } - } - res = HttpMocks.createResponse() - }) - - it('should create a LoginRequest instance', () => { - return LoginRequest.loginTls(req, res) - .then(() => { - expect(LoginRequest.fromParams).to.have.been.calledWith(req, res) - LoginRequest.fromParams.reset() - LoginRequest.login.reset() - }) - }) - - it('should invoke login()', () => { - return LoginRequest.loginTls(req, res) - .then(() => { - expect(LoginRequest.login).to.have.been.called() - LoginRequest.login.reset() - }) - }) - }) - - describe('fromParams()', () => { - let session = {} - let req = { - session, - app: { locals: { accountManager } }, - body: { username: 'alice', password: '12345' } - } - let res = HttpMocks.createResponse() - - it('should return a LoginRequest instance', () => { - let request = LoginRequest.fromParams(req, res) - - expect(request.response).to.equal(res) - expect(request.session).to.equal(session) - expect(request.accountManager).to.equal(accountManager) - }) - - it('should initialize the query params', () => { - let requestOptions = sinon.spy(AuthRequest, 'requestOptions') - LoginRequest.fromParams(req, res) - - expect(requestOptions).to.have.been.calledWith(req) - }) - }) - - describe('login()', () => { - let userStore = mockUserStore - let response - - let options = { - userStore, - accountManager, - localAuth: {} - } - - beforeEach(() => { - response = HttpMocks.createResponse() - }) - - it('should call initUserSession() for a valid user', () => { - let validUser = {} - options.response = response - options.authenticator = { - findValidUser: sinon.stub().resolves(validUser) - } - - let request = new LoginRequest(options) - - let initUserSession = sinon.spy(request, 'initUserSession') - - return LoginRequest.login(request) - .then(() => { - expect(initUserSession).to.have.been.calledWith(validUser) - }) - }) - - it('should call redirectPostLogin()', () => { - let validUser = {} - options.response = response - options.authenticator = { - findValidUser: sinon.stub().resolves(validUser) - } - - let request = new LoginRequest(options) - - let redirectPostLogin = sinon.spy(request, 'redirectPostLogin') - - return LoginRequest.login(request) - .then(() => { - expect(redirectPostLogin).to.have.been.calledWith(validUser) - }) - }) - }) - - describe('postLoginUrl()', () => { - it('should return the user account uri if no redirect_uri param', () => { - let request = new LoginRequest({ authQueryParams: {} }) - - let aliceAccount = 'https://alice.example.com' - let user = { accountUri: aliceAccount } - - expect(request.postLoginUrl(user)).to.equal(aliceAccount) - }) - }) - - describe('redirectPostLogin()', () => { - it('should redirect to the /sharing url if response_type includes token', () => { - let res = HttpMocks.createResponse() - let authUrl = 'https://localhost:8443/sharing?response_type=token' - let validUser = accountManager.userAccountFrom({ username: 'alice' }) - - let authQueryParams = { - response_type: 'token' - } - - let options = { accountManager, authQueryParams, response: res } - let request = new LoginRequest(options) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(authUrl) - }) - - it('should redirect to account uri if no client_id present', () => { - let res = HttpMocks.createResponse() - let authUrl = 'https://localhost/authorize?redirect_uri=https%3A%2F%2Ffd.xuwubk.eu.org%3A443%2Fhttps%2Fapp.example.com%2Fcallback' - let validUser = accountManager.userAccountFrom({ username: 'alice' }) - - let authQueryParams = {} - - let options = { accountManager, authQueryParams, response: res } - let request = new LoginRequest(options) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - let expectedUri = accountManager.accountUriFor('alice') - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(expectedUri) - }) - - it('should redirect to account uri if redirect_uri is string "undefined', () => { - let res = HttpMocks.createResponse() - let authUrl = 'https://localhost/authorize?client_id=123' - let validUser = accountManager.userAccountFrom({ username: 'alice' }) - - let body = { redirect_uri: 'undefined' } - - let options = { accountManager, response: res } - let request = new LoginRequest(options) - request.authQueryParams = AuthRequest.extractAuthParams({ body }) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - let expectedUri = accountManager.accountUriFor('alice') - - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(expectedUri) - }) - }) +import { describe, it, beforeEach } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +import HttpMocks from 'node-mocks-http' +import AuthRequest from '../../lib/requests/auth-request.mjs' +import { LoginRequest } from '../../lib/requests/login-request.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +const mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } +} + +const authMethod = 'oidc' +const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) +const accountManager = AccountManager.from({ host, authMethod }) +const localAuth = { password: true, tls: true } + +describe('LoginRequest', () => { + describe('loginPassword()', () => { + let res, req + + beforeEach(() => { + req = { + app: { locals: { oidc: { users: mockUserStore }, localAuth, accountManager } }, + body: { username: 'alice', password: '12345' } + } + res = HttpMocks.createResponse() + }) + + it('should create a LoginRequest instance', () => { + const fromParams = sinon.spy(LoginRequest, 'fromParams') + const loginStub = sinon.stub(LoginRequest, 'login') + .returns(Promise.resolve()) + + return LoginRequest.loginPassword(req, res) + .then(() => { + expect(fromParams).to.have.been.calledWith(req, res) + fromParams.restore() + loginStub.restore() + }) + }) + + it('should invoke login()', () => { + const login = sinon.spy(LoginRequest, 'login') + + return LoginRequest.loginPassword(req, res) + .then(() => { + expect(login).to.have.been.called() + login.restore() + }) + }) + }) + + describe('loginTls()', () => { + let res, req + + beforeEach(() => { + req = { + connection: {}, + app: { locals: { localAuth, accountManager } } + } + res = HttpMocks.createResponse() + }) + + it('should create a LoginRequest instance', () => { + const fromParams = sinon.spy(LoginRequest, 'fromParams') + const loginStub = sinon.stub(LoginRequest, 'login') + .returns(Promise.resolve()) + + return LoginRequest.loginTls(req, res) + .then(() => { + expect(fromParams).to.have.been.calledWith(req, res) + fromParams.restore() + loginStub.restore() + }) + }) + + it('should invoke login()', () => { + const login = sinon.spy(LoginRequest, 'login') + + return LoginRequest.loginTls(req, res) + .then(() => { + expect(login).to.have.been.called() + login.restore() + }) + }) + }) + + describe('fromParams()', () => { + const session = {} + const req = { + session, + app: { locals: { accountManager } }, + body: { username: 'alice', password: '12345' } + } + const res = HttpMocks.createResponse() + + it('should return a LoginRequest instance', () => { + const request = LoginRequest.fromParams(req, res) + + expect(request.response).to.equal(res) + expect(request.session).to.equal(session) + expect(request.accountManager).to.equal(accountManager) + }) + + it('should initialize the query params', () => { + const requestOptions = sinon.spy(AuthRequest, 'requestOptions') + LoginRequest.fromParams(req, res) + + expect(requestOptions).to.have.been.calledWith(req) + requestOptions.restore() + }) + }) + + describe('login()', () => { + const userStore = mockUserStore + let response + + const options = { + userStore, + accountManager, + localAuth: {} + } + + beforeEach(() => { + response = HttpMocks.createResponse() + }) + + it('should call initUserSession() for a valid user', () => { + const validUser = {} + options.response = response + options.authenticator = { + findValidUser: sinon.stub().resolves(validUser) + } + + const request = new LoginRequest(options) + + const initUserSession = sinon.spy(request, 'initUserSession') + + return LoginRequest.login(request) + .then(() => { + expect(initUserSession).to.have.been.calledWith(validUser) + }) + }) + + it('should call redirectPostLogin()', () => { + const validUser = {} + options.response = response + options.authenticator = { + findValidUser: sinon.stub().resolves(validUser) + } + + const request = new LoginRequest(options) + + const redirectPostLogin = sinon.spy(request, 'redirectPostLogin') + + return LoginRequest.login(request) + .then(() => { + expect(redirectPostLogin).to.have.been.calledWith(validUser) + }) + }) + }) + + describe('postLoginUrl()', () => { + it('should return the user account uri if no redirect_uri param', () => { + const request = new LoginRequest({ authQueryParams: {} }) + + const aliceAccount = 'https://alice.example.com' + const user = { accountUri: aliceAccount } + + expect(request.postLoginUrl(user)).to.equal(aliceAccount) + }) + }) + + describe('redirectPostLogin()', () => { + it('should redirect to the /sharing url if response_type includes token', () => { + const res = HttpMocks.createResponse() + const authUrl = 'https://localhost:8443/sharing?response_type=token' + const validUser = accountManager.userAccountFrom({ username: 'alice' }) + + const authQueryParams = { + response_type: 'token' + } + + const options = { accountManager, authQueryParams, response: res } + const request = new LoginRequest(options) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(authUrl) + }) + + it('should redirect to account uri if no client_id present', () => { + const res = HttpMocks.createResponse() + const authUrl = 'https://localhost/authorize?redirect_uri=https%3A%2F%2Ffd.xuwubk.eu.org%3A443%2Fhttps%2Fapp.example.com%2Fcallback' + const validUser = accountManager.userAccountFrom({ username: 'alice' }) + + const authQueryParams = {} + + const options = { accountManager, authQueryParams, response: res } + const request = new LoginRequest(options) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + const expectedUri = accountManager.accountUriFor('alice') + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) + + it('should redirect to account uri if redirect_uri is string "undefined"', () => { + const res = HttpMocks.createResponse() + const authUrl = 'https://localhost/authorize?client_id=123' + const validUser = accountManager.userAccountFrom({ username: 'alice' }) + + const body = { redirect_uri: 'undefined' } + + const options = { accountManager, response: res } + const request = new LoginRequest(options) + request.authQueryParams = AuthRequest.extractAuthParams({ body }) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + const expectedUri = accountManager.accountUriFor('alice') + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) + }) }) diff --git a/test/unit/oidc-manager-test.js b/test/unit/oidc-manager-test.js deleted file mode 100644 index 4c49da090..000000000 --- a/test/unit/oidc-manager-test.js +++ /dev/null @@ -1,37 +0,0 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const path = require('path') - -const OidcManager = require('../../lib/models/oidc-manager') -const SolidHost = require('../../lib/models/solid-host') - -describe('OidcManager', () => { - describe('fromServerConfig()', () => { - it('should error if no serverUri is provided in argv', () => { - - }) - - it('should result in an initialized oidc object', () => { - let serverUri = 'https://localhost:8443' - let host = SolidHost.from({ serverUri }) - - let dbPath = path.join(__dirname, '../resources/db') - let saltRounds = 5 - let argv = { - host, - dbPath, - saltRounds - } - - let oidc = OidcManager.fromServerConfig(argv) - - expect(oidc.rs.defaults.query).to.be.true - expect(oidc.clients.store.backend.path.endsWith('db/rp/clients')) - expect(oidc.provider.issuer).to.equal(serverUri) - expect(oidc.users.backend.path.endsWith('db/users')) - expect(oidc.users.saltRounds).to.equal(saltRounds) - }) - }) -}) diff --git a/test/unit/oidc-manager-test.mjs b/test/unit/oidc-manager-test.mjs new file mode 100644 index 000000000..ec40ff00a --- /dev/null +++ b/test/unit/oidc-manager-test.mjs @@ -0,0 +1,49 @@ +// import { createRequire } from 'module' +import { fileURLToPath } from 'url' +import path from 'path' +import chai from 'chai' + +// const require = createRequire(import.meta.url) +// const OidcManager = require('../../lib/models/oidc-manager') +// const SolidHost = require('../../lib/models/solid-host') +import * as OidcManager from '../../lib/models/oidc-manager.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' + +const { expect } = chai + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +describe('OidcManager', () => { + describe('fromServerConfig()', () => { + it('should error if no serverUri is provided in argv', () => { + + }) + + it('should result in an initialized oidc object', () => { + const serverUri = 'https://localhost:8443' + const host = SolidHost.from({ serverUri }) + + const dbPath = path.join(__dirname, '../resources/db') + const saltRounds = 5 + const argv = { + host, + dbPath, + saltRounds + } + + const oidc = OidcManager.fromServerConfig(argv) + + expect(oidc.rs.defaults.query).to.be.true + const clientsPath = oidc.clients.store.backend.path + const usersPath = oidc.users.backend.path + // Check that the clients path contains an 'rp' segment (or 'clients') to handle layout differences + const clientsSegments = clientsPath.split(path.sep) + expect(clientsSegments.includes('rp') || clientsSegments.includes('clients')).to.be.true + expect(oidc.provider.issuer).to.equal(serverUri) + const usersSegments = usersPath.split(path.sep) + expect(usersSegments.includes('users')).to.be.true + expect(oidc.users.saltRounds).to.equal(saltRounds) + }) + }) +}) diff --git a/test/unit/options.js b/test/unit/options.js deleted file mode 100644 index 641dee55f..000000000 --- a/test/unit/options.js +++ /dev/null @@ -1,18 +0,0 @@ -var assert = require('chai').assert - -var options = require('../../bin/lib/options') - -describe('Command line options', function () { - describe('options', function () { - it('is an array', function () { - assert.equal(Array.isArray(options), true) - }) - - it('contains only `name`s that are kebab-case', function () { - assert.equal( - options.every(({name}) => (/^[a-z][a-z0-9-]*$/).test(name)), - true - ) - }) - }) -}) diff --git a/test/unit/password-authenticator-test.js b/test/unit/password-authenticator-test.js deleted file mode 100644 index e57b63f1e..000000000 --- a/test/unit/password-authenticator-test.js +++ /dev/null @@ -1,228 +0,0 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -chai.use(require('sinon-chai')) -chai.use(require('dirty-chai')) -chai.should() - -const { PasswordAuthenticator } = require('../../lib/models/authenticator') - -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') - -const mockUserStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: (user, password) => { return Promise.resolve(user) } -} - -const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) -const accountManager = AccountManager.from({ host }) - -describe('PasswordAuthenticator', () => { - describe('fromParams()', () => { - let req = { - body: { username: 'alice', password: '12345' } - } - let options = { userStore: mockUserStore, accountManager } - - it('should return a PasswordAuthenticator instance', () => { - let pwAuth = PasswordAuthenticator.fromParams(req, options) - - expect(pwAuth.userStore).to.equal(mockUserStore) - expect(pwAuth.accountManager).to.equal(accountManager) - expect(pwAuth.username).to.equal('alice') - expect(pwAuth.password).to.equal('12345') - }) - - it('should init with undefined username and password if no body is provided', () => { - let req = {} - - let pwAuth = PasswordAuthenticator.fromParams(req, {}) - - expect(pwAuth.username).to.be.undefined() - expect(pwAuth.password).to.be.undefined() - }) - }) - - describe('validate()', () => { - it('should throw a 400 error if no username was provided', done => { - let options = { username: null, password: '12345' } - let pwAuth = new PasswordAuthenticator(options) - - try { - pwAuth.validate() - } catch (error) { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('Username required') - done() - } - }) - - it('should throw a 400 error if no password was provided', done => { - let options = { username: 'alice', password: null } - let pwAuth = new PasswordAuthenticator(options) - - try { - pwAuth.validate() - } catch (error) { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('Password required') - done() - } - }) - }) - - describe('findValidUser()', () => { - it('should throw a 400 if no valid user is found in the user store', done => { - let options = { - username: 'alice', - password: '1234', - accountManager - } - let pwAuth = new PasswordAuthenticator(options) - - pwAuth.userStore = { - findUser: () => { return Promise.resolve(false) } - } - - pwAuth.findValidUser() - .catch(error => { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('No user found for that username') - done() - }) - }) - - it('should throw a 400 if user is found but password does not match', done => { - let options = { - username: 'alice', - password: '1234', - accountManager - } - let pwAuth = new PasswordAuthenticator(options) - - pwAuth.userStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: () => { return Promise.resolve(false) } - } - - pwAuth.findValidUser() - .catch(error => { - expect(error.statusCode).to.equal(400) - expect(error.message).to.equal('User found but no password match') - done() - }) - }) - - it('should return a valid user if one is found and password matches', () => { - let webId = 'https://alice.example.com/#me' - let validUser = { username: 'alice', webId } - let options = { - username: 'alice', - password: '1234', - accountManager - } - let pwAuth = new PasswordAuthenticator(options) - - pwAuth.userStore = { - findUser: () => { return Promise.resolve(validUser) }, - matchPassword: (user, password) => { return Promise.resolve(user) } - } - - return pwAuth.findValidUser() - .then(foundUser => { - expect(foundUser.webId).to.equal(webId) - }) - }) - - describe('in Multi User mode', () => { - let multiuser = true - let serverUri = 'https://example.com' - let host = SolidHost.from({ serverUri }) - - let accountManager = AccountManager.from({ multiuser, host }) - - let aliceRecord = { webId: 'https://alice.example.com/profile/card#me' } - let mockUserStore = { - findUser: sinon.stub().resolves(aliceRecord), - matchPassword: (user, password) => { return Promise.resolve(user) } - } - - it('should load user from store if provided with username', () => { - let options = { - username: 'alice', - password: '1234', - userStore: mockUserStore, - accountManager - } - let pwAuth = new PasswordAuthenticator(options) - - let userStoreKey = 'alice.example.com/profile/card#me' - - return pwAuth.findValidUser() - .then(() => { - expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) - }) - }) - - it('should load user from store if provided with WebID', () => { - let webId = 'https://alice.example.com/profile/card#me' - let options = { - username: webId, - password: '1234', - userStore: mockUserStore, - accountManager - } - let pwAuth = new PasswordAuthenticator(options) - - let userStoreKey = 'alice.example.com/profile/card#me' - - return pwAuth.findValidUser() - .then(() => { - expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) - }) - }) - }) - - describe('in Single User mode', () => { - let multiuser = false - let serverUri = 'https://localhost:8443' - let host = SolidHost.from({ serverUri }) - - let accountManager = AccountManager.from({ multiuser, host }) - - let aliceRecord = { webId: 'https://localhost:8443/profile/card#me' } - let mockUserStore = { - findUser: sinon.stub().resolves(aliceRecord), - matchPassword: (user, password) => { return Promise.resolve(user) } - } - - it('should load user from store if provided with username', () => { - let options = { username: 'admin', password: '1234', userStore: mockUserStore, accountManager } - let pwAuth = new PasswordAuthenticator(options) - - let userStoreKey = 'localhost:8443/profile/card#me' - - return pwAuth.findValidUser() - .then(() => { - expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) - }) - }) - - it('should load user from store if provided with WebID', () => { - let webId = 'https://localhost:8443/profile/card#me' - let options = { username: webId, password: '1234', userStore: mockUserStore, accountManager } - let pwAuth = new PasswordAuthenticator(options) - - let userStoreKey = 'localhost:8443/profile/card#me' - - return pwAuth.findValidUser() - .then(() => { - expect(mockUserStore.findUser).to.be.calledWith(userStoreKey) - }) - }) - }) - }) -}) diff --git a/test/unit/password-authenticator-test.mjs b/test/unit/password-authenticator-test.mjs new file mode 100644 index 000000000..9540d71d9 --- /dev/null +++ b/test/unit/password-authenticator-test.mjs @@ -0,0 +1,125 @@ +import { describe, it, beforeEach, afterEach } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +import { PasswordAuthenticator } from '../../lib/models/authenticator.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +const mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } +} + +const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) +const accountManager = AccountManager.from({ host }) + +describe('PasswordAuthenticator', () => { + describe('fromParams()', () => { + const req = { + body: { username: 'alice', password: '12345' } + } + const options = { userStore: mockUserStore, accountManager } + + it('should return a PasswordAuthenticator instance', () => { + const pwAuth = PasswordAuthenticator.fromParams(req, options) + + expect(pwAuth.userStore).to.equal(mockUserStore) + expect(pwAuth.accountManager).to.equal(accountManager) + expect(pwAuth.username).to.equal('alice') + expect(pwAuth.password).to.equal('12345') + }) + + it('should init with undefined username and password if no body is provided', () => { + const req = {} + const pwAuth = PasswordAuthenticator.fromParams(req, options) + + expect(pwAuth.username).to.be.undefined() + expect(pwAuth.password).to.be.undefined() + }) + }) + + describe('findValidUser()', () => { + let pwAuth, sandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + const req = { + body: { username: 'alice', password: '12345' } + } + const options = { userStore: mockUserStore, accountManager } + pwAuth = PasswordAuthenticator.fromParams(req, options) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should resolve with user if credentials are valid', () => { + sandbox.stub(mockUserStore, 'findUser') + .resolves({ username: 'alice' }) + sandbox.stub(mockUserStore, 'matchPassword') + .resolves({ username: 'alice' }) + + return pwAuth.findValidUser() + .then(user => { + expect(user.username).to.equal('alice') + }) + }) + + it('should reject if user is not found', () => { + sandbox.stub(mockUserStore, 'findUser') + .resolves(null) + + return pwAuth.findValidUser() + .catch(error => { + expect(error.message).to.include('Invalid username/password combination.') + }) + }) + + it('should reject if password does not match', () => { + sandbox.stub(mockUserStore, 'findUser') + .resolves({ username: 'alice' }) + sandbox.stub(mockUserStore, 'matchPassword') + .resolves(null) + + return pwAuth.findValidUser() + .catch(error => { + expect(error.message).to.include('Invalid username/password combination.') + }) + }) + + it('should reject with error if userStore throws', () => { + sandbox.stub(mockUserStore, 'findUser') + .rejects(new Error('Database error')) + + return pwAuth.findValidUser() + .catch(error => { + expect(error.message).to.equal('Database error') + }) + }) + }) + + describe('validate()', () => { + it('should throw a 400 error if no username was provided', () => { + const options = { username: null, password: '12345' } + const pwAuth = new PasswordAuthenticator(options) + + expect(() => pwAuth.validate()).to.throw('Username required') + }) + + it('should throw a 400 error if no password was provided', () => { + const options = { username: 'alice', password: null } + const pwAuth = new PasswordAuthenticator(options) + + expect(() => pwAuth.validate()).to.throw('Password required') + }) + }) +}) diff --git a/test/unit/password-change-request-test.js b/test/unit/password-change-request-test.mjs similarity index 65% rename from test/unit/password-change-request-test.js rename to test/unit/password-change-request-test.mjs index 50943e273..3a8529002 100644 --- a/test/unit/password-change-request-test.js +++ b/test/unit/password-change-request-test.mjs @@ -1,260 +1,259 @@ -'use strict' - -const chai = require('chai') -const sinon = require('sinon') -const expect = chai.expect -const dirtyChai = require('dirty-chai') -chai.use(dirtyChai) -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() - -const HttpMocks = require('node-mocks-http') - -const PasswordChangeRequest = require('../../lib/requests/password-change-request') -const SolidHost = require('../../lib/models/solid-host') - -describe('PasswordChangeRequest', () => { - sinon.spy(PasswordChangeRequest.prototype, 'error') - - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - let res = HttpMocks.createResponse() - - let accountManager = {} - let userStore = {} - - let options = { - accountManager, - userStore, - returnToUrl: 'https://example.com/resource', - response: res, - token: '12345', - newPassword: 'swordfish' - } - - let request = new PasswordChangeRequest(options) - - expect(request.returnToUrl).to.equal(options.returnToUrl) - expect(request.response).to.equal(res) - expect(request.token).to.equal(options.token) - expect(request.newPassword).to.equal(options.newPassword) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - let returnToUrl = 'https://example.com/resource' - let token = '12345' - let newPassword = 'swordfish' - let accountManager = {} - let userStore = {} - - let req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token }, - body: { newPassword } - } - let res = HttpMocks.createResponse() - - let request = PasswordChangeRequest.fromParams(req, res) - - expect(request.returnToUrl).to.equal(returnToUrl) - expect(request.response).to.equal(res) - expect(request.token).to.equal(token) - expect(request.newPassword).to.equal(newPassword) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('get()', () => { - let returnToUrl = 'https://example.com/resource' - let token = '12345' - let userStore = {} - let res = HttpMocks.createResponse() - sinon.spy(res, 'render') - - it('should create an instance and render a change password form', () => { - let accountManager = { - validateResetToken: sinon.stub().resolves(true) - } - let req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token } - } - - return PasswordChangeRequest.get(req, res) - .then(() => { - expect(accountManager.validateResetToken) - .to.have.been.called() - expect(res.render).to.have.been.calledWith('auth/change-password', - { returnToUrl, token, validToken: true }) - }) - }) - - it('should display an error message on an invalid token', () => { - let accountManager = { - validateResetToken: sinon.stub().throws() - } - let req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token } - } - - return PasswordChangeRequest.get(req, res) - .then(() => { - expect(PasswordChangeRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(PasswordChangeRequest, 'handlePost') - - let returnToUrl = 'https://example.com/resource' - let token = '12345' - let newPassword = 'swordfish' - let host = SolidHost.from({ serverUri: 'https://example.com' }) - let alice = { - webId: 'https://alice.example.com/#me' - } - let storedToken = { webId: alice.webId } - let store = { - findUser: sinon.stub().resolves(alice), - updatePassword: sinon.stub() - } - let accountManager = { - host, - store, - userAccountFrom: sinon.stub().resolves(alice), - validateResetToken: sinon.stub().resolves(storedToken) - } - - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendPasswordResetEmail = sinon.stub().resolves() - - let req = { - app: { locals: { accountManager, oidc: { users: store } } }, - query: { returnToUrl }, - body: { token, newPassword } - } - let res = HttpMocks.createResponse() - - return PasswordChangeRequest.post(req, res) - .then(() => { - expect(PasswordChangeRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('handlePost()', () => { - it('should display error message if validation error encountered', () => { - let returnToUrl = 'https://example.com/resource' - let token = '12345' - let userStore = {} - let res = HttpMocks.createResponse() - let accountManager = { - validateResetToken: sinon.stub().throws() - } - let req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token } - } - - let request = PasswordChangeRequest.fromParams(req, res) - - return PasswordChangeRequest.handlePost(request) - .then(() => { - expect(PasswordChangeRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('validateToken()', () => { - it('should return false if no token is present', () => { - let accountManager = { - validateResetToken: sinon.stub() - } - let request = new PasswordChangeRequest({ accountManager, token: null }) - - return request.validateToken() - .then(result => { - expect(result).to.be.false() - expect(accountManager.validateResetToken).to.not.have.been.called() - }) - }) - }) - - describe('validatePost()', () => { - it('should throw an error if no new password was entered', () => { - let request = new PasswordChangeRequest({ newPassword: null }) - - expect(() => request.validatePost()).to.throw('Please enter a new password') - }) - }) - - describe('error()', () => { - it('should invoke renderForm() with the error', () => { - let request = new PasswordChangeRequest({}) - request.renderForm = sinon.stub() - let error = new Error('error message') - - request.error(error) - - expect(request.renderForm).to.have.been.calledWith(error) - }) - }) - - describe('changePassword()', () => { - it('should create a new user store entry if none exists', () => { - // this would be the case for legacy pre-user-store accounts - let webId = 'https://alice.example.com/#me' - let user = { webId, id: webId } - let accountManager = { - userAccountFrom: sinon.stub().returns(user) - } - let userStore = { - findUser: sinon.stub().resolves(null), // no user found - createUser: sinon.stub().resolves(), - updatePassword: sinon.stub().resolves() - } - - let options = { - accountManager, userStore, newPassword: 'swordfish' - } - let request = new PasswordChangeRequest(options) - - return request.changePassword(user) - .then(() => { - expect(userStore.createUser).to.have.been.calledWith(user, options.newPassword) - }) - }) - }) - - describe('renderForm()', () => { - it('should set response status to error status, if error exists', () => { - let returnToUrl = 'https://example.com/resource' - let token = '12345' - let response = HttpMocks.createResponse() - sinon.spy(response, 'render') - - let options = { returnToUrl, token, response } - - let request = new PasswordChangeRequest(options) - - let error = new Error('error message') - - request.renderForm(error) - - expect(response.render).to.have.been.calledWith('auth/change-password', - { validToken: false, token, returnToUrl, error: 'error message' }) - }) - }) -}) +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' +// const PasswordChangeRequest = require('../../lib/requests/password-change-request') +// const SolidHost = require('../../lib/models/solid-host') +import PasswordChangeRequest from '../../lib/requests/password-change-request.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('PasswordChangeRequest', () => { + sinon.spy(PasswordChangeRequest.prototype, 'error') + + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const accountManager = {} + const userStore = {} + + const options = { + accountManager, + userStore, + returnToUrl: 'https://example.com/resource', + response: res, + token: '12345', + newPassword: 'swordfish' + } + + const request = new PasswordChangeRequest(options) + + expect(request.returnToUrl).to.equal(options.returnToUrl) + expect(request.response).to.equal(res) + expect(request.token).to.equal(options.token) + expect(request.newPassword).to.equal(options.newPassword) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const newPassword = 'swordfish' + const accountManager = {} + const userStore = {} + + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token }, + body: { newPassword } + } + const res = HttpMocks.createResponse() + + const request = PasswordChangeRequest.fromParams(req, res) + + expect(request.returnToUrl).to.equal(returnToUrl) + expect(request.response).to.equal(res) + expect(request.token).to.equal(token) + expect(request.newPassword).to.equal(newPassword) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('get()', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + sinon.spy(res, 'render') + + it('should create an instance and render a change password form', () => { + const accountManager = { + validateResetToken: sinon.stub().resolves(true) + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + return PasswordChangeRequest.get(req, res) + .then(() => { + expect(accountManager.validateResetToken) + .to.have.been.called() + expect(res.render).to.have.been.calledWith('auth/change-password', + { returnToUrl, token, validToken: true }) + }) + }) + + it('should display an error message on an invalid token', () => { + const accountManager = { + validateResetToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + return PasswordChangeRequest.get(req, res) + .then(() => { + expect(PasswordChangeRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(PasswordChangeRequest, 'handlePost') + + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const newPassword = 'swordfish' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const alice = { + webId: 'https://alice.example.com/#me' + } + const storedToken = { webId: alice.webId } + const store = { + findUser: sinon.stub().resolves(alice), + updatePassword: sinon.stub() + } + const accountManager = { + host, + store, + userAccountFrom: sinon.stub().resolves(alice), + validateResetToken: sinon.stub().resolves(storedToken) + } + + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + + const req = { + app: { locals: { accountManager, oidc: { users: store } } }, + query: { returnToUrl }, + body: { token, newPassword } + } + const res = HttpMocks.createResponse() + + return PasswordChangeRequest.post(req, res) + .then(() => { + expect(PasswordChangeRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('handlePost()', () => { + it('should display error message if validation error encountered', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + const accountManager = { + validateResetToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + const request = PasswordChangeRequest.fromParams(req, res) + + return PasswordChangeRequest.handlePost(request) + .then(() => { + expect(PasswordChangeRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('validateToken()', () => { + it('should return false if no token is present', () => { + const accountManager = { + validateResetToken: sinon.stub() + } + const request = new PasswordChangeRequest({ accountManager, token: null }) + + return request.validateToken() + .then(result => { + expect(result).to.be.false() + expect(accountManager.validateResetToken).to.not.have.been.called() + }) + }) + }) + + describe('validatePost()', () => { + it('should throw an error if no new password was entered', () => { + const request = new PasswordChangeRequest({ newPassword: null }) + + expect(() => request.validatePost()).to.throw('Please enter a new password') + }) + }) + + describe('error()', () => { + it('should invoke renderForm() with the error', () => { + const request = new PasswordChangeRequest({}) + request.renderForm = sinon.stub() + const error = new Error('error message') + + request.error(error) + + expect(request.renderForm).to.have.been.calledWith(error) + }) + }) + + describe('changePassword()', () => { + it('should create a new user store entry if none exists', () => { + // this would be the case for legacy pre-user-store accounts + const webId = 'https://alice.example.com/#me' + const user = { webId, id: webId } + const accountManager = { + userAccountFrom: sinon.stub().returns(user) + } + const userStore = { + findUser: sinon.stub().resolves(null), // no user found + createUser: sinon.stub().resolves(), + updatePassword: sinon.stub().resolves() + } + + const options = { + accountManager, userStore, newPassword: 'swordfish' + } + const request = new PasswordChangeRequest(options) + + return request.changePassword(user) + .then(() => { + expect(userStore.createUser).to.have.been.calledWith(user, options.newPassword) + }) + }) + }) + + describe('renderForm()', () => { + it('should set response status to error status, if error exists', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const response = HttpMocks.createResponse() + sinon.spy(response, 'render') + + const options = { returnToUrl, token, response } + + const request = new PasswordChangeRequest(options) + + const error = new Error('error message') + + request.renderForm(error) + + expect(response.render).to.have.been.calledWith('auth/change-password', + { validToken: false, token, returnToUrl, error: 'error message' }) + }) + }) +}) diff --git a/test/unit/password-reset-email-request-test.js b/test/unit/password-reset-email-request-test.js deleted file mode 100644 index 9bda0e210..000000000 --- a/test/unit/password-reset-email-request-test.js +++ /dev/null @@ -1,192 +0,0 @@ -'use strict' - -const chai = require('chai') -const sinon = require('sinon') -const expect = chai.expect -const dirtyChai = require('dirty-chai') -chai.use(dirtyChai) -const sinonChai = require('sinon-chai') -chai.use(sinonChai) -chai.should() - -const HttpMocks = require('node-mocks-http') - -const PasswordResetEmailRequest = require('../../lib/requests/password-reset-email-request') -const AccountManager = require('../../lib/models/account-manager') -const SolidHost = require('../../lib/models/solid-host') - -describe('PasswordResetEmailRequest', () => { - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - let res = HttpMocks.createResponse() - - let options = { - returnToUrl: 'https://example.com/resource', - response: res, - username: 'alice' - } - - let request = new PasswordResetEmailRequest(options) - - expect(request.returnToUrl).to.equal(options.returnToUrl) - expect(request.response).to.equal(res) - expect(request.username).to.equal(options.username) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - let returnToUrl = 'https://example.com/resource' - let username = 'alice' - let accountManager = {} - - let req = { - app: { locals: { accountManager } }, - query: { returnToUrl }, - body: { username } - } - let res = HttpMocks.createResponse() - - let request = PasswordResetEmailRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.returnToUrl).to.equal(returnToUrl) - expect(request.username).to.equal(username) - expect(request.response).to.equal(res) - }) - }) - - describe('get()', () => { - it('should create an instance and render a reset password form', () => { - let returnToUrl = 'https://example.com/resource' - let username = 'alice' - let accountManager = { multiuser: true } - - let req = { - app: { locals: { accountManager } }, - query: { returnToUrl }, - body: { username } - } - let res = HttpMocks.createResponse() - res.render = sinon.stub() - - PasswordResetEmailRequest.get(req, res) - - expect(res.render).to.have.been.calledWith('auth/reset-password', - { returnToUrl, multiuser: true }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(PasswordResetEmailRequest, 'handlePost') - - let returnToUrl = 'https://example.com/resource' - let username = 'alice' - let host = SolidHost.from({ serverUri: 'https://example.com' }) - let store = { - suffixAcl: '.acl' - } - let accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendPasswordResetEmail = sinon.stub().resolves() - - let req = { - app: { locals: { accountManager } }, - query: { returnToUrl }, - body: { username } - } - let res = HttpMocks.createResponse() - - PasswordResetEmailRequest.post(req, res) - .then(() => { - expect(PasswordResetEmailRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('validate()', () => { - it('should throw an error if username is missing in multi-user mode', () => { - let host = SolidHost.from({ serverUri: 'https://example.com' }) - let accountManager = AccountManager.from({ host, multiuser: true }) - - let request = new PasswordResetEmailRequest({ accountManager }) - - expect(() => request.validate()).to.throw(/Username required/) - }) - - it('should not throw an error if username is missing in single user mode', () => { - let host = SolidHost.from({ serverUri: 'https://example.com' }) - let accountManager = AccountManager.from({ host, multiuser: false }) - - let request = new PasswordResetEmailRequest({ accountManager }) - - expect(() => request.validate()).to.not.throw() - }) - }) - - describe('handlePost()', () => { - it('should handle the post request', () => { - let host = SolidHost.from({ serverUri: 'https://example.com' }) - let store = { suffixAcl: '.acl' } - let accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendPasswordResetEmail = sinon.stub().resolves() - accountManager.accountExists = sinon.stub().resolves(true) - - let returnToUrl = 'https://example.com/resource' - let username = 'alice' - let response = HttpMocks.createResponse() - response.render = sinon.stub() - - let options = { accountManager, username, returnToUrl, response } - let request = new PasswordResetEmailRequest(options) - - sinon.spy(request, 'error') - - return PasswordResetEmailRequest.handlePost(request) - .then(() => { - expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() - expect(accountManager.sendPasswordResetEmail).to.have.been.called() - expect(response.render).to.have.been.calledWith('auth/reset-link-sent') - expect(request.error).to.not.have.been.called() - }) - }) - }) - - describe('loadUser()', () => { - it('should return a UserAccount instance based on username', () => { - let host = SolidHost.from({ serverUri: 'https://example.com' }) - let store = { suffixAcl: '.acl' } - let accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - let username = 'alice' - - let options = { accountManager, username } - let request = new PasswordResetEmailRequest(options) - - return request.loadUser() - .then(account => { - expect(account.webId).to.equal('https://alice.example.com/profile/card#me') - }) - }) - - it('should throw an error if the user does not exist', done => { - let host = SolidHost.from({ serverUri: 'https://example.com' }) - let store = { suffixAcl: '.acl' } - let accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(false) - let username = 'alice' - - let options = { accountManager, username } - let request = new PasswordResetEmailRequest(options) - - request.loadUser() - .catch(error => { - expect(error.message).to.equal('Account not found for that username') - done() - }) - }) - }) -}) diff --git a/test/unit/password-reset-email-request-test.mjs b/test/unit/password-reset-email-request-test.mjs new file mode 100644 index 000000000..05e4349b4 --- /dev/null +++ b/test/unit/password-reset-email-request-test.mjs @@ -0,0 +1,234 @@ +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' + +import PasswordResetEmailRequest from '../../lib/requests/password-reset-email-request.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import EmailService from '../../lib/services/email-service.mjs' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('PasswordResetEmailRequest', () => { + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const options = { + returnToUrl: 'https://example.com/resource', + response: res, + username: 'alice' + } + + const request = new PasswordResetEmailRequest(options) + + expect(request.returnToUrl).to.equal(options.returnToUrl) + expect(request.response).to.equal(res) + expect(request.username).to.equal(options.username) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const accountManager = {} + + const req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + const res = HttpMocks.createResponse() + + const request = PasswordResetEmailRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.returnToUrl).to.equal(returnToUrl) + expect(request.username).to.equal(username) + expect(request.response).to.equal(res) + }) + }) + + describe('get()', () => { + it('should create an instance and render a reset password form', () => { + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const accountManager = { multiuser: true } + + const req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + const res = HttpMocks.createResponse() + res.render = sinon.stub() + + PasswordResetEmailRequest.get(req, res) + + expect(res.render).to.have.been.calledWith('auth/reset-password', + { returnToUrl, multiuser: true }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(PasswordResetEmailRequest, 'handlePost') + + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { + suffixAcl: '.acl' + } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + + const req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + const res = HttpMocks.createResponse() + + PasswordResetEmailRequest.post(req, res) + .then(() => { + expect(PasswordResetEmailRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('validate()', () => { + it('should throw an error if username is missing in multi-user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: true }) + + const request = new PasswordResetEmailRequest({ accountManager }) + + expect(() => request.validate()).to.throw(/Username required/) + }) + + it('should not throw an error if username is missing in single user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: false }) + + const request = new PasswordResetEmailRequest({ accountManager }) + + expect(() => request.validate()).to.not.throw() + }) + }) + + describe('handlePost()', () => { + it('should handle the post request', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + accountManager.accountExists = sinon.stub().resolves(true) + + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const response = HttpMocks.createResponse() + response.render = sinon.stub() + + const options = { accountManager, username, returnToUrl, response } + const request = new PasswordResetEmailRequest(options) + + sinon.spy(request, 'error') + + return PasswordResetEmailRequest.handlePost(request) + .then(() => { + expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() + expect(accountManager.sendPasswordResetEmail).to.have.been.called() + expect(response.render).to.have.been.calledWith('auth/reset-link-sent') + expect(request.error).to.not.have.been.called() + }) + }) + + it('should hande a reset request with no username without privacy leakage', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + accountManager.accountExists = sinon.stub().resolves(false) + + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const response = HttpMocks.createResponse() + response.render = sinon.stub() + + const options = { accountManager, username, returnToUrl, response } + const request = new PasswordResetEmailRequest(options) + + sinon.spy(request, 'error') + sinon.spy(request, 'validate') + sinon.spy(request, 'loadUser') + + return PasswordResetEmailRequest.handlePost(request) + .then(() => { + expect(request.validate).to.have.been.called() + expect(request.loadUser).to.have.been.called() + expect(request.loadUser).to.throw() + }).catch(() => { + expect(request.error).to.have.been.called() + expect(response.render).to.have.been.calledWith('auth/reset-link-sent') + expect(accountManager.loadAccountRecoveryEmail).to.not.have.been.called() + expect(accountManager.sendPasswordResetEmail).to.not.have.been.called() + }) + }) + }) + + describe('loadUser()', () => { + it('should return a UserAccount instance based on username', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + const username = 'alice' + + const options = { accountManager, username } + const request = new PasswordResetEmailRequest(options) + + return request.loadUser() + .then(account => { + expect(account.webId).to.equal('https://alice.example.com/profile/card#me') + }) + }) + + it('should throw an error if the user does not exist', done => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const emailService = sinon.stub().returns(EmailService) + const accountManager = AccountManager.from({ host, multiuser: true, store, emailService }) + accountManager.accountExists = sinon.stub().resolves(false) + const username = 'alice' + const options = { accountManager, username } + const request = new PasswordResetEmailRequest(options) + + sinon.spy(request, 'resetLinkMessage') + sinon.spy(accountManager, 'userAccountFrom') + sinon.spy(accountManager, 'verifyEmailDependencies') + + request.loadUser() + .then(() => { + expect(accountManager.userAccountFrom).to.have.been.called() + expect(accountManager.verifyEmailDependencies).to.have.been.called() + expect(accountManager.verifyEmailDependencies).to.throw() + done() + }) + .catch(() => { + expect(request.resetLinkMessage).to.have.been.called() + done() + }) + }) + }) +}) diff --git a/test/unit/resource-mapper-test.js b/test/unit/resource-mapper-test.mjs similarity index 53% rename from test/unit/resource-mapper-test.js rename to test/unit/resource-mapper-test.mjs index fc22521e3..2669316d3 100644 --- a/test/unit/resource-mapper-test.js +++ b/test/unit/resource-mapper-test.mjs @@ -1,674 +1,673 @@ -const ResourceMapper = require('../../lib/resource-mapper') -const chai = require('chai') -const { expect } = chai -chai.use(require('chai-as-promised')) - -const rootUrl = 'http://localhost/' -const rootPath = '/var/www/folder/' - -const itMapsUrl = asserter(mapsUrl) -const itMapsFile = asserter(mapsFile) - -describe('ResourceMapper', () => { - describe('A ResourceMapper instance for a single-host setup', () => { - const mapper = new ResourceMapper({ - rootUrl, - rootPath, - includeHost: false - }) - - // PUT base cases from https://www.w3.org/DesignIssues/HTTPFilenameMapping.html - - itMapsUrl(mapper, 'a URL with an extension that matches the content type', - { - url: 'http://localhost/space/foo.html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, "a URL with a bogus extension that doesn't match the content type", - { - url: 'http://localhost/space/foo.bar', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.bar$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, "a URL with a real extension that doesn't match the content type", - { - url: 'http://localhost/space/foo.exe', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.exe$.html`, - contentType: 'text/html' - }) - - // Additional PUT cases - - itMapsUrl(mapper, 'a URL without content type', - { - url: 'http://localhost/space/foo.html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.html$.unknown`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL with an alternative extension that matches the content type', - { - url: 'http://localhost/space/foo.jpeg', - contentType: 'image/jpeg', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.jpeg`, - contentType: 'image/jpeg' - }) - - itMapsUrl(mapper, 'a URL with an uppercase extension that matches the content type', - { - url: 'http://localhost/space/foo.JPG', - contentType: 'image/jpeg', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.JPG`, - contentType: 'image/jpeg' - }) - - itMapsUrl(mapper, 'a URL with a mixed-case extension that matches the content type', - { - url: 'http://localhost/space/foo.jPeG', - contentType: 'image/jpeg', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.jPeG`, - contentType: 'image/jpeg' - }) - - itMapsUrl(mapper, 'a URL with an overridden extension that matches the content type', - { - url: 'http://localhost/space/foo.acl', - contentType: 'text/turtle', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.acl`, - contentType: 'text/turtle' - }) - - itMapsUrl(mapper, 'a URL with an alternative overridden extension that matches the content type', - { - url: 'http://localhost/space/foo.acl', - contentType: 'text/n3', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.acl$.n3`, - contentType: 'text/n3' - }) - - // GET/HEAD/POST/DELETE/PATCH base cases - - itMapsUrl(mapper, 'a URL of a non-existing file', - { - url: 'http://localhost/space/foo.html' - }, - [/* no files */], - new Error('Resource not found: /space/foo.html')) - - itMapsUrl(mapper, 'a URL of an existing file with extension', - { - url: 'http://localhost/space/foo.html' - }, - [ - `${rootPath}space/foo.html` - ], - { - path: `${rootPath}space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'an extensionless URL of an existing file', - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.html` - ], - { - path: `${rootPath}space/foo$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'an extensionless URL of an existing file, with multiple choices', - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.html`, - `${rootPath}space/foo$.ttl`, - `${rootPath}space/foo$.png` - ], - { - path: `${rootPath}space/foo$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'an extensionless URL of an existing file with an uppercase extension', - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.HTML` - ], - { - path: `${rootPath}space/foo$.HTML`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'an extensionless URL of an existing file with a mixed-case extension', - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.HtMl` - ], - { - path: `${rootPath}space/foo$.HtMl`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL of an existing file with encoded characters', - { - url: 'http://localhost/space/foo%20bar%20bar.html' - }, - [ - `${rootPath}space/foo bar bar.html` - ], - { - path: `${rootPath}space/foo bar bar.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL of a new file with encoded characters', - { - url: 'http://localhost/space%2Ffoo%20bar%20bar.html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo bar bar.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL of an existing .acl file', - { - url: 'http://localhost/space/.acl' - }, - [ - `${rootPath}space/.acl` - ], - { - path: `${rootPath}space/.acl`, - contentType: 'text/turtle' - }) - - itMapsUrl(mapper, 'a URL of an existing .acl file with a different content type', - { - url: 'http://localhost/space/.acl' - }, - [ - `${rootPath}space/.acl$.n3` - ], - { - path: `${rootPath}space/.acl$.n3`, - contentType: 'text/n3' - }) - - itMapsUrl(mapper, 'a URL ending with a slash when index.html is available', - { - url: 'http://localhost/space/', - contentType: 'text/html' - }, - [ - `${rootPath}space/index.html`, - `${rootPath}space/index$.ttl` - ], - { - path: `${rootPath}space/index.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL ending with a slash when index.ttl is available', - { - url: 'http://localhost/space/' - }, - [ - `${rootPath}space/index.ttl` - ], - { - path: `${rootPath}space/`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL ending with a slash when index$.html is available', - { - url: 'http://localhost/space/' - }, - [ - `${rootPath}space/index$.html`, - `${rootPath}space/index$.ttl` - ], - { - path: `${rootPath}space/`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL ending with a slash when index$.ttl is available', - { - url: 'http://localhost/space/' - }, - [ - `${rootPath}space/index$.ttl` - ], - { - path: `${rootPath}space/`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL ending with a slash to a folder when index.html is available but index is skipped', - { - url: 'http://localhost/space/', - searchIndex: false - }, - [ - `${rootPath}space/index.html`, - `${rootPath}space/index$.ttl` - ], - { - path: `${rootPath}space/`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL ending with a slash to a folder when no index is available', - { - url: 'http://localhost/space/' - }, - { - path: `${rootPath}space/`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL of that has an accompanying acl file, but no actual file', - { - url: 'http://localhost/space/' - }, - [ - `${rootPath}space/index.acl` - ], - { - path: `${rootPath}space/`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL ending with a slash for text/html when index.html is not available', - { - url: 'http://localhost/space/', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/index.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL of that has an accompanying meta file, but no actual file', - { - url: 'http://localhost/space/', - contentType: 'text/html', - createIfNotExists: true - }, - [ - `${rootPath}space/index.meta` - ], - { - path: `${rootPath}space/index.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL ending with a slash to a folder when index is skipped', - { - url: 'http://localhost/space/', - contentType: 'application/octet-stream', - createIfNotExists: true, - searchIndex: false - }, - { - path: `${rootPath}space/`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL ending with a slash for text/turtle', - { - url: 'http://localhost/space/', - contentType: 'text/turtle', - createIfNotExists: true - }, - new Error('Index file needs to have text/html as content type')) - - // Security cases - - itMapsUrl(mapper, 'a URL with an unknown content type', - { - url: 'http://localhost/space/foo.html', - contentTypes: ['text/unknown'], - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.html$.unknown`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL with a /.. path segment', - { - url: 'http://localhost/space/../bar' - }, - new Error('Disallowed /.. segment in URL')) - - itMapsUrl(mapper, 'a URL with an encoded /.. path segment', - { - url: 'http://localhost/space%2F..%2Fbar' - }, - new Error('Disallowed /.. segment in URL')) - - // File to URL mapping - - itMapsFile(mapper, 'an HTML file', - { path: `${rootPath}space/foo.html` }, - { - url: 'http://localhost/space/foo.html', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a Turtle file', - { path: `${rootPath}space/foo.ttl` }, - { - url: 'http://localhost/space/foo.ttl', - contentType: 'text/turtle' - }) - - itMapsFile(mapper, 'an ACL file', - { path: `${rootPath}space/.acl` }, - { - url: 'http://localhost/space/.acl', - contentType: 'text/turtle' - }) - - itMapsFile(mapper, 'an unknown file type', - { path: `${rootPath}space/foo.bar` }, - { - url: 'http://localhost/space/foo.bar', - contentType: 'application/octet-stream' - }) - - itMapsFile(mapper, 'a file with an uppercase extension', - { path: `${rootPath}space/foo.HTML` }, - { - url: 'http://localhost/space/foo.HTML', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file with a mixed-case extension', - { path: `${rootPath}space/foo.HtMl` }, - { - url: 'http://localhost/space/foo.HtMl', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'an extensionless HTML file', - { path: `${rootPath}space/foo$.html` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'an extensionless Turtle file', - { path: `${rootPath}space/foo$.ttl` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/turtle' - }) - - itMapsFile(mapper, 'an extensionless unknown file type', - { path: `${rootPath}space/foo$.bar` }, - { - url: 'http://localhost/space/foo', - contentType: 'application/octet-stream' - }) - - itMapsFile(mapper, 'an extensionless file with an uppercase extension', - { path: `${rootPath}space/foo$.HTML` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'an extensionless file with a mixed-case extension', - { path: `${rootPath}space/foo$.HtMl` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file with disallowed IRI characters', - { path: `${rootPath}space/foo bar bar.html` }, - { - url: 'http://localhost/space/foo%20bar%20bar.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for a multi-host setup', () => { - const mapper = new ResourceMapper({ rootUrl, rootPath, includeHost: true }) - - itMapsUrl(mapper, 'a URL with a host', - { - url: 'http://example.org/space/foo.html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL with a host specified as a URL object', - { - url: { - hostname: 'example.org', - path: '/space/foo.html' - }, - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL with a host specified as an Express request object', - { - url: { - hostname: 'example.org', - pathname: '/space/foo.html' - }, - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL with a host with a port', - { - url: 'http://example.org:3000/space/foo.html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file on a host', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'http://example.org/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for a multi-host setup with a subfolder root URL', () => { - const rootUrl = 'https://localhost/foo/bar/' - const mapper = new ResourceMapper({ rootUrl, rootPath, includeHost: true }) - - itMapsFile(mapper, 'a file on a host', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'https://example.org/foo/bar/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTP host with non-default port', () => { - const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'http://localhost:81/example.org/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTP host with non-default port in a multi-host setup', () => { - const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath, includeHost: true }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'http://example.org:81/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTPS host with non-default port', () => { - const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'https://localhost:81/example.org/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { - const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'https://example.org:81/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { - const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) - - it('throws an error when there is an improper file path', () => { - return expect(mapper.mapFileToUrl({ - path: `${rootPath}example.orgspace/foo.html`, - hostname: 'example.org' - })).to.be.rejectedWith(Error, 'Path must start with hostname (/example.org)') - }) - }) +import { describe, it } from 'mocha' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' + +// Import CommonJS modules +// const ResourceMapper = require('../../lib/resource-mapper') +import ResourceMapper from '../../lib/resource-mapper.mjs' +// import { createRequire } from 'module' + +// const require = createRequire(import.meta.url) +const { expect } = chai +chai.use(chaiAsPromised) + +const rootUrl = 'http://localhost/' +const rootPath = '/var/www/folder/' + +// Helper functions for testing +function asserter (fn) { + return function (mapper, label, ...args) { + return fn(it, mapper, label, ...args) + } +} + +function mapsUrl (it, mapper, label, options, files, expected) { + // Shift parameters if necessary + if (!expected) { + expected = files + files = undefined // No files array means don't mock filesystem + } + + // Mock filesystem only if files array is provided + function mockReaddir () { + if (files !== undefined) { + mapper._readdir = async (path) => { + // For the tests to work, we need to check if the path is in the expected range + expect(path.startsWith(rootPath)).to.equal(true) + + if (!files.length) { + // When empty files array is provided, simulate directory not found + throw new Error(`${path} Resource not found`) + } + + // Return just the filenames (not full paths) that are in the requested directory + // Normalize the path to handle different slash directions + const requestedDir = path.replace(/\\/g, '/') + + const matchingFiles = files + .filter(f => { + const normalizedFile = f.replace(/\\/g, '/') + const fileDir = normalizedFile.substring(0, normalizedFile.lastIndexOf('/') + 1) + return fileDir === requestedDir + }) + .map(f => { + const normalizedFile = f.replace(/\\/g, '/') + const filename = normalizedFile.substring(normalizedFile.lastIndexOf('/') + 1) + return filename + }) + .filter(f => f) // Only non-empty filenames + + return matchingFiles + } + } + // If no files array, don't mock - let it use real filesystem or default behavior + } + + // Set up positive test + if (!(expected instanceof Error)) { + it(`maps ${label}`, async () => { + mockReaddir() + const actual = await mapper.mapUrlToFile(options) + expect(actual).to.deep.equal(expected) + }) + // Set up error test + } else { + it(`does not map ${label}`, async () => { + mockReaddir() + const actual = mapper.mapUrlToFile(options) + await expect(actual).to.be.rejectedWith(expected.message) + }) + } +} + +function mapsFile (it, mapper, label, options, expected) { + it(`maps ${label}`, async () => { + const actual = await mapper.mapFileToUrl(options) + expect(actual).to.deep.equal(expected) + }) +} + +const itMapsUrl = asserter(mapsUrl) +const itMapsFile = asserter(mapsFile) + +describe('ResourceMapper', () => { + describe('A ResourceMapper instance for a single-host setup', () => { + const mapper = new ResourceMapper({ + rootUrl, + rootPath, + includeHost: false + }) + + // PUT base cases from https://www.w3.org/DesignIssues/HTTPFilenameMapping.html + + itMapsUrl(mapper, 'a URL with an extension that matches the content type', + { + url: 'http://localhost/space/%20foo .html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/ foo .html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL with a bogus extension that doesn't match the content type", + { + url: 'http://localhost/space/foo.bar', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.bar$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL with a real extension that doesn't match the content type", + { + url: 'http://localhost/space/foo.exe', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.exe$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL that doesn't have an extension but should be saved as HTML", + { + url: 'http://localhost/space/foo', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL that already has the right extension', + { + url: 'http://localhost/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + + // GET base cases + + itMapsUrl(mapper, 'a URL with a proper extension', + { + url: 'http://localhost/space/foo.html' + }, + [ + `${rootPath}space/foo.html` + ], + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL that doesn't have an extension", + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html`, + `${rootPath}space/foo$.json`, + `${rootPath}space/foo$.md`, + `${rootPath}space/foo$.rdf`, + `${rootPath}space/foo$.xml`, + `${rootPath}space/foo$.txt`, + `${rootPath}space/foo$.ttl`, + `${rootPath}space/foo$.jsonld`, + `${rootPath}space/foo` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL that doesn't have an extension but has multiple possible files", + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html`, + `${rootPath}space/foo$.ttl` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + // Test with various content types + const contentTypes = [ + ['text/turtle', 'ttl'], + ['application/ld+json', 'jsonld'], + ['application/json', 'json'], + ['text/plain', 'txt'], + ['text/markdown', 'md'], + ['application/rdf+xml', 'rdf'], + ['application/xml', 'xml'] + ] + + contentTypes.forEach(([contentType, extension]) => { + itMapsUrl(mapper, `a URL for ${contentType}`, + { + url: `http://localhost/space/foo.${extension}`, + contentType, + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.${extension}`, + contentType + }) + }) + + // Directory mapping tests + itMapsUrl(mapper, 'a directory URL', + { + url: 'http://localhost/space/' + }, + [ + `${rootPath}space/index.html` + ], + { + path: `${rootPath}space/index.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'the root directory URL', + { + url: 'http://localhost/' + }, + [ + `${rootPath}index.html` + ], + { + path: `${rootPath}index.html`, + contentType: 'text/html' + }) + + // Test file to URL mapping + itMapsFile(mapper, 'a regular file path', + { + path: `${rootPath}space/foo.html`, + hostname: 'localhost' + }, + { + url: 'http://localhost/space/foo.html', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a directory path', + { + path: `${rootPath}space/`, + hostname: 'localhost' + }, + { + url: 'http://localhost/space/', + contentType: 'text/turtle' + }) + // --- Additional error and edge-case tests for full parity --- + itMapsUrl(mapper, 'a URL without content type', + { + url: 'http://localhost/space/foo.html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html$.unknown`, + contentType: 'application/octet-stream' + }) + + itMapsUrl(mapper, 'a URL with an unknown content type', + { + url: 'http://localhost/space/foo.html', + contentTypes: ['text/unknown'], + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html$.unknown`, + contentType: 'application/octet-stream' + }) + + itMapsUrl(mapper, 'a URL with a /.. path segment', + { + url: 'http://localhost/space/../bar' + }, + new Error('Disallowed /.. segment in URL')) + + itMapsUrl(mapper, 'a URL ending with a slash for text/turtle', + { + url: 'http://localhost/space/', + contentType: 'text/turtle', + createIfNotExists: true + }, + new Error('Index file needs to have text/html as content type')) + + itMapsUrl(mapper, 'a URL of a non-existent folder', + { + url: 'http://localhost/space/foo/' + }, + [], + new Error('/space/foo/ Resource not found')) + + itMapsUrl(mapper, 'a URL of a non-existent file', + { + url: 'http://localhost/space/foo.html' + }, + [], + new Error('/space/foo.html Resource not found')) + + itMapsUrl(mapper, 'a URL of an existing .acl file', + { + url: 'http://localhost/space/.acl' + }, + [ + `${rootPath}space/.acl` + ], + { + path: `${rootPath}space/.acl`, + contentType: 'text/turtle' + }) + + itMapsUrl(mapper, 'a URL of an existing .acl file with a different content type', + { + url: 'http://localhost/space/.acl' + }, + [ + `${rootPath}space/.acl$.n3` + ], + { + path: `${rootPath}space/.acl$.n3`, + contentType: 'text/n3' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file, with multiple choices', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html`, + `${rootPath}space/foo$.ttl`, + `${rootPath}space/foo$.png` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file with an uppercase extension', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.HTML` + ], + { + path: `${rootPath}space/foo$.HTML`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file with a mixed-case extension', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.HtMl` + ], + { + path: `${rootPath}space/foo$.HtMl`, + contentType: 'text/html' + }) + itMapsFile(mapper, 'an unknown file type', + { path: `${rootPath}space/foo.bar` }, + { + url: 'http://localhost/space/foo.bar', + contentType: 'application/octet-stream' + }) + + itMapsFile(mapper, 'a file with an uppercase extension', + { path: `${rootPath}space/foo.HTML` }, + { + url: 'http://localhost/space/foo.HTML', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with a mixed-case extension', + { path: `${rootPath}space/foo.HtMl` }, + { + url: 'http://localhost/space/foo.HtMl', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless HTML file', + { path: `${rootPath}space/foo$.html` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless Turtle file', + { path: `${rootPath}space/foo$.ttl` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/turtle' + }) + + itMapsFile(mapper, 'an extensionless unknown file type', + { path: `${rootPath}space/%2ffoo%2F$.bar` }, + { + url: 'http://localhost/space/%2ffoo%2F', + contentType: 'application/octet-stream' + }) + + itMapsFile(mapper, 'an extensionless file with an uppercase extension', + { path: `${rootPath}space/foo$.HTML` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless file with a mixed-case extension', + { path: `${rootPath}space/foo$.HtMl` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with disallowed IRI characters', + { path: `${rootPath}space/foo bar bar.html` }, + { + url: 'http://localhost/space/foo%20bar%20bar.html', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with %encoded /', + { path: `${rootPath}%2Fspace/%25252Ffoo%2f.html` }, + { + url: 'http://localhost/%2Fspace/%25252Ffoo%2f.html', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with even stranger disallowed IRI characters', + { path: `${rootPath}%2fspace%2F/Blog discovery for the future? · Issue #96 · scripting:Scripting-News · GitHub.pdf` }, + { + url: 'http://localhost/%2fspace%2F/Blog%20discovery%20for%20the%20future%3F%20%C2%B7%20Issue%20%2396%20%C2%B7%20scripting%3AScripting-News%20%C2%B7%20GitHub.pdf', + contentType: 'application/pdf' + }) + }) + + describe('A ResourceMapper instance for a multi-host setup', () => { + const mapper = new ResourceMapper({ + rootUrl, + rootPath, + includeHost: true + }) + + itMapsUrl(mapper, 'a URL with host in path', + { + url: 'http://example.org/space/foo.html' + }, + [ + `${rootPath}example.org/space/foo.html` + ], + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file path with host directory', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html' + }) + itMapsUrl(mapper, 'a URL with a host', + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL with a host specified as a URL object', + { + url: { + hostname: 'example.org', + path: '/space/foo.html' + }, + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL with a host specified as an Express request object', + { + url: { + hostname: 'example.org', + pathname: '/space/foo.html' + }, + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL with a host with a port', + { + url: 'http://example.org:3000/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file on a host', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for a multi-host setup with a subfolder root URL', () => { + const rootUrl = 'https://localhost/foo/bar/' + const mapper = new ResourceMapper({ rootUrl, rootPath, includeHost: true }) + + itMapsFile(mapper, 'a file on a host', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'https://example.org/foo/bar/space/foo.html', + contentType: 'text/html' + }) + describe('A ResourceMapper instance for an HTTP host with non-default port', () => { + const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://localhost:81/example.org/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTP host with non-default port in a multi-host setup', () => { + const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath, includeHost: true }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org:81/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port', () => { + const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'https://localhost:81/example.org/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { + const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'https://example.org:81/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { + const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) + + it('throws an error when there is an improper file path', () => { + return expect(mapper.mapFileToUrl({ + path: `${rootPath}example.orgspace/foo.html`, + hostname: 'example.org' + })).to.be.rejectedWith(Error, 'Path must start with hostname (/example.org)') + }) + }) + }) + + // Additional test cases for various port configurations + describe('A ResourceMapper instance for an HTTP host with non-default port', () => { + const mapper = new ResourceMapper({ + rootUrl: 'http://localhost:8080/', + rootPath, + includeHost: false + }) + + itMapsUrl(mapper, 'a URL with non-default HTTP port', + { + url: 'http://localhost:8080/space/foo.html' + }, + [ + `${rootPath}space/foo.html` + ], + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port', () => { + const mapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath, + includeHost: false + }) + + itMapsUrl(mapper, 'a URL with non-default HTTPS port', + { + url: 'https://localhost:8443/space/foo.html' + }, + [ + `${rootPath}space/foo.html` + ], + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + }) }) - -function asserter (assert) { - const f = (...args) => assert(it, ...args) - f.skip = (...args) => assert(it.skip, ...args) - f.only = (...args) => assert(it.only, ...args) - return f -} - -function mapsUrl (it, mapper, label, options, files, expected) { - // Shift parameters if necessary - if (!expected) { - expected = files - files = [] - } - - // Mock filesystem - function mockReaddir () { - mapper._readdir = async (path) => { - expect(path.startsWith(`${rootPath}space/`)).to.equal(true) - return files.map(f => f.replace(/.*\//, '')) - } - } - - // Set up positive test - if (!(expected instanceof Error)) { - it(`maps ${label}`, async () => { - mockReaddir() - const actual = await mapper.mapUrlToFile(options) - expect(actual).to.deep.equal(expected) - }) - // Set up error test - } else { - it(`does not map ${label}`, async () => { - mockReaddir() - const actual = mapper.mapUrlToFile(options) - await expect(actual).to.be.rejectedWith(expected.message) - }) - } -} - -function mapsFile (it, mapper, label, options, expected) { - it(`maps ${label}`, async () => { - const actual = await mapper.mapFileToUrl(options) - expect(actual).to.deep.equal(expected) - }) -} diff --git a/test/unit/solid-host-test.js b/test/unit/solid-host-test.mjs similarity index 82% rename from test/unit/solid-host-test.js rename to test/unit/solid-host-test.mjs index fc2d77b6d..1a7312ce6 100644 --- a/test/unit/solid-host-test.js +++ b/test/unit/solid-host-test.mjs @@ -1,120 +1,118 @@ -'use strict' - -const expect = require('chai').expect - -const SolidHost = require('../../lib/models/solid-host') -const defaults = require('../../config/defaults') - -describe('SolidHost', () => { - describe('from()', () => { - it('should init with provided params', () => { - let config = { - port: 3000, - serverUri: 'https://localhost:3000', - live: true, - root: '/data/solid/', - multiuser: true, - webid: true - } - let host = SolidHost.from(config) - - expect(host.port).to.equal(3000) - expect(host.serverUri).to.equal('https://localhost:3000') - expect(host.hostname).to.equal('localhost') - expect(host.live).to.be.true - expect(host.root).to.equal('/data/solid/') - expect(host.multiuser).to.be.true - expect(host.webid).to.be.true - }) - - it('should init to default port and serverUri values', () => { - let host = SolidHost.from({}) - expect(host.port).to.equal(defaults.port) - expect(host.serverUri).to.equal(defaults.serverUri) - }) - }) - - describe('accountUriFor()', () => { - it('should compose an account uri for an account name', () => { - let config = { - serverUri: 'https://test.local' - } - let host = SolidHost.from(config) - - expect(host.accountUriFor('alice')).to.equal('https://alice.test.local') - }) - - it('should throw an error if no account name is passed in', () => { - let host = SolidHost.from() - expect(() => { host.accountUriFor() }).to.throw(TypeError) - }) - }) - - describe('allowsSessionFor()', () => { - let host - before(() => { - host = SolidHost.from({ - serverUri: 'https://test.local' - }) - }) - - it('should allow an empty userId and origin', () => { - expect(host.allowsSessionFor('', '', [])).to.be.true - }) - - it('should allow a userId with empty origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', '', [])).to.be.true - }) - - it('should allow a userId with the user subdomain as origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://user.own', [])).to.be.true - }) - - it('should allow a userId with the server domain as origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://test.local', [])).to.be.true - }) - - it('should allow a userId with a server subdomain as origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.test.local', [])).to.be.true - }) - - it('should disallow a userId from a different domain', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', [])).to.be.false - }) - - it('should allow user from a trusted domain', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', ['https://other.remote'])).to.be.true - }) - }) - - describe('cookieDomain getter', () => { - it('should return null for single-part domains (localhost)', () => { - let host = SolidHost.from({ - serverUri: 'https://localhost:8443' - }) - - expect(host.cookieDomain).to.be.null - }) - - it('should return a cookie domain for multi-part domains', () => { - let host = SolidHost.from({ - serverUri: 'https://example.com:8443' - }) - - expect(host.cookieDomain).to.equal('.example.com') - }) - }) - - describe('authEndpoint getter', () => { - it('should return an /authorize url object', () => { - let host = SolidHost.from({ - serverUri: 'https://localhost:8443' - }) - - let authUrl = host.authEndpoint - - expect(authUrl.host).to.equal('localhost:8443') - expect(authUrl.path).to.equal('/authorize') - }) - }) -}) +import { describe, it, before } from 'mocha' +import { expect } from 'chai' +import SolidHost from '../../lib/models/solid-host.mjs' +import defaults from '../../config/defaults.mjs' + +describe('SolidHost', () => { + describe('from()', () => { + it('should init with provided params', () => { + const config = { + port: 3000, + serverUri: 'https://localhost:3000', + live: true, + root: '/data/solid/', + multiuser: true, + webid: true + } + const host = SolidHost.from(config) + + expect(host.port).to.equal(3000) + expect(host.serverUri).to.equal('https://localhost:3000') + expect(host.hostname).to.equal('localhost') + expect(host.live).to.be.true + expect(host.root).to.equal('/data/solid/') + expect(host.multiuser).to.be.true + expect(host.webid).to.be.true + }) + + it('should init to default port and serverUri values', () => { + const host = SolidHost.from({}) + expect(host.port).to.equal(defaults.port) + expect(host.serverUri).to.equal(defaults.serverUri) + }) + }) + + describe('accountUriFor()', () => { + it('should compose an account uri for an account name', () => { + const config = { + serverUri: 'https://test.local' + } + const host = SolidHost.from(config) + + expect(host.accountUriFor('alice')).to.equal('https://alice.test.local') + }) + + it('should throw an error if no account name is passed in', () => { + const host = SolidHost.from() + expect(() => { host.accountUriFor() }).to.throw(TypeError) + }) + }) + + describe('allowsSessionFor()', () => { + let host + before(() => { + host = SolidHost.from({ + serverUri: 'https://test.local' + }) + }) + + it('should allow an empty userId and origin', () => { + expect(host.allowsSessionFor('', '', [])).to.be.true + }) + + it('should allow a userId with empty origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', '', [])).to.be.true + }) + + it('should allow a userId with the user subdomain as origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://user.own', [])).to.be.true + }) + + it('should allow a userId with the server domain as origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://test.local', [])).to.be.true + }) + + it('should allow a userId with a server subdomain as origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.test.local', [])).to.be.true + }) + + it('should disallow a userId from a different domain', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', [])).to.be.false + }) + + it('should allow user from a trusted domain', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', ['https://other.remote'])).to.be.true + }) + }) + + describe('cookieDomain getter', () => { + it('should return null for single-part domains (localhost)', () => { + const host = SolidHost.from({ + serverUri: 'https://localhost:8443' + }) + + expect(host.cookieDomain).to.be.null + }) + + it('should return a cookie domain for multi-part domains', () => { + const host = SolidHost.from({ + serverUri: 'https://example.com:8443' + }) + + expect(host.cookieDomain).to.equal('.example.com') + }) + }) + + describe('authEndpoint getter', () => { + it('should return an /authorize url object', () => { + const host = SolidHost.from({ + serverUri: 'https://localhost:8443' + }) + + const authUrl = host.authEndpoint + + expect(authUrl.host).to.equal('localhost:8443') + expect(authUrl.pathname).to.equal('/authorize') + }) + }) +}) diff --git a/test/unit/tls-authenticator-test.js b/test/unit/tls-authenticator-test.mjs similarity index 63% rename from test/unit/tls-authenticator-test.js rename to test/unit/tls-authenticator-test.mjs index 27c2be391..06c5acacb 100644 --- a/test/unit/tls-authenticator-test.js +++ b/test/unit/tls-authenticator-test.mjs @@ -1,173 +1,174 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const sinon = require('sinon') -chai.use(require('sinon-chai')) -chai.use(require('dirty-chai')) -chai.use(require('chai-as-promised')) -chai.should() - -const { TlsAuthenticator } = require('../../lib/models/authenticator') - -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') - -const host = SolidHost.from({ serverUri: 'https://example.com' }) -const accountManager = AccountManager.from({ host, multiuser: true }) - -describe('TlsAuthenticator', () => { - describe('fromParams()', () => { - let req = { - connection: {} - } - let options = { accountManager } - - it('should return a TlsAuthenticator instance', () => { - let tlsAuth = TlsAuthenticator.fromParams(req, options) - - expect(tlsAuth.accountManager).to.equal(accountManager) - expect(tlsAuth.connection).to.equal(req.connection) - }) - }) - - describe('findValidUser()', () => { - let webId = 'https://alice.example.com/#me' - let certificate = { uri: webId } - let connection = { - renegotiate: sinon.stub().yields(), - getPeerCertificate: sinon.stub().returns(certificate) - } - let options = { accountManager, connection } - - let tlsAuth = new TlsAuthenticator(options) - - tlsAuth.extractWebId = sinon.stub().resolves(webId) - sinon.spy(tlsAuth, 'renegotiateTls') - sinon.spy(tlsAuth, 'loadUser') - - return tlsAuth.findValidUser() - .then(validUser => { - expect(tlsAuth.renegotiateTls).to.have.been.called() - expect(connection.getPeerCertificate).to.have.been.called() - expect(tlsAuth.extractWebId).to.have.been.calledWith(certificate) - expect(tlsAuth.loadUser).to.have.been.calledWith(webId) - - expect(validUser.webId).to.equal(webId) - }) - }) - - describe('renegotiateTls()', () => { - it('should reject if an error occurs while renegotiating', () => { - let connection = { - renegotiate: sinon.stub().yields(new Error('Error renegotiating')) - } - - let tlsAuth = new TlsAuthenticator({ connection }) - - expect(tlsAuth.renegotiateTls()).to.be.rejectedWith(/Error renegotiating/) - }) - - it('should resolve if no error occurs', () => { - let connection = { - renegotiate: sinon.stub().yields(null) - } - - let tlsAuth = new TlsAuthenticator({ connection }) - - expect(tlsAuth.renegotiateTls()).to.be.fulfilled() - }) - }) - - describe('getCertificate()', () => { - it('should throw on a non-existent certificate', () => { - let connection = { - getPeerCertificate: sinon.stub().returns(null) - } - - let tlsAuth = new TlsAuthenticator({ connection }) - - expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) - }) - - it('should throw on an empty certificate', () => { - let connection = { - getPeerCertificate: sinon.stub().returns({}) - } - - let tlsAuth = new TlsAuthenticator({ connection }) - - expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) - }) - - it('should return a certificate if no error occurs', () => { - let certificate = { uri: 'https://alice.example.com/#me' } - let connection = { - getPeerCertificate: sinon.stub().returns(certificate) - } - - let tlsAuth = new TlsAuthenticator({ connection }) - - expect(tlsAuth.getCertificate()).to.equal(certificate) - }) - }) - - describe('extractWebId()', () => { - it('should reject if an error occurs verifying certificate', () => { - let tlsAuth = new TlsAuthenticator({}) - - tlsAuth.verifyWebId = sinon.stub().yields(new Error('Error processing certificate')) - - expect(tlsAuth.extractWebId()).to.be.rejectedWith(/Error processing certificate/) - }) - - it('should resolve with a verified web id', () => { - let tlsAuth = new TlsAuthenticator({}) - - let webId = 'https://alice.example.com/#me' - tlsAuth.verifyWebId = sinon.stub().yields(null, webId) - - let certificate = { uri: webId } - - expect(tlsAuth.extractWebId(certificate)).to.become(webId) - }) - }) - - describe('loadUser()', () => { - it('should return a user instance if the webid is local', () => { - let tlsAuth = new TlsAuthenticator({ accountManager }) - - let webId = 'https://alice.example.com/#me' - - let user = tlsAuth.loadUser(webId) - - expect(user.username).to.equal('alice') - expect(user.webId).to.equal(webId) - }) - - it('should return a user instance if external user and this server is authorized provider', () => { - let tlsAuth = new TlsAuthenticator({ accountManager }) - - let externalWebId = 'https://alice.someothersite.com#me' - - tlsAuth.discoverProviderFor = sinon.stub().resolves('https://example.com') - - let user = tlsAuth.loadUser(externalWebId) - - expect(user.username).to.equal(externalWebId) - expect(user.webId).to.equal(externalWebId) - }) - }) - - describe('verifyWebId()', () => { - it('should yield an error if no cert is given', done => { - let tlsAuth = new TlsAuthenticator({}) - - tlsAuth.verifyWebId(null, (error) => { - expect(error.message).to.equal('No certificate given') - - done() - }) - }) - }) +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' +import chaiAsPromised from 'chai-as-promised' + +import { TlsAuthenticator } from '../../lib/models/authenticator.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.use(chaiAsPromised) +chai.should() + +const host = SolidHost.from({ serverUri: 'https://example.com' }) +const accountManager = AccountManager.from({ host, multiuser: true }) + +describe('TlsAuthenticator', () => { + describe('fromParams()', () => { + const req = { + connection: {} + } + const options = { accountManager } + + it('should return a TlsAuthenticator instance', () => { + const tlsAuth = TlsAuthenticator.fromParams(req, options) + + expect(tlsAuth.accountManager).to.equal(accountManager) + expect(tlsAuth.connection).to.equal(req.connection) + }) + }) + + describe('findValidUser()', () => { + const webId = 'https://alice.example.com/#me' + const certificate = { uri: webId } + const connection = { + renegotiate: sinon.stub().yields(), + getPeerCertificate: sinon.stub().returns(certificate) + } + const options = { accountManager, connection } + + const tlsAuth = new TlsAuthenticator(options) + + tlsAuth.extractWebId = sinon.stub().resolves(webId) + sinon.spy(tlsAuth, 'renegotiateTls') + sinon.spy(tlsAuth, 'loadUser') + + return tlsAuth.findValidUser() + .then(validUser => { + expect(tlsAuth.renegotiateTls).to.have.been.called() + expect(connection.getPeerCertificate).to.have.been.called() + expect(tlsAuth.extractWebId).to.have.been.calledWith(certificate) + expect(tlsAuth.loadUser).to.have.been.calledWith(webId) + + expect(validUser.webId).to.equal(webId) + }) + }) + + describe('renegotiateTls()', () => { + it('should reject if an error occurs while renegotiating', () => { + const connection = { + renegotiate: sinon.stub().yields(new Error('Error renegotiating')) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.renegotiateTls()).to.be.rejectedWith(/Error renegotiating/) + }) + + it('should resolve if no error occurs', () => { + const connection = { + renegotiate: sinon.stub().yields(null) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.renegotiateTls()).to.be.fulfilled() + }) + }) + + describe('getCertificate()', () => { + it('should throw on a non-existent certificate', () => { + const connection = { + getPeerCertificate: sinon.stub().returns(null) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) + }) + + it('should throw on an empty certificate', () => { + const connection = { + getPeerCertificate: sinon.stub().returns({}) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) + }) + + it('should return a certificate if no error occurs', () => { + const certificate = { uri: 'https://alice.example.com/#me' } + const connection = { + getPeerCertificate: sinon.stub().returns(certificate) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.getCertificate()).to.equal(certificate) + }) + }) + + describe('extractWebId()', () => { + it('should reject if an error occurs verifying certificate', () => { + const tlsAuth = new TlsAuthenticator({}) + + tlsAuth.verifyWebId = sinon.stub().yields(new Error('Error processing certificate')) + + expect(tlsAuth.extractWebId()).to.be.rejectedWith(/Error processing certificate/) + }) + + it('should resolve with a verified web id', () => { + const tlsAuth = new TlsAuthenticator({}) + + const webId = 'https://alice.example.com/#me' + tlsAuth.verifyWebId = sinon.stub().yields(null, webId) + + const certificate = { uri: webId } + + expect(tlsAuth.extractWebId(certificate)).to.become(webId) + }) + }) + + describe('loadUser()', () => { + it('should return a user instance if the webid is local', () => { + const tlsAuth = new TlsAuthenticator({ accountManager }) + + const webId = 'https://alice.example.com/#me' + + const user = tlsAuth.loadUser(webId) + + expect(user.username).to.equal('alice') + expect(user.webId).to.equal(webId) + }) + + it('should return a user instance if external user and this server is authorized provider', () => { + const tlsAuth = new TlsAuthenticator({ accountManager }) + + const externalWebId = 'https://alice.someothersite.com#me' + + tlsAuth.discoverProviderFor = sinon.stub().resolves('https://example.com') + + const user = tlsAuth.loadUser(externalWebId) + + expect(user.username).to.equal(externalWebId) + expect(user.webId).to.equal(externalWebId) + }) + }) + + describe('verifyWebId()', () => { + it('should yield an error if no cert is given', done => { + const tlsAuth = new TlsAuthenticator({}) + + tlsAuth.verifyWebId(null, (error) => { + expect(error.message).to.equal('No certificate given') + + done() + }) + }) + }) }) diff --git a/test/unit/token-service-test.js b/test/unit/token-service-test.js deleted file mode 100644 index 2751a2abb..000000000 --- a/test/unit/token-service-test.js +++ /dev/null @@ -1,88 +0,0 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const dirtyChai = require('dirty-chai') -chai.use(dirtyChai) -chai.should() - -const TokenService = require('../../lib/services/token-service') - -describe('TokenService', () => { - describe('constructor()', () => { - it('should init with an empty tokens store', () => { - let service = new TokenService() - - expect(service.tokens).to.exist() - }) - }) - - describe('generate()', () => { - it('should generate a new token and return a token key', () => { - let service = new TokenService() - - let token = service.generate('test') - let value = service.tokens.test[token] - - expect(token).to.exist() - expect(value).to.have.property('exp') - }) - }) - - describe('verify()', () => { - it('should return false for expired tokens', () => { - let service = new TokenService() - - let token = service.generate('foo') - - service.tokens.foo[token].exp = new Date(Date.now() - 1000) - - expect(service.verify('foo', token)).to.be.false() - }) - - it('should return false for non-existent tokens', () => { - let service = new TokenService() - - service.generate('foo') // to have generated the domain - let token = 'invalid token 123' - - expect(service.verify('foo', token)).to.be.false() - }) - - it('should return the token value if token not expired', () => { - let service = new TokenService() - - let token = service.generate('foo') - - expect(service.verify('foo', token)).to.be.ok() - }) - - it('should throw error if invalid domain', () => { - let service = new TokenService() - - let token = service.generate('foo') - - expect(() => service.verify('bar', token)).to.throw() - }) - }) - - describe('remove()', () => { - it('should remove a generated token from the service', () => { - let service = new TokenService() - - let token = service.generate('bar') - - service.remove('bar', token) - - expect(service.tokens.bar[token]).to.not.exist() - }) - - it('should throw an error if invalid domain', () => { - let service = new TokenService() - - let token = service.generate('foo') - - expect(() => service.remove('bar', token)).to.throw() - }) - }) -}) diff --git a/test/unit/token-service-test.mjs b/test/unit/token-service-test.mjs new file mode 100644 index 000000000..6be7452f4 --- /dev/null +++ b/test/unit/token-service-test.mjs @@ -0,0 +1,82 @@ +import { describe, it } from 'mocha' +import chai from 'chai' +import dirtyChai from 'dirty-chai' +import TokenService from '../../lib/services/token-service.mjs' + +const { expect } = chai +chai.use(dirtyChai) +chai.should() + +describe('TokenService', () => { + describe('constructor()', () => { + it('should init with an empty tokens store', () => { + const service = new TokenService() + + expect(service.tokens).to.exist() + }) + }) + + describe('generate()', () => { + it('should generate a new token and return a token key', () => { + const service = new TokenService() + + const token = service.generate('test') + const value = service.tokens.test[token] + + expect(token).to.exist() + expect(value).to.have.property('exp') + }) + }) + + describe('verify()', () => { + it('should return false for expired tokens', () => { + const service = new TokenService() + + const token = service.generate('foo') + + service.tokens.foo[token].exp = new Date(Date.now() - 1000) + + expect(service.verify('foo', token)).to.be.false() + }) + + it('should return the token value for valid tokens', () => { + const service = new TokenService() + + const token = service.generate('bar') + const value = service.verify('bar', token) + + expect(value).to.exist() + expect(value).to.have.property('exp') + expect(value.exp).to.be.greaterThan(new Date()) + }) + + it('should throw error for invalid token domain', () => { + const service = new TokenService() + + const token = service.generate('valid') + + expect(() => service.verify('invalid', token)).to.throw('Invalid domain for tokens: invalid') + }) + + it('should return false for non-existent tokens', () => { + const service = new TokenService() + + // First create the domain + service.generate('foo') + + expect(service.verify('foo', 'nonexistent')).to.be.false() + }) + }) + + describe('remove()', () => { + it('should remove specific tokens', () => { + const service = new TokenService() + + const token = service.generate('test') + + service.remove('test', token) + + expect(service.tokens.test).to.not.have.property(token) + }) + }) +}) diff --git a/test/unit/user-account-test.js b/test/unit/user-account-test.mjs similarity index 66% rename from test/unit/user-account-test.js rename to test/unit/user-account-test.mjs index 420a346a7..2c182a5fd 100644 --- a/test/unit/user-account-test.js +++ b/test/unit/user-account-test.mjs @@ -1,39 +1,37 @@ -'use strict' - -const chai = require('chai') -const expect = chai.expect -const UserAccount = require('../../lib/models/user-account') - -describe('UserAccount', () => { - describe('from()', () => { - it('initializes the object with passed in options', () => { - let options = { - username: 'alice', - webId: 'https://alice.com/#me', - name: 'Alice', - email: 'alice@alice.com' - } - - let account = UserAccount.from(options) - expect(account.username).to.equal(options.username) - expect(account.webId).to.equal(options.webId) - expect(account.name).to.equal(options.name) - expect(account.email).to.equal(options.email) - }) - }) - - describe('id getter', () => { - it('should return null if webId is null', () => { - let account = new UserAccount() - - expect(account.id).to.be.null - }) - - it('should return the WebID uri minus the protocol and slashes', () => { - let webId = 'https://alice.example.com/profile/card#me' - let account = new UserAccount({ webId }) - - expect(account.id).to.equal('alice.example.com/profile/card#me') - }) - }) -}) +import { describe, it } from 'mocha' +import { expect } from 'chai' +import UserAccount from '../../lib/models/user-account.mjs' + +describe('UserAccount', () => { + describe('from()', () => { + it('initializes the object with passed in options', () => { + const options = { + username: 'alice', + webId: 'https://alice.com/#me', + name: 'Alice', + email: 'alice@alice.com' + } + + const account = UserAccount.from(options) + expect(account.username).to.equal(options.username) + expect(account.webId).to.equal(options.webId) + expect(account.name).to.equal(options.name) + expect(account.email).to.equal(options.email) + }) + }) + + describe('id getter', () => { + it('should return null if webId is null', () => { + const account = new UserAccount() + + expect(account.id).to.be.null + }) + + it('should return the WebID uri minus the protocol and slashes', () => { + const webId = 'https://alice.example.com/profile/card#me' + const account = new UserAccount({ webId }) + + expect(account.id).to.equal('alice.example.com/profile/card#me') + }) + }) +}) diff --git a/test/unit/user-accounts-api-test.js b/test/unit/user-accounts-api-test.js deleted file mode 100644 index 32c909ed1..000000000 --- a/test/unit/user-accounts-api-test.js +++ /dev/null @@ -1,60 +0,0 @@ -'use strict' - -const path = require('path') -const chai = require('chai') -const expect = chai.expect -// const sinon = require('sinon') -// const sinonChai = require('sinon-chai') -// chai.use(sinonChai) -chai.should() -const HttpMocks = require('node-mocks-http') - -const LDP = require('../../lib/ldp') -const SolidHost = require('../../lib/models/solid-host') -const AccountManager = require('../../lib/models/account-manager') -const testAccountsDir = path.join(__dirname, '..', 'resources', 'accounts') -var ResourceMapper = require('../../lib/resource-mapper') - -const api = require('../../lib/api/accounts/user-accounts') - -var host - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) -}) - -describe('api/accounts/user-accounts', () => { - describe('newCertificate()', () => { - describe('in multi user mode', () => { - let multiuser = true - let resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - let store = new LDP({ multiuser, resourceMapper }) - - it('should throw a 400 error if spkac param is missing', done => { - let options = { host, store, multiuser, authMethod: 'oidc' } - let accountManager = AccountManager.from(options) - - let req = { - body: { - webid: 'https://alice.example.com/#me' - }, - session: { userId: 'https://alice.example.com/#me' }, - get: () => { return 'https://example.com' } - } - let res = HttpMocks.createResponse() - - let newCertificate = api.newCertificate(accountManager) - - newCertificate(req, res, (err) => { - expect(err.status).to.equal(400) - expect(err.message).to.equal('Missing spkac parameter') - done() - }) - }) - }) - }) -}) diff --git a/test/unit/user-accounts-api-test.mjs b/test/unit/user-accounts-api-test.mjs new file mode 100644 index 000000000..069451351 --- /dev/null +++ b/test/unit/user-accounts-api-test.mjs @@ -0,0 +1,59 @@ +import chai from 'chai' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import HttpMocks from 'node-mocks-http' +import LDP from '../../lib/ldp.mjs' +import SolidHost from '../../lib/models/solid-host.mjs' +import AccountManager from '../../lib/models/account-manager.mjs' +import ResourceMapper from '../../lib/resource-mapper.mjs' + +import * as api from '../../lib/api/accounts/user-accounts.mjs' + +const { expect } = chai +chai.should() + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const testAccountsDir = join(__dirname, '..', '..', 'test', 'resources', 'accounts') + +let host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +describe('api/accounts/user-accounts', () => { + describe('newCertificate()', () => { + describe('in multi user mode', () => { + const multiuser = true + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ multiuser, resourceMapper }) + + it('should throw a 400 error if spkac param is missing', done => { + const options = { host, store, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const req = { + body: { + webid: 'https://alice.example.com/#me' + }, + session: { userId: 'https://alice.example.com/#me' }, + get: () => { return 'https://example.com' } + } + const res = HttpMocks.createResponse() + + const newCertificate = api.newCertificate(accountManager) + + newCertificate(req, res, (err) => { + expect(err.status).to.equal(400) + expect(err.message).to.equal('Missing spkac parameter') + done() + }) + }) + }) + }) +}) diff --git a/test/unit/user-utils-test.js b/test/unit/user-utils-test.mjs similarity index 89% rename from test/unit/user-utils-test.js rename to test/unit/user-utils-test.mjs index 67dea178d..06cb2381e 100644 --- a/test/unit/user-utils-test.js +++ b/test/unit/user-utils-test.mjs @@ -1,63 +1,64 @@ -const chai = require('chai') -const expect = chai.expect -const userUtils = require('../../lib/common/user-utils') -const $rdf = require('rdflib') - -describe('user-utils', () => { - describe('getName', () => { - let ldp - const webId = 'http://test#me' - const name = 'NAME' - - beforeEach(() => { - const store = $rdf.graph() - store.add($rdf.sym(webId), $rdf.sym('http://www.w3.org/2006/vcard/ns#fn'), $rdf.lit(name)) - ldp = { fetchGraph: () => Promise.resolve(store) } - }) - - it('should return name from graph', async () => { - const returnedName = await userUtils.getName(webId, ldp.fetchGraph) - expect(returnedName).to.equal(name) - }) - }) - - describe('getWebId', () => { - let fetchGraph - const webId = 'https://test.localhost:8443/profile/card#me' - const suffixMeta = '.meta' - - beforeEach(() => { - fetchGraph = () => Promise.resolve(`<${webId}> .`) - }) - - it('should return webId from meta file', async () => { - const returnedWebId = await userUtils.getWebId('foo', 'https://bar/', suffixMeta, fetchGraph) - expect(returnedWebId).to.equal(webId) - }) - }) - - describe('isValidUsername', () => { - it('should accect valid usernames', () => { - const usernames = [ - 'foo', - 'bar' - ] - const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) - expect(validUsernames.length).to.equal(usernames.length) - }) - - it('should not accect invalid usernames', () => { - const usernames = [ - '-', - '-a', - 'a-', - '9-', - 'alice--bob', - 'alice bob', - 'alice.bob' - ] - const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) - expect(validUsernames.length).to.equal(0) - }) - }) +import * as userUtils from '../../lib/common/user-utils.mjs' +import $rdf from 'rdflib' +import chai from 'chai' + +const { expect } = chai + +describe('user-utils', () => { + describe('getName', () => { + let ldp + const webId = 'http://test#me' + const name = 'NAME' + + beforeEach(() => { + const store = $rdf.graph() + store.add($rdf.sym(webId), $rdf.sym('http://www.w3.org/2006/vcard/ns#fn'), $rdf.lit(name)) + ldp = { fetchGraph: () => Promise.resolve(store) } + }) + + it('should return name from graph', async () => { + const returnedName = await userUtils.getName(webId, ldp.fetchGraph) + expect(returnedName).to.equal(name) + }) + }) + + describe('getWebId', () => { + let fetchGraph + const webId = 'https://test.localhost:8443/profile/card#me' + const suffixMeta = '.meta' + + beforeEach(() => { + fetchGraph = () => Promise.resolve(`<${webId}> .`) + }) + + it('should return webId from meta file', async () => { + const returnedWebId = await userUtils.getWebId('foo', 'https://bar/', suffixMeta, fetchGraph) + expect(returnedWebId).to.equal(webId) + }) + }) + + describe('isValidUsername', () => { + it('should accect valid usernames', () => { + const usernames = [ + 'foo', + 'bar' + ] + const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) + expect(validUsernames.length).to.equal(usernames.length) + }) + + it('should not accect invalid usernames', () => { + const usernames = [ + '-', + '-a', + 'a-', + '9-', + 'alice--bob', + 'alice bob', + 'alice.bob' + ] + const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) + expect(validUsernames.length).to.equal(0) + }) + }) }) diff --git a/test/unit/utils-test.js b/test/unit/utils-test.mjs similarity index 50% rename from test/unit/utils-test.js rename to test/unit/utils-test.mjs index 21129296a..4877187ae 100644 --- a/test/unit/utils-test.js +++ b/test/unit/utils-test.mjs @@ -1,106 +1,112 @@ -var assert = require('chai').assert - -var utils = require('../../lib/utils') - -describe('Utility functions', function () { - describe('pathBasename', function () { - it('should return bar as relative path for /foo/bar', function () { - assert.equal(utils.pathBasename('/foo/bar'), 'bar') - }) - it('should return empty as relative path for /foo/', function () { - assert.equal(utils.pathBasename('/foo/'), '') - }) - it('should return empty as relative path for /', function () { - assert.equal(utils.pathBasename('/'), '') - }) - it('should return empty as relative path for empty path', function () { - assert.equal(utils.pathBasename(''), '') - }) - it('should return empty as relative path for undefined path', function () { - assert.equal(utils.pathBasename(undefined), '') - }) - }) - - describe('stripLineEndings()', () => { - it('should pass through falsy string arguments', () => { - assert.equal(utils.stripLineEndings(''), '') - assert.equal(utils.stripLineEndings(null), null) - assert.equal(utils.stripLineEndings(undefined), undefined) - }) - - it('should remove line-endings characters', () => { - let str = '123\n456' - assert.equal(utils.stripLineEndings(str), '123456') - - str = `123 -456` - assert.equal(utils.stripLineEndings(str), '123456') - }) - }) - - describe('debrack()', () => { - it('should return null if no string is passed', () => { - assert.equal(utils.debrack(), null) - }) - - it('should return the string if no brackets are present', () => { - assert.equal(utils.debrack('test string'), 'test string') - }) - - it('should return the string if less than 2 chars long', () => { - assert.equal(utils.debrack(''), '') - assert.equal(utils.debrack('<'), '<') - }) - - it('should remove brackets if wrapping the string', () => { - assert.equal(utils.debrack(''), 'test string') - }) - }) - - describe('fullUrlForReq()', () => { - it('should extract a fully-qualified url from an Express request', () => { - let req = { - protocol: 'https:', - get: (host) => 'example.com', - baseUrl: '/', - path: '/resource1', - query: { sort: 'desc' } - } - - assert.equal(utils.fullUrlForReq(req), 'https://example.com/resource1?sort=desc') - }) - }) - - describe('getContentType()', () => { - describe('for Express headers', () => { - it('should default to text/plain', () => { - assert.equal(utils.getContentType({}), 'text/plain') - }) - - it('should get a basic content type', () => { - assert.equal(utils.getContentType({'content-type': 'text/html'}), 'text/html') - }) - - it('should get a content type without its charset', () => { - assert.equal(utils.getContentType({'content-type': 'text/html; charset=us-ascii'}), 'text/html') - }) - }) - - describe('for Fetch API headers', () => { - it('should default to text/plain', () => { - // eslint-disable-next-line no-undef - assert.equal(utils.getContentType(new Headers({})), 'text/plain') - }) - - it('should get a basic content type', () => { - // eslint-disable-next-line no-undef - assert.equal(utils.getContentType(new Headers({'content-type': 'text/html'})), 'text/html') - }) - - it('should get a content type without its charset', () => { - // eslint-disable-next-line no-undef - assert.equal(utils.getContentType(new Headers({'content-type': 'text/html; charset=us-ascii'})), 'text/html') - }) - }) - }) -}) +import { describe, it } from 'mocha' +import { assert } from 'chai' + +import * as utils from '../../lib/utils.mjs' + +const { + pathBasename, + stripLineEndings, + debrack, + fullUrlForReq, + getContentType +} = utils + +describe('Utility functions', function () { + describe('pathBasename', function () { + it('should return bar as relative path for /foo/bar', function () { + assert.equal(pathBasename('/foo/bar'), 'bar') + }) + it('should return empty as relative path for /foo/', function () { + assert.equal(pathBasename('/foo/'), '') + }) + it('should return empty as relative path for /', function () { + assert.equal(pathBasename('/'), '') + }) + it('should return empty as relative path for empty path', function () { + assert.equal(pathBasename(''), '') + }) + it('should return empty as relative path for undefined path', function () { + assert.equal(pathBasename(undefined), '') + }) + }) + + describe('stripLineEndings()', () => { + it('should pass through falsy string arguments', () => { + assert.equal(stripLineEndings(''), '') + assert.equal(stripLineEndings(null), null) + assert.equal(stripLineEndings(undefined), undefined) + }) + + it('should remove line-endings characters', () => { + let str = '123\n456' + assert.equal(stripLineEndings(str), '123456') + + str = `123 +456` + assert.equal(stripLineEndings(str), '123456') + }) + }) + + describe('debrack()', () => { + it('should return null if no string is passed', () => { + assert.equal(debrack(), null) + }) + + it('should return the string if no brackets are present', () => { + assert.equal(debrack('test string'), 'test string') + }) + + it('should return the string if less than 2 chars long', () => { + assert.equal(debrack(''), '') + assert.equal(debrack('<'), '<') + }) + + it('should remove brackets if wrapping the string', () => { + assert.equal(debrack(''), 'test string') + }) + }) + + describe('fullUrlForReq()', () => { + it('should extract a fully-qualified url from an Express request', () => { + const req = { + protocol: 'https:', + get: (host) => 'example.com', + baseUrl: '/', + path: '/resource1', + query: { sort: 'desc' } + } + + assert.equal(fullUrlForReq(req), 'https://example.com/resource1?sort=desc') + }) + }) + + describe('getContentType()', () => { + describe('for Express headers', () => { + it('should not default', () => { + assert.equal(getContentType({}), '') + }) + + it('should get a basic content type', () => { + assert.equal(getContentType({ 'content-type': 'text/html' }), 'text/html') + }) + + it('should get a content type without its charset', () => { + assert.equal(getContentType({ 'content-type': 'text/html; charset=us-ascii' }), 'text/html') + }) + }) + + describe('for Fetch API headers', () => { + it('should not default', () => { + assert.equal(getContentType(new Headers({})), '') + }) + + it('should get a basic content type', () => { + assert.equal(getContentType(new Headers({ 'content-type': 'text/html' })), 'text/html') + }) + + it('should get a content type without its charset', () => { + assert.equal(getContentType(new Headers({ 'content-type': 'text/html; charset=us-ascii' })), 'text/html') + }) + }) + }) +}) diff --git a/test/utils.js b/test/utils.js deleted file mode 100644 index 59a31956a..000000000 --- a/test/utils.js +++ /dev/null @@ -1,96 +0,0 @@ -const fs = require('fs-extra') -const rimraf = require('rimraf') -const path = require('path') -const OIDCProvider = require('@solid/oidc-op') -const dns = require('dns') -const ldnode = require('../index') -const supertest = require('supertest') - -const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] - -exports.rm = function (file) { - return rimraf.sync(path.join(__dirname, '/resources/' + file)) -} - -exports.cleanDir = function (dirPath) { - fs.removeSync(path.join(dirPath, '.well-known/.acl')) - fs.removeSync(path.join(dirPath, '.acl')) - fs.removeSync(path.join(dirPath, 'favicon.ico')) - fs.removeSync(path.join(dirPath, 'favicon.ico.acl')) - fs.removeSync(path.join(dirPath, 'index.html')) - fs.removeSync(path.join(dirPath, 'index.html.acl')) - fs.removeSync(path.join(dirPath, 'robots.txt')) - fs.removeSync(path.join(dirPath, 'robots.txt.acl')) -} - -exports.write = function (text, file) { - return fs.writeFileSync(path.join(__dirname, '/resources/' + file), text) -} - -exports.cp = function (src, dest) { - return fs.copySync( - path.join(__dirname, '/resources/' + src), - path.join(__dirname, '/resources/' + dest)) -} - -exports.read = function (file) { - return fs.readFileSync(path.join(__dirname, '/resources/' + file), { - 'encoding': 'utf8' - }) -} - -// Backs up the given file -exports.backup = function (src) { - exports.cp(src, src + '.bak') -} - -// Restores a backup of the given file -exports.restore = function (src) { - exports.cp(src + '.bak', src) - exports.rm(src + '.bak') -} - -// Verifies that all HOSTS entries are present -exports.checkDnsSettings = function () { - return Promise.all(TEST_HOSTS.map(hostname => { - return new Promise((resolve, reject) => { - dns.lookup(hostname, (error, ip) => { - if (error || ip !== '127.0.0.1') { - reject(error) - } else { - resolve(true) - } - }) - }) - })) - .catch(() => { - throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) - }) -} - -/** - * @param configPath {string} - * - * @returns {Promise} - */ -exports.loadProvider = function loadProvider (configPath) { - return Promise.resolve() - .then(() => { - const config = require(configPath) - - const provider = new OIDCProvider(config) - - return provider.initializeKeyChain(config.keys) - }) -} - -exports.createServer = createServer -function createServer (options) { - return ldnode.createServer(options) -} - -exports.setupSupertestServer = setupSuperServer -function setupSuperServer (options) { - const ldpServer = createServer(options) - return supertest(ldpServer) -} diff --git a/test/utils.mjs b/test/utils.mjs new file mode 100644 index 000000000..e09e78a48 --- /dev/null +++ b/test/utils.mjs @@ -0,0 +1,204 @@ +// import fs from 'fs-extra' // see fs-extra/esm and fs-extra doc + +import fs from 'fs' +import path from 'path' +import dns from 'dns' +import https from 'https' +import { createRequire } from 'module' +import rimraf from 'rimraf' +import fse from 'fs-extra' +import Provider from '@solid/oidc-op' +import supertest from 'supertest' +import ldnode from '../index.mjs' +import { fileURLToPath } from 'url' + +const require = createRequire(import.meta.url) +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const OIDCProvider = Provider + +const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] + +// Configurable test root directory +// For custom route +let TEST_ROOT = path.join(__dirname, '/resources/') +// For default root (process.cwd()): +// let TEST_ROOT = path.join(process.cwd(), 'test-esm/resources') + +export function setTestRoot (rootPath) { + TEST_ROOT = rootPath +} +export function getTestRoot () { + return TEST_ROOT +} + +export function rm (file) { + return rimraf.sync(path.join(TEST_ROOT, file)) +} + +export function cleanDir (dirPath) { + fse.removeSync(path.join(dirPath, '.well-known/.acl')) + fse.removeSync(path.join(dirPath, '.acl')) + fse.removeSync(path.join(dirPath, 'favicon.ico')) + fse.removeSync(path.join(dirPath, 'favicon.ico.acl')) + fse.removeSync(path.join(dirPath, 'index.html')) + fse.removeSync(path.join(dirPath, 'index.html.acl')) + fse.removeSync(path.join(dirPath, 'robots.txt')) + fse.removeSync(path.join(dirPath, 'robots.txt.acl')) +} + +export function write (text, file) { + // console.log('Writing to', path.join(TEST_ROOT, file)) + // fs.mkdirSync(path.dirname(path.join(TEST_ROOT, file), { recursive: true })) + return fs.writeFileSync(path.join(TEST_ROOT, file), text) +} + +export function cp (src, dest) { + return fse.copySync( + path.join(TEST_ROOT, src), + path.join(TEST_ROOT, dest)) +} + +export function read (file) { + // console.log('Reading from', path.join(TEST_ROOT, file)) + return fs.readFileSync(path.join(TEST_ROOT, file), { + encoding: 'utf8' + }) +} + +// Backs up the given file +export function backup (src) { + cp(src, src + '.bak') +} + +// Restores a backup of the given file +export function restore (src) { + cp(src + '.bak', src) + rm(src + '.bak') +} + +// Verifies that all HOSTS entries are present +export function checkDnsSettings () { + return Promise.all(TEST_HOSTS.map(hostname => { + return new Promise((resolve, reject) => { + dns.lookup(hostname, (error, ip) => { + if (error || (ip !== '127.0.0.1' && ip !== '::1')) { + reject(error) + } else { + resolve(true) + } + }) + }) + })) + .catch(() => { + throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) + }) +} + +/** + * @param configPath {string} + * + * @returns {Promise} + */ +export function loadProvider (configPath) { + return Promise.resolve() + .then(() => { + const config = require(configPath) + + const provider = new OIDCProvider(config) + + return provider.initializeKeyChain(config.keys) + }) +} + +export function createServer (options) { + // console.log('Creating server with root:', options.root || process.cwd()) + return ldnode.createServer(options) +} + +export function setupSupertestServer (options) { + const ldpServer = createServer(options) + return supertest(ldpServer) +} + +// Lightweight adapter to replace `request` with `node-fetch` in tests +// Supports signatures: +// - request(options, cb) +// - request(url, options, cb) +// And methods: get, post, put, patch, head, delete, del +function buildAgentFn (options = {}) { + const aOpts = options.agentOptions || {} + if (!aOpts || (!aOpts.cert && !aOpts.key)) { + return undefined + } + const httpsAgent = new https.Agent({ + cert: aOpts.cert, + key: aOpts.key, + // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here + rejectUnauthorized: false + }) + return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined +} + +async function doFetch (method, url, options = {}, cb) { + try { + const headers = options.headers || {} + const body = options.body + const agent = buildAgentFn(options) + const res = await fetch(url, { method, headers, body, agent }) + // Build a response object similar to `request`'s + const headersObj = {} + res.headers.forEach((value, key) => { headersObj[key] = value }) + const response = { + statusCode: res.status, + statusMessage: res.statusText, + headers: headersObj + } + const hasBody = method !== 'HEAD' + const text = hasBody ? await res.text() : '' + cb(null, response, text) + } catch (err) { + cb(err) + } +} + +function requestAdapter (arg1, arg2, arg3) { + let url, options, cb + if (typeof arg1 === 'string') { + url = arg1 + options = arg2 || {} + cb = arg3 + } else { + options = arg1 || {} + url = options.url + cb = arg2 + } + const method = (options && options.method) || 'GET' + return doFetch(method, url, options, cb) +} + +;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { + const name = m.toLowerCase() + requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) +}) +// Alias +requestAdapter.del = requestAdapter.delete + +export const httpRequest = requestAdapter + +// Provide default export for compatibility +export default { + rm, + cleanDir, + write, + cp, + read, + backup, + restore, + checkDnsSettings, + loadProvider, + createServer, + setupSupertestServer, + httpRequest +} diff --git a/test/utils/index.mjs b/test/utils/index.mjs new file mode 100644 index 000000000..b64156439 --- /dev/null +++ b/test/utils/index.mjs @@ -0,0 +1,166 @@ +import fs from 'fs-extra' +import rimraf from 'rimraf' +import path from 'path' +import { fileURLToPath } from 'url' +import OIDCProvider from '@solid/oidc-op' +import dns from 'dns' +import ldnode from '../../index.mjs' +import supertest from 'supertest' +import https from 'https' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] + +export function rm (file) { + return rimraf.sync(path.normalize(path.join(__dirname, '../resources/' + file))) +} + +export function cleanDir (dirPath) { + fs.removeSync(path.normalize(path.join(dirPath, '.well-known/.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, '.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico'))) + fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'index.html'))) + fs.removeSync(path.normalize(path.join(dirPath, 'index.html.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt'))) + fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt.acl'))) +} + +export function write (text, file) { + return fs.writeFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), text) +} + +export function cp (src, dest) { + return fs.copySync( + path.normalize(path.join(__dirname, '../resources/' + src)), + path.normalize(path.join(__dirname, '../resources/' + dest))) +} + +export function read (file) { + return fs.readFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), { + encoding: 'utf8' + }) +} + +// Backs up the given file +export function backup (src) { + cp(src, src + '.bak') +} + +// Restores a backup of the given file +export function restore (src) { + cp(src + '.bak', src) + rm(src + '.bak') +} + +// Verifies that all HOSTS entries are present +export function checkDnsSettings () { + return Promise.all(TEST_HOSTS.map(hostname => { + return new Promise((resolve, reject) => { + dns.lookup(hostname, (error, ip) => { + if (error || (ip !== '127.0.0.1' && ip !== '::1')) { + reject(error) + } else { + resolve(true) + } + }) + }) + })) + .catch(() => { + throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) + }) +} + +/** + * @param configPath {string} + * + * @returns {Promise} + */ +export function loadProvider (configPath) { + return Promise.resolve() + .then(async () => { + const { default: config } = await import(configPath) + + const provider = new OIDCProvider(config) + + return provider.initializeKeyChain(config.keys) + }) +} + +export { createServer } +function createServer (options) { + return ldnode.createServer(options) +} + +export { setupSupertestServer } +function setupSupertestServer (options) { + const ldpServer = createServer(options) + return supertest(ldpServer) +} + +// Lightweight adapter to replace `request` with `node-fetch` in tests +// Supports signatures: +// - request(options, cb) +// - request(url, options, cb) +// And methods: get, post, put, patch, head, delete, del +function buildAgentFn (options = {}) { + const aOpts = options.agentOptions || {} + if (!aOpts || (!aOpts.cert && !aOpts.key)) { + return undefined + } + const httpsAgent = new https.Agent({ + cert: aOpts.cert, + key: aOpts.key, + // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here + rejectUnauthorized: false + }) + return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined +} + +async function doFetch (method, url, options = {}, cb) { + try { + const headers = options.headers || {} + const body = options.body + const agent = buildAgentFn(options) + const res = await fetch(url, { method, headers, body, agent }) + // Build a response object similar to `request`'s + const headersObj = {} + res.headers.forEach((value, key) => { headersObj[key] = value }) + const response = { + statusCode: res.status, + statusMessage: res.statusText, + headers: headersObj + } + const hasBody = method !== 'HEAD' + const text = hasBody ? await res.text() : '' + cb(null, response, text) + } catch (err) { + cb(err) + } +} + +function requestAdapter (arg1, arg2, arg3) { + let url, options, cb + if (typeof arg1 === 'string') { + url = arg1 + options = arg2 || {} + cb = arg3 + } else { + options = arg1 || {} + url = options.url + cb = arg2 + } + const method = (options && options.method) || 'GET' + return doFetch(method, url, options, cb) +} + +;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { + const name = m.toLowerCase() + requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) +}) +// Alias +requestAdapter.del = requestAdapter.delete + +export const httpRequest = requestAdapter diff --git a/test/validate-turtle.js b/test/validate-turtle.mjs similarity index 69% rename from test/validate-turtle.js rename to test/validate-turtle.mjs index f5247e22f..00c7aaf8d 100644 --- a/test/validate-turtle.js +++ b/test/validate-turtle.mjs @@ -1,10 +1,14 @@ +import { fileURLToPath } from 'url' +import fs from 'node:fs' +import Handlebars from 'handlebars' +import path from 'node:path' +import validateModule from 'turtle-validator/lib/validator.js' -const fs = require('fs') -const Handlebars = require('handlebars') -const path = require('path') -const validate = require('turtle-validator/lib/validator') +const validate = validateModule.default || validateModule +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) -const regex = new RegExp('\\.(acl|ttl)$', 'i') +const regex = /\\.(acl|ttl)$/i const substitutions = { webId: 'http://example.com/#me', email: 'test@example.com',