diff --git a/.eslintrc.js b/.eslintrc.js index 6fc5b99a6..1f5ce5cbd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,6 +52,8 @@ module.exports = { "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", + // We're okay with assertion errors when we ask for them + "@typescript-eslint/no-non-null-assertion": "off", "quotes": "off", // We use a `logger` intermediary module diff --git a/.github/workflows/jsdoc.yml b/.github/workflows/jsdoc.yml new file mode 100644 index 000000000..e36db60dd --- /dev/null +++ b/.github/workflows/jsdoc.yml @@ -0,0 +1,53 @@ +name: Release Process +on: + release: + types: [ published ] +concurrency: ${{ github.workflow }}-${{ github.ref }} +jobs: + jsdoc: + name: Publish Documentation + runs-on: ubuntu-latest + steps: + - name: 🧮 Checkout code + uses: actions/checkout@v3 + + - name: 🔧 Yarn cache + uses: actions/setup-node@v3 + with: + cache: "yarn" + + - name: 🔨 Install dependencies + run: "yarn install --pure-lockfile" + + - name: 📖 Generate JSDoc + run: "yarn gendoc" + + - name: 📋 Copy to temp + run: | + ls -lah + tag="${{ github.ref_name }}" + version="${tag#v}" + echo "VERSION=$version" >> $GITHUB_ENV + cp -a "./.jsdoc/matrix-js-sdk/$version" $RUNNER_TEMP + + - name: 🧮 Checkout gh-pages + uses: actions/checkout@v3 + with: + ref: gh-pages + + - name: 🔪 Prepare + run: | + cp -a "$RUNNER_TEMP/$VERSION" . + + # Add the new directory to the index if it isn't there already + if ! grep -q "Version $VERSION" index.html; then + perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print + "
  • Version ${rel}
  • \n"' "$VERSION" index.html + fi + + - name: 🚀 Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + keep_files: true + publish_dir: . diff --git a/.github/workflows/notify-downstream.yaml b/.github/workflows/notify-downstream.yaml index dc0d91af5..2de50fd8b 100644 --- a/.github/workflows/notify-downstream.yaml +++ b/.github/workflows/notify-downstream.yaml @@ -2,13 +2,26 @@ name: Notify Downstream Projects on: push: branches: [ develop ] +concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: - notify-matrix-react-sdk: + notify-downstream: + # Only respect triggers from our develop branch, ignore that of forks + if: github.repository == 'matrix-org/matrix-js-sdk' + continue-on-error: true + strategy: + fail-fast: false + matrix: + include: + - repo: vector-im/element-web + event: element-web-notify + - repo: matrix-org/matrix-react-sdk + event: upstream-sdk-notify + runs-on: ubuntu-latest steps: - name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it uses: peter-evans/repository-dispatch@v1 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} - repository: vector-im/element-web - event-type: upstream-sdk-notify + repository: ${{ matrix.repo }} + event-type: ${{ matrix.event }} diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 2680a8f56..6cb936888 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -1,24 +1,92 @@ name: Pull Request on: pull_request_target: - types: [ opened, edited, labeled, unlabeled ] + types: [ opened, edited, labeled, unlabeled, synchronize ] + workflow_call: + inputs: + labels: + type: string + default: "T-Defect,T-Deprecation,T-Enhancement,T-Task" + required: false + description: "No longer used, uses allchange logic now, will be removed at a later date" + secrets: + ELEMENT_BOT_TOKEN: + required: true +concurrency: ${{ github.workflow }}-${{ github.event.pull_request.head.ref }} jobs: changelog: name: Preview Changelog + if: github.event.action != 'synchronize' runs-on: ubuntu-latest steps: - uses: matrix-org/allchange@main with: ghToken: ${{ secrets.GITHUB_TOKEN }} + requireLabel: true - enforce-label: - name: Enforce Labels + prevent-blocked: + name: Prevent Blocked runs-on: ubuntu-latest permissions: pull-requests: read steps: - - uses: yogevbd/enforce-label-action@2.1.0 + - name: Add notice + uses: actions/github-script@v5 + if: contains(github.event.pull_request.labels.*.name, 'X-Blocked') with: - REQUIRED_LABELS_ANY: "T-Defect,T-Deprecation,T-Enhancement,T-Task" - BANNED_LABELS: "X-Blocked" - BANNED_LABELS_DESCRIPTION: "Preventing merge whilst PR is marked blocked!" + script: | + core.setFailed("Preventing merge whilst PR is marked blocked!"); + + community-prs: + name: Label Community PRs + runs-on: ubuntu-latest + if: github.event.action == 'opened' + steps: + - name: Check membership + uses: tspascoal/get-user-teams-membership@v1 + id: teams + with: + username: ${{ github.event.pull_request.user.login }} + organization: matrix-org + team: Core Team + GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + + - name: Add label + if: ${{ steps.teams.outputs.isTeamMember == 'false' }} + uses: actions/github-script@v5 + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['Z-Community-PR'] + }); + + close-if-fork-develop: + name: Forbid develop branch fork contributions + runs-on: ubuntu-latest + if: > + github.event.action == 'opened' && + github.event.pull_request.head.ref == 'develop' && + github.event.pull_request.head.repo.full_name != github.repository + steps: + - name: Close pull request + uses: actions/github-script@v5 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" + + " branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity." + + " See https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md", + }); + + github.rest.pulls.update({ + pull_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed' + }); diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..e9d965f02 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,24 @@ +# Must only be called from a workflow_run in the context of the upstream repo +name: SonarCloud +on: + workflow_call: + secrets: + SONAR_TOKEN: + required: true +jobs: + sonarqube: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + steps: + - name: "🩻 SonarCloud Scan" + uses: matrix-org/sonarcloud-workflow-action@v2.2 + with: + repository: ${{ github.event.workflow_run.head_repository.full_name }} + is_pr: ${{ github.event.workflow_run.event == 'pull_request' }} + version_cmd: 'cat package.json | jq -r .version' + branch: ${{ github.event.workflow_run.head_branch }} + revision: ${{ github.event.workflow_run.head_sha }} + token: ${{ secrets.SONAR_TOKEN }} + coverage_run_id: ${{ github.event.workflow_run.id }} + coverage_workflow_name: tests.yml + coverage_extract_path: coverage diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 61238ed1b..a5360c64f 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -4,62 +4,12 @@ on: workflows: [ "Tests" ] types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true jobs: sonarqube: - name: SonarQube - runs-on: ubuntu-latest - if: github.event.workflow_run.conclusion == 'success' - steps: - - uses: actions/checkout@v3 - with: - repository: ${{ github.event.workflow_run.head_repository.full_name }} - ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - # fetch develop so that Sonar can identify new issues in PR builds - - name: Fetch develop - if: "github.event.workflow_run.head_branch != 'develop'" - run: git rev-parse HEAD && git fetch origin develop:develop && git status && git rev-parse HEAD - - # There's a 'download artifact' action, but it hasn't been updated for the workflow_run action - # (https://github.com/actions/download-artifact/issues/60) so instead we get this mess: - - name: Download Coverage Report - uses: actions/github-script@v3.1.0 - with: - script: | - const artifacts = await github.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }}, - }); - const matchArtifact = artifacts.data.artifacts.filter((artifact) => { - return artifact.name == "coverage" - })[0]; - const download = await github.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: matchArtifact.id, - archive_format: 'zip', - }); - const fs = require('fs'); - fs.writeFileSync('${{github.workspace}}/coverage.zip', Buffer.from(download.data)); - - - name: Extract Coverage Report - run: unzip -d coverage coverage.zip && rm coverage.zip - - - name: Read version - id: version - uses: WyriHaximus/github-action-get-previous-tag@v1 - - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - with: - args: > - -Dsonar.projectVersion=${{ steps.version.outputs.tag }} - -Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }} - -Dsonar.pullrequest.key=${{ github.event.workflow_run.pull_requests[0].number }} - -Dsonar.pullrequest.branch=${{ github.event.workflow_run.pull_requests[0].head.ref }} - -Dsonar.pullrequest.base=${{ github.event.workflow_run.pull_requests[0].base.ref }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + name: 🩻 SonarQube + uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 6260587d7..0f0ae69d3 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -3,6 +3,9 @@ on: pull_request: { } push: branches: [ develop, master ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: ts_lint: name: "Typescript Syntax Check" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 15fc3c45f..e8e7f113a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,10 @@ name: Tests on: pull_request: { } push: - branches: [ develop, main, master ] + branches: [ develop, master ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: jest: name: Jest @@ -23,7 +26,7 @@ jobs: run: "yarn build" - name: Run tests with coverage - run: "yarn coverage --ci" + run: "yarn coverage --ci --reporters github-actions" - name: Upload Artifact uses: actions/upload-artifact@v2 diff --git a/.istanbul.yml b/.istanbul.yml deleted file mode 100644 index 17e759f6e..000000000 --- a/.istanbul.yml +++ /dev/null @@ -1,2 +0,0 @@ -instrumentation: - compact: false diff --git a/CHANGELOG.md b/CHANGELOG.md index a23992df2..bf48bc37e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +Changes in [18.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v18.1.0) (2022-06-07) +================================================================================================== + +## ✨ Features + * Convert `getLocalAliases` to a stable API call ([\#2402](https://github.com/matrix-org/matrix-js-sdk/pull/2402)). + +## 🐛 Bug Fixes + * Fix request, crypto, and bs58 imports ([\#2414](https://github.com/matrix-org/matrix-js-sdk/pull/2414)). Fixes #2415. + * Update relations after every decryption attempt ([\#2387](https://github.com/matrix-org/matrix-js-sdk/pull/2387)). Fixes vector-im/element-web#22258. Contributed by @weeman1337. + * Fix degraded mode for the IDBStore and test it ([\#2400](https://github.com/matrix-org/matrix-js-sdk/pull/2400)). Fixes matrix-org/element-web-rageshakes#13170. + * Don't cancel SAS verifications if `ready` is received after `start` ([\#2250](https://github.com/matrix-org/matrix-js-sdk/pull/2250)). + * Prevent overlapping sync accumulator persists ([\#2392](https://github.com/matrix-org/matrix-js-sdk/pull/2392)). Fixes vector-im/element-web#21541. + * Fix behaviour of isRelation with relation m.replace for state events ([\#2389](https://github.com/matrix-org/matrix-js-sdk/pull/2389)). Fixes vector-im/element-web#22280. + * Fixes #2384 ([\#2385](https://github.com/matrix-org/matrix-js-sdk/pull/2385)). Fixes undefined/matrix-js-sdk#2384. Contributed by @schmop. + * Ensure rooms are recalculated on re-invites ([\#2374](https://github.com/matrix-org/matrix-js-sdk/pull/2374)). Fixes vector-im/element-web#22106. + +Changes in [18.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v18.0.0) (2022-05-24) +================================================================================================== + +## 🚨 BREAKING CHANGES (to experimental methods) + * Implement changes to MSC2285 (private read receipts) ([\#2221](https://github.com/matrix-org/matrix-js-sdk/pull/2221)). + +## ✨ Features + * Add support for HTML renderings of room topics ([\#2272](https://github.com/matrix-org/matrix-js-sdk/pull/2272)). + * Add stopClient parameter to MatrixClient::logout ([\#2367](https://github.com/matrix-org/matrix-js-sdk/pull/2367)). + * registration: add function to re-request email token ([\#2357](https://github.com/matrix-org/matrix-js-sdk/pull/2357)). + * Remove hacky custom status feature ([\#2350](https://github.com/matrix-org/matrix-js-sdk/pull/2350)). + +## 🐛 Bug Fixes + * Remove default push rule override for MSC1930 ([\#2376](https://github.com/matrix-org/matrix-js-sdk/pull/2376)). Fixes vector-im/element-web#15439. + * Tweak thread creation & event adding to fix bugs around relations ([\#2369](https://github.com/matrix-org/matrix-js-sdk/pull/2369)). Fixes vector-im/element-web#22162 and vector-im/element-web#22180. + * Prune both clear & wire content on redaction ([\#2346](https://github.com/matrix-org/matrix-js-sdk/pull/2346)). Fixes vector-im/element-web#21929. + * MSC3786: Add a default push rule to ignore `m.room.server_acl` events ([\#2333](https://github.com/matrix-org/matrix-js-sdk/pull/2333)). Fixes vector-im/element-web#20788. + Changes in [17.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v17.2.0) (2022-05-10) ================================================================================================== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44c93dd42..7df3845e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,18 @@ Things that should go into your PR description: * A changelog entry in the `Notes` section (see below) * References to any bugs fixed by the change (in GitHub's `Fixes` notation) * Describe the why and what is changing in the PR description so it's easy for - onlookers and reviewers to onboard and context switch. + onlookers and reviewers to onboard and context switch. This information is + also helpful when we come back to look at this in 6 months and ask "why did + we do it like that?" we have a chance of finding out. + * Why didn't it work before? Why does it work now? What use cases does it + unlock? + * If you find yourself adding information on how the code works or why you + chose to do it the way you did, make sure this information is instead + written as comments in the code itself. + * Sometimes a PR can change considerably as it is developed. In this case, + the description should be updated to reflect the most recent state of + the PR. (It can be helpful to retain the old content under a suitable + heading, for additional context.) * Include both **before** and **after** screenshots to easily compare and discuss what's changing. * Include a step-by-step testing strategy so that a reviewer can check out the @@ -31,11 +42,6 @@ Things that should go into your PR description: * Add comments to the diff for the reviewer that might help them to understand why the change is necessary or how they might better understand and review it. -Things that should *not* go into your PR description: - * Any information on how the code works or why you chose to do it the way - you did. If this isn't obvious from your code, you haven't written enough - comments. - We rely on information in pull request to populate the information that goes into the changelogs our users see, both for the JS SDK itself and also for some projects based on it. This is picked up from both labels on the pull request and @@ -129,6 +135,16 @@ When writing unit tests, please aim for a high level of test coverage for new code - 80% or greater. If you cannot achieve that, please document why it's not possible in your PR. +Some sections of code are not sensible to add coverage for, such as those +which explicitly inhibit noisy logging for tests. Which can be hidden using +an istanbul magic comment as [documented here][1]. See example: +```javascript +/* istanbul ignore if */ +if (process.env.NODE_ENV !== "test") { + logger.error("Log line that is noisy enough in tests to want to skip"); +} +``` + Tests validate that your change works as intended and also document concisely what is being changed. Ideally, your new tests fail prior to your change, and succeed once it has been applied. You may @@ -244,14 +260,25 @@ on Git 2.17+ you can mass signoff using rebase: git rebase --signoff origin/develop ``` +Review expectations +=================== + +See https://github.com/vector-im/element-meta/wiki/Review-process + + Merge Strategy ============== The preferred method for merging pull requests is squash merging to keep the commit history trim, but it is up to the discretion of the team member merging -the change. When stacking pull requests, you may wish to do the following: +the change. We do not support rebase merges due to `allchange` being unable to +handle them. When merging make sure to leave the default commit title, or +at least leave the PR number at the end in brackets like by default. +When stacking pull requests, you may wish to do the following: 1. Branch from develop to your branch (branch1), push commits onto it and open a pull request 2. Branch from your base branch (branch1) to your work branch (branch2), push commits and open a pull request configuring the base to be branch1, saying in the description that it is based on your other PR. 3. Merge the first PR using a merge commit otherwise your stacked PR will need a rebase. Github will automatically adjust the base branch of your other PR to be develop. + +[1]: https://github.com/gotwarlost/istanbul/blob/master/ignoring-code-for-coverage.md diff --git a/package.json b/package.json index b4e6763d4..0cd570a14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "17.2.0", + "version": "18.1.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=12.9.0" @@ -81,28 +81,28 @@ "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz", "@types/bs58": "^4.0.1", "@types/content-type": "^1.1.5", - "@types/jest": "^26.0.20", + "@types/jest": "^27.0.0", "@types/node": "12", "@types/request": "^2.48.5", "@typescript-eslint/eslint-plugin": "^5.6.0", "@typescript-eslint/parser": "^5.6.0", "allchange": "^1.0.6", - "babel-jest": "^26.6.3", + "babel-jest": "^28.0.0", "babelify": "^10.0.0", "better-docs": "^2.4.0-beta.9", "browserify": "^17.0.0", "docdash": "^1.2.0", - "eslint": "8.9.0", + "eslint": "8.16.0", "eslint-config-google": "^0.14.0", "eslint-plugin-import": "^2.25.4", - "eslint-plugin-matrix-org": "^0.4.0", + "eslint-plugin-matrix-org": "^0.5.0", "exorcist": "^1.0.1", "fake-indexeddb": "^3.1.2", - "jest": "^26.6.3", + "jest": "^28.0.0", "jest-localstorage-mock": "^2.4.6", "jest-sonar-reporter": "^2.0.0", "jsdoc": "^3.6.6", - "matrix-mock-request": "^1.2.3", + "matrix-mock-request": "^2.0.1", "rimraf": "^3.0.2", "terser": "^5.5.1", "tsify": "^5.0.2", diff --git a/release.sh b/release.sh index 4550d7b7a..c92ae36d5 100755 --- a/release.sh +++ b/release.sh @@ -29,7 +29,7 @@ fi npm --version > /dev/null || (echo "npm is required: please install it"; kill $$) yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$) -USAGE="$0 [-xz] [-c changelog_file] vX.Y.Z" +USAGE="$0 [-x] [-c changelog_file] vX.Y.Z" help() { cat </ && print - "
  • Version ${rel}
  • \n"' \ - $release index.html - git add "$release" - git commit --no-verify -m "Add jsdoc for $release" index.html "$release" - git push origin gh-pages -fi - # if it is a pre-release, leave it on the release branch for now. if [ $prerelease -eq 1 ]; then git checkout "$rel_branch" diff --git a/renovate.json b/renovate.json new file mode 100644 index 000000000..a8015ea2b --- /dev/null +++ b/renovate.json @@ -0,0 +1,16 @@ +{ + "extends": [ + "config:base", + ":dependencyDashboardApproval" + ], + "labels": ["T-Task", "Dependencies"], + "lockFileMaintenance": { "enabled": true }, + "groupName": "all", + "packageRules": [{ + "matchFiles": ["package.json"], + "rangeStrategy": "update-lockfile" + }], + "platformAutomerge": true, + "automerge": true, + "automergeType": "pr" +} diff --git a/spec/TestClient.js b/spec/TestClient.js deleted file mode 100644 index 7b2474c15..000000000 --- a/spec/TestClient.js +++ /dev/null @@ -1,238 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018-2019 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// load olm before the sdk if possible -import './olm-loader'; - -import MockHttpBackend from 'matrix-mock-request'; - -import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; -import { logger } from '../src/logger'; -import { WebStorageSessionStore } from "../src/store/session/webstorage"; -import { syncPromise } from "./test-utils/test-utils"; -import { createClient } from "../src/matrix"; -import { MockStorageApi } from "./MockStorageApi"; - -/** - * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient - * - * @constructor - * @param {string} userId - * @param {string} deviceId - * @param {string} accessToken - * - * @param {WebStorage=} sessionStoreBackend a web storage object to use for the - * session store. If undefined, we will create a MockStorageApi. - * @param {object} options additional options to pass to the client - */ -export function TestClient( - userId, deviceId, accessToken, sessionStoreBackend, options, -) { - this.userId = userId; - this.deviceId = deviceId; - - if (sessionStoreBackend === undefined) { - sessionStoreBackend = new MockStorageApi(); - } - const sessionStore = new WebStorageSessionStore(sessionStoreBackend); - - this.httpBackend = new MockHttpBackend(); - - options = Object.assign({ - baseUrl: "http://" + userId + ".test.server", - userId: userId, - accessToken: accessToken, - deviceId: deviceId, - sessionStore: sessionStore, - request: this.httpBackend.requestFn, - }, options); - if (!options.cryptoStore) { - // expose this so the tests can get to it - this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); - options.cryptoStore = this.cryptoStore; - } - this.client = createClient(options); - - this.deviceKeys = null; - this.oneTimeKeys = {}; - this.callEventHandler = { - calls: new Map(), - }; -} - -TestClient.prototype.toString = function() { - return 'TestClient[' + this.userId + ']'; -}; - -/** - * start the client, and wait for it to initialise. - * - * @return {Promise} - */ -TestClient.prototype.start = function() { - logger.log(this + ': starting'); - this.httpBackend.when("GET", "/versions").respond(200, {}); - this.httpBackend.when("GET", "/pushrules").respond(200, {}); - this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); - this.expectDeviceKeyUpload(); - - // we let the client do a very basic initial sync, which it needs before - // it will upload one-time keys. - this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 }); - - this.client.startClient({ - // set this so that we can get hold of failed events - pendingEventOrdering: 'detached', - }); - - return Promise.all([ - this.httpBackend.flushAllExpected(), - syncPromise(this.client), - ]).then(() => { - logger.log(this + ': started'); - }); -}; - -/** - * stop the client - * @return {Promise} Resolves once the mock http backend has finished all pending flushes - */ -TestClient.prototype.stop = function() { - this.client.stopClient(); - return this.httpBackend.stop(); -}; - -/** - * Set up expectations that the client will upload device keys. - */ -TestClient.prototype.expectDeviceKeyUpload = function() { - const self = this; - this.httpBackend.when("POST", "/keys/upload").respond(200, function(path, content) { - expect(content.one_time_keys).toBe(undefined); - expect(content.device_keys).toBeTruthy(); - - logger.log(self + ': received device keys'); - // we expect this to happen before any one-time keys are uploaded. - expect(Object.keys(self.oneTimeKeys).length).toEqual(0); - - self.deviceKeys = content.device_keys; - return { one_time_key_counts: { signed_curve25519: 0 } }; - }); -}; - -/** - * If one-time keys have already been uploaded, return them. Otherwise, - * set up an expectation that the keys will be uploaded, and wait for - * that to happen. - * - * @returns {Promise} for the one-time keys - */ -TestClient.prototype.awaitOneTimeKeyUpload = function() { - if (Object.keys(this.oneTimeKeys).length != 0) { - // already got one-time keys - return Promise.resolve(this.oneTimeKeys); - } - - this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { - expect(content.device_keys).toBe(undefined); - expect(content.one_time_keys).toBe(undefined); - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys).length, - } }; - }); - - this.httpBackend.when("POST", "/keys/upload") - .respond(200, (path, content) => { - expect(content.device_keys).toBe(undefined); - expect(content.one_time_keys).toBeTruthy(); - expect(content.one_time_keys).not.toEqual({}); - logger.log('%s: received %i one-time keys', this, - Object.keys(content.one_time_keys).length); - this.oneTimeKeys = content.one_time_keys; - return { one_time_key_counts: { - signed_curve25519: Object.keys(this.oneTimeKeys).length, - } }; - }); - - // this can take ages - return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { - expect(flushed).toEqual(2); - return this.oneTimeKeys; - }); -}; - -/** - * Set up expectations that the client will query device keys. - * - * We check that the query contains each of the users in `response`. - * - * @param {Object} response response to the query. - */ -TestClient.prototype.expectKeyQuery = function(response) { - this.httpBackend.when('POST', '/keys/query').respond( - 200, (path, content) => { - Object.keys(response.device_keys).forEach((userId) => { - expect(content.device_keys[userId]).toEqual( - [], - "Expected key query for " + userId + ", got " + - Object.keys(content.device_keys), - ); - }); - return response; - }); -}; - -/** - * get the uploaded curve25519 device key - * - * @return {string} base64 device key - */ -TestClient.prototype.getDeviceKey = function() { - const keyId = 'curve25519:' + this.deviceId; - return this.deviceKeys.keys[keyId]; -}; - -/** - * get the uploaded ed25519 device key - * - * @return {string} base64 device key - */ -TestClient.prototype.getSigningKey = function() { - const keyId = 'ed25519:' + this.deviceId; - return this.deviceKeys.keys[keyId]; -}; - -/** - * flush a single /sync request, and wait for the syncing event - * - * @returns {Promise} promise which completes once the sync has been flushed - */ -TestClient.prototype.flushSync = function() { - logger.log(`${this}: flushSync`); - return Promise.all([ - this.httpBackend.flush('/sync', 1), - syncPromise(this.client), - ]).then(() => { - logger.log(`${this}: flushSync completed`); - }); -}; - -TestClient.prototype.isFallbackICEServerAllowed = function() { - return true; -}; diff --git a/spec/TestClient.ts b/spec/TestClient.ts new file mode 100644 index 000000000..244a9d6e3 --- /dev/null +++ b/spec/TestClient.ts @@ -0,0 +1,239 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// load olm before the sdk if possible +import './olm-loader'; + +import MockHttpBackend from 'matrix-mock-request'; + +import { LocalStorageCryptoStore } from '../src/crypto/store/localStorage-crypto-store'; +import { logger } from '../src/logger'; +import { syncPromise } from "./test-utils/test-utils"; +import { createClient } from "../src/matrix"; +import { ICreateClientOpts, IDownloadKeyResult, MatrixClient, PendingEventOrdering } from "../src/client"; +import { MockStorageApi } from "./MockStorageApi"; +import { encodeUri } from "../src/utils"; +import { IDeviceKeys, IOneTimeKey } from "../src/crypto/dehydration"; +import { IKeyBackupSession } from "../src/crypto/keybackup"; +import { IHttpOpts } from "../src/http-api"; +import { IKeysUploadResponse, IUploadKeysRequest } from '../src/client'; + +/** + * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient + */ +export class TestClient { + public readonly httpBackend: MockHttpBackend; + public readonly client: MatrixClient; + private deviceKeys: IDeviceKeys; + private oneTimeKeys: Record; + + constructor( + public readonly userId?: string, + public readonly deviceId?: string, + accessToken?: string, + sessionStoreBackend?: Storage, + options?: Partial, + ) { + if (sessionStoreBackend === undefined) { + sessionStoreBackend = new MockStorageApi(); + } + + this.httpBackend = new MockHttpBackend(); + + const fullOptions: ICreateClientOpts = { + baseUrl: "http://" + userId + ".test.server", + userId: userId, + accessToken: accessToken, + deviceId: deviceId, + request: this.httpBackend.requestFn as IHttpOpts["request"], + ...options, + }; + if (!fullOptions.cryptoStore) { + // expose this so the tests can get to it + fullOptions.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); + } + this.client = createClient(fullOptions); + + this.deviceKeys = null; + this.oneTimeKeys = {}; + } + + public toString(): string { + return 'TestClient[' + this.userId + ']'; + } + + /** + * start the client, and wait for it to initialise. + */ + public start(): Promise { + logger.log(this + ': starting'); + this.httpBackend.when("GET", "/versions").respond(200, {}); + this.httpBackend.when("GET", "/pushrules").respond(200, {}); + this.httpBackend.when("POST", "/filter").respond(200, { filter_id: "fid" }); + this.expectDeviceKeyUpload(); + + // we let the client do a very basic initial sync, which it needs before + // it will upload one-time keys. + this.httpBackend.when("GET", "/sync").respond(200, { next_batch: 1 }); + + this.client.startClient({ + // set this so that we can get hold of failed events + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + return Promise.all([ + this.httpBackend.flushAllExpected(), + syncPromise(this.client), + ]).then(() => { + logger.log(this + ': started'); + }); + } + + /** + * stop the client + * @return {Promise} Resolves once the mock http backend has finished all pending flushes + */ + public async stop(): Promise { + this.client.stopClient(); + await this.httpBackend.stop(); + } + + /** + * Set up expectations that the client will upload device keys. + */ + public expectDeviceKeyUpload() { + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (_path, content) => { + expect(content.one_time_keys).toBe(undefined); + expect(content.device_keys).toBeTruthy(); + + logger.log(this + ': received device keys'); + // we expect this to happen before any one-time keys are uploaded. + expect(Object.keys(this.oneTimeKeys).length).toEqual(0); + + this.deviceKeys = content.device_keys; + return { one_time_key_counts: { signed_curve25519: 0 } }; + }); + } + + /** + * If one-time keys have already been uploaded, return them. Otherwise, + * set up an expectation that the keys will be uploaded, and wait for + * that to happen. + * + * @returns {Promise} for the one-time keys + */ + public awaitOneTimeKeyUpload(): Promise> { + if (Object.keys(this.oneTimeKeys).length != 0) { + // already got one-time keys + return Promise.resolve(this.oneTimeKeys); + } + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (_path, content: IUploadKeysRequest) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBe(undefined); + return { one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + } }; + }); + + this.httpBackend.when("POST", "/keys/upload") + .respond(200, (_path, content: IUploadKeysRequest) => { + expect(content.device_keys).toBe(undefined); + expect(content.one_time_keys).toBeTruthy(); + expect(content.one_time_keys).not.toEqual({}); + logger.log('%s: received %i one-time keys', this, + Object.keys(content.one_time_keys).length); + this.oneTimeKeys = content.one_time_keys; + return { one_time_key_counts: { + signed_curve25519: Object.keys(this.oneTimeKeys).length, + } }; + }); + + // this can take ages + return this.httpBackend.flush('/keys/upload', 2, 1000).then((flushed) => { + expect(flushed).toEqual(2); + return this.oneTimeKeys; + }); + } + + /** + * Set up expectations that the client will query device keys. + * + * We check that the query contains each of the users in `response`. + * + * @param {Object} response response to the query. + */ + public expectKeyQuery(response: IDownloadKeyResult) { + this.httpBackend.when('POST', '/keys/query').respond( + 200, (_path, content) => { + Object.keys(response.device_keys).forEach((userId) => { + expect(content.device_keys[userId]).toEqual([]); + }); + return response; + }); + } + + /** + * Set up expectations that the client will query key backups for a particular session + */ + public expectKeyBackupQuery(roomId: string, sessionId: string, status: number, response: IKeyBackupSession) { + this.httpBackend.when('GET', encodeUri("/room_keys/keys/$roomId/$sessionId", { + $roomId: roomId, + $sessionId: sessionId, + })).respond(status, response); + } + + /** + * get the uploaded curve25519 device key + * + * @return {string} base64 device key + */ + public getDeviceKey(): string { + const keyId = 'curve25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; + } + + /** + * get the uploaded ed25519 device key + * + * @return {string} base64 device key + */ + public getSigningKey(): string { + const keyId = 'ed25519:' + this.deviceId; + return this.deviceKeys.keys[keyId]; + } + + /** + * flush a single /sync request, and wait for the syncing event + */ + public flushSync(): Promise { + logger.log(`${this}: flushSync`); + return Promise.all([ + this.httpBackend.flush('/sync', 1), + syncPromise(this.client), + ]).then(() => { + logger.log(`${this}: flushSync completed`); + }); + } + + public isFallbackICEServerAllowed(): boolean { + return true; + } +} diff --git a/spec/integ/devicelist-integ-spec.js b/spec/integ/devicelist-integ.spec.js similarity index 58% rename from spec/integ/devicelist-integ-spec.js rename to spec/integ/devicelist-integ.spec.js index 12f7a5a43..8be2ca59a 100644 --- a/spec/integ/devicelist-integ-spec.js +++ b/spec/integ/devicelist-integ.spec.js @@ -136,138 +136,137 @@ describe("DeviceList management:", function() { }); }); - it("We should not get confused by out-of-order device query responses", - () => { - // https://github.com/vector-im/element-web/issues/3126 - aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } }); - return aliceTestClient.start().then(() => { - aliceTestClient.httpBackend.when('GET', '/sync').respond( - 200, getSyncResponse(['@bob:xyz', '@chris:abc'])); - return aliceTestClient.flushSync(); - }).then(() => { - // to make sure the initial device queries are flushed out, we - // attempt to send a message. + it.skip("We should not get confused by out-of-order device query responses", () => { + // https://github.com/vector-im/element-web/issues/3126 + aliceTestClient.expectKeyQuery({ device_keys: { '@alice:localhost': {} } }); + return aliceTestClient.start().then(() => { + aliceTestClient.httpBackend.when('GET', '/sync').respond( + 200, getSyncResponse(['@bob:xyz', '@chris:abc'])); + return aliceTestClient.flushSync(); + }).then(() => { + // to make sure the initial device queries are flushed out, we + // attempt to send a message. - aliceTestClient.httpBackend.when('POST', '/keys/query').respond( - 200, { - device_keys: { - '@bob:xyz': {}, - '@chris:abc': {}, - }, - }, - ); + aliceTestClient.httpBackend.when('POST', '/keys/query').respond( + 200, { + device_keys: { + '@bob:xyz': {}, + '@chris:abc': {}, + }, + }, + ); - aliceTestClient.httpBackend.when('PUT', '/send/').respond( - 200, { event_id: '$event1' }); + aliceTestClient.httpBackend.when('PUT', '/send/').respond( + 200, { event_id: '$event1' }); - return Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), - aliceTestClient.httpBackend.flush('/keys/query', 1).then( - () => aliceTestClient.httpBackend.flush('/send/', 1), - ), - aliceTestClient.client.crypto.deviceList.saveIfDirty(), - ]); - }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { - expect(data.syncToken).toEqual(1); - }); + return Promise.all([ + aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + aliceTestClient.httpBackend.flush('/keys/query', 1).then( + () => aliceTestClient.httpBackend.flush('/send/', 1), + ), + aliceTestClient.client.crypto.deviceList.saveIfDirty(), + ]); + }).then(() => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { + expect(data.syncToken).toEqual(1); + }); - // invalidate bob's and chris's device lists in separate syncs - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { - next_batch: '2', - device_lists: { - changed: ['@bob:xyz'], - }, - }); - aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { - next_batch: '3', - device_lists: { - changed: ['@chris:abc'], - }, - }); - // flush both syncs - return aliceTestClient.flushSync().then(() => { - return aliceTestClient.flushSync(); - }); - }).then(() => { - // check that we don't yet have a request for chris's devices. - aliceTestClient.httpBackend.when('POST', '/keys/query', { - device_keys: { - '@chris:abc': {}, - }, - token: '3', - }).respond(200, { - device_keys: { '@chris:abc': {} }, - }); - return aliceTestClient.httpBackend.flush('/keys/query', 1); - }).then((flushed) => { - expect(flushed).toEqual(0); - return aliceTestClient.client.crypto.deviceList.saveIfDirty(); - }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; - if (bobStat != 1 && bobStat != 2) { - throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + - bobStat); - } - const chrisStat = data.trackingStatus['@chris:abc']; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error( - 'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat, - ); - } - }); + // invalidate bob's and chris's device lists in separate syncs + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { + next_batch: '2', + device_lists: { + changed: ['@bob:xyz'], + }, + }); + aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { + next_batch: '3', + device_lists: { + changed: ['@chris:abc'], + }, + }); + // flush both syncs + return aliceTestClient.flushSync().then(() => { + return aliceTestClient.flushSync(); + }); + }).then(() => { + // check that we don't yet have a request for chris's devices. + aliceTestClient.httpBackend.when('POST', '/keys/query', { + device_keys: { + '@chris:abc': {}, + }, + token: '3', + }).respond(200, { + device_keys: { '@chris:abc': {} }, + }); + return aliceTestClient.httpBackend.flush('/keys/query', 1); + }).then((flushed) => { + expect(flushed).toEqual(0); + return aliceTestClient.client.crypto.deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + if (bobStat != 1 && bobStat != 2) { + throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + + bobStat); + } + const chrisStat = data.trackingStatus['@chris:abc']; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error( + 'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat, + ); + } + }); - // now add an expectation for a query for bob's devices, and let - // it complete. - aliceTestClient.httpBackend.when('POST', '/keys/query', { - device_keys: { - '@bob:xyz': {}, - }, - token: '2', - }).respond(200, { - device_keys: { '@bob:xyz': {} }, - }); - return aliceTestClient.httpBackend.flush('/keys/query', 1); - }).then((flushed) => { - expect(flushed).toEqual(1); + // now add an expectation for a query for bob's devices, and let + // it complete. + aliceTestClient.httpBackend.when('POST', '/keys/query', { + device_keys: { + '@bob:xyz': {}, + }, + token: '2', + }).respond(200, { + device_keys: { '@bob:xyz': {} }, + }); + return aliceTestClient.httpBackend.flush('/keys/query', 1); + }).then((flushed) => { + expect(flushed).toEqual(1); - // wait for the client to stop processing the response - return aliceTestClient.client.downloadKeys(['@bob:xyz']); - }).then(() => { - return aliceTestClient.client.crypto.deviceList.saveIfDirty(); - }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; - expect(bobStat).toEqual(3); - const chrisStat = data.trackingStatus['@chris:abc']; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error( - 'Unexpected status for chris: wanted 1 or 2, got ' + bobStat, - ); - } - }); + // wait for the client to stop processing the response + return aliceTestClient.client.downloadKeys(['@bob:xyz']); + }).then(() => { + return aliceTestClient.client.crypto.deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + expect(bobStat).toEqual(3); + const chrisStat = data.trackingStatus['@chris:abc']; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error( + 'Unexpected status for chris: wanted 1 or 2, got ' + bobStat, + ); + } + }); - // now let the query for chris's devices complete. - return aliceTestClient.httpBackend.flush('/keys/query', 1); - }).then((flushed) => { - expect(flushed).toEqual(1); + // now let the query for chris's devices complete. + return aliceTestClient.httpBackend.flush('/keys/query', 1); + }).then((flushed) => { + expect(flushed).toEqual(1); - // wait for the client to stop processing the response - return aliceTestClient.client.downloadKeys(['@chris:abc']); - }).then(() => { - return aliceTestClient.client.crypto.deviceList.saveIfDirty(); - }).then(() => { - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { - const bobStat = data.trackingStatus['@bob:xyz']; - const chrisStat = data.trackingStatus['@bob:xyz']; + // wait for the client to stop processing the response + return aliceTestClient.client.downloadKeys(['@chris:abc']); + }).then(() => { + return aliceTestClient.client.crypto.deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + const chrisStat = data.trackingStatus['@bob:xyz']; - expect(bobStat).toEqual(3); - expect(chrisStat).toEqual(3); - expect(data.syncToken).toEqual(3); - }); - }); - }).timeout(3000); + expect(bobStat).toEqual(3); + expect(chrisStat).toEqual(3); + expect(data.syncToken).toEqual(3); + }); + }); + }); // https://github.com/vector-im/element-web/issues/4983 describe("Alice should know she has stale device lists", () => { @@ -288,7 +287,7 @@ describe("DeviceList management:", function() { await aliceTestClient.httpBackend.flush('/keys/query', 1); await aliceTestClient.client.crypto.deviceList.saveIfDirty(); - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toBeGreaterThan( @@ -324,7 +323,7 @@ describe("DeviceList management:", function() { await aliceTestClient.flushSync(); await aliceTestClient.client.crypto.deviceList.saveIfDirty(); - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toEqual( @@ -360,7 +359,7 @@ describe("DeviceList management:", function() { await aliceTestClient.flushSync(); await aliceTestClient.client.crypto.deviceList.saveIfDirty(); - aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliceTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toEqual( @@ -381,7 +380,7 @@ describe("DeviceList management:", function() { await anotherTestClient.flushSync(); await anotherTestClient.client.crypto.deviceList.saveIfDirty(); - anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + anotherTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; expect(bobStat).toEqual( diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index 954b62a76..a886ccab7 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -161,7 +161,7 @@ function aliDownloadsKeys() { return Promise.all([p1, p2]).then(() => { return aliTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { - aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + aliTestClient.client.cryptoStore.getEndToEndDeviceData(null, (data) => { const devices = data.devices[bobUserId]; expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); expect(devices[bobDeviceId].verified). diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 6e93a063a..c165a7057 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -1,5 +1,5 @@ import * as utils from "../test-utils/test-utils"; -import { EventTimeline } from "../../src/matrix"; +import { EventTimeline, Filter, MatrixEvent } from "../../src/matrix"; import { logger } from "../../src/logger"; import { TestClient } from "../TestClient"; import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread"; @@ -70,10 +70,23 @@ const EVENTS = [ }), ]; -const THREAD_ROOT = utils.mkMessage({ +const THREAD_ROOT = utils.mkEvent({ room: roomId, user: userId, - msg: "thread root", + type: "m.room.message", + content: { + "body": "thread root", + "msgtype": "m.text", + }, + unsigned: { + "m.relations": { + "io.element.thread": { + "latest_event": undefined, + "count": 1, + "current_user_participated": true, + }, + }, + }, }); const THREAD_REPLY = utils.mkEvent({ @@ -91,6 +104,8 @@ const THREAD_REPLY = utils.mkEvent({ }, }); +THREAD_ROOT.unsigned["m.relations"]["io.element.thread"].latest_event = THREAD_REPLY; + // start the client, and wait for it to initialise function startClient(httpBackend, client) { httpBackend.when("GET", "/versions").respond(200, {}); @@ -500,7 +515,8 @@ describe("MatrixClient event timelines", function() { Thread.setServerSideSupport(true); client.stopClient(); // we don't need the client to be syncing at this time const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const thread = room.createThread(THREAD_ROOT.event_id, undefined, [], false); + const timelineSet = thread.timelineSet; httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) .respond(200, function() { @@ -526,8 +542,7 @@ describe("MatrixClient event timelines", function() { return { original_event: THREAD_ROOT, chunk: [THREAD_REPLY], - next_batch: "next_batch_token0", - prev_batch: "prev_batch_token0", + // no next batch as this is the oldest end of the timeline }; }); @@ -536,8 +551,189 @@ describe("MatrixClient event timelines", function() { const timeline = await timelinePromise; - expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)); - expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)); + expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); + expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy(); + }); + + it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => { + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(true); + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const threadRoot = new MatrixEvent(THREAD_ROOT); + const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: THREAD_ROOT, + events_after: [], + end: "end_token0", + state: [], + }; + }); + + const [timeline] = await Promise.all([ + client.getEventTimeline(timelineSet, THREAD_ROOT.event_id), + httpBackend.flushAllExpected(), + ]); + + expect(timeline).not.toBe(thread.liveTimeline); + expect(timelineSet.getTimelines()).toContain(timeline); + expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy(); + }); + + it("should return undefined when event is not in the thread that the given timelineSet is representing", () => { + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(true); + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const threadRoot = new MatrixEvent(THREAD_ROOT); + const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false); + const timelineSet = thread.timelineSet; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + }; + }); + + return Promise.all([ + expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id)).resolves.toBeUndefined(), + httpBackend.flushAllExpected(), + ]); + }); + + it("should return undefined when event is within a thread but timelineSet is not", () => { + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(true); + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id)) + .respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: THREAD_REPLY, + events_after: [], + end: "end_token0", + state: [], + }; + }); + + return Promise.all([ + expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id)).resolves.toBeUndefined(), + httpBackend.flushAllExpected(), + ]); + }); + + it("should should add lazy loading filter when requested", async () => { + client.clientOpts.lazyLoadMembers = true; + client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id)); + req.respond(200, function() { + return { + start: "start_token0", + events_before: [], + event: EVENTS[0], + events_after: [], + end: "end_token0", + state: [], + }; + }); + req.check((request) => { + expect(request.opts.qs.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER)); + }); + + await Promise.all([ + client.getEventTimeline(timelineSet, EVENTS[0].event_id), + httpBackend.flushAllExpected(), + ]); + }); + }); + + describe("getLatestTimeline", function() { + it("should create a new timeline for new events", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + const latestMessageId = 'event1:bar'; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, function() { + return { + chunk: [{ + event_id: latestMessageId, + }], + }; + }); + + httpBackend.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`) + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + ROOM_NAME_EVENT, + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + return Promise.all([ + client.getLatestTimeline(timelineSet).then(function(tl) { + // Instead of this assertion logic, we could just add a spy + // for `getEventTimeline` and make sure it's called with the + // correct parameters. This doesn't feel too bad to make sure + // `getLatestTimeline` is doing the right thing though. + expect(tl.getEvents().length).toEqual(4); + for (let i = 0; i < 4; i++) { + expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl.getEvents()[i].sender.name).toEqual(userName); + } + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token"); + expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + it("should throw error when /messages does not return a message", () => { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, () => { + return { + chunk: [ + // No messages to return + ], + }; + }); + + return Promise.all([ + expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(), + httpBackend.flushAllExpected(), + ]); }); }); diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index 86e5fd185..5c9855b2e 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -1,3 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import * as utils from "../test-utils/test-utils"; import { CRYPTO_ENABLED } from "../../src/client"; import { MatrixEvent } from "../../src/models/event"; @@ -26,7 +42,7 @@ describe("MatrixClient", function() { }); describe("uploadContent", function() { - const buf = new Buffer('hello world'); + const buf = Buffer.from('hello world'); it("should upload the file", function() { httpBackend.when( "POST", "/_matrix/media/r0/upload", @@ -458,6 +474,10 @@ describe("MatrixClient", function() { return client.initCrypto(); }); + afterEach(() => { + client.stopClient(); + }); + it("should do an HTTP request and then store the keys", function() { const ed25519key = "7wG2lzAqbjcyEkOP7O4gU7ItYcn+chKzh5sT/5r2l78"; // ed25519key = client.getDeviceEd25519Key(); @@ -823,6 +843,171 @@ describe("MatrixClient", function() { ]); }); }); + + describe("getThirdpartyUser", () => { + it("should hit the expected API endpoint", async () => { + const response = [{ + userid: "@Bob", + protocol: "irc", + fields: {}, + }]; + + const prom = client.getThirdpartyUser("irc", {}); + httpBackend.when("GET", "/thirdparty/user/irc").respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("getThirdpartyLocation", () => { + it("should hit the expected API endpoint", async () => { + const response = [{ + alias: "#alias", + protocol: "irc", + fields: {}, + }]; + + const prom = client.getThirdpartyLocation("irc", {}); + httpBackend.when("GET", "/thirdparty/location/irc").respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("getPushers", () => { + it("should hit the expected API endpoint", async () => { + const response = { + pushers: [], + }; + + const prom = client.getPushers(); + httpBackend.when("GET", "/pushers").respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("getKeyChanges", () => { + it("should hit the expected API endpoint", async () => { + const response = { + changed: [], + left: [], + }; + + const prom = client.getKeyChanges("old", "new"); + httpBackend.when("GET", "/keys/changes").check((req) => { + expect(req.queryParams.from).toEqual("old"); + expect(req.queryParams.to).toEqual("new"); + }).respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("getDevices", () => { + it("should hit the expected API endpoint", async () => { + const response = { + devices: [], + }; + + const prom = client.getDevices(); + httpBackend.when("GET", "/devices").respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("getDevice", () => { + it("should hit the expected API endpoint", async () => { + const response = { + device_id: "DEADBEEF", + display_name: "NotAPhone", + last_seen_ip: "127.0.0.1", + last_seen_ts: 1, + }; + + const prom = client.getDevice("DEADBEEF"); + httpBackend.when("GET", "/devices/DEADBEEF").respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("getThreePids", () => { + it("should hit the expected API endpoint", async () => { + const response = { + threepids: [], + }; + + const prom = client.getThreePids(); + httpBackend.when("GET", "/account/3pid").respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("deleteAlias", () => { + it("should hit the expected API endpoint", async () => { + const response = {}; + const prom = client.deleteAlias("#foo:bar"); + httpBackend.when("DELETE", "/directory/room/" + encodeURIComponent("#foo:bar")).respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("deleteRoomTag", () => { + it("should hit the expected API endpoint", async () => { + const response = {}; + const prom = client.deleteRoomTag("!roomId:server", "u.tag"); + const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags/u.tag`; + httpBackend.when("DELETE", url).respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("getRoomTags", () => { + it("should hit the expected API endpoint", async () => { + const response = { + tags: { + "u.tag": { + order: 0.5, + }, + }, + }; + + const prom = client.getRoomTags("!roomId:server"); + const url = `/user/${encodeURIComponent(userId)}/rooms/${encodeURIComponent("!roomId:server")}/tags`; + httpBackend.when("GET", url).respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); + + describe("requestRegisterEmailToken", () => { + it("should hit the expected API endpoint", async () => { + const response = { + sid: "random_sid", + submit_url: "https://foobar.matrix/_matrix/matrix", + }; + + httpBackend.when("GET", "/_matrix/client/versions").respond(200, { + versions: ["r0.5.0"], + }); + + const prom = client.requestRegisterEmailToken("bob@email", "secret", 1); + httpBackend.when("POST", "/register/email/requestToken").check(req => { + expect(req.data).toStrictEqual({ + email: "bob@email", + client_secret: "secret", + send_attempt: 1, + }); + }).respond(200, response); + await httpBackend.flush(); + expect(await prom).toStrictEqual(response); + }); + }); }); function withThreadId(event, newThreadId) { diff --git a/spec/integ/matrix-client-opts.spec.js b/spec/integ/matrix-client-opts.spec.js index 81c4ba6ab..8e342b259 100644 --- a/spec/integ/matrix-client-opts.spec.js +++ b/spec/integ/matrix-client-opts.spec.js @@ -8,7 +8,6 @@ import { MatrixError } from "../../src/http-api"; describe("MatrixClient opts", function() { const baseUrl = "http://localhost.or.something"; - let client = null; let httpBackend = null; const userId = "@alice:localhost"; const userB = "@bob:localhost"; @@ -65,6 +64,7 @@ describe("MatrixClient opts", function() { }); describe("without opts.store", function() { + let client; beforeEach(function() { client = new MatrixClient({ request: httpBackend.requestFn, @@ -124,6 +124,7 @@ describe("MatrixClient opts", function() { }); describe("without opts.scheduler", function() { + let client; beforeEach(function() { client = new MatrixClient({ request: httpBackend.requestFn, @@ -135,6 +136,10 @@ describe("MatrixClient opts", function() { }); }); + afterEach(function() { + client.stopClient(); + }); + it("shouldn't retry sending events", function(done) { httpBackend.when("PUT", "/txn1").fail(500, new MatrixError({ errcode: "M_SOMETHING", diff --git a/spec/integ/matrix-client-retrying.spec.ts b/spec/integ/matrix-client-retrying.spec.ts index 6f74e4188..31354b89a 100644 --- a/spec/integ/matrix-client-retrying.spec.ts +++ b/spec/integ/matrix-client-retrying.spec.ts @@ -1,10 +1,26 @@ -import { EventStatus, RoomEvent } from "../../src/matrix"; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventStatus, RoomEvent, MatrixClient } from "../../src/matrix"; import { MatrixScheduler } from "../../src/scheduler"; import { Room } from "../../src/models/room"; import { TestClient } from "../TestClient"; describe("MatrixClient retrying", function() { - let client: TestClient = null; + let client: MatrixClient = null; let httpBackend: TestClient["httpBackend"] = null; let scheduler; const userId = "@alice:localhost"; diff --git a/spec/integ/matrix-client-room-timeline.spec.js b/spec/integ/matrix-client-room-timeline.spec.js index edb38175b..acf751a8c 100644 --- a/spec/integ/matrix-client-room-timeline.spec.js +++ b/spec/integ/matrix-client-room-timeline.spec.js @@ -1,5 +1,6 @@ import * as utils from "../test-utils/test-utils"; import { EventStatus } from "../../src/models/event"; +import { RoomEvent } from "../../src"; import { TestClient } from "../TestClient"; describe("MatrixClient room timelines", function() { @@ -579,7 +580,7 @@ describe("MatrixClient room timelines", function() { }); }); - it("should emit a 'Room.timelineReset' event", function() { + it("should emit a `RoomEvent.TimelineReset` event when the sync response is `limited`", function() { const eventData = [ utils.mkMessage({ user: userId, room: roomId }), ]; @@ -608,4 +609,271 @@ describe("MatrixClient room timelines", function() { }); }); }); + + describe('Refresh live timeline', () => { + const initialSyncEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + utils.mkMessage({ user: userId, room: roomId }), + utils.mkMessage({ user: userId, room: roomId }), + ]; + + const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` + + `${encodeURIComponent(initialSyncEventData[2].event_id)}`; + const contextResponse = { + start: "start_token", + events_before: [initialSyncEventData[1], initialSyncEventData[0]], + event: initialSyncEventData[2], + events_after: [], + state: [ + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + + let room; + beforeEach(async () => { + setNextSyncData(initialSyncEventData); + + // Create a room from the sync + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Get the room after the first sync so the room is created + room = client.getRoom(roomId); + expect(room).toBeTruthy(); + }); + + it('should clear and refresh messages in timeline', async () => { + // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` + // to construct a new timeline from. + httpBackend.when("GET", contextUrl) + .respond(200, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return contextResponse; + }); + + // Refresh the timeline. + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Make sure the message are visible + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + initialSyncEventData[0].event_id, + initialSyncEventData[1].event_id, + initialSyncEventData[2].event_id, + ]); + }); + + it('Perfectly merges timelines if a sync finishes while refreshing the timeline', async () => { + // `/context` request for `refreshLiveTimeline()` -> + // `getEventTimeline()` to construct a new timeline from. + // + // We only resolve this request after we detect that the timeline + // was reset(when it goes blank) and force a sync to happen in the + // middle of all of this refresh timeline logic. We want to make + // sure the sync pagination still works as expected after messing + // the refresh timline logic messes with the pagination tokens. + httpBackend.when("GET", contextUrl) + .respond(200, () => { + // Now finally return and make the `/context` request respond + return contextResponse; + }); + + // Wait for the timeline to reset(when it goes blank) which means + // it's in the middle of the refrsh logic right before the + // `getEventTimeline()` -> `/context`. Then simulate a racey `/sync` + // to happen in the middle of all of this refresh timeline logic. We + // want to make sure the sync pagination still works as expected + // after messing the refresh timline logic messes with the + // pagination tokens. + // + // We define this here so the event listener is in place before we + // call `room.refreshLiveTimeline()`. + const racingSyncEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => { + let eventFired = false; + // Throw a more descriptive error if this part of the test times out. + const failTimeout = setTimeout(() => { + if (eventFired) { + reject(new Error( + 'TestError: `RoomEvent.TimelineReset` fired but we timed out trying to make' + + 'a `/sync` happen in time.', + )); + } else { + reject(new Error( + 'TestError: Timed out while waiting for `RoomEvent.TimelineReset` to fire.', + )); + } + }, 4000 /* FIXME: Is there a way to reference the current timeout of this test in Jest? */); + + room.on(RoomEvent.TimelineReset, async () => { + try { + eventFired = true; + + // The timeline should be cleared at this point in the refresh + expect(room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length).toEqual(0); + + // Then make a `/sync` happen by sending a message and seeing that it + // shows up (simulate a /sync naturally racing with us). + setNextSyncData(racingSyncEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client, 1), + ]); + // Make sure the timeline has the racey sync data + const afterRaceySyncTimelineEvents = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents(); + const afterRaceySyncTimelineEventIds = afterRaceySyncTimelineEvents + .map((event) => event.getId()); + expect(afterRaceySyncTimelineEventIds).toEqual([ + racingSyncEventData[0].event_id, + ]); + + clearTimeout(failTimeout); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + + // Refresh the timeline. Just start the function, we will wait for + // it to finish after the racey sync. + const refreshLiveTimelinePromise = room.refreshLiveTimeline(); + + await waitForRaceySyncAfterResetPromise; + + await Promise.all([ + refreshLiveTimelinePromise, + // Then flush the remaining `/context` to left the refresh logic complete + httpBackend.flushAllExpected(), + ]); + + // Make sure sync pagination still works by seeing a new message show up + // after refreshing the timeline. + const afterRefreshEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + setNextSyncData(afterRefreshEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Make sure the timeline includes the the events from the `/sync` + // that raced and beat us in the middle of everything and the + // `/sync` after the refresh. Since the `/sync` beat us to create + // the timeline, `initialSyncEventData` won't be visible unless we + // paginate backwards with `/messages`. + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + racingSyncEventData[0].event_id, + afterRefreshEventData[0].event_id, + ]); + }); + + it('Timeline recovers after `/context` request to generate new timeline fails', async () => { + // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` + // to construct a new timeline from. + httpBackend.when("GET", contextUrl) + .respond(500, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return { + errcode: 'TEST_FAKE_ERROR', + error: 'We purposely intercepted this /context request to make it fail ' + + 'in order to test whether the refresh timeline code is resilient', + }; + }); + + // Refresh the timeline and expect it to fail + const settledFailedRefreshPromises = await Promise.allSettled([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + // We only expect `TEST_FAKE_ERROR` here. Anything else is + // unexpected and should fail the test. + if (settledFailedRefreshPromises[0].status === 'fulfilled') { + throw new Error('Expected the /context request to fail with a 500'); + } else if (settledFailedRefreshPromises[0].reason.errcode !== 'TEST_FAKE_ERROR') { + throw settledFailedRefreshPromises[0].reason; + } + + // The timeline will be empty after we refresh the timeline and fail + // to construct a new timeline. + expect(room.timeline.length).toEqual(0); + + // `/messages` request for `refreshLiveTimeline()` -> + // `getLatestTimeline()` to construct a new timeline from. + httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) + .respond(200, function() { + return { + chunk: [{ + // The latest message in the room + event_id: initialSyncEventData[2].event_id, + }], + }; + }); + // `/context` request for `refreshLiveTimeline()` -> + // `getLatestTimeline()` -> `getEventTimeline()` to construct a new + // timeline from. + httpBackend.when("GET", contextUrl) + .respond(200, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return contextResponse; + }); + + // Refresh the timeline again but this time it should pass + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Make sure sync pagination still works by seeing a new message show up + // after refreshing the timeline. + const afterRefreshEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + setNextSyncData(afterRefreshEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Make sure the message are visible + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + initialSyncEventData[0].event_id, + initialSyncEventData[1].event_id, + initialSyncEventData[2].event_id, + afterRefreshEventData[0].event_id, + ]); + }); + }); }); diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js index 6adb35a50..0c571707a 100644 --- a/spec/integ/matrix-client-syncing.spec.js +++ b/spec/integ/matrix-client-syncing.spec.js @@ -1,5 +1,21 @@ -import { MatrixEvent } from "../../src/models/event"; -import { EventTimeline } from "../../src/models/event-timeline"; +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventTimeline, MatrixEvent, RoomEvent, RoomStateEvent, RoomMemberEvent } from "../../src"; +import { UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; @@ -60,6 +76,112 @@ describe("MatrixClient syncing", function() { done(); }); }); + + it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => { + const roomId = "!cycles:example.org"; + + // First sync: an invite + const inviteSyncRoomSection = { + invite: { + [roomId]: { + invite_state: { + events: [{ + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "invite", + }, + }], + }, + }, + }, + }; + httpBackend.when("GET", "/sync").respond(200, { + ...syncData, + rooms: inviteSyncRoomSection, + }); + + // Second sync: a leave (reject of some kind) + httpBackend.when("POST", "/leave").respond(200, {}); + httpBackend.when("GET", "/sync").respond(200, { + ...syncData, + rooms: { + leave: { + [roomId]: { + account_data: { events: [] }, + ephemeral: { events: [] }, + state: { + events: [{ + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "leave", + }, + prev_content: { + membership: "invite", + }, + // XXX: And other fields required on an event + }], + }, + timeline: { + limited: false, + events: [{ + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "leave", + }, + prev_content: { + membership: "invite", + }, + // XXX: And other fields required on an event + }], + }, + }, + }, + }, + }); + + // Third sync: another invite + httpBackend.when("GET", "/sync").respond(200, { + ...syncData, + rooms: inviteSyncRoomSection, + }); + + // First fire: an initial invite + let fires = 0; + client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { // Room, string, string + fires++; + expect(room.roomId).toBe(roomId); + expect(membership).toBe("invite"); + expect(oldMembership).toBeFalsy(); + + // Second fire: a leave + client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { + fires++; + expect(room.roomId).toBe(roomId); + expect(membership).toBe("leave"); + expect(oldMembership).toBe("invite"); + + // Third/final fire: a second invite + client.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { + fires++; + expect(room.roomId).toBe(roomId); + expect(membership).toBe("invite"); + expect(oldMembership).toBe("leave"); + }); + }); + + // For maximum safety, "leave" the room after we register the handler + client.leave(roomId); + }); + + // noinspection ES6MissingAwait + client.startClient(); + await httpBackend.flushAllExpected(); + + expect(fires).toBe(3); + }); }); describe("resolving invites to profile info", function() { @@ -177,7 +299,7 @@ describe("MatrixClient syncing", function() { httpBackend.when("GET", "/sync").respond(200, syncData); let latestFiredName = null; - client.on("RoomMember.name", function(event, m) { + client.on(RoomMemberEvent.Name, function(event, m) { if (m.userId === userC && m.roomId === roomOne) { latestFiredName = m.name; } @@ -461,6 +583,477 @@ describe("MatrixClient syncing", function() { xit("should update the room topic", function() { }); + + describe("onMarkerStateEvent", () => { + const normalMessageEvent = utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }); + + it('new marker event *NOT* from the room creator in a subsequent syncs ' + + 'should *NOT* mark the timeline as needing a refresh', async () => { + const roomCreateEvent = utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + room_version: '9', + }, + }); + const normalFirstSync = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + normalFirstSync.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should normally trigger + // `timelineNeedsRefresh=true` but this marker isn't + // being sent by the room creator so it has no + // special meaning in existing room versions. + utils.mkEvent({ + type: UNSTABLE_MSC2716_MARKER.name, + room: roomOne, + // The important part we're testing is here! + // `userC` is not the room creator. + user: userC, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ], + prev_batch: "pagTok", + }, + }; + + // Ensure the marker is being sent by someone who is not the room creator + // because this is the main thing we're testing in this spec. + const markerEvent = nextSyncData.rooms.join[roomOne].timeline.events[0]; + expect(markerEvent.sender).toBeDefined(); + expect(markerEvent.sender).not.toEqual(roomCreateEvent.sender); + + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + [{ + label: 'In existing room versions (when the room creator sends the MSC2716 events)', + roomVersion: '9', + }, { + label: 'In a MSC2716 supported room version', + roomVersion: 'org.matrix.msc2716v3', + }].forEach((testMeta) => { + describe(testMeta.label, () => { + const roomCreateEvent = utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + room_version: testMeta.roomVersion, + }, + }); + + const markerEventFromRoomCreator = utils.mkEvent({ + type: UNSTABLE_MSC2716_MARKER.name, room: roomOne, user: otherUserId, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }); + + const normalFirstSync = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + normalFirstSync.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + it('no marker event in sync response '+ + 'should *NOT* mark the timeline as needing a refresh (check for a sane default)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + it('marker event already sent within timeline range when you join ' + + 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [markerEventFromRoomCreator], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + it('marker event already sent before joining (in state) ' + + 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [ + roomCreateEvent, + markerEventFromRoomCreator, + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + it('new marker event in a subsequent syncs timeline range ' + + 'should mark the timeline as needing a refresh', async () => { + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + prev_batch: "pagTok", + }, + }; + + const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id; + + // Only do the first sync + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + + let emitCount = 0; + room.on(RoomEvent.HistoryImportedWithinTimeline, function(markerEvent, room) { + expect(markerEvent.getId()).toEqual(markerEventId); + expect(room.roomId).toEqual(roomOne); + emitCount += 1; + }); + + // Now do a subsequent sync with the marker event + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + expect(room.getTimelineNeedsRefresh()).toEqual(true); + // Make sure `RoomEvent.HistoryImportedWithinTimeline` was emitted + expect(emitCount).toEqual(1); + }); + + // Mimic a marker event being sent far back in the scroll back but since our last sync + it('new marker event in sync state should mark the timeline as needing a refresh', async () => { + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello again", + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + // In subsequent syncs, a marker event in state + // should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(true); + }); + }); + }); + }); + + // Make sure the state listeners work and events are re-emitted properly from + // the client regardless if we reset and refresh the timeline. + describe('state listeners and re-registered when RoomEvent.CurrentStateUpdated is fired', () => { + const EVENTS = [ + utils.mkMessage({ + room: roomOne, user: userA, msg: "we", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "could", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "be", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "heroes", + }), + ]; + + const SOME_STATE_EVENT = utils.mkEvent({ + event: true, + type: 'org.matrix.test_state', + room: roomOne, + user: userA, + skey: "", + content: { + "foo": "bar", + }, + }); + + const USER_MEMBERSHIP_EVENT = utils.mkMembership({ + room: roomOne, mship: "join", user: userA, + }); + + // This appears to work even if we comment out + // `RoomEvent.CurrentStateUpdated` part which triggers everything to + // re-listen after the `room.currentState` reference changes. I'm + // not sure how it's getting re-emitted. + it("should be able to listen to state events even after " + + "the timeline is reset during `limited` sync response", async () => { + // Create a room from the sync + httpBackend.when("GET", "/sync").respond(200, syncData); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + expect(room).toBeTruthy(); + + let stateEventEmitCount = 0; + client.on(RoomStateEvent.Update, () => { + stateEventEmitCount += 1; + }); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can listen to the room state events before the reset + expect(stateEventEmitCount).toEqual(1); + + // Make a `limited` sync which will cause a `room.resetLiveTimeline` + const limitedSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + limitedSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + // The important part, make the sync `limited` + limited: true, + prev_batch: "newerTok", + }, + }; + httpBackend.when("GET", "/sync").respond(200, limitedSyncData); + + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // This got incremented again from processing the sync response + expect(stateEventEmitCount).toEqual(2); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can still listen to the room state events after the reset + expect(stateEventEmitCount).toEqual(3); + }); + + // Make sure it re-registers the state listeners after the + // `room.currentState` reference changes + it("should be able to listen to state events even after " + + "refreshing the timeline", async () => { + const testClientWithTimelineSupport = new TestClient( + selfUserId, + "DEVICE", + selfAccessToken, + undefined, + { timelineSupport: true }, + ); + httpBackend = testClientWithTimelineSupport.httpBackend; + httpBackend.when("GET", "/versions").respond(200, {}); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + client = testClientWithTimelineSupport.client; + + // Create a room from the sync + httpBackend.when("GET", "/sync").respond(200, syncData); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + expect(room).toBeTruthy(); + + let stateEventEmitCount = 0; + client.on(RoomStateEvent.Update, () => { + stateEventEmitCount += 1; + }); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can listen to the room state events before the reset + expect(stateEventEmitCount).toEqual(1); + + const eventsInRoom = syncData.rooms.join[roomOne].timeline.events; + const contextUrl = `/rooms/${encodeURIComponent(roomOne)}/context/` + + `${encodeURIComponent(eventsInRoom[0].event_id)}`; + httpBackend.when("GET", contextUrl) + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + // Refresh the timeline. This will cause the `room.currentState` + // reference to change + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can still listen to the room state events after the reset + expect(stateEventEmitCount).toEqual(2); + }); + }); }); describe("timeline", function() { @@ -516,7 +1109,7 @@ describe("MatrixClient syncing", function() { awaitSyncEvent(), ]).then(function() { const room = client.getRoom(roomTwo); - expect(room).toBeDefined(); + expect(room).toBeTruthy(); const tok = room.getLiveTimeline() .getPaginationToken(EventTimeline.BACKWARDS); expect(tok).toEqual("roomtwotok"); @@ -545,7 +1138,7 @@ describe("MatrixClient syncing", function() { let resetCallCount = 0; // the token should be set *before* timelineReset is emitted - client.on("Room.timelineReset", function(room) { + client.on(RoomEvent.TimelineReset, function(room) { resetCallCount++; const tl = room.getLiveTimeline(); diff --git a/spec/integ/megolm-backup.spec.ts b/spec/integ/megolm-backup.spec.ts new file mode 100644 index 000000000..5fa675519 --- /dev/null +++ b/spec/integ/megolm-backup.spec.ts @@ -0,0 +1,165 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Account } from "@matrix-org/olm"; + +import { logger } from "../../src/logger"; +import { decodeRecoveryKey } from "../../src/crypto/recoverykey"; +import { IKeyBackupInfo, IKeyBackupSession } from "../../src/crypto/keybackup"; +import { TestClient } from "../TestClient"; +import { IEvent } from "../../src"; +import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; + +const ROOM_ID = '!ROOM:ID'; + +const SESSION_ID = 'o+21hSjP+mgEmcfdslPsQdvzWnkdt0Wyo00Kp++R8Kc'; + +const ENCRYPTED_EVENT: Partial = { + type: 'm.room.encrypted', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: 'SENDER_CURVE25519', + session_id: SESSION_ID, + ciphertext: 'AwgAEjD+VwXZ7PoGPRS/H4kwpAsMp/g+WPvJVtPEKE8fmM9IcT/N' + + 'CiwPb8PehecDKP0cjm1XO88k6Bw3D17aGiBHr5iBoP7oSw8CXULXAMTkBl' + + 'mkufRQq2+d0Giy1s4/Cg5n13jSVrSb2q7VTSv1ZHAFjUCsLSfR0gxqcQs', + }, + room_id: '!ROOM:ID', + event_id: '$event1', + origin_server_ts: 1507753886000, +}; + +const CURVE25519_KEY_BACKUP_DATA: IKeyBackupSession = { + first_message_index: 0, + forwarded_count: 0, + is_verified: false, + session_data: { + ciphertext: '2z2M7CZ+azAiTHN1oFzZ3smAFFt+LEOYY6h3QO3XXGdw' + + '6YpNn/gpHDO6I/rgj1zNd4FoTmzcQgvKdU8kN20u5BWRHxaHTZ' + + 'Slne5RxE6vUdREsBgZePglBNyG0AogR/PVdcrv/v18Y6rLM5O9' + + 'SELmwbV63uV9Kuu/misMxoqbuqEdG7uujyaEKtjlQsJ5MGPQOy' + + 'Syw7XrnesSwF6XWRMxcPGRV0xZr3s9PI350Wve3EncjRgJ9IGF' + + 'ru1bcptMqfXgPZkOyGvrphHoFfoK7nY3xMEHUiaTRfRIjq8HNV' + + '4o8QY1qmWGnxNBQgOlL8MZlykjg3ULmQ3DtFfQPj/YYGS3jzxv' + + 'C+EBjaafmsg+52CTeK3Rswu72PX450BnSZ1i3If4xWAUKvjTpe' + + 'Ug5aDLqttOv1pITolTJDw5W/SD+b5rjEKg1CFCHGEGE9wwV3Nf' + + 'QHVCQL+dfpd7Or0poy4dqKMAi3g0o3Tg7edIF8d5rREmxaALPy' + + 'iie8PHD8mj/5Y0GLqrac4CD6+Mop7eUTzVovprjg', + mac: '5lxYBHQU80M', + ephemeral: '/Bn0A4UMFwJaDDvh0aEk1XZj3k1IfgCxgFY9P9a0b14', + }, +}; + +const CURVE25519_BACKUP_INFO: IKeyBackupInfo = { + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + version: "1", + auth_data: { + public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", + }, +}; + +const RECOVERY_KEY = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d"; + +/** + * start an Olm session with a given recipient + */ +function createOlmSession(olmAccount: Olm.Account, recipientTestClient: TestClient): Promise { + return recipientTestClient.awaitOneTimeKeyUpload().then((keys) => { + const otkId = Object.keys(keys)[0]; + const otk = keys[otkId]; + + const session = new global.Olm.Session(); + session.create_outbound( + olmAccount, recipientTestClient.getDeviceKey(), otk.key, + ); + return session; + }); +} + +describe("megolm key backups", function() { + if (!global.Olm) { + logger.warn('not running megolm tests: Olm not present'); + return; + } + const Olm = global.Olm; + + let testOlmAccount: Account; + let aliceTestClient: TestClient; + + beforeAll(function() { + return Olm.init(); + }); + + beforeEach(async function() { + aliceTestClient = new TestClient( + "@alice:localhost", "xzcvb", "akjgkrgjs", + ); + testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + await aliceTestClient.client.initCrypto(); + aliceTestClient.client.crypto.backupManager.backupInfo = CURVE25519_BACKUP_INFO; + }); + + afterEach(function() { + return aliceTestClient.stop(); + }); + + it("Alice checks key backups when receiving a message she can't decrypt", function() { + const syncResponse = { + next_batch: 1, + rooms: { + join: {}, + }, + }; + syncResponse.rooms.join[ROOM_ID] = { + timeline: { + events: [ENCRYPTED_EVENT], + }, + }; + + return aliceTestClient.start().then(() => { + return createOlmSession(testOlmAccount, aliceTestClient); + }).then(() => { + const privkey = decodeRecoveryKey(RECOVERY_KEY); + return aliceTestClient.client.crypto.storeSessionBackupPrivateKey(privkey); + }).then(() => { + aliceTestClient.httpBackend.when("GET", "/sync").respond(200, syncResponse); + aliceTestClient.expectKeyBackupQuery( + ROOM_ID, + SESSION_ID, + 200, + CURVE25519_KEY_BACKUP_DATA, + ); + return aliceTestClient.httpBackend.flushAllExpected(); + }).then(function(): Promise { + const room = aliceTestClient.client.getRoom(ROOM_ID); + const event = room.getLiveTimeline().getEvents()[0]; + + if (event.getContent()) { + return Promise.resolve(event); + } + + return new Promise((resolve, reject) => { + event.once(MatrixEventEvent.Decrypted, (ev) => { + logger.log(`${Date.now()} event ${event.getId()} now decrypted`); + resolve(ev); + }); + }); + }).then((event) => { + expect(event.getContent()).toEqual('testytest'); + }); + }); +}); diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index 35374f9ef..3e4cdbe51 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -1029,6 +1029,7 @@ describe("megolm", function() { }); return event.attemptDecryption(testClient.client.crypto, true).then(() => { expect(event.isKeySourceUntrusted()).toBeFalsy(); + testClient.stop(); }); }); }); diff --git a/spec/test-utils/beacon.ts b/spec/test-utils/beacon.ts index 0823cca0c..252c85c81 100644 --- a/spec/test-utils/beacon.ts +++ b/spec/test-utils/beacon.ts @@ -27,6 +27,7 @@ type InfoContentProps = { isLive?: boolean; assetType?: LocationAssetType; description?: string; + timestamp?: number; }; const DEFAULT_INFO_CONTENT_PROPS: InfoContentProps = { timeout: 3600000, @@ -44,7 +45,11 @@ export const makeBeaconInfoEvent = ( eventId?: string, ): MatrixEvent => { const { - timeout, isLive, description, assetType, + timeout, + isLive, + description, + assetType, + timestamp, } = { ...DEFAULT_INFO_CONTENT_PROPS, ...contentProps, @@ -53,10 +58,10 @@ export const makeBeaconInfoEvent = ( type: M_BEACON_INFO.name, room_id: roomId, state_key: sender, - content: makeBeaconInfoContent(timeout, isLive, description, assetType), + content: makeBeaconInfoContent(timeout, isLive, description, assetType, timestamp), }); - event.event.origin_server_ts = Date.now(); + event.event.origin_server_ts = timestamp || Date.now(); // live beacons use the beacon_info event id // set or default this diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 5b4fb9850..84a9662e4 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -6,7 +6,7 @@ import '../olm-loader'; import { logger } from '../../src/logger'; import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event"; -import { ClientEvent, EventType, MatrixClient } from "../../src"; +import { ClientEvent, EventType, MatrixClient, MsgType } from "../../src"; import { SyncState } from "../../src/sync"; import { eventMapperFor } from "../../src/event-mapper"; @@ -74,7 +74,6 @@ interface IEventOpts { sender?: string; skey?: string; content: IContent; - event?: boolean; user?: string; unsigned?: IUnsigned; redacts?: string; @@ -93,7 +92,9 @@ let testEventIndex = 1; // counter for events, easier for comparison of randomly * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object} a JSON object representing this event. */ -export function mkEvent(opts: IEventOpts, client?: MatrixClient): object | MatrixEvent { +export function mkEvent(opts: IEventOpts & { event: true }, client?: MatrixClient): MatrixEvent; +export function mkEvent(opts: IEventOpts & { event?: false }, client?: MatrixClient): object; +export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixClient): object | MatrixEvent { if (!opts.type || !opts.content) { throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); } @@ -143,7 +144,9 @@ interface IPresenceOpts { * @param {Object} opts Values for the presence. * @return {Object|MatrixEvent} The event */ -export function mkPresence(opts: IPresenceOpts): object | MatrixEvent { +export function mkPresence(opts: IPresenceOpts & { event: true }): MatrixEvent; +export function mkPresence(opts: IPresenceOpts & { event?: false }): object; +export function mkPresence(opts: IPresenceOpts & { event?: boolean }): object | MatrixEvent { const event = { event_id: "$" + Math.random() + "-" + Math.random(), type: "m.presence", @@ -182,7 +185,9 @@ interface IMembershipOpts { * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object|MatrixEvent} The event */ -export function mkMembership(opts: IMembershipOpts): object | MatrixEvent { +export function mkMembership(opts: IMembershipOpts & { event: true }): MatrixEvent; +export function mkMembership(opts: IMembershipOpts & { event?: false }): object; +export function mkMembership(opts: IMembershipOpts & { event?: boolean }): object | MatrixEvent { const eventOpts: IEventOpts = { ...opts, type: EventType.RoomMember, @@ -220,12 +225,14 @@ interface IMessageOpts { * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. * @return {Object|MatrixEvent} The event */ -export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | MatrixEvent { +export function mkMessage(opts: IMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent; +export function mkMessage(opts: IMessageOpts & { event?: false }, client?: MatrixClient): object; +export function mkMessage(opts: IMessageOpts & { event?: boolean }, client?: MatrixClient): object | MatrixEvent { const eventOpts: IEventOpts = { ...opts, type: EventType.RoomMessage, content: { - msgtype: "m.text", + msgtype: MsgType.Text, body: opts.msg, }, }; @@ -236,6 +243,50 @@ export function mkMessage(opts: IMessageOpts, client?: MatrixClient): object | M return mkEvent(eventOpts, client); } +interface IReplyMessageOpts extends IMessageOpts { + replyToMessage: MatrixEvent; +} + +/** + * Create a reply message. + * + * @param {Object} opts Values for the message + * @param {string} opts.room The room ID for the event. + * @param {string} opts.user The user ID for the event. + * @param {string} opts.msg Optional. The content.body for the event. + * @param {MatrixEvent} opts.replyToMessage The replied message + * @param {boolean} opts.event True to make a MatrixEvent. + * @param {MatrixClient} client If passed along with opts.event=true will be used to set up re-emitters. + * @return {Object|MatrixEvent} The event + */ +export function mkReplyMessage(opts: IReplyMessageOpts & { event: true }, client?: MatrixClient): MatrixEvent; +export function mkReplyMessage(opts: IReplyMessageOpts & { event?: false }, client?: MatrixClient): object; +export function mkReplyMessage( + opts: IReplyMessageOpts & { event?: boolean }, + client?: MatrixClient, +): object | MatrixEvent { + const eventOpts: IEventOpts = { + ...opts, + type: EventType.RoomMessage, + content: { + "msgtype": MsgType.Text, + "body": opts.msg, + "m.relates_to": { + "rel_type": "m.in_reply_to", + "event_id": opts.replyToMessage.getId(), + "m.in_reply_to": { + "event_id": opts.replyToMessage.getId(), + }, + }, + }, + }; + + if (!eventOpts.content.body) { + eventOpts.content.body = "Random->" + Math.random(); + } + return mkEvent(eventOpts, client); +} + /** * A mock implementation of webstorage * diff --git a/spec/unit/content-helpers.spec.ts b/spec/unit/content-helpers.spec.ts index 71b7344ed..ba431d9d5 100644 --- a/spec/unit/content-helpers.spec.ts +++ b/spec/unit/content-helpers.spec.ts @@ -17,7 +17,13 @@ limitations under the License. import { REFERENCE_RELATION } from "matrix-events-sdk"; import { LocationAssetType, M_ASSET, M_LOCATION, M_TIMESTAMP } from "../../src/@types/location"; -import { makeBeaconContent, makeBeaconInfoContent } from "../../src/content-helpers"; +import { M_TOPIC } from "../../src/@types/topic"; +import { + makeBeaconContent, + makeBeaconInfoContent, + makeTopicContent, + parseTopicContent, +} from "../../src/content-helpers"; describe('Beacon content helpers', () => { describe('makeBeaconInfoContent()', () => { @@ -122,3 +128,68 @@ describe('Beacon content helpers', () => { }); }); }); + +describe('Topic content helpers', () => { + describe('makeTopicContent()', () => { + it('creates fully defined event content without html', () => { + expect(makeTopicContent("pizza")).toEqual({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/plain", + }], + }); + }); + + it('creates fully defined event content with html', () => { + expect(makeTopicContent("pizza", "pizza")).toEqual({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/plain", + }, { + body: "pizza", + mimetype: "text/html", + }], + }); + }); + }); + + describe('parseTopicContent()', () => { + it('parses event content with plain text topic without mimetype', () => { + expect(parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + }], + })).toEqual({ + text: "pizza", + }); + }); + + it('parses event content with plain text topic', () => { + expect(parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/plain", + }], + })).toEqual({ + text: "pizza", + }); + }); + + it('parses event content with html topic', () => { + expect(parseTopicContent({ + topic: "pizza", + [M_TOPIC.name]: [{ + body: "pizza", + mimetype: "text/html", + }], + })).toEqual({ + text: "pizza", + html: "pizza", + }); + }); + }); +}); diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index 3c3a738be..ba74eb517 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -3,7 +3,6 @@ import '../olm-loader'; import { EventEmitter } from "events"; import { Crypto } from "../../src/crypto"; -import { WebStorageSessionStore } from "../../src/store/session/webstorage"; import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store"; import { MockStorageApi } from "../MockStorageApi"; import { TestClient } from "../TestClient"; @@ -14,9 +13,47 @@ import { sleep } from "../../src/utils"; import { CRYPTO_ENABLED } from "../../src/client"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { logger } from '../../src/logger'; +import { MemoryStore } from "../../src"; const Olm = global.Olm; +function awaitEvent(emitter, event) { + return new Promise((resolve, reject) => { + emitter.once(event, (result) => { + resolve(result); + }); + }); +} + +async function keyshareEventForEvent(client, event, index) { + const roomId = event.getRoomId(); + const eventContent = event.getWireContent(); + const key = await client.crypto.olmDevice.getInboundGroupSessionKey( + roomId, + eventContent.sender_key, + eventContent.session_id, + index, + ); + const ksEvent = new MatrixEvent({ + type: "m.forwarded_room_key", + sender: client.getUserId(), + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: eventContent.sender_key, + sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, + session_id: eventContent.session_id, + session_key: key.key, + chain_index: key.chain_index, + forwarding_curve25519_key_chain: + key.forwarding_curve_key_chain, + }, + }); + // make onRoomKeyEvent think this was an encrypted event + ksEvent.senderCurve25519Key = "akey"; + return ksEvent; +} + describe("Crypto", function() { if (!CRYPTO_ENABLED) { return; @@ -116,7 +153,7 @@ describe("Crypto", function() { beforeEach(async function() { const mockStorage = new MockStorageApi(); - const sessionStore = new WebStorageSessionStore(mockStorage); + const clientStore = new MemoryStore({ localStorage: mockStorage }); const cryptoStore = new MemoryCryptoStore(mockStorage); cryptoStore.storeEndToEndDeviceData({ @@ -143,10 +180,9 @@ describe("Crypto", function() { crypto = new Crypto( mockBaseApis, - sessionStore, "@alice:home.server", "FLIBBLE", - sessionStore, + clientStore, cryptoStore, mockRoomList, ); @@ -203,136 +239,141 @@ describe("Crypto", function() { bobClient.stopClient(); }); - it( - "does not cancel keyshare requests if some messages are not decrypted", - async function() { - function awaitEvent(emitter, event) { - return new Promise((resolve, reject) => { - emitter.once(event, (result) => { - resolve(result); - }); - }); - } - - async function keyshareEventForEvent(event, index) { - const eventContent = event.getWireContent(); - const key = await aliceClient.crypto.olmDevice - .getInboundGroupSessionKey( - roomId, eventContent.sender_key, eventContent.session_id, - index, - ); - const ksEvent = new MatrixEvent({ - type: "m.forwarded_room_key", - sender: "@alice:example.com", - content: { - algorithm: olmlib.MEGOLM_ALGORITHM, - room_id: roomId, - sender_key: eventContent.sender_key, - sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, - session_id: eventContent.session_id, - session_key: key.key, - chain_index: key.chain_index, - forwarding_curve25519_key_chain: - key.forwarding_curve_key_chain, - }, - }); - // make onRoomKeyEvent think this was an encrypted event - ksEvent.senderCurve25519Key = "akey"; - return ksEvent; - } - - const encryptionCfg = { - "algorithm": "m.megolm.v1.aes-sha2", - }; - const roomId = "!someroom"; - const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); - const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); - aliceClient.store.storeRoom(aliceRoom); - bobClient.store.storeRoom(bobRoom); - await aliceClient.setRoomEncryption(roomId, encryptionCfg); - await bobClient.setRoomEncryption(roomId, encryptionCfg); - const events = [ - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$1", - content: { - msgtype: "m.text", - body: "1", - }, - }), - new MatrixEvent({ - type: "m.room.message", - sender: "@alice:example.com", - room_id: roomId, - event_id: "$2", - content: { - msgtype: "m.text", - body: "2", - }, - }), - ]; - await Promise.all(events.map(async (event) => { - // alice encrypts each event, and then bob tries to decrypt - // them without any keys, so that they'll be in pending - await aliceClient.crypto.encryptEvent(event, aliceRoom); - event.clearEvent = undefined; - event.senderCurve25519Key = null; - event.claimedEd25519Key = null; - try { - await bobClient.crypto.decryptEvent(event); - } catch (e) { - // we expect this to fail because we don't have the - // decryption keys yet - } - })); - - const bobDecryptor = bobClient.crypto.getRoomDecryptor( - roomId, olmlib.MEGOLM_ALGORITHM, - ); - - let eventPromise = Promise.all(events.map((ev) => { - return awaitEvent(ev, "Event.decrypted"); - })); - - // keyshare the session key starting at the second message, so - // the first message can't be decrypted yet, but the second one - // can - let ksEvent = await keyshareEventForEvent(events[1], 1); - await bobDecryptor.onRoomKeyEvent(ksEvent); - await eventPromise; - expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); - expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); - - const cryptoStore = bobClient.cryptoStore; - const eventContent = events[0].getWireContent(); - const senderKey = eventContent.sender_key; - const sessionId = eventContent.session_id; - const roomKeyRequestBody = { - algorithm: olmlib.MEGOLM_ALGORITHM, + it("does not cancel keyshare requests if some messages are not decrypted", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + const events = [ + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", room_id: roomId, - sender_key: senderKey, - session_id: sessionId, - }; - // the room key request should still be there, since we haven't - // decrypted everything - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) - .toBeDefined(); + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }), + new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$2", + content: { + msgtype: "m.text", + body: "2", + }, + }), + ]; + await Promise.all(events.map(async (event) => { + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + event.clearEvent = undefined; + event.senderCurve25519Key = null; + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + })); - // keyshare the session key starting at the first message, so - // that it can now be decrypted - eventPromise = awaitEvent(events[0], "Event.decrypted"); - ksEvent = await keyshareEventForEvent(events[0], 0); - await bobDecryptor.onRoomKeyEvent(ksEvent); - await eventPromise; - expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); - await sleep(1); - // the room key request should be gone since we've now decrypted everything - expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)) - .toBeFalsy(); - }, - ); + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + let eventPromise = Promise.all(events.map((ev) => { + return awaitEvent(ev, "Event.decrypted"); + })); + + // keyshare the session key starting at the second message, so + // the first message can't be decrypted yet, but the second one + // can + let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1); + await bobDecryptor.onRoomKeyEvent(ksEvent); + await eventPromise; + expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); + expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); + + const cryptoStore = bobClient.cryptoStore; + const eventContent = events[0].getWireContent(); + const senderKey = eventContent.sender_key; + const sessionId = eventContent.session_id; + const roomKeyRequestBody = { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: senderKey, + session_id: sessionId, + }; + // the room key request should still be there, since we haven't + // decrypted everything + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); + + // keyshare the session key starting at the first message, so + // that it can now be decrypted + eventPromise = awaitEvent(events[0], "Event.decrypted"); + ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); + await bobDecryptor.onRoomKeyEvent(ksEvent); + await eventPromise; + expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); + await sleep(1); + // the room key request should be gone since we've now decrypted everything + expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy(); + }); + + it("should error if a forwarded room key lacks a content.sender_key", async function() { + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); + const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); + aliceClient.store.storeRoom(aliceRoom); + bobClient.store.storeRoom(bobRoom); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + await bobClient.setRoomEncryption(roomId, encryptionCfg); + const event = new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$1", + content: { + msgtype: "m.text", + body: "1", + }, + }); + // alice encrypts each event, and then bob tries to decrypt + // them without any keys, so that they'll be in pending + await aliceClient.crypto.encryptEvent(event, aliceRoom); + event.clearEvent = undefined; + event.senderCurve25519Key = null; + event.claimedEd25519Key = null; + try { + await bobClient.crypto.decryptEvent(event); + } catch (e) { + // we expect this to fail because we don't have the + // decryption keys yet + } + + const bobDecryptor = bobClient.crypto.getRoomDecryptor( + roomId, olmlib.MEGOLM_ALGORITHM, + ); + + const ksEvent = await keyshareEventForEvent(aliceClient, event, 1); + ksEvent.getContent().sender_key = undefined; // test + bobClient.crypto.addInboundGroupSession = jest.fn(); + await bobDecryptor.onRoomKeyEvent(ksEvent); + expect(bobClient.crypto.addInboundGroupSession).not.toHaveBeenCalled(); + }); it("creates a new keyshare request if we request a keyshare", async function() { // make sure that cancelAndResend... creates a new keyshare request @@ -423,6 +464,7 @@ describe("Crypto", function() { await client.crypto.bootstrapSecretStorage({ createSecretStorageKey, }); + client.stopClient(); }); }); }); diff --git a/spec/unit/crypto/CrossSigningInfo.spec.js b/spec/unit/crypto/CrossSigningInfo.spec.ts similarity index 72% rename from spec/unit/crypto/CrossSigningInfo.spec.js rename to spec/unit/crypto/CrossSigningInfo.spec.ts index 5c3ebade1..9ed50a60c 100644 --- a/spec/unit/crypto/CrossSigningInfo.spec.js +++ b/spec/unit/crypto/CrossSigningInfo.spec.ts @@ -66,23 +66,23 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { }); it.each(types)("should throw if the callback returns falsey", - async ({ type, shouldCache }) => { - const info = new CrossSigningInfo(userId, { - getCrossSigningKey: () => false, + async ({ type, shouldCache }) => { + const info = new CrossSigningInfo(userId, { + getCrossSigningKey: async () => false as unknown as Uint8Array, + }); + await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); }); - await expect(info.getCrossSigningKey(type)).rejects.toThrow("falsey"); - }); it("should throw if the expected key doesn't come back", async () => { const info = new CrossSigningInfo(userId, { - getCrossSigningKey: () => masterKeyPub, + getCrossSigningKey: async () => masterKeyPub as unknown as Uint8Array, }); await expect(info.getCrossSigningKey("master", "")).rejects.toThrow(); }); it("should return a key from its callback", async () => { const info = new CrossSigningInfo(userId, { - getCrossSigningKey: () => testKey, + getCrossSigningKey: async () => testKey, }); const [pubKey, pkSigning] = await info.getCrossSigningKey("master", masterKeyPub); expect(pubKey).toEqual(masterKeyPub); @@ -99,7 +99,7 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { it.each(types)("should request a key from the cache callback (if set)" + " and does not call app if one is found" + " %o", - async ({ type, shouldCache }) => { + async ({ type, shouldCache }) => { const getCrossSigningKey = jest.fn().mockImplementation(() => { if (shouldCache) { return Promise.reject(new Error("Regular callback called")); @@ -122,58 +122,58 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { }); it.each(types)("should store a key with the cache callback (if set)", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { storeCrossSigningKeyCache }, - ); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); - if (shouldCache) { - expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); - expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); - } - }); + async ({ type, shouldCache }) => { + const getCrossSigningKey = jest.fn().mockResolvedValue(testKey); + const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const info = new CrossSigningInfo( + userId, + { getCrossSigningKey }, + { storeCrossSigningKeyCache }, + ); + const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); + expect(pubKey).toEqual(masterKeyPub); + expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(shouldCache ? 1 : 0); + if (shouldCache) { + expect(storeCrossSigningKeyCache.mock.calls[0][0]).toBe(type); + expect(storeCrossSigningKeyCache.mock.calls[0][1]).toBe(testKey); + } + }); it.each(types)("does not store a bad key to the cache", - async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); - const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { storeCrossSigningKeyCache }, - ); - await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); - expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); - }); + async ({ type, shouldCache }) => { + const getCrossSigningKey = jest.fn().mockResolvedValue(badKey); + const storeCrossSigningKeyCache = jest.fn().mockResolvedValue(undefined); + const info = new CrossSigningInfo( + userId, + { getCrossSigningKey }, + { storeCrossSigningKeyCache }, + ); + await expect(info.getCrossSigningKey(type, masterKeyPub)).rejects.toThrow(); + expect(storeCrossSigningKeyCache.mock.calls.length).toEqual(0); + }); it.each(types)("does not store a value to the cache if it came from the cache", async ({ type, shouldCache }) => { - const getCrossSigningKey = jest.fn().mockImplementation(() => { - if (shouldCache) { - return Promise.reject(new Error("Regular callback called")); - } else { - return Promise.resolve(testKey); - } + const getCrossSigningKey = jest.fn().mockImplementation(() => { + if (shouldCache) { + return Promise.reject(new Error("Regular callback called")); + } else { + return Promise.resolve(testKey); + } + }); + const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); + const storeCrossSigningKeyCache = jest.fn().mockRejectedValue( + new Error("Tried to store a value from cache"), + ); + const info = new CrossSigningInfo( + userId, + { getCrossSigningKey }, + { getCrossSigningKeyCache, storeCrossSigningKeyCache }, + ); + expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); + const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); + expect(pubKey).toEqual(masterKeyPub); }); - const getCrossSigningKeyCache = jest.fn().mockResolvedValue(testKey); - const storeCrossSigningKeyCache = jest.fn().mockRejectedValue( - new Error("Tried to store a value from cache"), - ); - const info = new CrossSigningInfo( - userId, - { getCrossSigningKey }, - { getCrossSigningKeyCache, storeCrossSigningKeyCache }, - ); - expect(storeCrossSigningKeyCache.mock.calls.length).toBe(0); - const [pubKey] = await info.getCrossSigningKey(type, masterKeyPub); - expect(pubKey).toEqual(masterKeyPub); - }); it.each(types)("requests a key from the cache callback (if set) and then calls app" + " if one is not found", async ({ type, shouldCache }) => { @@ -220,12 +220,14 @@ describe("CrossSigningInfo.getCrossSigningKey", function() { */ describe.each([ ["IndexedDBCryptoStore", - () => new IndexedDBCryptoStore(global.indexedDB, "tests")], + () => new IndexedDBCryptoStore(global.indexedDB, "tests")], ["LocalStorageCryptoStore", - () => new IndexedDBCryptoStore(undefined, "tests")], + () => new IndexedDBCryptoStore(undefined, "tests")], ["MemoryCryptoStore", () => { const store = new IndexedDBCryptoStore(undefined, "tests"); + // @ts-ignore set private properties store._backend = new MemoryCryptoStore(); + // @ts-ignore store._backendPromise = Promise.resolve(store._backend); return store; }], diff --git a/spec/unit/crypto/DeviceList.spec.js b/spec/unit/crypto/DeviceList.spec.ts similarity index 87% rename from spec/unit/crypto/DeviceList.spec.js rename to spec/unit/crypto/DeviceList.spec.ts index 251f07358..cb7f0fb0f 100644 --- a/spec/unit/crypto/DeviceList.spec.js +++ b/spec/unit/crypto/DeviceList.spec.ts @@ -1,7 +1,7 @@ /* Copyright 2017 Vector Creations Ltd Copyright 2018, 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,8 +20,10 @@ import { logger } from "../../../src/logger"; import * as utils from "../../../src/utils"; import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; import { DeviceList } from "../../../src/crypto/DeviceList"; +import { IDownloadKeyResult, MatrixClient } from "../../../src"; +import { OlmDevice } from "../../../src/crypto/OlmDevice"; -const signedDeviceList = { +const signedDeviceList: IDownloadKeyResult = { "failures": {}, "device_keys": { "@test1:sw1v.org": { @@ -45,13 +47,15 @@ const signedDeviceList = { "m.megolm.v1.aes-sha2", ], "device_id": "HGKAWHRVJQ", - "unsigned": {}, + "unsigned": { + "device_display_name": "", + }, }, }, }, }; -const signedDeviceList2 = { +const signedDeviceList2: IDownloadKeyResult = { "failures": {}, "device_keys": { "@test2:sw1v.org": { @@ -75,7 +79,9 @@ const signedDeviceList2 = { "m.megolm.v1.aes-sha2", ], "device_id": "QJVRHWAKGH", - "unsigned": {}, + "unsigned": { + "device_display_name": "", + }, }, }, }, @@ -104,10 +110,10 @@ describe('DeviceList', function() { downloadKeysForUsers: downloadSpy, getUserId: () => '@test1:sw1v.org', deviceId: 'HGKAWHRVJQ', - }; + } as unknown as MatrixClient; const mockOlm = { verifySignature: function(key, message, signature) {}, - }; + } as unknown as OlmDevice; const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize); deviceLists.push(dl); return dl; @@ -118,7 +124,7 @@ describe('DeviceList', function() { dl.startTrackingDeviceList('@test1:sw1v.org'); - const queryDefer1 = utils.defer(); + const queryDefer1 = utils.defer(); downloadSpy.mockReturnValue(queryDefer1.promise); const prom1 = dl.refreshOutdatedDeviceLists(); @@ -128,6 +134,7 @@ describe('DeviceList', function() { return prom1.then(() => { const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); + dl.stop(); }); }); @@ -137,7 +144,7 @@ describe('DeviceList', function() { dl.startTrackingDeviceList('@test1:sw1v.org'); - const queryDefer1 = utils.defer(); + const queryDefer1 = utils.defer(); downloadSpy.mockReturnValue(queryDefer1.promise); const prom1 = dl.refreshOutdatedDeviceLists(); @@ -154,6 +161,7 @@ describe('DeviceList', function() { dl.saveIfDirty().then(() => { // the first request completes queryDefer1.resolve({ + failures: {}, device_keys: { '@test1:sw1v.org': {}, }, @@ -165,11 +173,12 @@ describe('DeviceList', function() { logger.log("Creating new devicelist to simulate app reload"); downloadSpy.mockReset(); const dl2 = createTestDeviceList(); - const queryDefer3 = utils.defer(); + const queryDefer3 = utils.defer(); downloadSpy.mockReturnValue(queryDefer3.promise); const prom3 = dl2.refreshOutdatedDeviceLists(); expect(downloadSpy).toHaveBeenCalledWith(['@test1:sw1v.org'], {}); + dl2.stop(); queryDefer3.resolve(utils.deepCopy(signedDeviceList)); @@ -178,6 +187,7 @@ describe('DeviceList', function() { }).then(() => { const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); + dl.stop(); }); }); @@ -187,9 +197,9 @@ describe('DeviceList', function() { dl.startTrackingDeviceList('@test1:sw1v.org'); dl.startTrackingDeviceList('@test2:sw1v.org'); - const queryDefer1 = utils.defer(); + const queryDefer1 = utils.defer(); downloadSpy.mockReturnValueOnce(queryDefer1.promise); - const queryDefer2 = utils.defer(); + const queryDefer2 = utils.defer(); downloadSpy.mockReturnValueOnce(queryDefer2.promise); const prom1 = dl.refreshOutdatedDeviceLists(); @@ -204,6 +214,7 @@ describe('DeviceList', function() { expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']); const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org'); expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']); + dl.stop(); }); }); }); diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 22888d1a3..262ea8f47 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -257,6 +257,8 @@ describe("MegolmDecryption", function() { }); describe("session reuse and key reshares", () => { + const rotationPeriodMs = 999 * 24 * 60 * 60 * 1000; // 999 days, so we don't have to deal with it + let megolmEncryption; let aliceDeviceInfo; let mockRoom; @@ -318,7 +320,7 @@ describe("MegolmDecryption", function() { baseApis: mockBaseApis, roomId: ROOM_ID, config: { - rotation_period_ms: 9999999999999, + rotation_period_ms: rotationPeriodMs, }, }); @@ -336,6 +338,31 @@ describe("MegolmDecryption", function() { }; }); + it("should use larger otkTimeout when preparing to encrypt room", async () => { + megolmEncryption.prepareToEncrypt(mockRoom); + await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some text", + }); + expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); + + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalledWith( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', 10000, + ); + }); + + it("should generate a new session if this one needs rotation", async () => { + const session = await megolmEncryption.prepareNewSession(false); + session.creationTime -= rotationPeriodMs + 10000; // a smidge over the rotation time + // Inject expired session which needs rotation + megolmEncryption.setupPromise = Promise.resolve(session); + + const prepareNewSessionSpy = jest.spyOn(megolmEncryption, "prepareNewSession"); + await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some text", + }); + expect(prepareNewSessionSpy).toHaveBeenCalledTimes(1); + }); + it("re-uses sessions for sequential messages", async function() { const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { body: "Some text", @@ -603,6 +630,8 @@ describe("MegolmDecryption", function() { }); await aliceClient.crypto.encryptEvent(event, aliceRoom); await sendPromise; + aliceClient.stopClient(); + bobClient.stopClient(); }); it("throws an error describing why it doesn't have a key", async function() { @@ -673,6 +702,8 @@ describe("MegolmDecryption", function() { session_id: "session_id2", }, }))).rejects.toThrow("The sender has blocked you."); + aliceClient.stopClient(); + bobClient.stopClient(); }); it("throws an error describing the lack of an olm session", async function() { @@ -756,6 +787,8 @@ describe("MegolmDecryption", function() { }, origin_server_ts: now, }))).rejects.toThrow("The sender was unable to establish a secure channel."); + aliceClient.stopClient(); + bobClient.stopClient(); }); it("throws an error to indicate a wedged olm session", async function() { @@ -806,5 +839,7 @@ describe("MegolmDecryption", function() { }, origin_server_ts: now, }))).rejects.toThrow("The secure channel with the sender was corrupted."); + aliceClient.stopClient(); + bobClient.stopClient(); }); }); diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js index b75bd26c5..cab0c0d0d 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.js @@ -21,7 +21,6 @@ import * as olmlib from "../../../src/crypto/olmlib"; import { MatrixClient } from "../../../src/client"; import { MatrixEvent } from "../../../src/models/event"; import * as algorithms from "../../../src/crypto/algorithms"; -import { WebStorageSessionStore } from "../../../src/store/session/webstorage"; import { MemoryCryptoStore } from "../../../src/crypto/store/memory-crypto-store"; import { MockStorageApi } from "../../MockStorageApi"; import * as testUtils from "../../test-utils/test-utils"; @@ -118,7 +117,7 @@ function saveCrossSigningKeys(k) { Object.assign(keys, k); } -function makeTestClient(sessionStore, cryptoStore) { +function makeTestClient(cryptoStore) { const scheduler = [ "getQueueForEvent", "queueEvent", "removeEventFromQueue", "setProcessFunction", @@ -141,7 +140,6 @@ function makeTestClient(sessionStore, cryptoStore) { scheduler: scheduler, userId: "@alice:bar", deviceId: "device", - sessionStore: sessionStore, cryptoStore: cryptoStore, cryptoCallbacks: { getCrossSigningKey, saveCrossSigningKeys }, }); @@ -161,7 +159,6 @@ describe("MegolmBackup", function() { let mockOlmLib; let mockCrypto; let mockStorage; - let sessionStore; let cryptoStore; let megolmDecryption; beforeEach(async function() { @@ -174,7 +171,6 @@ describe("MegolmBackup", function() { mockCrypto.backupInfo = CURVE25519_BACKUP_INFO; mockStorage = new MockStorageApi(); - sessionStore = new WebStorageSessionStore(mockStorage); cryptoStore = new MemoryCryptoStore(mockStorage); olmDevice = new OlmDevice(cryptoStore); @@ -261,7 +257,7 @@ describe("MegolmBackup", function() { const ibGroupSession = new Olm.InboundGroupSession(); ibGroupSession.create(groupSession.session_key()); - const client = makeTestClient(sessionStore, cryptoStore); + const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', @@ -329,6 +325,7 @@ describe("MegolmBackup", function() { ); }).then(() => { expect(numCalls).toBe(1); + client.stopClient(); }); }); }); @@ -339,7 +336,7 @@ describe("MegolmBackup", function() { const ibGroupSession = new Olm.InboundGroupSession(); ibGroupSession.create(groupSession.session_key()); - const client = makeTestClient(sessionStore, cryptoStore); + const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', @@ -411,6 +408,7 @@ describe("MegolmBackup", function() { ); }).then(() => { expect(numCalls).toBe(1); + client.stopClient(); }); }); }); @@ -421,7 +419,7 @@ describe("MegolmBackup", function() { const ibGroupSession = new Olm.InboundGroupSession(); ibGroupSession.create(groupSession.session_key()); - const client = makeTestClient(sessionStore, cryptoStore); + const client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', @@ -487,6 +485,7 @@ describe("MegolmBackup", function() { }), ]); expect(numCalls).toBe(2); + client.stopClient(); }); it('retries when a backup fails', function() { @@ -517,7 +516,6 @@ describe("MegolmBackup", function() { scheduler: scheduler, userId: "@alice:bar", deviceId: "device", - sessionStore: sessionStore, cryptoStore: cryptoStore, }); @@ -593,6 +591,7 @@ describe("MegolmBackup", function() { ); }).then(() => { expect(numCalls).toBe(2); + client.stopClient(); }); }); }); @@ -602,7 +601,7 @@ describe("MegolmBackup", function() { let client; beforeEach(function() { - client = makeTestClient(sessionStore, cryptoStore); + client = makeTestClient(cryptoStore); megolmDecryption = new MegolmDecryption({ userId: '@user:id', diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.ts similarity index 83% rename from spec/unit/crypto/cross-signing.spec.js rename to spec/unit/crypto/cross-signing.spec.ts index 780aea800..30c1bf82c 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.ts @@ -17,12 +17,16 @@ limitations under the License. import '../../olm-loader'; import anotherjson from 'another-json'; +import { PkSigning } from '@matrix-org/olm'; import * as olmlib from "../../../src/crypto/olmlib"; -import { TestClient } from '../../TestClient'; -import { resetCrossSigningKeys } from "./crypto-utils"; import { MatrixError } from '../../../src/http-api'; import { logger } from '../../../src/logger'; +import { ICrossSigningKey, ICreateClientOpts, ISignedKey } from '../../../src/client'; +import { CryptoEvent } from '../../../src/crypto'; +import { IDevice } from '../../../src/crypto/deviceinfo'; +import { TestClient } from '../../TestClient'; +import { resetCrossSigningKeys } from "./crypto-utils"; const PUSH_RULES_RESPONSE = { method: "GET", @@ -47,9 +51,11 @@ function setHttpResponses(httpBackend, responses) { }); } -async function makeTestClient(userInfo, options, keys) { - if (!keys) keys = {}; - +async function makeTestClient( + userInfo: { userId: string, deviceId: string}, + options: Partial = {}, + keys = {}, +) { function getCrossSigningKey(type) { return keys[type]; } @@ -58,7 +64,6 @@ async function makeTestClient(userInfo, options, keys) { Object.assign(keys, k); } - if (!options) options = {}; options.cryptoCallbacks = Object.assign( {}, { getCrossSigningKey, saveCrossSigningKeys }, options.cryptoCallbacks || {}, ); @@ -86,20 +91,21 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => { + alice.uploadDeviceSigningKeys = jest.fn().mockImplementation(async (auth, keys) => { await olmlib.verifySignature( alice.crypto.olmDevice, keys.master_key, "@alice:example.com", "Osborne2", alice.crypto.olmDevice.deviceEd25519Key, ); }); - alice.uploadKeySignatures = async () => {}; - alice.setAccountData = async () => {}; - alice.getAccountDataFromServer = async () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); + alice.setAccountData = async () => ({}); + alice.getAccountDataFromServer = async () => ({} as T); // set Alice's cross-signing key await alice.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => await func({}), + authUploadDeviceSigningKeys: async func => { await func({}); }, }); expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled(); + alice.stopClient(); }); it("should abort bootstrap if device signing auth fails", async function() { @@ -133,9 +139,9 @@ describe("Cross Signing", function() { error.httpStatus == 401; throw error; }; - alice.uploadKeySignatures = async () => {}; - alice.setAccountData = async () => {}; - alice.getAccountDataFromServer = async () => { }; + alice.uploadKeySignatures = async () => ({ failures: {} }); + alice.setAccountData = async () => ({}); + alice.getAccountDataFromServer = async (): Promise => ({} as T); const authUploadDeviceSigningKeys = async func => await func({}); // Try bootstrap, expecting `authUploadDeviceSigningKeys` to pass @@ -151,14 +157,15 @@ describe("Cross Signing", function() { } } expect(bootstrapDidThrow).toBeTruthy(); + alice.stopClient(); }); it("should upload a signature when a user is verified", async function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's device key @@ -172,16 +179,20 @@ describe("Cross Signing", function() { }, }, }, + firstUse: false, + crossSigningVerifiedBefore: false, }); // Alice verifies Bob's key const promise = new Promise((resolve, reject) => { - alice.uploadKeySignatures = (...args) => { + alice.uploadKeySignatures = async (...args) => { resolve(...args); + return { failures: {} }; }; }); await alice.setDeviceVerified("@bob:example.com", "bobs+master+pubkey", true); // Alice should send a signature of Bob's key to the server await promise; + alice.stopClient(); }); it.skip("should get cross-signing keys from sync", async function() { @@ -203,7 +214,7 @@ describe("Cross Signing", function() { { cryptoCallbacks: { // will be called to sign our own device - getCrossSigningKey: type => { + getCrossSigningKey: async type => { if (type === 'master') { return masterKey; } else { @@ -215,7 +226,7 @@ describe("Cross Signing", function() { ); const keyChangePromise = new Promise((resolve, reject) => { - alice.once("crossSigning.keysChanged", async (e) => { + alice.once(CryptoEvent.KeysChanged, async (e) => { resolve(e); await alice.checkOwnCrossSigningTrust({ allowPrivateKeyRequests: true, @@ -223,14 +234,14 @@ describe("Cross Signing", function() { }); }); - const uploadSigsPromise = new Promise((resolve, reject) => { - alice.uploadKeySignatures = jest.fn(async (content) => { + const uploadSigsPromise = new Promise((resolve, reject) => { + alice.uploadKeySignatures = jest.fn().mockImplementation(async (content) => { try { await olmlib.verifySignature( alice.crypto.olmDevice, content["@alice:example.com"][ "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk" - ], + ], "@alice:example.com", "Osborne2", alice.crypto.olmDevice.deviceEd25519Key, ); @@ -246,16 +257,22 @@ describe("Cross Signing", function() { }); }); + // @ts-ignore private property const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", + keys: deviceInfo.keys, + algorithms: deviceInfo.algorithms, }; - aliceDevice.keys = deviceInfo.keys; - aliceDevice.algorithms = deviceInfo.algorithms; await alice.crypto.signObject(aliceDevice); - olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com"); + olmlib.pkSign( + aliceDevice as ISignedKey, + selfSigningKey as unknown as PkSigning, + "@alice:example.com", + '', + ); // feed sync result that includes master key, ssk, device key const responses = [ @@ -353,14 +370,15 @@ describe("Cross Signing", function() { expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy(); expect(aliceDeviceTrust.isTofu()).toBeTruthy(); expect(aliceDeviceTrust.isVerified()).toBeTruthy(); + alice.stopClient(); }); it("should use trust chain to determine device verification", async function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's ssk and device key @@ -370,7 +388,7 @@ describe("Cross Signing", function() { const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK = { + const bobSSK: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -394,10 +412,10 @@ describe("Cross Signing", function() { }, self_signing: bobSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); - const bobDevice = { + const bobDeviceUnsigned = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], @@ -406,11 +424,16 @@ describe("Cross Signing", function() { "ed25519:Dynabook": "someOtherPubkey", }, }; - const sig = bobSigning.sign(anotherjson.stringify(bobDevice)); - bobDevice.signatures = { - "@bob:example.com": { - ["ed25519:" + bobPubkey]: sig, + const sig = bobSigning.sign(anotherjson.stringify(bobDeviceUnsigned)); + const bobDevice: IDevice = { + ...bobDeviceUnsigned, + signatures: { + "@bob:example.com": { + ["ed25519:" + bobPubkey]: sig, + }, }, + verified: 0, + known: false, }; alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, @@ -425,7 +448,7 @@ describe("Cross Signing", function() { expect(bobDeviceTrust.isTofu()).toBeTruthy(); // Alice verifies Bob's SSK - alice.uploadKeySignatures = () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); // Bob's device key should be trusted @@ -437,10 +460,11 @@ describe("Cross Signing", function() { expect(bobDeviceTrust2.isCrossSigningVerified()).toBeTruthy(); expect(bobDeviceTrust2.isLocallyVerified()).toBeFalsy(); expect(bobDeviceTrust2.isTofu()).toBeTruthy(); + alice.stopClient(); }); it.skip("should trust signatures received from other devices", async function() { - const aliceKeys = {}; + const aliceKeys: Record = {}; const { client: alice, httpBackend } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, null, @@ -448,8 +472,8 @@ describe("Cross Signing", function() { ); alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com"); alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {}; - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); @@ -461,28 +485,29 @@ describe("Cross Signing", function() { 0x34, 0xf2, 0x4b, 0x64, 0x9b, 0x52, 0xf8, 0x5f, ]); - const keyChangePromise = new Promise((resolve, reject) => { - alice.crypto.deviceList.once("userCrossSigningUpdated", (userId) => { + const keyChangePromise = new Promise((resolve, reject) => { + alice.crypto.deviceList.once(CryptoEvent.UserCrossSigningUpdated, (userId) => { if (userId === "@bob:example.com") { resolve(); } }); }); + // @ts-ignore private property const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", device_id: "Osborne2", + keys: deviceInfo.keys, + algorithms: deviceInfo.algorithms, }; - aliceDevice.keys = deviceInfo.keys; - aliceDevice.algorithms = deviceInfo.algorithms; await alice.crypto.signObject(aliceDevice); const bobOlmAccount = new global.Olm.Account(); bobOlmAccount.create(); const bobKeys = JSON.parse(bobOlmAccount.identity_keys()); - const bobDevice = { + const bobDeviceUnsigned = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], @@ -491,15 +516,25 @@ describe("Cross Signing", function() { "curve25519:Dynabook": bobKeys.curve25519, }, }; - const deviceStr = anotherjson.stringify(bobDevice); - bobDevice.signatures = { - "@bob:example.com": { - "ed25519:Dynabook": bobOlmAccount.sign(deviceStr), + const deviceStr = anotherjson.stringify(bobDeviceUnsigned); + const bobDevice: IDevice = { + ...bobDeviceUnsigned, + signatures: { + "@bob:example.com": { + "ed25519:Dynabook": bobOlmAccount.sign(deviceStr), + }, }, + verified: 0, + known: false, }; - olmlib.pkSign(bobDevice, selfSigningKey, "@bob:example.com"); + olmlib.pkSign( + bobDevice, + selfSigningKey as unknown as PkSigning, + "@bob:example.com", + '', + ); - const bobMaster = { + const bobMaster: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["master"], keys: { @@ -507,7 +542,7 @@ describe("Cross Signing", function() { "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk", }, }; - olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com"); + olmlib.pkSign(bobMaster, aliceKeys.user_signing, "@alice:example.com", ''); // Alice downloads Bob's keys // - device key @@ -600,14 +635,15 @@ describe("Cross Signing", function() { expect(bobDeviceTrust.isCrossSigningVerified()).toBeTruthy(); expect(bobDeviceTrust.isLocallyVerified()).toBeFalsy(); expect(bobDeviceTrust.isTofu()).toBeTruthy(); + alice.stopClient(); }); it("should dis-trust an unsigned device", async function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's ssk and device key @@ -618,7 +654,7 @@ describe("Cross Signing", function() { const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK = { + const bobSSK: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -642,8 +678,8 @@ describe("Cross Signing", function() { }, self_signing: bobSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); const bobDevice = { user_id: "@bob:example.com", @@ -655,7 +691,7 @@ describe("Cross Signing", function() { }, }; alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { - Dynabook: bobDevice, + Dynabook: bobDevice as unknown as IDevice, }); // Bob's device key should be untrusted const bobDeviceTrust = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); @@ -669,14 +705,15 @@ describe("Cross Signing", function() { const bobDeviceTrust2 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust2.isVerified()).toBeFalsy(); expect(bobDeviceTrust2.isTofu()).toBeFalsy(); + alice.stopClient(); }); it("should dis-trust a user when their ssk changes", async function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); await resetCrossSigningKeys(alice); // Alice downloads Bob's keys const bobMasterSigning = new global.Olm.PkSigning(); @@ -685,7 +722,7 @@ describe("Cross Signing", function() { const bobSigning = new global.Olm.PkSigning(); const bobPrivkey = bobSigning.generate_seed(); const bobPubkey = bobSigning.init_with_seed(bobPrivkey); - const bobSSK = { + const bobSSK: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -709,10 +746,10 @@ describe("Cross Signing", function() { }, self_signing: bobSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); - const bobDevice = { + const bobDeviceUnsigned = { user_id: "@bob:example.com", device_id: "Dynabook", algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], @@ -721,16 +758,23 @@ describe("Cross Signing", function() { "ed25519:Dynabook": "someOtherPubkey", }, }; - const bobDeviceString = anotherjson.stringify(bobDevice); + const bobDeviceString = anotherjson.stringify(bobDeviceUnsigned); const sig = bobSigning.sign(bobDeviceString); - bobDevice.signatures = {}; - bobDevice.signatures["@bob:example.com"] = {}; - bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig; + const bobDevice: IDevice = { + ...bobDeviceUnsigned, + verified: 0, + known: false, + signatures: { + "@bob:example.com": { + ["ed25519:" + bobPubkey]: sig, + }, + }, + }; alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Alice verifies Bob's SSK - alice.uploadKeySignatures = () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey, true); // Bob's device key should be trusted @@ -745,7 +789,7 @@ describe("Cross Signing", function() { const bobSigning2 = new global.Olm.PkSigning(); const bobPrivkey2 = bobSigning2.generate_seed(); const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2); - const bobSSK2 = { + const bobSSK2: ICrossSigningKey = { user_id: "@bob:example.com", usage: ["self_signing"], keys: { @@ -769,8 +813,8 @@ describe("Cross Signing", function() { }, self_signing: bobSSK2, }, - firstUse: 0, - unsigned: {}, + firstUse: false, + crossSigningVerifiedBefore: false, }); // Bob's and his device should be untrusted const bobTrust = alice.checkUserTrust("@bob:example.com"); @@ -782,7 +826,7 @@ describe("Cross Signing", function() { expect(bobDeviceTrust2.isTofu()).toBeFalsy(); // Alice verifies Bob's SSK - alice.uploadKeySignatures = () => {}; + alice.uploadKeySignatures = async () => ({ failures: {} }); await alice.setDeviceVerified("@bob:example.com", bobMasterPubkey2, true); // Bob should be trusted but not his device @@ -805,6 +849,7 @@ describe("Cross Signing", function() { const bobDeviceTrust4 = alice.checkDeviceTrust("@bob:example.com", "Dynabook"); expect(bobDeviceTrust4.isCrossSigningVerified()).toBeTruthy(); + alice.stopClient(); }); it("should offer to upgrade device verifications to cross-signing", async function() { @@ -814,20 +859,21 @@ describe("Cross Signing", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - shouldUpgradeDeviceVerifications: (verifs) => { + shouldUpgradeDeviceVerifications: async (verifs) => { expect(verifs.users["@bob:example.com"]).toBeDefined(); upgradeResolveFunc(); return ["@bob:example.com"]; }, }, }, + ); const { client: bob } = await makeTestClient( { userId: "@bob:example.com", deviceId: "Dynabook" }, ); - bob.uploadDeviceSigningKeys = async () => {}; - bob.uploadKeySignatures = async () => {}; + bob.uploadDeviceSigningKeys = async () => ({}); + bob.uploadKeySignatures = async () => ({ failures: {} }); // set Bob's cross-signing key await resetCrossSigningKeys(bob); alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { @@ -846,8 +892,8 @@ describe("Cross Signing", function() { bob.crypto.crossSigningInfo.toStorage(), ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // when alice sets up cross-signing, she should notice that bob's // cross-signing key is signed by his Dynabook, which alice has // verified, and ask if the device verification should be upgraded to a @@ -873,15 +919,17 @@ describe("Cross Signing", function() { upgradePromise = new Promise((resolve) => { upgradeResolveFunc = resolve; }); - alice.crypto.deviceList.emit("userCrossSigningUpdated", "@bob:example.com"); + alice.crypto.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, "@bob:example.com"); await new Promise((resolve) => { - alice.crypto.on("userTrustStatusChanged", resolve); + alice.crypto.on(CryptoEvent.UserTrustStatusChanged, resolve); }); await upgradePromise; const bobTrust3 = alice.checkUserTrust("@bob:example.com"); expect(bobTrust3.isCrossSigningVerified()).toBeTruthy(); expect(bobTrust3.isTofu()).toBeTruthy(); + alice.stopClient(); + bob.stopClient(); }); it( @@ -890,8 +938,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // Generate Alice's SSK etc const aliceMasterSigning = new global.Olm.PkSigning(); @@ -900,7 +948,7 @@ describe("Cross Signing", function() { const aliceSigning = new global.Olm.PkSigning(); const alicePrivkey = aliceSigning.generate_seed(); const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK = { + const aliceSSK: ICrossSigningKey = { user_id: "@alice:example.com", usage: ["self_signing"], keys: { @@ -926,34 +974,42 @@ describe("Cross Signing", function() { }, self_signing: aliceSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); // Alice has a second device that's cross-signed - const aliceCrossSignedDevice = { + const aliceDeviceId = 'Dynabook'; + const aliceUnsignedDevice = { user_id: "@alice:example.com", - device_id: "Dynabook", + device_id: aliceDeviceId, algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": "somePubkey", "ed25519:Dynabook": "someOtherPubkey", }, }; - const sig = aliceSigning.sign(anotherjson.stringify(aliceCrossSignedDevice)); - aliceCrossSignedDevice.signatures = { - "@alice:example.com": { - ["ed25519:" + alicePubkey]: sig, - }, - }; + const sig = aliceSigning.sign(anotherjson.stringify(aliceUnsignedDevice)); + const aliceCrossSignedDevice: IDevice = { + ...aliceUnsignedDevice, + verified: 0, + known: false, + signatures: { + "@alice:example.com": { + ["ed25519:" + alicePubkey]: sig, + }, + } }; alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", { - Dynabook: aliceCrossSignedDevice, + [aliceDeviceId]: aliceCrossSignedDevice, }); // We don't trust the cross-signing keys yet... - expect(alice.checkDeviceTrust(aliceCrossSignedDevice.device_id).isCrossSigningVerified()).toBeFalsy(); + expect( + alice.checkDeviceTrust("@alice:example.com", aliceDeviceId).isCrossSigningVerified(), + ).toBeFalsy(); // ... but we do acknowledge that the device is signed by them - expect(alice.checkIfOwnDeviceCrossSigned(aliceCrossSignedDevice.device_id)).toBeTruthy(); + expect(alice.checkIfOwnDeviceCrossSigned(aliceDeviceId)).toBeTruthy(); + alice.stopClient(); }, ); @@ -961,8 +1017,8 @@ describe("Cross Signing", function() { const { client: alice } = await makeTestClient( { userId: "@alice:example.com", deviceId: "Osborne2" }, ); - alice.uploadDeviceSigningKeys = async () => {}; - alice.uploadKeySignatures = async () => {}; + alice.uploadDeviceSigningKeys = async () => ({}); + alice.uploadKeySignatures = async () => ({ failures: {} }); // Generate Alice's SSK etc const aliceMasterSigning = new global.Olm.PkSigning(); @@ -971,7 +1027,7 @@ describe("Cross Signing", function() { const aliceSigning = new global.Olm.PkSigning(); const alicePrivkey = aliceSigning.generate_seed(); const alicePubkey = aliceSigning.init_with_seed(alicePrivkey); - const aliceSSK = { + const aliceSSK: ICrossSigningKey = { user_id: "@alice:example.com", usage: ["self_signing"], keys: { @@ -997,14 +1053,14 @@ describe("Cross Signing", function() { }, self_signing: aliceSSK, }, - firstUse: 1, - unsigned: {}, + firstUse: true, + crossSigningVerifiedBefore: false, }); - // Alice has a second device that's also not cross-signed - const aliceNotCrossSignedDevice = { - user_id: "@alice:example.com", - device_id: "Dynabook", + const deviceId = "Dynabook"; + const aliceNotCrossSignedDevice: IDevice = { + verified: 0, + known: false, algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { "curve25519:Dynabook": "somePubkey", @@ -1012,9 +1068,10 @@ describe("Cross Signing", function() { }, }; alice.crypto.deviceList.storeDevicesForUser("@alice:example.com", { - Dynabook: aliceNotCrossSignedDevice, + [deviceId]: aliceNotCrossSignedDevice, }); - expect(alice.checkIfOwnDeviceCrossSigned(aliceNotCrossSignedDevice.device_id)).toBeFalsy(); + expect(alice.checkIfOwnDeviceCrossSigned(deviceId)).toBeFalsy(); + alice.stopClient(); }); }); diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.js b/spec/unit/crypto/outgoing-room-key-requests.spec.ts similarity index 73% rename from spec/unit/crypto/outgoing-room-key-requests.spec.js rename to spec/unit/crypto/outgoing-room-key-requests.spec.ts index 4a18e1765..c572a63eb 100644 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.js +++ b/spec/unit/crypto/outgoing-room-key-requests.spec.ts @@ -43,13 +43,15 @@ const requests = [ describe.each([ ["IndexedDBCryptoStore", - () => new IndexedDBCryptoStore(global.indexedDB, "tests")], + () => new IndexedDBCryptoStore(global.indexedDB, "tests")], ["LocalStorageCryptoStore", - () => new IndexedDBCryptoStore(undefined, "tests")], + () => new IndexedDBCryptoStore(undefined, "tests")], ["MemoryCryptoStore", () => { const store = new IndexedDBCryptoStore(undefined, "tests"); - store._backend = new MemoryCryptoStore(); - store._backendPromise = Promise.resolve(store._backend); + // @ts-ignore set private properties + store.backend = new MemoryCryptoStore(); + // @ts-ignore + store.backendPromise = Promise.resolve(store.backend); return store; }], ])("Outgoing room key requests [%s]", function(name, dbFactory) { @@ -64,22 +66,22 @@ describe.each([ }); it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", - async () => { - const r = await + async () => { + const r = await store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); - expect(r).toHaveLength(2); - requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { - expect(r).toContainEqual(e); + expect(r).toHaveLength(2); + requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { + expect(r).toContainEqual(e); + }); }); - }); test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", - async () => { - const r = + async () => { + const r = await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); - expect(r).not.toBeNull(); - expect(r).not.toBeUndefined(); - expect(r.state).toEqual(RoomKeyRequestState.Sent); - expect(requests).toContainEqual(r); - }); + expect(r).not.toBeNull(); + expect(r).not.toBeUndefined(); + expect(r.state).toEqual(RoomKeyRequestState.Sent); + expect(requests).toContainEqual(r); + }); }); diff --git a/spec/unit/crypto/secrets.spec.js b/spec/unit/crypto/secrets.spec.ts similarity index 89% rename from spec/unit/crypto/secrets.spec.js rename to spec/unit/crypto/secrets.spec.ts index e8a5a4015..7939cdae2 100644 --- a/spec/unit/crypto/secrets.spec.js +++ b/spec/unit/crypto/secrets.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2022 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,15 +24,18 @@ import { encryptAES } from "../../../src/crypto/aes"; import { resetCrossSigningKeys, createSecretStorageKey } from "./crypto-utils"; import { logger } from '../../../src/logger'; import * as utils from "../../../src/utils"; +import { ICreateClientOpts } from '../../../src/client'; +import { ISecretStorageKeyInfo } from '../../../src/crypto/api'; try { + // eslint-disable-next-line @typescript-eslint/no-var-requires const crypto = require('crypto'); utils.setCrypto(crypto); } catch (err) { logger.log('nodejs was compiled without crypto support'); } -async function makeTestClient(userInfo, options) { +async function makeTestClient(userInfo: { userId: string, deviceId: string}, options: Partial = {}) { const client = (new TestClient( userInfo.userId, userInfo.deviceId, undefined, undefined, options, )).client; @@ -46,7 +49,7 @@ async function makeTestClient(userInfo, options) { await client.initCrypto(); // No need to download keys for these tests - client.crypto.downloadKeys = async function() {}; + jest.spyOn(client.crypto, 'downloadKeys').mockResolvedValue({}); return client; } @@ -54,7 +57,7 @@ async function makeTestClient(userInfo, options) { // Wrapper around pkSign to return a signed object. pkSign returns the // signature, rather than the signed object. function sign(obj, key, userId) { - olmlib.pkSign(obj, key, userId); + olmlib.pkSign(obj, key, userId, ''); return obj; } @@ -84,7 +87,7 @@ describe("Secrets", function() { }, }; - const getKey = jest.fn(e => { + const getKey = jest.fn().mockImplementation(async e => { expect(Object.keys(e.keys)).toEqual(["abc"]); return ['abc', key]; }); @@ -93,7 +96,7 @@ describe("Secrets", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: t => signingKey, + getCrossSigningKey: async t => signingKey, getSecretStorageKey: getKey, }, }, @@ -104,17 +107,19 @@ describe("Secrets", function() { const secretStorage = alice.crypto.secretStorage; - alice.setAccountData = async function(eventType, contents, callback) { - alice.store.storeAccountDataEvents([ - new MatrixEvent({ - type: eventType, - content: contents, - }), - ]); - if (callback) { - callback(); - } - }; + jest.spyOn(alice, 'setAccountData').mockImplementation( + async function(eventType, contents, callback) { + alice.store.storeAccountDataEvents([ + new MatrixEvent({ + type: eventType, + content: contents, + }), + ]); + if (callback) { + callback(undefined, undefined); + } + return {}; + }); const keyAccountData = { algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, @@ -136,6 +141,7 @@ describe("Secrets", function() { expect(await secretStorage.get("foo")).toBe("bar"); expect(getKey).toHaveBeenCalled(); + alice.stopClient(); }); it("should throw if given a key that doesn't exist", async function() { @@ -150,6 +156,7 @@ describe("Secrets", function() { expect(true).toBeFalsy(); } catch (e) { } + alice.stopClient(); }); it("should refuse to encrypt with zero keys", async function() { @@ -162,12 +169,13 @@ describe("Secrets", function() { expect(true).toBeFalsy(); } catch (e) { } + alice.stopClient(); }); it("should encrypt with default key if keys is null", async function() { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn(e => { + const getKey = jest.fn().mockImplementation(async e => { expect(Object.keys(e.keys)).toEqual([newKeyId]); return [newKeyId, key]; }); @@ -190,11 +198,12 @@ describe("Secrets", function() { content: contents, }), ]); + return {}; }; resetCrossSigningKeys(alice); const { keyId: newKeyId } = await alice.addSecretStorageKey( - SECRET_STORAGE_ALGORITHM_V1_AES, + SECRET_STORAGE_ALGORITHM_V1_AES, { pubkey: undefined, key: undefined }, ); // we don't await on this because it waits for the event to come down the sync // which won't happen in the test setup @@ -203,6 +212,7 @@ describe("Secrets", function() { const accountData = alice.getAccountData('foo'); expect(accountData.getContent().encrypted).toBeTruthy(); + alice.stopClient(); }); it("should refuse to encrypt if no keys given and no default key", async function() { @@ -215,10 +225,11 @@ describe("Secrets", function() { expect(true).toBeFalsy(); } catch (e) { } + alice.stopClient(); }); it("should request secrets from other clients", async function() { - const [osborne2, vax] = await makeTestClients( + const [[osborne2, vax], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@alice:example.com", deviceId: "VAX" }, @@ -273,6 +284,9 @@ describe("Secrets", function() { const secret = await request.promise; expect(secret).toBe("bar"); + osborne2.stop(); + vax.stop(); + clearTestClientTimeouts(); }); describe("bootstrap", function() { @@ -298,7 +312,7 @@ describe("Secrets", function() { it("bootstraps when no storage or cross-signing keys locally", async function() { const key = new Uint8Array(16); for (let i = 0; i < 16; i++) key[i] = i; - const getKey = jest.fn(e => { + const getKey = jest.fn().mockImplementation(async e => { return [Object.keys(e.keys)[0], key]; }); @@ -313,8 +327,8 @@ describe("Secrets", function() { }, }, ); - bob.uploadDeviceSigningKeys = async () => {}; - bob.uploadKeySignatures = async () => {}; + bob.uploadDeviceSigningKeys = async () => ({}); + bob.uploadKeySignatures = jest.fn().mockResolvedValue(undefined); bob.setAccountData = async function(eventType, contents, callback) { const event = new MatrixEvent({ type: eventType, @@ -324,10 +338,11 @@ describe("Secrets", function() { event, ]); this.emit("accountData", event); + return {}; }; await bob.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async func => await func({}), + authUploadDeviceSigningKeys: async func => { await func({}); }, }); await bob.bootstrapSecretStorage({ createSecretStorageKey, @@ -340,6 +355,7 @@ describe("Secrets", function() { expect(await crossSigning.isStoredInSecretStorage(secretStorage)) .toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy(); + bob.stopClient(); }); it("bootstraps when cross-signing keys in secret storage", async function() { @@ -406,10 +422,11 @@ describe("Secrets", function() { expect(await crossSigning.isStoredInSecretStorage(secretStorage)) .toBeTruthy(); expect(await secretStorage.hasKey()).toBeTruthy(); + bob.stopClient(); }); it("adds passphrase checking if it's lacking", async function() { - let crossSigningKeys = { + let crossSigningKeys: Record = { master: XSK, user_signing: USK, self_signing: SSK, @@ -421,9 +438,9 @@ describe("Secrets", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: t => crossSigningKeys[t], + getCrossSigningKey: async t => crossSigningKeys[t], saveCrossSigningKeys: k => crossSigningKeys = k, - getSecretStorageKey: ({ keys }, name) => { + getSecretStorageKey: async ({ keys }, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { return [keyId, secretStorageKeys[keyId]]; @@ -479,6 +496,8 @@ describe("Secrets", function() { }), ]); alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { + firstUse: false, + crossSigningVerifiedBefore: false, keys: { master: { user_id: "@alice:example.com", @@ -519,14 +538,15 @@ describe("Secrets", function() { }); alice.store.storeAccountDataEvents([event]); this.emit("accountData", event); + return {}; }; - await alice.bootstrapSecretStorage(); + await alice.bootstrapSecretStorage({}); expect(alice.getAccountData("m.secret_storage.default_key").getContent()) .toEqual({ key: "key_id" }); const keyInfo = alice.getAccountData("m.secret_storage.key.key_id") - .getContent(); + .getContent() as ISecretStorageKeyInfo; expect(keyInfo.algorithm) .toEqual("m.secret_storage.v1.aes-hmac-sha2"); expect(keyInfo.passphrase).toEqual({ @@ -538,9 +558,10 @@ describe("Secrets", function() { expect(keyInfo).toHaveProperty("mac"); expect(alice.checkSecretStorageKey(secretStorageKeys.key_id, keyInfo)) .toBeTruthy(); + alice.stopClient(); }); it("fixes backup keys in the wrong format", async function() { - let crossSigningKeys = { + let crossSigningKeys: Record = { master: XSK, user_signing: USK, self_signing: SSK, @@ -552,9 +573,9 @@ describe("Secrets", function() { { userId: "@alice:example.com", deviceId: "Osborne2" }, { cryptoCallbacks: { - getCrossSigningKey: t => crossSigningKeys[t], + getCrossSigningKey: async t => crossSigningKeys[t], saveCrossSigningKeys: k => crossSigningKeys = k, - getSecretStorageKey: ({ keys }, name) => { + getSecretStorageKey: async ({ keys }, name) => { for (const keyId of Object.keys(keys)) { if (secretStorageKeys[keyId]) { return [keyId, secretStorageKeys[keyId]]; @@ -619,6 +640,8 @@ describe("Secrets", function() { }), ]); alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { + firstUse: false, + crossSigningVerifiedBefore: false, keys: { master: { user_id: "@alice:example.com", @@ -659,15 +682,17 @@ describe("Secrets", function() { }); alice.store.storeAccountDataEvents([event]); this.emit("accountData", event); + return {}; }; - await alice.bootstrapSecretStorage(); + await alice.bootstrapSecretStorage({}); const backupKey = alice.getAccountData("m.megolm_backup.v1") .getContent(); expect(backupKey.encrypted).toHaveProperty("key_id"); expect(await alice.getSecret("m.megolm_backup.v1")) .toEqual("ey0GB1kB6jhOWgwiBUMIWg=="); + alice.stopClient(); }); }); }); diff --git a/spec/unit/crypto/verification/request.spec.js b/spec/unit/crypto/verification/request.spec.js index 6b09e58cf..e530344e2 100644 --- a/spec/unit/crypto/verification/request.spec.js +++ b/spec/unit/crypto/verification/request.spec.js @@ -40,7 +40,7 @@ describe("verification request integration tests with crypto layer", function() }); it("should request and accept a verification", async function() { - const [alice, bob] = await makeTestClients( + const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -78,5 +78,9 @@ describe("verification request integration tests with crypto layer", function() // XXX: Private function access (but it's a test, so we're okay) aliceVerifier.endTimer(); + + alice.stop(); + bob.stop(); + clearTestClientTimeouts(); }); }); diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.js index fcb73de29..0a57e55a3 100644 --- a/spec/unit/crypto/verification/sas.spec.js +++ b/spec/unit/crypto/verification/sas.spec.js @@ -75,9 +75,10 @@ describe("SAS verification", function() { let bobSasEvent; let aliceVerifier; let bobPromise; + let clearTestClientTimeouts; beforeEach(async () => { - [alice, bob] = await makeTestClients( + [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -178,6 +179,8 @@ describe("SAS verification", function() { alice.stop(), bob.stop(), ]); + + clearTestClientTimeouts(); }); it("should verify a key", async () => { @@ -334,7 +337,7 @@ describe("SAS verification", function() { }); it("should send a cancellation message on error", async function() { - const [alice, bob] = await makeTestClients( + const [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -377,6 +380,10 @@ describe("SAS verification", function() { .not.toHaveBeenCalled(); expect(bob.client.setDeviceVerified) .not.toHaveBeenCalled(); + + alice.stop(); + bob.stop(); + clearTestClientTimeouts(); }); describe("verification in DM", function() { @@ -386,9 +393,10 @@ describe("SAS verification", function() { let bobSasEvent; let aliceVerifier; let bobPromise; + let clearTestClientTimeouts; beforeEach(async function() { - [alice, bob] = await makeTestClients( + [[alice, bob], clearTestClientTimeouts] = await makeTestClients( [ { userId: "@alice:example.com", deviceId: "Osborne2" }, { userId: "@bob:example.com", deviceId: "Dynabook" }, @@ -488,6 +496,8 @@ describe("SAS verification", function() { alice.stop(), bob.stop(), ]); + + clearTestClientTimeouts(); }); it("should verify a key", async function() { diff --git a/spec/unit/crypto/verification/util.js b/spec/unit/crypto/verification/util.js index a6532dff1..572a4b270 100644 --- a/spec/unit/crypto/verification/util.js +++ b/spec/unit/crypto/verification/util.js @@ -23,6 +23,7 @@ import { logger } from '../../../../src/logger'; export async function makeTestClients(userInfos, options) { const clients = []; + const timeouts = []; const clientMap = {}; const sendToDevice = function(type, map) { // logger.log(this.getUserId(), "sends", type, map); @@ -66,7 +67,7 @@ export async function makeTestClients(userInfos, options) { }, })); - setImmediate(() => { + const timeout = setTimeout(() => { for (const tc of clients) { if (tc.client === this) { // eslint-disable-line @babel/no-invalid-this logger.log("sending remote echo!!"); @@ -77,6 +78,8 @@ export async function makeTestClients(userInfos, options) { } }); + timeouts.push(timeout); + return Promise.resolve({ event_id: eventId }); }; @@ -103,7 +106,11 @@ export async function makeTestClients(userInfos, options) { await Promise.all(clients.map((testClient) => testClient.client.initCrypto())); - return clients; + const destroy = () => { + timeouts.forEach((t) => clearTimeout(t)); + }; + + return [clients, destroy]; } export function setupWebcrypto() { diff --git a/spec/unit/event-mapper.spec.ts b/spec/unit/event-mapper.spec.ts index a46c955b7..a444c34fb 100644 --- a/spec/unit/event-mapper.spec.ts +++ b/spec/unit/event-mapper.spec.ts @@ -44,6 +44,10 @@ describe("eventMapperFor", function() { rooms = []; }); + afterEach(() => { + client.stopClient(); + }); + it("should de-duplicate MatrixEvent instances by means of findEventById on the room object", async () => { const roomId = "!room:example.org"; const room = new Room(roomId, client, userId); diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts new file mode 100644 index 000000000..42f4bca4d --- /dev/null +++ b/spec/unit/event-timeline-set.spec.ts @@ -0,0 +1,294 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as utils from "../test-utils/test-utils"; +import { + EventTimeline, + EventTimelineSet, + EventType, + MatrixClient, + MatrixEvent, + MatrixEventEvent, + Room, + DuplicateStrategy, +} from '../../src'; +import { Thread } from "../../src/models/thread"; +import { ReEmitter } from "../../src/ReEmitter"; + +describe('EventTimelineSet', () => { + const roomId = '!foo:bar'; + const userA = "@alice:bar"; + + let room: Room; + let eventTimeline: EventTimeline; + let eventTimelineSet: EventTimelineSet; + let client: MatrixClient; + + let messageEvent: MatrixEvent; + let replyEvent: MatrixEvent; + + const itShouldReturnTheRelatedEvents = () => { + it('should return the related events', () => { + eventTimelineSet.relations.aggregateChildEvent(messageEvent); + const relations = eventTimelineSet.relations.getChildEventsForEvent( + messageEvent.getId(), + "m.in_reply_to", + EventType.RoomMessage, + ); + expect(relations).toBeDefined(); + expect(relations.getRelations().length).toBe(1); + expect(relations.getRelations()[0].getId()).toBe(replyEvent.getId()); + }); + }; + + beforeEach(() => { + client = utils.mock(MatrixClient, 'MatrixClient'); + client.reEmitter = utils.mock(ReEmitter, 'ReEmitter'); + room = new Room(roomId, client, userA); + eventTimelineSet = new EventTimelineSet(room); + eventTimeline = new EventTimeline(eventTimelineSet); + messageEvent = utils.mkMessage({ + room: roomId, + user: userA, + msg: 'Hi!', + event: true, + }); + replyEvent = utils.mkReplyMessage({ + room: roomId, + user: userA, + msg: 'Hoo!', + event: true, + replyToMessage: messageEvent, + }); + }); + + describe('addLiveEvent', () => { + it("Adds event to the live timeline in the timeline set", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addLiveEvent(messageEvent); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + }); + + it("should replace a timeline event if dupe strategy is 'replace'", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addLiveEvent(messageEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + + // make a duplicate + const duplicateMessageEvent = utils.mkMessage({ + room: roomId, user: userA, msg: "dupe", event: true, + }); + duplicateMessageEvent.event.event_id = messageEvent.getId(); + + // Adding the duplicate event should replace the `messageEvent` + // because it has the same `event_id` and duplicate strategy is + // replace. + eventTimelineSet.addLiveEvent(duplicateMessageEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + }); + + const eventsInLiveTimeline = liveTimeline.getEvents(); + expect(eventsInLiveTimeline.length).toStrictEqual(1); + expect(eventsInLiveTimeline[0]).toStrictEqual(duplicateMessageEvent); + }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Replace, false)).not.toThrow(); + expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Ignore, true)).not.toThrow(); + }); + }); + + describe('addEventToTimeline', () => { + it("Adds event to timeline", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { + toStartOfTimeline: true, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(() => { + eventTimelineSet.addEventToTimeline( + messageEvent, + liveTimeline, + true, + ); + }).not.toThrow(); + expect(() => { + eventTimelineSet.addEventToTimeline( + messageEvent, + liveTimeline, + true, + false, + ); + }).not.toThrow(); + }); + }); + + describe('aggregateRelations', () => { + describe('with unencrypted events', () => { + beforeEach(() => { + eventTimelineSet.addEventsToTimeline( + [ + messageEvent, + replyEvent, + ], + true, + eventTimeline, + 'foo', + ); + }); + + itShouldReturnTheRelatedEvents(); + }); + + describe('with events to be decrypted', () => { + let messageEventShouldAttemptDecryptionSpy: jest.SpyInstance; + let messageEventIsDecryptionFailureSpy: jest.SpyInstance; + + let replyEventShouldAttemptDecryptionSpy: jest.SpyInstance; + let replyEventIsDecryptionFailureSpy: jest.SpyInstance; + + beforeEach(() => { + messageEventShouldAttemptDecryptionSpy = jest.spyOn(messageEvent, 'shouldAttemptDecryption'); + messageEventShouldAttemptDecryptionSpy.mockReturnValue(true); + messageEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure'); + + replyEventShouldAttemptDecryptionSpy = jest.spyOn(replyEvent, 'shouldAttemptDecryption'); + replyEventShouldAttemptDecryptionSpy.mockReturnValue(true); + replyEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, 'isDecryptionFailure'); + + eventTimelineSet.addEventsToTimeline( + [ + messageEvent, + replyEvent, + ], + true, + eventTimeline, + 'foo', + ); + }); + + it('should not return the related events', () => { + eventTimelineSet.relations.aggregateChildEvent(messageEvent); + const relations = eventTimelineSet.relations.getChildEventsForEvent( + messageEvent.getId(), + "m.in_reply_to", + EventType.RoomMessage, + ); + expect(relations).toBeUndefined(); + }); + + describe('after decryption', () => { + beforeEach(() => { + // simulate decryption failure once + messageEventIsDecryptionFailureSpy.mockReturnValue(true); + replyEventIsDecryptionFailureSpy.mockReturnValue(true); + + messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent); + replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent); + + // simulate decryption + messageEventIsDecryptionFailureSpy.mockReturnValue(false); + replyEventIsDecryptionFailureSpy.mockReturnValue(false); + + messageEventShouldAttemptDecryptionSpy.mockReturnValue(false); + replyEventShouldAttemptDecryptionSpy.mockReturnValue(false); + + messageEvent.emit(MatrixEventEvent.Decrypted, messageEvent); + replyEvent.emit(MatrixEventEvent.Decrypted, replyEvent); + }); + + itShouldReturnTheRelatedEvents(); + }); + }); + }); + + describe("canContain", () => { + const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + "event_id": root.getId(), + }, + "rel_type": "m.thread", + }, + }, + }, room.client); + + let thread: Thread; + + beforeEach(() => { + (client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true); + thread = new Thread("!thread_id:server", messageEvent, { room, client }); + }); + + it("should throw if timeline set has no room", () => { + const eventTimelineSet = new EventTimelineSet(undefined, {}, client); + expect(() => eventTimelineSet.canContain(messageEvent)).toThrowError(); + }); + + it("should return false if timeline set is for thread but event is not threaded", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + expect(eventTimelineSet.canContain(replyEvent)).toBeFalsy(); + }); + + it("should return false if timeline set it for thread but event it for a different thread", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + const event = mkThreadResponse(replyEvent); + expect(eventTimelineSet.canContain(event)).toBeFalsy(); + }); + + it("should return false if timeline set is not for a thread but event is a thread response", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client); + const event = mkThreadResponse(replyEvent); + expect(eventTimelineSet.canContain(event)).toBeFalsy(); + }); + + it("should return true if the timeline set is not for a thread and the event is a thread root", () => { + const eventTimelineSet = new EventTimelineSet(room, {}, client); + expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy(); + }); + + it("should return true if the timeline set is for a thread and the event is its thread root", () => { + const thread = new Thread(messageEvent.getId(), messageEvent, { room, client }); + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + messageEvent.setThread(thread); + expect(eventTimelineSet.canContain(messageEvent)).toBeTruthy(); + }); + + it("should return true if the timeline set is for a thread and the event is a response to it", () => { + const thread = new Thread(messageEvent.getId(), messageEvent, { room, client }); + const eventTimelineSet = new EventTimelineSet(room, {}, client, thread); + messageEvent.setThread(thread); + const event = mkThreadResponse(messageEvent); + expect(eventTimelineSet.canContain(event)).toBeTruthy(); + }); + }); +}); diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index c9311d0e3..ed5047c11 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -50,9 +50,11 @@ describe("EventTimeline", function() { timeline.initialiseState(events); expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( events, + { timelineWasEmpty: undefined }, ); expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( events, + { timelineWasEmpty: undefined }, ); }); @@ -73,7 +75,7 @@ describe("EventTimeline", function() { expect(function() { timeline.initialiseState(state); }).not.toThrow(); - timeline.addEvent(event, false); + timeline.addEvent(event, { toStartOfTimeline: false }); expect(function() { timeline.initialiseState(state); }).toThrow(); @@ -149,9 +151,9 @@ describe("EventTimeline", function() { ]; it("should be able to add events to the end", function() { - timeline.addEvent(events[0], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], false); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[0]); @@ -159,9 +161,9 @@ describe("EventTimeline", function() { }); it("should be able to add events to the start", function() { - timeline.addEvent(events[0], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], true); + timeline.addEvent(events[1], { toStartOfTimeline: true }); expect(timeline.getBaseIndex()).toEqual(initialIndex + 1); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[1]); @@ -203,9 +205,9 @@ describe("EventTimeline", function() { content: { name: "Old Room Name" }, }); - timeline.addEvent(newEv, false); + timeline.addEvent(newEv, { toStartOfTimeline: false }); expect(newEv.sender).toEqual(sentinel); - timeline.addEvent(oldEv, true); + timeline.addEvent(oldEv, { toStartOfTimeline: true }); expect(oldEv.sender).toEqual(oldSentinel); }); @@ -242,9 +244,9 @@ describe("EventTimeline", function() { const oldEv = utils.mkMembership({ room: roomId, mship: "ban", user: userB, skey: userA, event: true, }); - timeline.addEvent(newEv, false); + timeline.addEvent(newEv, { toStartOfTimeline: false }); expect(newEv.target).toEqual(sentinel); - timeline.addEvent(oldEv, true); + timeline.addEvent(oldEv, { toStartOfTimeline: true }); expect(oldEv.target).toEqual(oldSentinel); }); @@ -262,13 +264,13 @@ describe("EventTimeline", function() { }), ]; - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). - toHaveBeenCalledWith([events[0]]); + toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined }); expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -291,13 +293,13 @@ describe("EventTimeline", function() { }), ]; - timeline.addEvent(events[0], true); - timeline.addEvent(events[1], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); + timeline.addEvent(events[1], { toStartOfTimeline: true }); expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). - toHaveBeenCalledWith([events[0]]); + toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined }); expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); @@ -305,6 +307,11 @@ describe("EventTimeline", function() { expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). not.toHaveBeenCalled(); }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => timeline.addEvent(events[0], { toStartOfTimeline: true })).not.toThrow(); + expect(() => timeline.addEvent(events[0], { stateContext: new RoomState() })).not.toThrow(); + }); }); describe("removeEvent", function() { @@ -324,8 +331,8 @@ describe("EventTimeline", function() { ]; it("should remove events", function() { - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getEvents().length).toEqual(2); let ev = timeline.removeEvent(events[0].getId()); @@ -338,9 +345,9 @@ describe("EventTimeline", function() { }); it("should update baseIndex", function() { - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], true); - timeline.addEvent(events[2], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: true }); + timeline.addEvent(events[2], { toStartOfTimeline: false }); expect(timeline.getEvents().length).toEqual(3); expect(timeline.getBaseIndex()).toEqual(1); @@ -358,11 +365,11 @@ describe("EventTimeline", function() { // further addEvent(ev, false) calls made the index increase. it("should not make baseIndex assplode when removing the last event", function() { - timeline.addEvent(events[0], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); timeline.removeEvent(events[0].getId()); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], false); - timeline.addEvent(events[2], false); + timeline.addEvent(events[1], { toStartOfTimeline: false }); + timeline.addEvent(events[2], { toStartOfTimeline: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); }); diff --git a/spec/unit/filter-component.spec.ts b/spec/unit/filter-component.spec.ts index 47ffb37cf..a0a337cd1 100644 --- a/spec/unit/filter-component.spec.ts +++ b/spec/unit/filter-component.spec.ts @@ -1,7 +1,4 @@ -import { - MatrixEvent, - RelationType, -} from "../../src"; +import { RelationType } from "../../src"; import { FilterComponent } from "../../src/filter-component"; import { mkEvent } from '../test-utils/test-utils'; @@ -14,7 +11,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }) as MatrixEvent; + }); const checkResult = filter.check(event); @@ -28,7 +25,7 @@ describe("Filter Component", function() { content: { }, room: 'roomId', event: true, - }) as MatrixEvent; + }); const checkResult = filter.check(event); @@ -55,7 +52,7 @@ describe("Filter Component", function() { }, }, }, - }) as MatrixEvent; + }); expect(filter.check(threadRootNotParticipated)).toBe(false); }); @@ -80,7 +77,7 @@ describe("Filter Component", function() { user: '@someone-else:server.org', room: 'roomId', event: true, - }) as MatrixEvent; + }); expect(filter.check(threadRootParticipated)).toBe(true); }); @@ -100,7 +97,7 @@ describe("Filter Component", function() { [RelationType.Reference]: {}, }, }, - }) as MatrixEvent; + }); expect(filter.check(referenceRelationEvent)).toBe(false); }); @@ -123,7 +120,7 @@ describe("Filter Component", function() { }, room: 'roomId', event: true, - }) as MatrixEvent; + }); const eventWithMultipleRelations = mkEvent({ "type": "m.room.message", @@ -148,7 +145,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }) as MatrixEvent; + }); const noMatchEvent = mkEvent({ "type": "m.room.message", @@ -160,7 +157,7 @@ describe("Filter Component", function() { }, "room": 'roomId', "event": true, - }) as MatrixEvent; + }); expect(filter.check(threadRootEvent)).toBe(true); expect(filter.check(eventWithMultipleRelations)).toBe(true); diff --git a/spec/unit/interactive-auth.spec.js b/spec/unit/interactive-auth.spec.js index da2bf1917..6742d0590 100644 --- a/spec/unit/interactive-auth.spec.js +++ b/spec/unit/interactive-auth.spec.js @@ -18,6 +18,8 @@ limitations under the License. import { logger } from "../../src/logger"; import { InteractiveAuth } from "../../src/interactive-auth"; import { MatrixError } from "../../src/http-api"; +import { sleep } from "../../src/utils"; +import { randomString } from "../../src/randomstring"; // Trivial client object to test interactive auth // (we do not need TestClient here) @@ -172,4 +174,107 @@ describe("InteractiveAuth", function() { expect(error.message).toBe('No appropriate authentication flow found'); }); }); + + describe("requestEmailToken", () => { + it("increases auth attempts", async () => { + const doRequest = jest.fn(); + const stateUpdated = jest.fn(); + const requestEmailToken = jest.fn(); + requestEmailToken.mockImplementation(async () => ({ sid: "" })); + + const ia = new InteractiveAuth({ + matrixClient: new FakeClient(), + doRequest, stateUpdated, requestEmailToken, + }); + + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined); + }); + + it("increases auth attempts", async () => { + const doRequest = jest.fn(); + const stateUpdated = jest.fn(); + const requestEmailToken = jest.fn(); + requestEmailToken.mockImplementation(async () => ({ sid: "" })); + + const ia = new InteractiveAuth({ + matrixClient: new FakeClient(), + doRequest, stateUpdated, requestEmailToken, + }); + + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 1, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 2, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 3, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 4, undefined); + requestEmailToken.mockClear(); + await ia.requestEmailToken(); + expect(requestEmailToken).toHaveBeenLastCalledWith(undefined, ia.getClientSecret(), 5, undefined); + }); + + it("passes errors through", async () => { + const doRequest = jest.fn(); + const stateUpdated = jest.fn(); + const requestEmailToken = jest.fn(); + requestEmailToken.mockImplementation(async () => { + throw new Error("unspecific network error"); + }); + + const ia = new InteractiveAuth({ + matrixClient: new FakeClient(), + doRequest, stateUpdated, requestEmailToken, + }); + + expect(async () => await ia.requestEmailToken()).rejects.toThrowError("unspecific network error"); + }); + + it("only starts one request at a time", async () => { + const doRequest = jest.fn(); + const stateUpdated = jest.fn(); + const requestEmailToken = jest.fn(); + requestEmailToken.mockImplementation(() => sleep(500, { sid: "" })); + + const ia = new InteractiveAuth({ + matrixClient: new FakeClient(), + doRequest, stateUpdated, requestEmailToken, + }); + + await Promise.all([ia.requestEmailToken(), ia.requestEmailToken(), ia.requestEmailToken()]); + expect(requestEmailToken).toHaveBeenCalledTimes(1); + }); + + it("stores result in email sid", async () => { + const doRequest = jest.fn(); + const stateUpdated = jest.fn(); + const requestEmailToken = jest.fn(); + const sid = randomString(24); + requestEmailToken.mockImplementation(() => sleep(500, { sid })); + + const ia = new InteractiveAuth({ + matrixClient: new FakeClient(), + doRequest, stateUpdated, requestEmailToken, + }); + + await ia.requestEmailToken(); + expect(ia.getEmailSid()).toEqual(sid); + }); + }); }); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index d1562d766..fbe8c67d7 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -33,7 +33,7 @@ import { ReceiptType } from "../../src/@types/read_receipts"; import * as testUtils from "../test-utils/test-utils"; import { makeBeaconInfoContent } from "../../src/content-helpers"; import { M_BEACON_INFO } from "../../src/@types/beacon"; -import { Room } from "../../src"; +import { ContentHelpers, Room } from "../../src"; import { makeBeaconEvent } from "../test-utils/beacon"; jest.useFakeTimers(); @@ -87,7 +87,7 @@ describe("MatrixClient", function() { // } // items are popped off when processed and block if no items left. ]; - let acceptKeepalives; + let acceptKeepalives: boolean; let pendingLookup = null; function httpReq(cb, method, path, qp, data, prefix) { if (path === KEEP_ALIVE_PATH && acceptKeepalives) { @@ -118,6 +118,7 @@ describe("MatrixClient", function() { method: method, path: path, }; + pendingLookup.promise.abort = () => {}; // to make it a valid IAbortablePromise return pendingLookup.promise; } if (next.path === path && next.method === method) { @@ -126,7 +127,7 @@ describe("MatrixClient", function() { (next.error ? "BAD" : "GOOD") + " response", ); if (next.expectBody) { - expect(next.expectBody).toEqual(data); + expect(data).toEqual(next.expectBody); } if (next.expectQueryParams) { Object.keys(next.expectQueryParams).forEach(function(k) { @@ -150,6 +151,10 @@ describe("MatrixClient", function() { } return Promise.resolve(next.data); } + // Jest doesn't let us have custom expectation errors, so if you're seeing this then + // you forgot to handle at least 1 pending request. Check your tests to ensure your + // number of expectations lines up with your number of requests made, and that those + // requests match your expectations. expect(true).toBe(false); return new Promise(() => {}); } @@ -205,6 +210,7 @@ describe("MatrixClient", function() { client.http.authedRequest.mockImplementation(function() { return new Promise(() => {}); }); + client.stopClient(); }); it("should create (unstable) file trees", async () => { @@ -725,18 +731,16 @@ describe("MatrixClient", function() { }); describe("guest rooms", function() { - it("should only do /sync calls (without filter/pushrules)", function(done) { - httpLookups = []; // no /pushrules or /filterw + it("should only do /sync calls (without filter/pushrules)", async function() { + httpLookups = []; // no /pushrules or /filter httpLookups.push({ method: "GET", path: "/sync", data: SYNC_DATA, - thenCall: function() { - done(); - }, }); client.setGuest(true); - client.startClient(); + await client.startClient(); + expect(httpLookups.length).toBe(0); }); xit("should be able to peek into a room using peekInRoom", function(done) { @@ -773,7 +777,7 @@ describe("MatrixClient", function() { expectBody: content, }]; - await client.sendEvent(roomId, EventType.RoomMessage, content, txnId); + await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId); }); it("overload with null threadId works", async () => { @@ -786,20 +790,99 @@ describe("MatrixClient", function() { expectBody: content, }]; - await client.sendEvent(roomId, null, EventType.RoomMessage, content, txnId); + await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId); }); it("overload with threadId works", async () => { const eventId = "$eventId:example.org"; const txnId = client.makeTxnId(); + const threadId = "$threadId:server"; httpLookups = [{ method: "PUT", path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, data: { event_id: eventId }, - expectBody: content, + expectBody: { + ...content, + "m.relates_to": { + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, }]; - await client.sendEvent(roomId, "$threadId:server", EventType.RoomMessage, content, txnId); + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: threadId, + }, + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing with reply", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const content = { + body, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + }, + }; + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + "event_id": threadId, + "is_falling_back": false, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); }); }); @@ -926,6 +1009,7 @@ describe("MatrixClient", function() { }; client.crypto = { // mock crypto encryptEvent: (event, room) => new Promise(() => {}), + stop: jest.fn(), }; }); @@ -1104,6 +1188,41 @@ describe("MatrixClient", function() { }); }); + describe("setRoomTopic", () => { + const roomId = "!foofoofoofoofoofoo:matrix.org"; + const createSendStateEventMock = (topic: string, htmlTopic?: string) => { + return jest.fn() + .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomTopic); + expect(content).toMatchObject(ContentHelpers.makeTopicContent(topic, htmlTopic)); + expect(stateKey).toBeUndefined(); + return Promise.resolve(); + }); + }; + + it("is called with plain text topic and sends state event", async () => { + const sendStateEvent = createSendStateEventMock("pizza"); + client.sendStateEvent = sendStateEvent; + await client.setRoomTopic(roomId, "pizza"); + expect(sendStateEvent).toHaveBeenCalledTimes(1); + }); + + it("is called with plain text topic and callback and sends state event", async () => { + const sendStateEvent = createSendStateEventMock("pizza"); + client.sendStateEvent = sendStateEvent; + await client.setRoomTopic(roomId, "pizza", () => {}); + expect(sendStateEvent).toHaveBeenCalledTimes(1); + }); + + it("is called with plain text and HTML topic and sends state event", async () => { + const sendStateEvent = createSendStateEventMock("pizza", "pizza"); + client.sendStateEvent = sendStateEvent; + await client.setRoomTopic(roomId, "pizza", "pizza"); + expect(sendStateEvent).toHaveBeenCalledTimes(1); + }); + }); + describe("setPassword", () => { const auth = { session: 'abcdef', type: 'foo' }; const newPassword = 'newpassword'; @@ -1156,4 +1275,26 @@ describe("MatrixClient", function() { passwordTest({ auth, new_password: newPassword, logout_devices: false }, callback); }); }); + + describe("getLocalAliases", () => { + it("should call the right endpoint", async () => { + const response = { + aliases: ["#woop:example.org", "#another:example.org"], + }; + client.http.authedRequest.mockClear().mockResolvedValue(response); + + const roomId = "!whatever:example.org"; + const result = await client.getLocalAliases(roomId); + + // Current version of the endpoint we support is v3 + const [callback, method, path, queryParams, data, opts] = client.http.authedRequest.mock.calls[0]; + expect(callback).toBeFalsy(); + expect(data).toBeFalsy(); + expect(method).toBe('GET'); + expect(path).toEqual(`/rooms/${encodeURIComponent(roomId)}/aliases`); + expect(opts).toMatchObject({ prefix: "/_matrix/client/v3" }); + expect(queryParams).toBeFalsy(); + expect(result!.aliases).toEqual(response.aliases); + }); + }); }); diff --git a/spec/unit/models/beacon.spec.ts b/spec/unit/models/beacon.spec.ts index dc4058d1c..73b6bc552 100644 --- a/spec/unit/models/beacon.spec.ts +++ b/spec/unit/models/beacon.spec.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixEvent } from "../../../src"; import { isTimestampInDuration, Beacon, @@ -65,33 +66,36 @@ describe('Beacon', () => { // beacon_info events // created 'an hour ago' // without timeout of 3 hours - let liveBeaconEvent; - let notLiveBeaconEvent; - let user2BeaconEvent; + let liveBeaconEvent: MatrixEvent; + let notLiveBeaconEvent: MatrixEvent; + let user2BeaconEvent: MatrixEvent; const advanceDateAndTime = (ms: number) => { // bc liveness check uses Date.now we have to advance this mock - jest.spyOn(global.Date, 'now').mockReturnValue(now + ms); + jest.spyOn(global.Date, 'now').mockReturnValue(Date.now() + ms); // then advance time for the interval by the same amount jest.advanceTimersByTime(ms); }; beforeEach(() => { - // go back in time to create the beacon - jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS); liveBeaconEvent = makeBeaconInfoEvent( userId, roomId, { timeout: HOUR_MS * 3, isLive: true, + timestamp: now - HOUR_MS, }, '$live123', ); notLiveBeaconEvent = makeBeaconInfoEvent( userId, roomId, - { timeout: HOUR_MS * 3, isLive: false }, + { + timeout: HOUR_MS * 3, + isLive: false, + timestamp: now - HOUR_MS, + }, '$dead123', ); user2BeaconEvent = makeBeaconInfoEvent( @@ -100,11 +104,12 @@ describe('Beacon', () => { { timeout: HOUR_MS * 3, isLive: true, + timestamp: now - HOUR_MS, }, '$user2live123', ); - // back to now + // back to 'now' jest.spyOn(global.Date, 'now').mockReturnValue(now); }); @@ -131,17 +136,81 @@ describe('Beacon', () => { }); it('returns false when beacon is expired', () => { - // time travel to beacon creation + 3 hours - jest.spyOn(global.Date, 'now').mockReturnValue(now - 3 * HOUR_MS); - const beacon = new Beacon(liveBeaconEvent); + const expiredBeaconEvent = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now - HOUR_MS * 2, + }, + '$user2live123', + ); + const beacon = new Beacon(expiredBeaconEvent); expect(beacon.isLive).toEqual(false); }); - it('returns false when beacon timestamp is in future', () => { - // time travel to before beacon events timestamp - // event was created now - 1 hour - jest.spyOn(global.Date, 'now').mockReturnValue(now - HOUR_MS - HOUR_MS); - const beacon = new Beacon(liveBeaconEvent); + it('returns false when beacon timestamp is in future by an hour', () => { + const beaconStartsInHour = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now + HOUR_MS, + }, + '$user2live123', + ); + const beacon = new Beacon(beaconStartsInHour); + expect(beacon.isLive).toEqual(false); + }); + + it('returns true when beacon timestamp is one minute in the future', () => { + const beaconStartsInOneMin = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now + 60000, + }, + '$user2live123', + ); + const beacon = new Beacon(beaconStartsInOneMin); + expect(beacon.isLive).toEqual(true); + }); + + it('returns true when beacon timestamp is one minute before expiry', () => { + // this test case is to check the start time leniency doesn't affect + // strict expiry time checks + const expiresInOneMin = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now - HOUR_MS + 60000, + }, + '$user2live123', + ); + const beacon = new Beacon(expiresInOneMin); + expect(beacon.isLive).toEqual(true); + }); + + it('returns false when beacon timestamp is one minute after expiry', () => { + // this test case is to check the start time leniency doesn't affect + // strict expiry time checks + const expiredOneMinAgo = makeBeaconInfoEvent( + userId2, + roomId, + { + timeout: HOUR_MS, + isLive: true, + timestamp: now - HOUR_MS - 60000, + }, + '$user2live123', + ); + const beacon = new Beacon(expiredOneMinAgo); expect(beacon.isLive).toEqual(false); }); @@ -224,13 +293,47 @@ describe('Beacon', () => { beacon.monitorLiveness(); // @ts-ignore - expect(beacon.livenessWatchInterval).toBeFalsy(); + expect(beacon.livenessWatchTimeout).toBeFalsy(); advanceDateAndTime(HOUR_MS * 2 + 1); // no emit expect(emitSpy).not.toHaveBeenCalled(); }); + it('checks liveness of beacon at expected start time', () => { + const futureBeaconEvent = makeBeaconInfoEvent( + userId, + roomId, + { + timeout: HOUR_MS * 3, + isLive: true, + // start timestamp hour in future + timestamp: now + HOUR_MS, + }, + '$live123', + ); + + const beacon = new Beacon(futureBeaconEvent); + expect(beacon.isLive).toBeFalsy(); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.monitorLiveness(); + + // advance to the start timestamp of the beacon + advanceDateAndTime(HOUR_MS + 1); + + // beacon is in live period now + expect(emitSpy).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, true, beacon); + + // check the expiry monitor is still setup ok + // advance to the expiry + advanceDateAndTime(HOUR_MS * 3 + 100); + + expect(emitSpy).toHaveBeenCalledTimes(2); + expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LivenessChange, false, beacon); + }); + it('checks liveness of beacon at expected expiry time', () => { // live beacon was created an hour ago // and has a 3hr duration @@ -253,12 +356,12 @@ describe('Beacon', () => { beacon.monitorLiveness(); // @ts-ignore - const oldMonitor = beacon.livenessWatchInterval; + const oldMonitor = beacon.livenessWatchTimeout; beacon.monitorLiveness(); // @ts-ignore - expect(beacon.livenessWatchInterval).not.toEqual(oldMonitor); + expect(beacon.livenessWatchTimeout).not.toEqual(oldMonitor); }); it('destroy kills liveness monitor and emits', () => { @@ -309,6 +412,57 @@ describe('Beacon', () => { expect(emitSpy).not.toHaveBeenCalled(); }); + describe('when beacon is live with a start timestamp is in the future', () => { + it('ignores locations before the beacon start timestamp', () => { + const startTimestamp = now + 60000; + const beacon = new Beacon(makeBeaconInfoEvent( + userId, + roomId, + { isLive: true, timeout: 60000, timestamp: startTimestamp }, + )); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.addLocations([ + // beacon has now + 60000 live period + makeBeaconEvent( + userId, + { + beaconInfoId: beacon.beaconInfoId, + // now < location timestamp < beacon timestamp + timestamp: now + 10, + }, + ), + ]); + + expect(beacon.latestLocationState).toBeFalsy(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + it('sets latest location when location timestamp is after startTimestamp', () => { + const startTimestamp = now + 60000; + const beacon = new Beacon(makeBeaconInfoEvent( + userId, + roomId, + { isLive: true, timeout: 600000, timestamp: startTimestamp }, + )); + const emitSpy = jest.spyOn(beacon, 'emit'); + + beacon.addLocations([ + // beacon has now + 600000 live period + makeBeaconEvent( + userId, + { + beaconInfoId: beacon.beaconInfoId, + // now < beacon timestamp < location timestamp + timestamp: startTimestamp + 10, + }, + ), + ]); + + expect(beacon.latestLocationState).toBeTruthy(); + expect(emitSpy).toHaveBeenCalled(); + }); + }); + it('sets latest location state to most recent location', () => { const beacon = new Beacon(makeBeaconInfoEvent(userId, roomId, { isLive: true, timeout: 60000 })); const emitSpy = jest.spyOn(beacon, 'emit'); @@ -338,6 +492,7 @@ describe('Beacon', () => { // the newest valid location expect(beacon.latestLocationState).toEqual(expectedLatestLocation); + expect(beacon.latestLocationEvent).toEqual(locations[1]); expect(emitSpy).toHaveBeenCalledWith(BeaconEvent.LocationUpdate, expectedLatestLocation); }); @@ -356,6 +511,7 @@ describe('Beacon', () => { expect(beacon.latestLocationState).toEqual(expect.objectContaining({ uri: 'geo:bar', })); + expect(beacon.latestLocationEvent).toEqual(newerLocation); const emitSpy = jest.spyOn(beacon, 'emit').mockClear(); diff --git a/spec/unit/models/thread.spec.ts b/spec/unit/models/thread.spec.ts new file mode 100644 index 000000000..109af08a3 --- /dev/null +++ b/spec/unit/models/thread.spec.ts @@ -0,0 +1,28 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Thread } from "../../../src/models/thread"; + +describe('Thread', () => { + describe("constructor", () => { + it("should explode for element-web#22141 logging", () => { + // Logging/debugging for https://github.com/vector-im/element-web/issues/22141 + expect(() => { + new Thread("$event", undefined, {} as any); // deliberate cast to test error case + }).toThrow("element-web#22141: A thread requires a room in order to function"); + }); + }); +}); diff --git a/spec/unit/pushprocessor.spec.js b/spec/unit/pushprocessor.spec.js index df7666d5c..0fab43d07 100644 --- a/spec/unit/pushprocessor.spec.js +++ b/spec/unit/pushprocessor.spec.js @@ -302,6 +302,7 @@ describe('NotificationService', function() { type: EventType.RoomServerAcl, room: testRoomId, user: "@alfred:localhost", + skey: "", event: true, content: {}, }); diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index 2472bd8f6..091d95ea9 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -96,19 +96,14 @@ describe("Relations", function() { }, }); - // Stub the room - - const room = new Room("room123", null, null); - // Add the target event first, then the relation event { + const room = new Room("room123", null, null); const relationsCreated = new Promise(resolve => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); - const timelineSet = new EventTimelineSet(room, { - unstableClientRelationAggregation: true, - }); + const timelineSet = new EventTimelineSet(room); timelineSet.addLiveEvent(targetEvent); timelineSet.addLiveEvent(relationEvent); @@ -117,13 +112,12 @@ describe("Relations", function() { // Add the relation event first, then the target event { + const room = new Room("room123", null, null); const relationsCreated = new Promise(resolve => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); - const timelineSet = new EventTimelineSet(room, { - unstableClientRelationAggregation: true, - }); + const timelineSet = new EventTimelineSet(room); timelineSet.addLiveEvent(relationEvent); timelineSet.addLiveEvent(targetEvent); @@ -131,6 +125,14 @@ describe("Relations", function() { } }); + it("should re-use Relations between all timeline sets in a room", async () => { + const room = new Room("room123", null, null); + const timelineSet1 = new EventTimelineSet(room); + const timelineSet2 = new EventTimelineSet(room); + expect(room.relations).toBe(timelineSet1.relations); + expect(room.relations).toBe(timelineSet2.relations); + }); + it("should ignore m.replace for state events", async () => { const userId = "@bob:example.com"; const room = new Room("room123", null, userId); @@ -168,6 +170,8 @@ describe("Relations", function() { await relations.setTargetEvent(originalTopic); expect(originalTopic.replacingEvent()).toBe(null); expect(originalTopic.getContent().topic).toBe("orig"); + expect(badlyEditedTopic.isRelation()).toBe(false); + expect(badlyEditedTopic.isRelation("m.replace")).toBe(false); await relations.addEvent(badlyEditedTopic); expect(originalTopic.replacingEvent()).toBe(null); diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index b353b7aa3..b54121431 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -3,7 +3,7 @@ import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; -import { EventType, RelationType } from "../../src/@types/event"; +import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import { MatrixEvent, MatrixEventEvent, @@ -258,6 +258,29 @@ describe("RoomState", function() { ); }); + it("should emit `RoomStateEvent.Marker` for each marker event", function() { + const events = [ + utils.mkEvent({ + event: true, + type: UNSTABLE_MSC2716_MARKER.name, + room: roomId, + user: userA, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ]; + let emitCount = 0; + state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) { + expect(markerEvent).toEqual(events[emitCount]); + expect(markerFoundOptions).toEqual({ timelineWasEmpty: true }); + emitCount += 1; + }); + state.setStateEvents(events, { timelineWasEmpty: true }); + expect(emitCount).toEqual(1); + }); + describe('beacon events', () => { it('adds new beacon info events to state and emits', () => { const beaconEvent = makeBeaconInfoEvent(userA, roomId); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index a33fccfeb..267c56109 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -52,7 +52,7 @@ describe("Room", function() { event: true, user: userA, room: roomId, - }, room.client) as MatrixEvent; + }, room.client); const mkReply = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -67,7 +67,7 @@ describe("Room", function() { }, }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkEdit = (target: MatrixEvent, salt = Math.random()) => utils.mkEvent({ event: true, @@ -84,7 +84,7 @@ describe("Room", function() { event_id: target.getId(), }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ event: true, @@ -101,7 +101,7 @@ describe("Room", function() { "rel_type": "m.thread", }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkReaction = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -115,7 +115,7 @@ describe("Room", function() { "key": Math.random().toString(), }, }, - }, room.client) as MatrixEvent; + }, room.client); const mkRedaction = (target: MatrixEvent) => utils.mkEvent({ event: true, @@ -124,7 +124,7 @@ describe("Room", function() { room: roomId, redacts: target.getId(), content: {}, - }, room.client) as MatrixEvent; + }, room.client); beforeEach(function() { room = new Room(roomId, new TestClient(userA, "device").client, userA); @@ -133,6 +133,27 @@ describe("Room", function() { room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); + describe('getCreator', () => { + it("should return the creator from m.room.create", function() { + room.currentState.getStateEvents.mockImplementation(function(type, key) { + if (type === EventType.RoomCreate && key === "") { + return utils.mkEvent({ + event: true, + type: EventType.RoomCreate, + skey: "", + room: roomId, + user: userA, + content: { + creator: userA, + }, + }); + } + }); + const roomCreator = room.getCreator(); + expect(roomCreator).toStrictEqual(userA); + }); + }); + describe("getAvatarUrl", function() { const hsUrl = "https://my.home.server"; @@ -189,29 +210,24 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "changing room name", event: true, - }) as MatrixEvent, + }), utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }) as MatrixEvent, + }), ]; - it("should call RoomState.setTypingEvent on m.typing events", function() { - const typing = utils.mkEvent({ - room: roomId, - type: EventType.Typing, - event: true, - content: { - user_ids: [userA], - }, - }); - room.addEphemeralEvents([typing]); - expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing); + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => room.addLiveEvents(events, DuplicateStrategy.Replace, false)).not.toThrow(); + expect(() => room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).not.toThrow(); + expect(() => room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false)).toThrow(); }); it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() { expect(function() { - room.addLiveEvents(events, "foo"); + room.addLiveEvents(events, { + duplicateStrategy: "foo", + }); }).toThrow(); }); @@ -219,11 +235,13 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }) as MatrixEvent; + }); dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); - room.addLiveEvents([dupe], DuplicateStrategy.Replace); + room.addLiveEvents([dupe], { + duplicateStrategy: DuplicateStrategy.Replace, + }); expect(room.timeline[0]).toEqual(dupe); }); @@ -231,11 +249,13 @@ describe("Room", function() { // make a duplicate const dupe = utils.mkMessage({ room: roomId, user: userA, msg: "dupe", event: true, - }) as MatrixEvent; + }); dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); - room.addLiveEvents([dupe], "ignore"); + room.addLiveEvents([dupe], { + duplicateStrategy: "ignore", + }); expect(room.timeline[0]).toEqual(events[0]); }); @@ -257,20 +277,22 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent, + }), utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }) as MatrixEvent, + }), ]; room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { timelineWasEmpty: undefined }, ); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { timelineWasEmpty: undefined }, ); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -296,13 +318,13 @@ describe("Room", function() { it("should emit Room.localEchoUpdated when a local echo is updated", function() { const localEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); localEvent.status = EventStatus.SENDING; const localEventId = localEvent.getId(); const remoteEvent = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; const remoteEventId = remoteEvent.getId(); @@ -341,6 +363,21 @@ describe("Room", function() { }); }); + describe('addEphemeralEvents', () => { + it("should call RoomState.setTypingEvent on m.typing events", function() { + const typing = utils.mkEvent({ + room: roomId, + type: EventType.Typing, + event: true, + content: { + user_ids: [userA], + }, + }); + room.addEphemeralEvents([typing]); + expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing); + }); + }); + describe("addEventsToTimeline", function() { const events = [ utils.mkMessage({ @@ -408,11 +445,11 @@ describe("Room", function() { const newEv = utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "New Room Name" }, - }) as MatrixEvent; + }); const oldEv = utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, event: true, content: { name: "Old Room Name" }, - }) as MatrixEvent; + }); room.addLiveEvents([newEv]); expect(newEv.sender).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -445,10 +482,10 @@ describe("Room", function() { const newEv = utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent; + }); const oldEv = utils.mkMembership({ room: roomId, mship: "ban", user: userB, skey: userA, event: true, - }) as MatrixEvent; + }); room.addLiveEvents([newEv]); expect(newEv.target).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); @@ -460,21 +497,23 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, - }) as MatrixEvent, + }), utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userB, event: true, content: { name: "New room", }, - }) as MatrixEvent, + }), ]; room.addEventsToTimeline(events, true, room.getLiveTimeline()); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { timelineWasEmpty: undefined }, ); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { timelineWasEmpty: undefined }, ); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); @@ -520,6 +559,23 @@ describe("Room", function() { it("should reset the legacy timeline fields", function() { room.addLiveEvents([events[0], events[1]]); expect(room.timeline.length).toEqual(2); + + const oldStateBeforeRunningReset = room.oldState; + let oldStateUpdateEmitCount = 0; + room.on(RoomEvent.OldStateUpdated, function(room, previousOldState, oldState) { + expect(previousOldState).toBe(oldStateBeforeRunningReset); + expect(oldState).toBe(room.oldState); + oldStateUpdateEmitCount += 1; + }); + + const currentStateBeforeRunningReset = room.currentState; + let currentStateUpdateEmitCount = 0; + room.on(RoomEvent.CurrentStateUpdated, function(room, previousCurrentState, currentState) { + expect(previousCurrentState).toBe(currentStateBeforeRunningReset); + expect(currentState).toBe(room.currentState); + currentStateUpdateEmitCount += 1; + }); + room.resetLiveTimeline('sometoken', 'someothertoken'); room.addLiveEvents([events[2]]); @@ -529,6 +585,10 @@ describe("Room", function() { newLiveTimeline.getState(EventTimeline.BACKWARDS)); expect(room.currentState).toEqual( newLiveTimeline.getState(EventTimeline.FORWARDS)); + // Make sure `RoomEvent.OldStateUpdated` was emitted + expect(oldStateUpdateEmitCount).toEqual(1); + // Make sure `RoomEvent.OldStateUpdated` was emitted if necessary + expect(currentStateUpdateEmitCount).toEqual(timelineSupport ? 1 : 0); }); it("should emit Room.timelineReset event and set the correct " + @@ -571,13 +631,13 @@ describe("Room", function() { const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent, + }), ]; it("should handle events in the same timeline", function() { @@ -718,26 +778,26 @@ describe("Room", function() { type: EventType.RoomJoinRules, room: roomId, user: userA, content: { join_rule: rule, }, event: true, - }) as MatrixEvent]); + })]); }; const setAltAliases = function(aliases: string[]) { room.addLiveEvents([utils.mkEvent({ type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alt_aliases: aliases, }, event: true, - }) as MatrixEvent]); + })]); }; const setAlias = function(alias: string) { room.addLiveEvents([utils.mkEvent({ type: EventType.RoomCanonicalAlias, room: roomId, skey: "", content: { alias }, event: true, - }) as MatrixEvent]); + })]); }; const setRoomName = function(name: string) { room.addLiveEvents([utils.mkEvent({ type: EventType.RoomName, room: roomId, user: userA, content: { name: name, }, event: true, - }) as MatrixEvent]); + })]); }; const addMember = function(userId: string, state = "join", opts: any = {}) { opts.room = roomId; @@ -745,7 +805,7 @@ describe("Room", function() { opts.user = opts.user || userId; opts.skey = userId; opts.event = true; - const event = utils.mkMembership(opts) as MatrixEvent; + const event = utils.mkMembership(opts); room.addLiveEvents([event]); return event; }; @@ -1053,7 +1113,7 @@ describe("Room", function() { const eventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE", event: true, - }) as MatrixEvent; + }); function mkReceipt(roomId: string, records) { const content = {}; @@ -1119,7 +1179,7 @@ describe("Room", function() { const nextEventToAck = utils.mkMessage({ room: roomId, user: userA, msg: "I AM HERE YOU KNOW", event: true, - }) as MatrixEvent; + }); const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1154,11 +1214,11 @@ describe("Room", function() { const eventTwo = utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent; + }); const eventThree = utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent; + }); const ts = 13787898424; room.addReceipt(mkReceipt(roomId, [ mkRecord(eventToAck.getId(), "m.read", userB, ts), @@ -1206,15 +1266,15 @@ describe("Room", function() { utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent, + }), ]; room.addLiveEvents(events); @@ -1244,15 +1304,15 @@ describe("Room", function() { utils.mkMessage({ room: roomId, user: userA, msg: "1111", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "2222", event: true, - }) as MatrixEvent, + }), utils.mkMessage({ room: roomId, user: userA, msg: "3333", event: true, - }) as MatrixEvent, + }), ]; room.addLiveEvents(events); @@ -1344,14 +1404,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }) as MatrixEvent; + }); const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }) as MatrixEvent; + }); eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }) as MatrixEvent; + }); room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1370,14 +1430,14 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, msg: "remote 1", event: true, - }) as MatrixEvent; + }); const eventB = utils.mkMessage({ room: roomId, user: userA, msg: "local 1", event: true, - }) as MatrixEvent; + }); eventB.status = EventStatus.SENDING; const eventC = utils.mkMessage({ room: roomId, user: userA, msg: "remote 2", event: true, - }) as MatrixEvent; + }); room.addLiveEvents([eventA]); room.addPendingEvent(eventB, "TXN1"); room.addLiveEvents([eventC]); @@ -1397,7 +1457,7 @@ describe("Room", function() { }); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1430,7 +1490,7 @@ describe("Room", function() { const room = new Room(roomId, null, userA); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, - }) as MatrixEvent; + }); eventA.status = EventStatus.SENDING; const eventId = eventA.getId(); @@ -1495,6 +1555,8 @@ describe("Room", function() { return Promise.resolve(); }, getSyncToken: () => "sync_token", + getPendingEvents: jest.fn().mockResolvedValue([]), + setPendingEvents: jest.fn().mockResolvedValue(undefined), }, }; } @@ -1505,7 +1567,7 @@ describe("Room", function() { room: roomId, event: true, name: "User A", - }) as MatrixEvent; + }); it("should load members from server on first call", async function() { const client = createClientMock([memberEvent]); @@ -1525,7 +1587,7 @@ describe("Room", function() { room: roomId, event: true, name: "Ms A", - }) as MatrixEvent; + }); const client = createClientMock([memberEvent2], [memberEvent]); const room = new Room(roomId, client as any, null, { lazyLoadMembers: true }); @@ -1596,7 +1658,7 @@ describe("Room", function() { mship: "join", room: roomId, event: true, - }) as MatrixEvent]); + })]); expect(room.guessDMUserId()).toEqual(userB); }); it("should return self if only member present", function() { @@ -1629,11 +1691,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1644,11 +1706,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "ban", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); @@ -1659,11 +1721,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "invite", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1674,11 +1736,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "leave", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); @@ -1689,15 +1751,15 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); }); @@ -1708,19 +1770,19 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), utils.mkMembership({ user: userD, mship: "join", room: roomId, event: true, name: "User D", - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); }); @@ -1733,18 +1795,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, content: { service_members: [], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1755,11 +1817,11 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", @@ -1768,7 +1830,7 @@ describe("Room", function() { content: { service_members: 1, }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1779,18 +1841,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, content: { service_members: userB, }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1801,18 +1863,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, content: { service_members: [userB], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); @@ -1823,22 +1885,22 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, user: userA, content: { service_members: [userC], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1849,22 +1911,22 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkMembership({ user: userC, mship: "join", room: roomId, event: true, name: "User C", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, user: userA, content: { service_members: [userB, userC], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); @@ -1875,18 +1937,18 @@ describe("Room", function() { utils.mkMembership({ user: userA, mship: "join", room: roomId, event: true, name: "User A", - }) as MatrixEvent, + }), utils.mkMembership({ user: userB, mship: "join", room: roomId, event: true, name: "User B", - }) as MatrixEvent, + }), utils.mkEvent({ type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, skey: "", room: roomId, event: true, user: userA, content: { service_members: [userC], }, - }) as MatrixEvent, + }), ]); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); @@ -1937,6 +1999,15 @@ describe("Room", function() { expect(() => room.createThread(rootEvent.getId(), rootEvent, [])).not.toThrow(); }); + it("creating thread from edited event should not conflate old versions of the event", () => { + const message = mkMessage(); + const edit = mkEdit(message); + message.makeReplaced(edit); + + const thread = room.createThread("$000", message, [], true); + expect(thread).toHaveLength(0); + }); + it("Edits update the lastReply event", async () => { room.client.supportsExperimentalThreads = () => true; @@ -2036,17 +2107,15 @@ describe("Room", function() { }, }); - let prom = emitPromise(room, ThreadEvent.New); + const prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); - prom = emitPromise(thread, ThreadEvent.Update); const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction); room.addLiveEvents([threadResponse2ReactionRedaction]); - await prom; expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); }); @@ -2267,7 +2336,7 @@ describe("Room", function() { const thread = threadRoot.getThread(); expect(thread.rootEvent).toBe(threadRoot); - const rootRelations = thread.timelineSet.getRelationsForEvent( + const rootRelations = thread.timelineSet.relations.getChildEventsForEvent( threadRoot.getId(), RelationType.Annotation, EventType.Reaction, @@ -2277,7 +2346,7 @@ describe("Room", function() { expect(rootRelations[0][1].size).toEqual(1); expect(rootRelations[0][1].has(rootReaction)).toBeTruthy(); - const responseRelations = thread.timelineSet.getRelationsForEvent( + const responseRelations = thread.timelineSet.relations.getChildEventsForEvent( threadResponse.getId(), RelationType.Annotation, EventType.Reaction, diff --git a/spec/unit/stores/indexeddb.spec.ts b/spec/unit/stores/indexeddb.spec.ts new file mode 100644 index 000000000..3fc7477cc --- /dev/null +++ b/spec/unit/stores/indexeddb.spec.ts @@ -0,0 +1,114 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'fake-indexeddb/auto'; +import 'jest-localstorage-mock'; + +import { IndexedDBStore, IStateEventWithRoomId, MemoryStore } from "../../../src"; +import { emitPromise } from "../../test-utils/test-utils"; +import { LocalIndexedDBStoreBackend } from "../../../src/store/indexeddb-local-backend"; + +describe("IndexedDBStore", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const roomId = "!room:id"; + it("should degrade to MemoryStore on IDB errors", async () => { + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage, + }); + await store.startup(); + + const member1: IStateEventWithRoomId = { + room_id: roomId, + event_id: "!ev1:id", + sender: "@user1:id", + state_key: "@user1:id", + type: "m.room.member", + origin_server_ts: 123, + content: {}, + }; + const member2: IStateEventWithRoomId = { + room_id: roomId, + event_id: "!ev2:id", + sender: "@user2:id", + state_key: "@user2:id", + type: "m.room.member", + origin_server_ts: 123, + content: {}, + }; + + expect(await store.getOutOfBandMembers(roomId)).toBe(null); + await store.setOutOfBandMembers(roomId, [member1]); + expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1); + + // Simulate a broken IDB + (store.backend as LocalIndexedDBStoreBackend)["db"].transaction = (): IDBTransaction => { + const err = new Error("Failed to execute 'transaction' on 'IDBDatabase': " + + "The database connection is closing."); + err.name = "InvalidStateError"; + throw err; + }; + + expect(await store.getOutOfBandMembers(roomId)).toHaveLength(1); + await Promise.all([ + emitPromise(store["emitter"], "degraded"), + store.setOutOfBandMembers(roomId, [member1, member2]), + ]); + expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2); + }); + + it("should use MemoryStore methods for pending events if no localStorage", async () => { + jest.spyOn(MemoryStore.prototype, "setPendingEvents"); + jest.spyOn(MemoryStore.prototype, "getPendingEvents"); + + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage: undefined, + }); + + const events = [{ type: "test" }]; + await store.setPendingEvents(roomId, events); + expect(MemoryStore.prototype.setPendingEvents).toHaveBeenCalledWith(roomId, events); + await expect(store.getPendingEvents(roomId)).resolves.toEqual(events); + expect(MemoryStore.prototype.getPendingEvents).toHaveBeenCalledWith(roomId); + }); + + it("should persist pending events to localStorage if available", async () => { + jest.spyOn(MemoryStore.prototype, "setPendingEvents"); + jest.spyOn(MemoryStore.prototype, "getPendingEvents"); + + const store = new IndexedDBStore({ + indexedDB: indexedDB, + dbName: "database", + localStorage, + }); + + await expect(store.getPendingEvents(roomId)).resolves.toEqual([]); + const events = [{ type: "test" }]; + await store.setPendingEvents(roomId, events); + expect(MemoryStore.prototype.setPendingEvents).not.toHaveBeenCalled(); + await expect(store.getPendingEvents(roomId)).resolves.toEqual(events); + expect(MemoryStore.prototype.getPendingEvents).not.toHaveBeenCalled(); + expect(localStorage.getItem("mx_pending_events_" + roomId)).toBe(JSON.stringify(events)); + await store.setPendingEvents(roomId, []); + expect(localStorage.getItem("mx_pending_events_" + roomId)).toBeNull(); + }); +}); diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index c9466412c..4fc782344 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -35,13 +35,14 @@ function createTimeline(numEvents, baseIndex) { return timeline; } -function addEventsToTimeline(timeline, numEvents, atStart) { +function addEventsToTimeline(timeline, numEvents, toStartOfTimeline) { for (let i = 0; i < numEvents; i++) { timeline.addEvent( utils.mkMessage({ room: ROOM_ID, user: USER_ID, event: true, - }), atStart, + }), + { toStartOfTimeline }, ); } } diff --git a/spec/unit/webrtc/call.spec.ts b/spec/unit/webrtc/call.spec.ts index fe7fa72ba..677b83a51 100644 --- a/spec/unit/webrtc/call.spec.ts +++ b/spec/unit/webrtc/call.spec.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { TestClient } from '../../TestClient'; -import { MatrixCall, CallErrorCode, CallEvent } from '../../../src/webrtc/call'; +import { MatrixCall, CallErrorCode, CallEvent, supportsMatrixCall } from '../../../src/webrtc/call'; import { SDPStreamMetadataKey, SDPStreamMetadataPurpose } from '../../../src/webrtc/callEventTypes'; import { RoomMember } from "../../../src"; @@ -525,4 +525,40 @@ describe('Call', function() { return sender?.track?.kind === "video"; }).track.id).toBe("video_track"); }); + + describe("supportsMatrixCall", () => { + it("should return true when the environment is right", () => { + expect(supportsMatrixCall()).toBe(true); + }); + + it("should return false if window or document are undefined", () => { + global.window = undefined; + expect(supportsMatrixCall()).toBe(false); + global.window = prevWindow; + global.document = undefined; + expect(supportsMatrixCall()).toBe(false); + }); + + it("should return false if RTCPeerConnection throws", () => { + // @ts-ignore - writing to window as we are simulating browser edge-cases + global.window = {}; + Object.defineProperty(global.window, "RTCPeerConnection", { + get: () => { + throw Error("Secure mode, naaah!"); + }, + }); + expect(supportsMatrixCall()).toBe(false); + }); + + it("should return false if RTCPeerConnection & RTCSessionDescription " + + "& RTCIceCandidate & mediaDevices are unavailable", + () => { + global.window.RTCPeerConnection = undefined; + global.window.RTCSessionDescription = undefined; + global.window.RTCIceCandidate = undefined; + // @ts-ignore - writing to a read-only property as we are simulating faulty browsers + global.navigator.mediaDevices = undefined; + expect(supportsMatrixCall()).toBe(false); + }); + }); }); diff --git a/spec/unit/webrtc/callEventHandler.spec.ts b/spec/unit/webrtc/callEventHandler.spec.ts index f1e9bcbaa..1fe6f788f 100644 --- a/spec/unit/webrtc/callEventHandler.spec.ts +++ b/spec/unit/webrtc/callEventHandler.spec.ts @@ -1,22 +1,52 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import { TestClient } from '../../TestClient'; -import { CallEventHandler } from '../../../src/webrtc/callEventHandler'; -import { MatrixEvent } from '../../../src/models/event'; -import { EventType } from '../../../src/@types/event'; +import { + ClientEvent, + EventTimeline, + EventTimelineSet, + EventType, + IRoomTimelineData, + MatrixEvent, + Room, + RoomEvent, +} from "../../../src"; +import { MatrixClient } from "../../../src/client"; +import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler"; +import { GroupCallEventHandler } from "../../../src/webrtc/groupCallEventHandler"; +import { SyncState } from "../../../src/sync"; -describe('CallEventHandler', function() { - let client; - - beforeEach(function() { - client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}); +describe("CallEventHandler", () => { + let client: MatrixClient; + beforeEach(() => { + client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}).client; + client.callEventHandler = new CallEventHandler(client); + client.callEventHandler.start(); + client.groupCallEventHandler = new GroupCallEventHandler(client); + client.groupCallEventHandler.start(); }); - afterEach(function() { - client.stop(); + afterEach(() => { + client.callEventHandler.stop(); + client.groupCallEventHandler.stop(); }); - it('should enforce inbound toDevice message ordering', async function() { - const callEventHandler = new CallEventHandler(client); - + it("should enforce inbound toDevice message ordering", async () => { + const callEventHandler = client.callEventHandler; const event1 = new MatrixEvent({ type: EventType.CallInvite, content: { @@ -80,4 +110,34 @@ describe('CallEventHandler', function() { expect(callEventHandler.nextSeqByCall.get("123")).toBe(5); expect(callEventHandler.toDeviceEventBuffers.get("123").length).toBe(0); }); + + it("should ignore a call if invite & hangup come within a single sync", () => { + const room = new Room("!room:id", client, "@user:id"); + const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) }; + + // Fire off call invite then hangup within a single sync + const callInvite = new MatrixEvent({ + type: EventType.CallInvite, + content: { + call_id: "123", + }, + }); + client.emit(RoomEvent.Timeline, callInvite, room, false, false, timelineData); + + const callHangup = new MatrixEvent({ + type: EventType.CallHangup, + content: { + call_id: "123", + }, + }); + client.emit(RoomEvent.Timeline, callHangup, room, false, false, timelineData); + + const incomingCallEmitted = jest.fn(); + client.on(CallEventHandlerEvent.Incoming, incomingCallEmitted); + + client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing); + client.emit(ClientEvent.Sync, SyncState.Syncing); + + expect(incomingCallEmitted).not.toHaveBeenCalled(); + }); }); diff --git a/src/@types/event.ts b/src/@types/event.ts index 0693481ab..8b226c7c0 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -155,6 +155,14 @@ export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc */ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch"); +/** + * Marker event type to point back at imported historical content in a room. See + * [MSC2716](https://github.com/matrix-org/matrix-spec-proposals/pull/2716). + * Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker"); + /** * Functional members type for declaring a purpose of room members (e.g. helpful bots). * Note that this reference is UNSTABLE and subject to breaking changes, including its diff --git a/src/@types/requests.ts b/src/@types/requests.ts index a3c950ab1..160a0af25 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -17,9 +17,12 @@ limitations under the License. import { Callback } from "../client"; import { IContent, IEvent } from "../models/event"; import { Preset, Visibility } from "./partials"; -import { SearchKey } from "./search"; +import { IEventWithRoomId, SearchKey } from "./search"; import { IRoomEventFilter } from "../filter"; import { Direction } from "../models/event-timeline"; +import { PushRuleAction } from "./PushRules"; +import { IRoomEvent } from "../sync-accumulator"; +import { RoomType } from "./event"; // allow camelcase as these are things that go onto the wire /* eslint-disable camelcase */ @@ -109,7 +112,8 @@ export interface IRoomDirectoryOptions { limit?: number; since?: string; filter?: { - generic_search_term: string; + generic_search_term?: string; + "org.matrix.msc3827.room_types"?: Array; }; include_all_networks?: boolean; third_party_instance_id?: string; @@ -155,4 +159,50 @@ export interface IRelationsResponse { prev_batch?: string; } +export interface IContextResponse { + end: string; + start: string; + state: IEventWithRoomId[]; + events_before: IEventWithRoomId[]; + events_after: IEventWithRoomId[]; + event: IEventWithRoomId; +} + +export interface IEventsResponse { + chunk: IEventWithRoomId[]; + end: string; + start: string; +} + +export interface INotification { + actions: PushRuleAction[]; + event: IRoomEvent; + profile_tag?: string; + read: boolean; + room_id: string; + ts: number; +} + +export interface INotificationsResponse { + next_token: string; + notifications: INotification[]; +} + +export interface IFilterResponse { + filter_id: string; +} + +export interface ITagsResponse { + tags: { + [tagId: string]: { + order: number; + }; + }; +} + +export interface IStatusResponse extends IPresenceOpts { + currently_active?: boolean; + last_active_ago?: number; +} + /* eslint-enable camelcase */ diff --git a/src/@types/topic.ts b/src/@types/topic.ts new file mode 100644 index 000000000..0d2708b2e --- /dev/null +++ b/src/@types/topic.ts @@ -0,0 +1,62 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EitherAnd, IMessageRendering } from "matrix-events-sdk"; + +import { UnstableValue } from "../NamespacedValue"; + +/** + * Extensible topic event type based on MSC3765 + * https://github.com/matrix-org/matrix-spec-proposals/pull/3765 + */ + +/** + * Eg + * { + * "type": "m.room.topic, + * "state_key": "", + * "content": { + * "topic": "All about **pizza**", + * "m.topic": [{ + * "body": "All about **pizza**", + * "mimetype": "text/plain", + * }, { + * "body": "All about pizza", + * "mimetype": "text/html", + * }], + * } + * } + */ + +/** + * The event type for an m.topic event (in content) + */ +export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic"); + +/** + * The event content for an m.topic event (in content) + */ +export type MTopicContent = IMessageRendering[]; + +/** + * The event definition for an m.topic event (in content) + */ +export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>; + +/** + * The event content for an m.room.topic event + */ +export type MRoomTopicEventContent = { topic: string } & MTopicEvent; diff --git a/src/autodiscovery.ts b/src/autodiscovery.ts index 5f875cc68..58c4cb157 100644 --- a/src/autodiscovery.ts +++ b/src/autodiscovery.ts @@ -17,8 +17,6 @@ limitations under the License. /** @module auto-discovery */ -import { URL as NodeURL } from "url"; - import { IClientWellKnown, IWellKnownConfig } from "./client"; import { logger } from './logger'; @@ -372,16 +370,11 @@ export class AutoDiscovery { if (!url) return false; try { - // We have to try and parse the URL using the NodeJS URL - // library if we're on NodeJS and use the browser's URL - // library when we're in a browser. To accomplish this, we - // try the NodeJS version first and fall back to the browser. let parsed = null; try { - if (NodeURL) parsed = new NodeURL(url); - else parsed = new URL(url); - } catch (e) { parsed = new URL(url); + } catch (e) { + logger.error("Could not parse url", e); } if (!parsed || !parsed.hostname) return false; diff --git a/src/client.ts b/src/client.ts index 0ecbcc6d1..32e05e972 100644 --- a/src/client.ts +++ b/src/client.ts @@ -32,7 +32,7 @@ import { MatrixEventHandlerMap, } from "./models/event"; import { StubStore } from "./store/stub"; -import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall } from "./webrtc/call"; +import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call"; import { Filter, IFilterDefinition } from "./filter"; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from './webrtc/groupCallEventHandler'; @@ -62,6 +62,7 @@ import { PREFIX_R0, PREFIX_UNSTABLE, PREFIX_V1, + PREFIX_V3, retryNetworkOperation, UploadContentResponseType, } from "./http-api"; @@ -114,6 +115,13 @@ import { RoomMemberEventHandlerMap, RoomStateEvent, RoomStateEventHandlerMap, + INotificationsResponse, + IFilterResponse, + ITagsResponse, + IStatusResponse, + IPushRule, + PushRuleActionName, + IAuthDict, } from "./matrix"; import { CrossSigningKey, @@ -133,6 +141,7 @@ import { Room } from "./models/room"; import { IAddThreePidOnlyBody, IBindThreePidBody, + IContextResponse, ICreateRoomOpts, IEventSearchOpts, IGuestAccessOpts, @@ -160,7 +169,6 @@ import { import { IAbortablePromise, IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import { randomString } from "./randomstring"; -import { WebStorageSessionStore } from "./store/session/webstorage"; import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; import { ISignatures } from "./@types/signed"; @@ -194,7 +202,6 @@ import { Thread, THREAD_RELATION_TYPE } from "./models/thread"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; export type Store = IStore; -export type SessionStore = WebStorageSessionStore; export type Callback = (err: Error | any | null, data?: T) => void; export type ResetTimelineCallback = (roomId: string) => boolean; @@ -314,21 +321,6 @@ export interface ICreateClientOpts { */ pickleKey?: string; - /** - * A store to be used for end-to-end crypto session data. Most data has been - * migrated out of here to `cryptoStore` instead. If not specified, - * end-to-end crypto will be disabled. The `createClient` helper - * _will not_ create this store at the moment. - */ - sessionStore?: SessionStore; - - /** - * Set to true to enable client-side aggregation of event relations - * via `EventTimelineSet#getRelationsForEvent`. - * This feature is currently unstable and the API may change without notice. - */ - unstableClientRelationAggregation?: boolean; - verificationMethods?: Array; /** @@ -589,13 +581,9 @@ export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse { intl_fmt: string; } -interface IUploadKeysRequest { +export interface IUploadKeysRequest { device_keys?: Required; - one_time_keys?: { - [userId: string]: { - [deviceId: string]: number; - }; - }; + one_time_keys?: Record; "org.matrix.msc2732.fallback_keys"?: Record; } @@ -633,6 +621,19 @@ interface IJoinedMembersResponse { }; } +export interface IRegisterRequestParams { + auth?: IAuthData; + username?: string; + password?: string; + refresh_token?: boolean; + guest_access_token?: string; + x_show_msisdn?: boolean; + bind_msisdn?: boolean; + bind_email?: boolean; + inhibit_login?: boolean; + initial_device_display_name?: string; +} + export interface IPublicRoomsChunkRoom { room_id: string; name?: string; @@ -802,6 +803,7 @@ type RoomEvents = RoomEvent.Name | RoomEvent.Receipt | RoomEvent.Tags | RoomEvent.LocalEchoUpdated + | RoomEvent.HistoryImportedWithinTimeline | RoomEvent.AccountData | RoomEvent.MyMembership | RoomEvent.Timeline @@ -811,6 +813,7 @@ type RoomStateEvents = RoomStateEvent.Events | RoomStateEvent.Members | RoomStateEvent.NewMember | RoomStateEvent.Update + | RoomStateEvent.Marker ; type CryptoEvents = CryptoEvent.KeySignatureUploadFailure @@ -897,9 +900,7 @@ export class MatrixClient extends TypedEventEmitter } = {}; - public unstableClientRelationAggregation = false; public identityServer: IIdentityServerProvider; - public sessionStore: SessionStore; // XXX: Intended private, used in code. public http: MatrixHttpApi; // XXX: Intended private, used in code. public crypto: Crypto; // XXX: Intended private, used in code. public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code. @@ -1022,10 +1023,7 @@ export class MatrixClient extends TypedEventEmitter | null> { + public isSecretStored(name: string): Promise | null> { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } - return this.crypto.isSecretStored(name, checkKey); + return this.crypto.isSecretStored(name); } /** @@ -2800,7 +2792,7 @@ export class MatrixClient extends TypedEventEmitter | null> { - return Promise.resolve(this.isSecretStored("m.megolm_backup.v1", false /* checkKey */)); + return Promise.resolve(this.isSecretStored("m.megolm_backup.v1")); } /** @@ -3625,28 +3617,45 @@ export class MatrixClient extends TypedEventEmitter { - return this.sendStateEvent(roomId, EventType.RoomTopic, { topic: topic }, undefined, callback); + public setRoomTopic( + roomId: string, + topic: string, + htmlTopic?: string, + ): Promise; + public setRoomTopic( + roomId: string, + topic: string, + callback?: Callback, + ): Promise; + public setRoomTopic( + roomId: string, + topic: string, + htmlTopicOrCallback?: string | Callback, + ): Promise { + const isCallback = typeof htmlTopicOrCallback === 'function'; + const htmlTopic = isCallback ? undefined : htmlTopicOrCallback; + const callback = isCallback ? htmlTopicOrCallback : undefined; + const content = ContentHelpers.makeTopicContent(topic, htmlTopic); + return this.sendStateEvent(roomId, EventType.RoomTopic, content, undefined, callback); } /** * @param {string} roomId * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: to an object keyed by tagId with objects containing a numeric order field. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getRoomTags(roomId: string, callback?: Callback): Promise { // TODO: Types - const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/", { + public getRoomTags(roomId: string, callback?: Callback): Promise { + const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", { $userId: this.credentials.userId, $roomId: roomId, }); - return this.http.authedRequest( - callback, Method.Get, path, undefined, - ); + return this.http.authedRequest(callback, Method.Get, path); } /** @@ -3670,7 +3679,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -3679,7 +3688,7 @@ export class MatrixClient extends TypedEventEmitter { return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status; - })?.getId(), + })?.getId() ?? threadId, }; } } @@ -4069,7 +4081,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -5031,7 +5043,7 @@ export class MatrixClient extends TypedEventEmitter { + ): Promise<{}> { // API returns an empty object if (utils.isFunction(reason)) { callback = reason as any as Callback; // legacy reason = undefined; @@ -5172,12 +5184,12 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public getPresence(userId: string, callback?: Callback): Promise { const path = utils.encodeUri("/presence/$userId/status", { $userId: userId, }); - return this.http.authedRequest(callback, Method.Get, path, undefined, undefined); + return this.http.authedRequest(callback, Method.Get, path); } /** @@ -5189,7 +5201,7 @@ export class MatrixClient extends TypedEventEmitterIf the EventTimelineSet object already has the given event in its store, the * corresponding timeline will be returned. Otherwise, a /context request is * made, and used to construct an EventTimeline. + * If the event does not belong to this EventTimelineSet then undefined will be returned. * - * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in + * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in, must be bound to a room * @param {string} eventId The ID of the event to look for * * @return {Promise} Resolves: * {@link module:models/event-timeline~EventTimeline} including the given event */ - public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { + public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + @@ -5320,7 +5333,7 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Get, path, params); // TODO types + const res = await this.http.authedRequest(undefined, Method.Get, path, params); if (!res.event) { throw new Error("'event' not in '/context' result - homeserver too old?"); } @@ -5333,45 +5346,44 @@ export class MatrixClient extends TypedEventEmitter { + // don't allow any timeline support unless it's been enabled. + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable it."); + } + + const messagesPath = utils.encodeUri( + "/rooms/$roomId/messages", { + $roomId: timelineSet.room.roomId, + }, + ); + + const params: Record = { + dir: 'b', + }; + if (this.clientOpts.lazyLoadMembers) { + params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + const res = await this.http.authedRequest(undefined, Method.Get, messagesPath, params); + const event = res.chunk?.[0]; + if (!event) { + throw new Error("No message returned from /messages when trying to construct getLatestTimeline"); + } + + return this.getEventTimeline(timelineSet, event.event_id); + } + /** * Makes a request to /messages with the appropriate lazy loading filter set. * XXX: if we do get rid of scrollback (as it's not used at the moment), @@ -5499,8 +5550,8 @@ export class MatrixClient extends TypedEventEmitter( // TODO types - undefined, Method.Get, path, params, undefined, + promise = this.http.authedRequest( + undefined, Method.Get, path, params, ).then(async (res) => { const token = res.next_token; const matrixEvents = []; @@ -5880,11 +5931,7 @@ export class MatrixClient extends TypedEventEmitter | void { - let promise: Promise; + let promise: Promise; let hasDontNotifyRule = false; // Get the existing room-kind push rule if any const roomPushRule = this.getRoomPushRule(scope, roomId); - if (roomPushRule) { - if (0 <= roomPushRule.actions.indexOf("dont_notify")) { - hasDontNotifyRule = true; - } + if (roomPushRule?.actions.includes(PushRuleActionName.DontNotify)) { + hasDontNotifyRule = true; } if (!mute) { @@ -5947,24 +5992,23 @@ export class MatrixClient extends TypedEventEmitter { - this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { - actions: ["dont_notify"], - }).then(() => { - deferred.resolve(); - }).catch((err) => { - deferred.reject(err); - }); + this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id).then(() => { + this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, { + actions: [PushRuleActionName.DontNotify], + }).then(() => { + deferred.resolve(); }).catch((err) => { deferred.reject(err); }); + }).catch((err) => { + deferred.reject(err); + }); promise = deferred.promise; } @@ -6176,15 +6220,13 @@ export class MatrixClient extends TypedEventEmitter(undefined, Method.Post, path, undefined, content).then((response) => { - // persist the filter - const filter = Filter.fromJson( - this.credentials.userId, response.filter_id, content, - ); - this.store.storeFilter(filter); - return filter; - }); + return this.http.authedRequest(undefined, Method.Post, path, undefined, content) + .then((response) => { + // persist the filter + const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content); + this.store.storeFilter(filter); + return filter; + }); } /** @@ -6209,9 +6251,7 @@ export class MatrixClient extends TypedEventEmitter( - undefined, Method.Get, path, undefined, undefined, - ).then((response) => { + return this.http.authedRequest(undefined, Method.Get, path).then((response) => { // persist the filter const filter = Filter.fromJson(userId, filterId, response); this.store.storeFilter(filter); @@ -6607,8 +6647,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types (many) + ): Promise { // backwards compat if (bindThreepids === true) { bindThreepids = { email: true }; @@ -6926,7 +6965,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types - const params: any = {}; + public registerRequest(data: IRegisterRequestParams, kind?: string, callback?: Callback): Promise { + const params: { kind?: string } = {}; if (kind) { params.kind = kind; } @@ -7142,9 +7181,10 @@ export class MatrixClient extends TypedEventEmitter { + public async logout(callback?: Callback, stopClient = false): Promise<{}> { if (this.crypto?.backupManager?.getKeyBackupEnabled()) { try { while (await this.crypto.backupManager.backupPendingKeys(200) > 0); @@ -7155,6 +7195,11 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/directory/room/$alias", { $alias: alias, }); - return this.http.authedRequest(callback, Method.Delete, path, undefined, undefined); + return this.http.authedRequest(callback, Method.Delete, path); } /** - * @param {string} roomId - * @param {module:client.callback} callback Optional. + * Gets the local aliases for the room. Note: this includes all local aliases, unlike the + * curated list from the m.room.canonical_alias state event. + * @param {string} roomId The room ID to get local aliases for. * @return {Promise} Resolves: an object with an `aliases` property, containing an array of local aliases * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public unstableGetLocalAliases(roomId: string, callback?: Callback): Promise<{ aliases: string[] }> { - const path = utils.encodeUri("/rooms/$roomId/aliases", - { $roomId: roomId }); - const prefix = PREFIX_UNSTABLE + "/org.matrix.msc2432"; - return this.http.authedRequest(callback, Method.Get, path, null, null, { prefix }); + public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> { + const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId }); + const prefix = PREFIX_V3; + return this.http.authedRequest(undefined, Method.Get, path, null, null, { prefix }); } /** @@ -7640,7 +7685,7 @@ export class MatrixClient extends TypedEventEmitter { @@ -7799,8 +7844,7 @@ export class MatrixClient extends TypedEventEmitter { - const path = "/account/3pid"; - return this.http.authedRequest(callback, Method.Get, path, undefined, undefined); + return this.http.authedRequest(callback, Method.Get, "/account/3pid"); } /** @@ -7836,7 +7880,7 @@ export class MatrixClient extends TypedEventEmitter/requestToken` on the homeserver. - * @return {Promise} Resolves: on success + * @return {Promise} Resolves: to an empty object {} * @return {module:http-api.MatrixError} Rejects: with an error response. */ public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> { @@ -7856,7 +7900,7 @@ export class MatrixClient extends TypedEventEmitter/requestToken` on the identity server. It should also * contain `id_server` and `id_access_token` fields as well. - * @return {Promise} Resolves: on success + * @return {Promise} Resolves: to an empty object {} * @return {module:http-api.MatrixError} Rejects: with an error response. */ public async bindThreePid(data: IBindThreePidBody): Promise<{}> { @@ -7917,7 +7961,7 @@ export class MatrixClient extends TypedEventEmitter { - return this.http.authedRequest(undefined, Method.Get, "/devices", undefined, undefined); + return this.http.authedRequest(undefined, Method.Get, "/devices"); } /** @@ -7977,7 +8021,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public deleteDevice(deviceId: string, auth?: IAuthDict): Promise { const path = utils.encodeUri("/devices/$device_id", { $device_id: deviceId, }); @@ -8027,7 +8071,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types + public deleteMultipleDevices(devices: string[], auth?: IAuthDict): Promise { const body: any = { devices }; if (auth) { @@ -8046,8 +8090,7 @@ export class MatrixClient extends TypedEventEmitter { - const path = "/pushers"; - return this.http.authedRequest(callback, Method.Get, path, undefined, undefined); + return this.http.authedRequest(callback, Method.Get, "/pushers"); } /** @@ -8088,9 +8131,9 @@ export class MatrixClient extends TypedEventEmitter, - body: any, + body: Pick, callback?: Callback, - ): Promise { // TODO: Types + ): Promise<{}> { // NB. Scope not uri encoded because devices need the '/' const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { $kind: kind, @@ -8112,7 +8155,7 @@ export class MatrixClient extends TypedEventEmitter, callback?: Callback, - ): Promise { // TODO: Types + ): Promise<{}> { // NB. Scope not uri encoded because devices need the '/' const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", { $kind: kind, @@ -8128,7 +8171,7 @@ export class MatrixClient extends TypedEventEmitter { + public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> { // API returns empty object const data = Object.assign({}, keys); if (auth) Object.assign(data, { auth }); return this.http.authedRequest( @@ -8725,7 +8767,7 @@ export class MatrixClient extends TypedEventEmitter { return this.http.authedRequest>( - undefined, Method.Get, "/thirdparty/protocols", undefined, undefined, + undefined, Method.Get, "/thirdparty/protocols", ).then((response) => { // sanity check if (!response || typeof (response) !== 'object') { @@ -8783,7 +8825,7 @@ export class MatrixClient extends TypedEventEmitter { // TODO: Types diff --git a/src/content-helpers.ts b/src/content-helpers.ts index 383b9b343..8d417d0a9 100644 --- a/src/content-helpers.ts +++ b/src/content-helpers.ts @@ -16,7 +16,7 @@ limitations under the License. /** @module ContentHelpers */ -import { REFERENCE_RELATION } from "matrix-events-sdk"; +import { isProvided, REFERENCE_RELATION } from "matrix-events-sdk"; import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon"; import { MsgType } from "./@types/event"; @@ -32,6 +32,7 @@ import { MAssetContent, LegacyLocationEventContent, } from "./@types/location"; +import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic"; /** * Generates the content for a HTML Message event @@ -138,10 +139,10 @@ export const getTextForLocationEvent = ( /** * Generates the content for a Location event * @param uri a geo:// uri for the location - * @param ts the timestamp when the location was correct (milliseconds since + * @param timestamp the timestamp when the location was correct (milliseconds since * the UNIX epoch) * @param description the (optional) label for this location on the map - * @param asset_type the (optional) asset type of this location e.g. "m.self" + * @param assetType the (optional) asset type of this location e.g. "m.self" * @param text optional. A text for the location */ export const makeLocationContent = ( @@ -190,6 +191,34 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent): return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType); }; +/** + * Topic event helpers + */ +export type MakeTopicContent = ( + topic: string, + htmlTopic?: string, +) => MRoomTopicEventContent; + +export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => { + const renderings = [{ body: topic, mimetype: "text/plain" }]; + if (isProvided(htmlTopic)) { + renderings.push({ body: htmlTopic, mimetype: "text/html" }); + } + return { topic, [M_TOPIC.name]: renderings }; +}; + +export type TopicState = { + text: string; + html?: string; +}; + +export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => { + const mtopic = M_TOPIC.findIn(content); + const text = mtopic?.find(r => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic; + const html = mtopic?.find(r => r.mimetype === "text/html")?.body; + return { text, html }; +}; + /** * Beacon event helpers */ diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 21dd0ee16..4e5c9f452 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -171,7 +171,7 @@ export class CrossSigningInfo { */ public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise> { // check what SSSS keys have encrypted the master key (if any) - const stored = await secretStorage.isStored("m.cross_signing.master", false) || {}; + const stored = await secretStorage.isStored("m.cross_signing.master") || {}; // then check which of those SSSS keys have also encrypted the SSK and USK function intersect(s: Record) { for (const k of Object.keys(stored)) { @@ -181,7 +181,7 @@ export class CrossSigningInfo { } } for (const type of ["self_signing", "user_signing"]) { - intersect(await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}); + intersect(await secretStorage.isStored(`m.cross_signing.${type}`) || {}); } return Object.keys(stored).length ? stored : null; } diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index 000e79f93..c203ce5da 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -942,7 +942,7 @@ async function updateStoredDeviceKeysForUser( async function storeDeviceKeys( olmDevice: OlmDevice, userStore: Record, - deviceResult: any, // TODO types + deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"], ): Promise { if (!deviceResult.keys) { // no keys? diff --git a/src/crypto/OlmDevice.ts b/src/crypto/OlmDevice.ts index 2de8313d9..0b2e616a8 100644 --- a/src/crypto/OlmDevice.ts +++ b/src/crypto/OlmDevice.ts @@ -92,7 +92,7 @@ export interface InboundGroupSessionData { sharedHistory?: boolean; } -interface IDecryptedGroupMessage { +export interface IDecryptedGroupMessage { result: string; keysClaimed: Record; senderKey: string; @@ -100,6 +100,11 @@ interface IDecryptedGroupMessage { untrusted: boolean; } +export interface IInboundSession { + payload: string; + session_id: string; +} + export interface IExportedDevice { pickleKey: string; pickledAccount: string; @@ -620,7 +625,7 @@ export class OlmDevice { theirDeviceIdentityKey: string, messageType: number, ciphertext: string, - ): Promise<{ payload: string, session_id: string }> { // eslint-disable-line camelcase + ): Promise { if (messageType !== 0) { throw new Error("Need messageType == 0 to create inbound session"); } diff --git a/src/crypto/SecretStorage.ts b/src/crypto/SecretStorage.ts index f36b66c92..0eef2ee7d 100644 --- a/src/crypto/SecretStorage.ts +++ b/src/crypto/SecretStorage.ts @@ -339,13 +339,12 @@ export class SecretStorage { * Check if a secret is stored on the server. * * @param {string} name the name of the secret - * @param {boolean} checkKey check if the secret is encrypted by a trusted key * * @return {object?} map of key name to key info the secret is encrypted * with, or null if it is not present or not encrypted with a trusted * key */ - public async isStored(name: string, checkKey = true): Promise | null> { + public async isStored(name: string): Promise | null> { // check if secret exists const secretInfo = await this.accountDataAdapter.getAccountDataFromServer(name); if (!secretInfo?.encrypted) return null; diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts index add9111ef..22bd4505d 100644 --- a/src/crypto/algorithms/base.ts +++ b/src/crypto/algorithms/base.ts @@ -179,7 +179,7 @@ export abstract class DecryptionAlgorithm { * * @param {module:models/event.MatrixEvent} params event key event */ - public onRoomKeyEvent(params: MatrixEvent): void { + public async onRoomKeyEvent(params: MatrixEvent): Promise { // ignore by default } diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts index 3dd3ecdda..a05e8153d 100644 --- a/src/crypto/algorithms/megolm.ts +++ b/src/crypto/algorithms/megolm.ts @@ -30,13 +30,13 @@ import { registerAlgorithm, UnknownDeviceError, } from "./base"; -import { WITHHELD_MESSAGES } from '../OlmDevice'; +import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from '../OlmDevice'; import { Room } from '../../models/room'; import { DeviceInfo } from "../deviceinfo"; import { IOlmSessionResult } from "../olmlib"; import { DeviceInfoMap } from "../DeviceList"; import { MatrixEvent } from "../.."; -import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; +import { IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; // determine whether the key can be shared with invitees export function isRoomSharedHistory(room: Room): boolean { @@ -100,12 +100,6 @@ interface IPayload extends Partial { algorithm?: string; sender_key?: string; } - -interface IEncryptedContent { - algorithm: string; - sender_key: string; - ciphertext: Record; -} /* eslint-enable camelcase */ interface SharedWithData { @@ -213,6 +207,8 @@ class OutboundSessionInfo { } } } + + return false; } } @@ -231,7 +227,7 @@ class MegolmEncryption extends EncryptionAlgorithm { // are using, and which devices we have shared the keys with. It resolves // with an OutboundSessionInfo (or undefined, for the first message in the // room). - private setupPromise = Promise.resolve(undefined); + private setupPromise = Promise.resolve(null); // Map of outbound sessions by sessions ID. Used if we need a particular // session (the session we're currently using to send is always obtained @@ -240,8 +236,8 @@ class MegolmEncryption extends EncryptionAlgorithm { private readonly sessionRotationPeriodMsgs: number; private readonly sessionRotationPeriodMs: number; - private encryptionPreparation: Promise; - private encryptionPreparationMetadata: { + private encryptionPreparation?: { + promise: Promise; startTime: number; }; @@ -270,193 +266,209 @@ class MegolmEncryption extends EncryptionAlgorithm { blocked: IBlockedMap, singleOlmCreationPhase = false, ): Promise { - let session: OutboundSessionInfo; - // takes the previous OutboundSessionInfo, and considers whether to create // a new one. Also shares the key with any (new) devices in the room. - // Updates `session` to hold the final OutboundSessionInfo. + // + // Returns the successful session whether keyshare succeeds or not. // // returns a promise which resolves once the keyshare is successful. - const prepareSession = async (oldSession: OutboundSessionInfo) => { - session = oldSession; - + const setup = async (oldSession: OutboundSessionInfo | null): Promise => { const sharedHistory = isRoomSharedHistory(room); - // history visibility changed - if (session && sharedHistory !== session.sharedHistory) { - session = null; + const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession); + + try { + await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); + } catch (e) { + logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); } - // need to make a brand new session? - if (session && session.needsRotation(this.sessionRotationPeriodMsgs, - this.sessionRotationPeriodMs) - ) { - logger.log("Starting new megolm session because we need to rotate."); - session = null; - } - - // determine if we have shared with anyone we shouldn't have - if (session && session.sharedWithTooManyDevices(devicesInRoom)) { - session = null; - } - - if (!session) { - logger.log(`Starting new megolm session for room ${this.roomId}`); - session = await this.prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + - `for room ${this.roomId}`); - this.outboundSessions[session.sessionId] = session; - } - - // now check if we need to share with any devices - const shareMap: Record = {}; - - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { - const key = deviceInfo.getIdentityKey(); - if (key == this.olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - - if ( - !session.sharedWithDevices[userId] || - session.sharedWithDevices[userId][deviceId] === undefined - ) { - shareMap[userId] = shareMap[userId] || []; - shareMap[userId].push(deviceInfo); - } - } - } - - const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); - const payload: IPayload = { - type: "m.room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this.roomId, - "session_id": session.sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "org.matrix.msc3061.shared_history": sharedHistory, - }, - }; - const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this.olmDevice, this.baseApis, shareMap, - ); - - await Promise.all([ - (async () => { - // share keys with devices that we already have a session for - logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions); - await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); - logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); - })(), - (async () => { - logger.debug( - `Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`, - devicesWithoutSession, - ); - const errorDevices: IOlmDevice[] = []; - - // meanwhile, establish olm sessions for devices that we don't - // already have a session for, and share keys with them. If - // we're doing two phases of olm session creation, use a - // shorter timeout when fetching one-time keys for the first - // phase. - const start = Date.now(); - const failedServers: string[] = []; - await this.shareKeyWithDevices( - session, key, payload, devicesWithoutSession, errorDevices, - singleOlmCreationPhase ? 10000 : 2000, failedServers, - ); - logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); - - if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { - // perform the second phase of olm session creation if requested, - // and if the first phase didn't take too long - (async () => { - // Retry sending keys to devices that we were unable to establish - // an olm session for. This time, we use a longer timeout, but we - // do this in the background and don't block anything else while we - // do this. We only need to retry users from servers that didn't - // respond the first time. - const retryDevices: Record = {}; - const failedServerMap = new Set; - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices = []; - for (const { userId, deviceInfo } of errorDevices) { - const userHS = userId.slice(userId.indexOf(":") + 1); - if (failedServerMap.has(userHS)) { - retryDevices[userId] = retryDevices[userId] || []; - retryDevices[userId].push(deviceInfo); - } else { - // if we aren't going to retry, then handle it - // as a failed device - failedDevices.push({ userId, deviceInfo }); - } - } - - logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); - await this.shareKeyWithDevices( - session, key, payload, retryDevices, failedDevices, 30000, - ); - logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); - - await this.notifyFailedOlmDevices(session, key, failedDevices); - })(); - } else { - await this.notifyFailedOlmDevices(session, key, errorDevices); - } - logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); - })(), - (async () => { - logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, - Object.entries(blocked)); - - // also, notify newly blocked devices that they're blocked - logger.debug(`Notifying newly blocked devices in ${this.roomId}`); - const blockedMap: Record> = {}; - let blockedCount = 0; - for (const [userId, userBlockedDevices] of Object.entries(blocked)) { - for (const [deviceId, device] of Object.entries(userBlockedDevices)) { - if ( - !session.blockedDevicesNotified[userId] || - session.blockedDevicesNotified[userId][deviceId] === undefined - ) { - blockedMap[userId] = blockedMap[userId] || {}; - blockedMap[userId][deviceId] = { device }; - blockedCount++; - } - } - } - - await this.notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap); - })(), - ]); + return session; }; - // helper which returns the session prepared by prepareSession - function returnSession() { - return session; - } - // first wait for the previous share to complete - const prom = this.setupPromise.then(prepareSession); + const prom = this.setupPromise.then(setup); // Ensure any failures are logged for debugging prom.catch(e => { - logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); + logger.error(`Failed to setup outbound session in ${this.roomId}`, e); }); // setupPromise resolves to `session` whether or not the share succeeds - this.setupPromise = prom.then(returnSession, returnSession); + this.setupPromise = prom; // but we return a promise which only resolves if the share was successful. - return prom.then(returnSession); + return prom; + } + + private async prepareSession( + devicesInRoom: DeviceInfoMap, + sharedHistory: boolean, + session: OutboundSessionInfo | null, + ): Promise { + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + + // need to make a brand new session? + if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) { + logger.log("Starting new megolm session because we need to rotate."); + session = null; + } + + // determine if we have shared with anyone we shouldn't have + if (session?.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } + + if (!session) { + logger.log(`Starting new megolm session for room ${this.roomId}`); + session = await this.prepareNewSession(sharedHistory); + logger.log(`Started new megolm session ${session.sessionId} ` + + `for room ${this.roomId}`); + this.outboundSessions[session.sessionId] = session; + } + + return session; + } + + private async shareSession( + devicesInRoom: DeviceInfoMap, + sharedHistory: boolean, + singleOlmCreationPhase: boolean, + blocked: IBlockedMap, + session: OutboundSessionInfo, + ) { + // now check if we need to share with any devices + const shareMap: Record = {}; + + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + + if ( + !session.sharedWithDevices[userId] || + session.sharedWithDevices[userId][deviceId] === undefined + ) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } + } + + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload: IPayload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, + }, + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( + this.olmDevice, this.baseApis, shareMap, + ); + + await Promise.all([ + (async () => { + // share keys with devices that we already have a session for + logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug( + `Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`, + devicesWithoutSession, + ); + const errorDevices: IOlmDevice[] = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers: string[] = []; + await this.shareKeyWithDevices( + session, key, payload, devicesWithoutSession, errorDevices, + singleOlmCreationPhase ? 10000 : 2000, failedServers, + ); + logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); + + if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async () => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices: Record = {}; + const failedServerMap = new Set; + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices: IOlmDevice[] = []; + for (const { userId, deviceInfo } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices[userId] = retryDevices[userId] || []; + retryDevices[userId].push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ userId, deviceInfo }); + } + } + + logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); + await this.shareKeyWithDevices( + session, key, payload, retryDevices, failedDevices, 30000, + ); + logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); + + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, + Object.entries(blocked)); + + // also, notify newly blocked devices that they're blocked + logger.debug(`Notifying newly blocked devices in ${this.roomId}`); + const blockedMap: Record> = {}; + let blockedCount = 0; + for (const [userId, userBlockedDevices] of Object.entries(blocked)) { + for (const [deviceId, device] of Object.entries(userBlockedDevices)) { + if ( + !session.blockedDevicesNotified[userId] || + session.blockedDevicesNotified[userId][deviceId] === undefined + ) { + blockedMap[userId] = blockedMap[userId] || {}; + blockedMap[userId][deviceId] = { device }; + blockedCount++; + } + } + } + + await this.notifyBlockedDevices(session, blockedMap); + logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap); + })(), + ]); } /** @@ -594,15 +606,14 @@ class MegolmEncryption extends EncryptionAlgorithm { return this.crypto.encryptAndSendToDevices( userDeviceMap, payload, - ).then((result) => { - const { contentMap, deviceInfoByDeviceId } = result; + ).then(({ contentMap, deviceInfoByUserIdAndDeviceId }) => { // store that we successfully uploaded the keys of the current slice for (const userId of Object.keys(contentMap)) { for (const deviceId of Object.keys(contentMap[userId])) { session.markSharedWithDevice( userId, deviceId, - deviceInfoByDeviceId.get(deviceId).getIdentityKey(), + deviceInfoByUserIdAndDeviceId.get(userId).get(deviceId).getIdentityKey(), chainIndex, ); } @@ -798,7 +809,7 @@ class MegolmEncryption extends EncryptionAlgorithm { logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); const devicemap = await olmlib.ensureOlmSessionsForDevices( this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, - logger.withPrefix(`[${this.roomId}]`), + logger.withPrefix?.(`[${this.roomId}]`), ); logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); @@ -938,11 +949,11 @@ class MegolmEncryption extends EncryptionAlgorithm { * @param {module:models/room} room the room the event is in */ public prepareToEncrypt(room: Room): void { - if (this.encryptionPreparation) { + if (this.encryptionPreparation != null) { // We're already preparing something, so don't do anything else. // FIXME: check if we need to restart // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; + const elapsedTime = Date.now() - this.encryptionPreparation.startTime; logger.debug( `Already started preparing to encrypt for ${this.roomId} ` + `${elapsedTime} ms ago, skipping`, @@ -952,32 +963,31 @@ class MegolmEncryption extends EncryptionAlgorithm { logger.debug(`Preparing to encrypt events for ${this.roomId}`); - this.encryptionPreparationMetadata = { + this.encryptionPreparation = { startTime: Date.now(), - }; - this.encryptionPreparation = (async () => { - try { - logger.debug(`Getting devices in ${this.roomId}`); - const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + promise: (async () => { + try { + logger.debug(`Getting devices in ${this.roomId}`); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); - if (this.crypto.getGlobalErrorOnUnknownDevices()) { - // Drop unknown devices for now. When the message gets sent, we'll - // throw an error, but we'll still be prepared to send to the known - // devices. - this.removeUnknownDevices(devicesInRoom); + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } + + logger.debug(`Ensuring outbound session in ${this.roomId}`); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + + logger.debug(`Ready to encrypt events for ${this.roomId}`); + } catch (e) { + logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); + } finally { + delete this.encryptionPreparation; } - - logger.debug(`Ensuring outbound session in ${this.roomId}`); - await this.ensureOutboundSession(room, devicesInRoom, blocked, true); - - logger.debug(`Ready to encrypt events for ${this.roomId}`); - } catch (e) { - logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); - } finally { - delete this.encryptionPreparationMetadata; - delete this.encryptionPreparation; - } - })(); + })(), + }; } /** @@ -992,12 +1002,12 @@ class MegolmEncryption extends EncryptionAlgorithm { public async encryptMessage(room: Room, eventType: string, content: object): Promise { logger.log(`Starting to encrypt event for ${this.roomId}`); - if (this.encryptionPreparation) { + if (this.encryptionPreparation != null) { // If we started sending keys, wait for it to be done. // FIXME: check if we need to cancel // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) try { - await this.encryptionPreparation; + await this.encryptionPreparation.promise; } catch (e) { // ignore any errors -- if the preparation failed, we'll just // restart everything here @@ -1212,7 +1222,7 @@ class MegolmDecryption extends DecryptionAlgorithm { // (fixes https://github.com/vector-im/element-web/issues/5001) this.addEventToPendingList(event); - let res; + let res: IDecryptedGroupMessage; try { res = await this.olmDevice.decryptGroupMessage( event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, @@ -1242,7 +1252,9 @@ class MegolmDecryption extends DecryptionAlgorithm { if (res === null) { // We've got a message for a session we don't have. - // + // try and get the missing key from the backup first + this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {}); + // (XXX: We might actually have received this key since we started // decrypting, in which case we'll have scheduled a retry, and this // request will be redundant. We could probably check to see if the @@ -1335,7 +1347,7 @@ class MegolmDecryption extends DecryptionAlgorithm { if (!senderPendingEvents.has(sessionId)) { senderPendingEvents.set(sessionId, new Set()); } - senderPendingEvents.get(sessionId).add(event); + senderPendingEvents.get(sessionId)?.add(event); } /** @@ -1369,17 +1381,17 @@ class MegolmDecryption extends DecryptionAlgorithm { * * @param {module:models/event.MatrixEvent} event key event */ - public onRoomKeyEvent(event: MatrixEvent): Promise { - const content = event.getContent(); - const sessionId = content.session_id; + public async onRoomKeyEvent(event: MatrixEvent): Promise { + const content = event.getContent>(); let senderKey = event.getSenderKey(); - let forwardingKeyChain = []; + let forwardingKeyChain: string[] = []; let exportFormat = false; - let keysClaimed; + let keysClaimed: ReturnType; if (!content.room_id || - !sessionId || - !content.session_key + !content.session_key || + !content.session_id || + !content.algorithm ) { logger.error("key event is missing fields"); return; @@ -1392,20 +1404,18 @@ class MegolmDecryption extends DecryptionAlgorithm { if (event.getType() == "m.forwarded_room_key") { exportFormat = true; - forwardingKeyChain = content.forwarding_curve25519_key_chain; - if (!Array.isArray(forwardingKeyChain)) { - forwardingKeyChain = []; - } + forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain) ? + content.forwarding_curve25519_key_chain : []; // copy content before we modify it forwardingKeyChain = forwardingKeyChain.slice(); forwardingKeyChain.push(senderKey); - senderKey = content.sender_key; - if (!senderKey) { + if (!content.sender_key) { logger.error("forwarded_room_key event is missing sender_key field"); return; } + senderKey = content.sender_key; const ed25519Key = content.sender_claimed_ed25519_key; if (!ed25519Key) { @@ -1426,34 +1436,39 @@ class MegolmDecryption extends DecryptionAlgorithm { if (content["org.matrix.msc3061.shared_history"]) { extraSessionData.sharedHistory = true; } - return this.olmDevice.addInboundGroupSession( - content.room_id, senderKey, forwardingKeyChain, sessionId, - content.session_key, keysClaimed, - exportFormat, extraSessionData, - ).then(() => { + + try { + await this.olmDevice.addInboundGroupSession( + content.room_id, + senderKey, + forwardingKeyChain, + content.session_id, + content.session_key, + keysClaimed, + exportFormat, + extraSessionData, + ); + // have another go at decrypting events sent with this session. - this.retryDecryption(senderKey, sessionId) - .then((success) => { - // cancel any outstanding room key requests for this session. - // Only do this if we managed to decrypt every message in the - // session, because if we didn't, we leave the other key - // requests in the hopes that someone sends us a key that - // includes an earlier index. - if (success) { - this.crypto.cancelRoomKeyRequest({ - algorithm: content.algorithm, - room_id: content.room_id, - session_id: content.session_id, - sender_key: senderKey, - }); - } + if (await this.retryDecryption(senderKey, content.session_id)) { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + this.crypto.cancelRoomKeyRequest({ + algorithm: content.algorithm, + room_id: content.room_id, + session_id: content.session_id, + sender_key: senderKey, }); - }).then(() => { + } + // don't wait for the keys to be backed up for the server - this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); - }).catch((e) => { + await this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); + } catch (e) { logger.error(`Error handling m.room_key_event: ${e}`); - }); + } } /** @@ -1651,7 +1666,10 @@ class MegolmDecryption extends DecryptionAlgorithm { * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted * @param {string} [opts.source] where the key came from */ - public importRoomKey(session: IMegolmSessionData, opts: any = {}): Promise { + public importRoomKey( + session: IMegolmSessionData, + opts: { untrusted?: boolean, source?: string } = {}, + ): Promise { const extraSessionData: any = {}; if (opts.untrusted || session.untrusted) { extraSessionData.untrusted = true; diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts index c640d14ef..aec39d49e 100644 --- a/src/crypto/algorithms/olm.ts +++ b/src/crypto/algorithms/olm.ts @@ -32,6 +32,7 @@ import { import { Room } from '../../models/room'; import { MatrixEvent } from "../.."; import { IEventDecryptionResult } from "../index"; +import { IInboundSession } from "../OlmDevice"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -51,7 +52,7 @@ interface IMessage { */ class OlmEncryption extends EncryptionAlgorithm { private sessionPrepared = false; - private prepPromise: Promise = null; + private prepPromise: Promise | null = null; /** * @private @@ -116,11 +117,11 @@ class OlmEncryption extends EncryptionAlgorithm { ciphertext: {}, }; - const promises = []; + const promises: Promise[] = []; for (let i = 0; i < users.length; ++i) { const userId = users[i]; - const devices = this.crypto.getStoredDevicesForUser(userId); + const devices = this.crypto.getStoredDevicesForUser(userId) || []; for (let j = 0; j < devices.length; ++j) { const deviceInfo = devices[j]; @@ -239,7 +240,7 @@ class OlmDecryption extends DecryptionAlgorithm { throw new DecryptionError( "OLM_BAD_ROOM", "Message intended for room " + payload.room_id, { - reported_room: event.getRoomId(), + reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED", }, ); } @@ -331,7 +332,7 @@ class OlmDecryption extends DecryptionAlgorithm { // prekey message which doesn't match any existing sessions: make a new // session. - let res; + let res: IInboundSession; try { res = await this.olmDevice.createInboundSession( theirDeviceIdentityKey, message.type, message.body, diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index c68e5def5..2f0386582 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -35,6 +35,7 @@ import { UnstableValue } from "../NamespacedValue"; import { CryptoEvent, IMegolmSessionData } from "./index"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; +const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms type AuthData = IKeyBackupInfo["auth_data"]; @@ -111,6 +112,8 @@ export class BackupManager { public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version public checkedForBackup: boolean; // Have we checked the server for a backup we can use? private sendingBackups: boolean; // Are we currently sending backups? + private sessionLastCheckAttemptedTime: Record = {}; // When did we last try to check the server for a given session id? + constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { this.checkedForBackup = false; this.sendingBackups = false; @@ -282,6 +285,26 @@ export class BackupManager { return this.checkAndStart(); } + /** + * Attempts to retrieve a session from a key backup, if enough time + * has elapsed since the last check for this session id. + */ + public async queryKeyBackupRateLimited( + targetRoomId: string | undefined, + targetSessionId: string | undefined, + ): Promise { + if (!this.backupInfo) { return; } + + const now = new Date().getTime(); + if ( + !this.sessionLastCheckAttemptedTime[targetSessionId] + || now - this.sessionLastCheckAttemptedTime[targetSessionId] > KEY_BACKUP_CHECK_RATE_LIMIT + ) { + this.sessionLastCheckAttemptedTime[targetSessionId] = now; + await this.baseApis.restoreKeyBackupWithCache(targetRoomId, targetSessionId, this.backupInfo, {}); + } + } + /** * Check if the given backup info is trusted. * @@ -428,7 +451,7 @@ export class BackupManager { // requests from different clients hitting the server all at // the same time when a new key is sent const delay = Math.random() * maxDelay; - await sleep(delay, undefined); + await sleep(delay); let numFailures = 0; // number of consecutive failures for (;;) { if (!this.algorithm) { @@ -462,7 +485,7 @@ export class BackupManager { } if (numFailures) { // exponential backoff if we have failures - await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)), undefined); + await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4))); } } } finally { @@ -474,8 +497,8 @@ export class BackupManager { * Take some e2e keys waiting to be backed up and send them * to the backup. * - * @param {integer} limit Maximum number of keys to back up - * @returns {integer} Number of sessions backed up + * @param {number} limit Maximum number of keys to back up + * @returns {number} Number of sessions backed up */ public async backupPendingKeys(limit: number): Promise { const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit); diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 21ed2ad74..f1658252a 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -59,7 +59,7 @@ import { keyFromPassphrase } from './key_passphrase'; import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey'; import { VerificationRequest } from "./verification/request/VerificationRequest"; import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; -import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; +import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel"; import { IllegalMethod } from "./verification/IllegalMethod"; import { KeySignatureUploadError } from "../errors"; import { calculateKeyCheck, decryptAES, encryptAES } from './aes'; @@ -76,7 +76,6 @@ import { ISignedKey, IUploadKeySignaturesResponse, MatrixClient, - SessionStore, } from "../client"; import type { IRoomEncryption, RoomList } from "./RoomList"; import { IKeyBackupInfo } from "./keybackup"; @@ -122,7 +121,7 @@ interface IInitOpts { export interface IBootstrapCrossSigningOpts { setupNewCrossSigning?: boolean; - authUploadDeviceSigningKeys?(makeRequest: (authData: any) => {}): Promise; + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise; } /* eslint-disable camelcase */ @@ -203,6 +202,19 @@ export interface IRequestsMap { setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void; } +/* eslint-disable camelcase */ +export interface IEncryptedContent { + algorithm: string; + sender_key: string; + ciphertext: Record; +} +/* eslint-enable camelcase */ + +interface IEncryptAndSendToDevicesResult { + contentMap: Record>; + deviceInfoByUserIdAndDeviceId: Map>; +} + export enum CryptoEvent { DeviceVerificationChanged = "deviceVerificationChanged", UserTrustStatusChanged = "userTrustStatusChanged", @@ -324,9 +336,6 @@ export class Crypto extends TypedEventEmitter | null> { - return this.secretStorage.isStored(name, checkKey); + public isSecretStored(name: string): Promise | null> { + return this.secretStorage.isStored(name); } public requestSecret(name: string, devices: string[]): ISecretRequest { @@ -1729,13 +1734,6 @@ export class Crypto extends TypedEventEmitter { - // This should be redundant post cross-signing is a thing, so just - // plonk it in localStorage for now. - this.sessionStore.setLocalTrustedBackupPubKey(trustedPubKey); - await this.backupManager.checkKeyBackup(); - } - /** */ public enableLazyLoading(): void { @@ -2322,8 +2320,8 @@ export class Crypto extends TypedEventEmitter { + let request: Request; if (transactionId) { request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); if (!request) { @@ -2594,7 +2592,7 @@ export class Crypto extends TypedEventEmitter = null; if (!existingConfig) { storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); } @@ -3131,36 +3129,40 @@ export class Crypto extends TypedEventEmitter[], payload: object, - ): Promise<{contentMap, deviceInfoByDeviceId}> { - const contentMap = {}; - const deviceInfoByDeviceId = new Map(); + ): Promise { + const contentMap: Record> = {}; + const deviceInfoByUserIdAndDeviceId = new Map>(); - const promises = []; - for (let i = 0; i < userDeviceInfoArr.length; i++) { - const encryptedContent = { + const promises: Promise[] = []; + for (const { userId, deviceInfo } of userDeviceInfoArr) { + const deviceId = deviceInfo.deviceId; + const encryptedContent: IEncryptedContent = { algorithm: olmlib.OLM_ALGORITHM, sender_key: this.olmDevice.deviceCurve25519Key, ciphertext: {}, }; - const val = userDeviceInfoArr[i]; - const userId = val.userId; - const deviceInfo = val.deviceInfo; - const deviceId = deviceInfo.deviceId; - deviceInfoByDeviceId.set(deviceId, deviceInfo); + + // Assign to temp value to make type-checking happy + let userIdDeviceInfo = deviceInfoByUserIdAndDeviceId.get(userId); + + if (userIdDeviceInfo === undefined) { + userIdDeviceInfo = new Map(); + deviceInfoByUserIdAndDeviceId.set(userId, userIdDeviceInfo); + } + + // We hold by reference, this updates deviceInfoByUserIdAndDeviceId[userId] + userIdDeviceInfo.set(deviceId, deviceInfo); if (!contentMap[userId]) { contentMap[userId] = {}; } contentMap[userId][deviceId] = encryptedContent; - const devicesByUser = {}; - devicesByUser[userId] = [deviceInfo]; - promises.push( olmlib.ensureOlmSessionsForDevices( this.olmDevice, this.baseApis, - devicesByUser, + { [userId]: [deviceInfo] }, ).then(() => olmlib.encryptMessageForDevice( encryptedContent.ciphertext, @@ -3183,16 +3185,13 @@ export class Crypto extends TypedEventEmitter ({ contentMap, deviceInfoByDeviceId }), + (response) => ({ contentMap, deviceInfoByUserIdAndDeviceId }), ).catch(error => { logger.error("sendToDevice failed", error); throw error; @@ -3402,7 +3401,7 @@ export class Crypto extends TypedEventEmitter, ourUserId: string, - ourDeviceId: string, + ourDeviceId: string | undefined, olmDevice: OlmDevice, recipientUserId: string, recipientDevice: DeviceInfo, @@ -323,7 +323,7 @@ export async function ensureOlmSessionsForDevices( } const oneTimeKeyAlgorithm = "signed_curve25519"; - let res; + let res: IClaimOTKsResult; let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`; try { log.debug(`Claiming ${taskDetail}`); diff --git a/src/crypto/recoverykey.ts b/src/crypto/recoverykey.ts index 124f6c77b..0ffef80b7 100644 --- a/src/crypto/recoverykey.ts +++ b/src/crypto/recoverykey.ts @@ -21,7 +21,7 @@ import * as bs58 from 'bs58'; const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; export function encodeRecoveryKey(key: ArrayLike): string { - const buf = new Buffer(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); + const buf = Buffer.alloc(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); buf.set(OLM_RECOVERY_KEY_PREFIX, 0); buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); diff --git a/src/crypto/store/indexeddb-crypto-store.ts b/src/crypto/store/indexeddb-crypto-store.ts index 0d3552928..ecc3d86c3 100644 --- a/src/crypto/store/indexeddb-crypto-store.ts +++ b/src/crypto/store/indexeddb-crypto-store.ts @@ -157,7 +157,7 @@ export class IndexedDBCryptoStore implements CryptoStore { } }).then(backend => { this.backend = backend; - return backend as CryptoStore; + return backend; }); return this.backendPromise; diff --git a/src/crypto/verification/request/VerificationRequest.ts b/src/crypto/verification/request/VerificationRequest.ts index e8eb5b8d3..d084a642d 100644 --- a/src/crypto/verification/request/VerificationRequest.ts +++ b/src/crypto/verification/request/VerificationRequest.ts @@ -841,11 +841,11 @@ export class VerificationRequest< } const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; - const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED; + const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && this.phase !== PHASE_STARTED; // only if phase has passed from PHASE_UNSENT should we cancel, because events // are allowed to come in in any order (at least with InRoomChannel). So we only know - // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED - // before that, we could be looking at somebody elses verification request and we just + // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED. + // Before that, we could be looking at somebody else's verification request and we just // happen to be in the room if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) { logger.warn(`Cancelling, unexpected ${type} verification ` + diff --git a/src/http-api.ts b/src/http-api.ts index c5c7517fd..23c692b1f 100644 --- a/src/http-api.ts +++ b/src/http-api.ts @@ -48,10 +48,15 @@ TODO: export const PREFIX_R0 = "/_matrix/client/r0"; /** - * A constant representing the URI path for release v1 of the Client-Server HTTP API. + * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API. */ export const PREFIX_V1 = "/_matrix/client/v1"; +/** + * A constant representing the URI path for Client-Server API endpoints versioned at v3. + */ +export const PREFIX_V3 = "/_matrix/client/v3"; + /** * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs. */ @@ -403,7 +408,7 @@ export class MatrixHttpApi { resp = bodyParser(resp); } } catch (err) { - err.http_status = xhr.status; + err.httpStatus = xhr.status; cb(err); return; } @@ -1055,7 +1060,7 @@ interface IErrorJson extends Partial { * @prop {string} name Same as MatrixError.errcode but with a default unknown string. * @prop {string} message The Matrix 'error' value, e.g. "Missing token." * @prop {Object} data The raw Matrix error JSON used to construct this object. - * @prop {integer} httpStatus The numeric HTTP status code given + * @prop {number} httpStatus The numeric HTTP status code given */ export class MatrixError extends Error { public readonly errcode: string; diff --git a/src/interactive-auth.ts b/src/interactive-auth.ts index f06369fe7..d5e10427e 100644 --- a/src/interactive-auth.ts +++ b/src/interactive-auth.ts @@ -44,11 +44,19 @@ export interface IStageStatus { export interface IAuthData { session?: string; + type?: string; completed?: string[]; flows?: IFlow[]; + available_flows?: IFlow[]; + stages?: string[]; + required_stages?: AuthType[]; params?: Record>; + data?: Record; errcode?: string; error?: string; + user_id?: string; + device_id?: string; + access_token?: string; } export enum AuthType { @@ -203,6 +211,8 @@ export class InteractiveAuth { private chosenFlow: IFlow = null; private currentStage: string = null; + private emailAttempt = 1; + // if we are currently trying to submit an auth dict (which includes polling) // the promise the will resolve/reject when it completes private submitPromise: Promise = null; @@ -408,6 +418,34 @@ export class InteractiveAuth { this.emailSid = sid; } + /** + * Requests a new email token and sets the email sid for the validation session + */ + public requestEmailToken = async () => { + if (!this.requestingEmailToken) { + logger.trace("Requesting email token. Attempt: " + this.emailAttempt); + // If we've picked a flow with email auth, we send the email + // now because we want the request to fail as soon as possible + // if the email address is not valid (ie. already taken or not + // registered, depending on what the operation is). + this.requestingEmailToken = true; + try { + const requestTokenResult = await this.requestEmailTokenCallback( + this.inputs.emailAddress, + this.clientSecret, + this.emailAttempt++, + this.data.session, + ); + this.emailSid = requestTokenResult.sid; + logger.trace("Email token request succeeded"); + } finally { + this.requestingEmailToken = false; + } + } else { + logger.warn("Could not request email token: Already requesting"); + } + }; + /** * Fire off a request, and either resolve the promise, or call * startAuthStage. @@ -458,24 +496,9 @@ export class InteractiveAuth { return; } - if ( - !this.emailSid && - !this.requestingEmailToken && - this.chosenFlow.stages.includes(AuthType.Email) - ) { - // If we've picked a flow with email auth, we send the email - // now because we want the request to fail as soon as possible - // if the email address is not valid (ie. already taken or not - // registered, depending on what the operation is). - this.requestingEmailToken = true; + if (!this.emailSid && this.chosenFlow.stages.includes(AuthType.Email)) { try { - const requestTokenResult = await this.requestEmailTokenCallback( - this.inputs.emailAddress, - this.clientSecret, - 1, // TODO: Multiple send attempts? - this.data.session, - ); - this.emailSid = requestTokenResult.sid; + await this.requestEmailToken(); // NB. promise is not resolved here - at some point, doRequest // will be called again and if the user has jumped through all // the hoops correctly, auth will be complete and the request @@ -491,8 +514,6 @@ export class InteractiveAuth { // send the email, for whatever reason. this.attemptAuthDeferred.reject(e); this.attemptAuthDeferred = null; - } finally { - this.requestingEmailToken = false; } } } diff --git a/src/matrix.ts b/src/matrix.ts index 02e1c9b2a..646f88798 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -41,7 +41,6 @@ export * from "./interactive-auth"; export * from "./service-types"; export * from "./store/memory"; export * from "./store/indexeddb"; -export * from "./store/session/webstorage"; export * from "./crypto/store/memory-crypto-store"; export * from "./crypto/store/indexeddb-crypto-store"; export * from "./content-repo"; diff --git a/src/models/beacon.ts b/src/models/beacon.ts index a4f769458..9df62bbe2 100644 --- a/src/models/beacon.ts +++ b/src/models/beacon.ts @@ -54,8 +54,8 @@ export class Beacon extends TypedEventEmitter; - private _latestLocationState: BeaconLocationState | undefined; + private livenessWatchTimeout: ReturnType; + private _latestLocationEvent: MatrixEvent | undefined; constructor( private rootEvent: MatrixEvent, @@ -90,7 +90,11 @@ export class Beacon extends TypedEventEmitter 1) { - this.livenessWatchInterval = setInterval( + this.livenessWatchTimeout = setTimeout( () => { this.monitorLiveness(); }, expiryInMs, ); } + } else if (this._beaconInfo?.timestamp > Date.now()) { + // beacon start timestamp is in the future + // check liveness again then + this.livenessWatchTimeout = setTimeout( + () => { this.monitorLiveness(); }, + this.beaconInfo?.timestamp - Date.now(), + ); } } @@ -161,13 +172,13 @@ export class Beacon extends TypedEventEmitter { - this._latestLocationState = undefined; + this._latestLocationEvent = undefined; this.emit(BeaconEvent.LocationUpdate, this.latestLocationState); }; @@ -178,8 +189,16 @@ export class Beacon extends TypedEventEmitter Date.now() ? + this._beaconInfo?.timestamp - 360000 /* 6min */ : + this._beaconInfo?.timestamp; this._isLive = this._beaconInfo?.live && - isTimestampInDuration(this._beaconInfo?.timestamp, this._beaconInfo?.timeout, Date.now()); + isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now()); if (prevLiveness !== this.isLive) { this.emit(BeaconEvent.LivenessChange, this.isLive, this); diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index aeb019112..6341e4820 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -18,15 +18,16 @@ limitations under the License. * @module models/event-timeline-set */ -import { EventTimeline } from "./event-timeline"; -import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event"; +import { EventTimeline, IAddEventOptions } from "./event-timeline"; +import { MatrixEvent } from "./event"; import { logger } from '../logger'; -import { Relations } from './relations'; import { Room, RoomEvent } from "./room"; import { Filter } from "../filter"; -import { EventType, RelationType } from "../@types/event"; import { RoomState } from "./room-state"; import { TypedEventEmitter } from "./typed-event-emitter"; +import { RelationsContainer } from "./relations-container"; +import { MatrixClient } from "../client"; +import { Thread } from "./thread"; const DEBUG = true; @@ -41,7 +42,6 @@ if (DEBUG) { interface IOpts { timelineSupport?: boolean; filter?: Filter; - unstableClientRelationAggregation?: boolean; pendingEvents?: boolean; } @@ -55,6 +55,23 @@ export interface IRoomTimelineData { liveEvent?: boolean; } +export interface IAddEventToTimelineOptions + extends Pick { + /** Whether the sync response came from cache */ + fromCache?: boolean; +} + +export interface IAddLiveEventOptions + extends Pick { + /** Applies to events in the timeline only. If this is 'replace' then if a + * duplicate is encountered, the event passed to this function will replace + * the existing event in the timeline. If this is not specified, or is + * 'ignore', then the event passed to this function will be ignored + * entirely, preserving the existing event in the timeline. Events are + * identical based on their event ID only. */ + duplicateStrategy?: DuplicateStrategy; +} + type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; export type EventTimelineSetHandlerMap = { @@ -64,14 +81,13 @@ export type EventTimelineSetHandlerMap = { }; export class EventTimelineSet extends TypedEventEmitter { + public readonly relations?: RelationsContainer; private readonly timelineSupport: boolean; - private unstableClientRelationAggregation: boolean; - private displayPendingEvents: boolean; + private readonly displayPendingEvents: boolean; private liveTimeline: EventTimeline; private timelines: EventTimeline[]; private _eventIdToTimeline: Record; private filter?: Filter; - private relations: Record>>; /** * Construct a set of EventTimeline objects, typically on behalf of a given @@ -95,7 +111,7 @@ export class EventTimelineSet extends TypedEventEmittereventId, relationType or eventType - * are not valid. - * - * @returns {?Relations} - * A container for relation events or undefined if there are no relation events for - * the relationType. + * @param event {MatrixEvent} the event to check whether it belongs to this timeline set. + * @throws {Error} if `room` was not set when constructing this timeline set. + * @return {boolean} whether the event belongs to this timeline set. */ - public getRelationsForEvent( - eventId: string, - relationType: RelationType | string, - eventType: EventType | string, - ): Relations | undefined { - if (!this.unstableClientRelationAggregation) { - throw new Error("Client-side relation aggregation is disabled"); + public canContain(event: MatrixEvent): boolean { + if (!this.room) { + throw new Error("Cannot call `EventTimelineSet::canContain without a `room` set. " + + "Set the room when creating the EventTimelineSet to call this method."); } - if (!eventId || !relationType || !eventType) { - throw new Error("Invalid arguments for `getRelationsForEvent`"); + const { threadId, shouldLiveInRoom } = this.room.eventShouldLiveIn(event); + + if (this.thread) { + return this.thread.id === threadId; } - - // debuglog("Getting relations for: ", eventId, relationType, eventType); - - const relationsForEvent = this.relations[eventId] || {}; - const relationsWithRelType = relationsForEvent[relationType] || {}; - return relationsWithRelType[eventType]; - } - - public getAllRelationsEventForEvent(eventId: string): MatrixEvent[] { - const relationsForEvent = this.relations?.[eventId] || {}; - const events = []; - for (const relationsRecord of Object.values(relationsForEvent)) { - for (const relations of Object.values(relationsRecord)) { - events.push(...relations.getRelations()); - } - } - return events; - } - - /** - * Set an event as the target event if any Relations exist for it already - * - * @param {MatrixEvent} event - * The event to check as relation target. - */ - public setRelationsTarget(event: MatrixEvent): void { - if (!this.unstableClientRelationAggregation) { - return; - } - - const relationsForEvent = this.relations[event.getId()]; - if (!relationsForEvent) { - return; - } - - for (const relationsWithRelType of Object.values(relationsForEvent)) { - for (const relationsWithEventType of Object.values(relationsWithRelType)) { - relationsWithEventType.setTargetEvent(event); - } - } - } - - /** - * Add relation events to the relevant relation collection. - * - * @param {MatrixEvent} event - * The new relation event to be aggregated. - */ - public aggregateRelations(event: MatrixEvent): void { - if (!this.unstableClientRelationAggregation) { - return; - } - - if (event.isRedacted() || event.status === EventStatus.CANCELLED) { - return; - } - - // If the event is currently encrypted, wait until it has been decrypted. - if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - event.once(MatrixEventEvent.Decrypted, () => { - this.aggregateRelations(event); - }); - return; - } - - const relation = event.getRelation(); - if (!relation) { - return; - } - - const relatesToEventId = relation.event_id; - const relationType = relation.rel_type; - const eventType = event.getType(); - - // debuglog("Aggregating relation: ", event.getId(), eventType, relation); - - let relationsForEvent: Record>> = this.relations[relatesToEventId]; - if (!relationsForEvent) { - relationsForEvent = this.relations[relatesToEventId] = {}; - } - let relationsWithRelType = relationsForEvent[relationType]; - if (!relationsWithRelType) { - relationsWithRelType = relationsForEvent[relationType] = {}; - } - let relationsWithEventType = relationsWithRelType[eventType]; - - if (!relationsWithEventType) { - relationsWithEventType = relationsWithRelType[eventType] = new Relations( - relationType, - eventType, - this.room, - ); - const relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId); - if (relatesToEvent) { - relationsWithEventType.setTargetEvent(relatesToEvent); - } - } - - relationsWithEventType.addEvent(event); + return shouldLiveInRoom; } } diff --git a/src/models/event-timeline.ts b/src/models/event-timeline.ts index fb0602735..c73086ec7 100644 --- a/src/models/event-timeline.ts +++ b/src/models/event-timeline.ts @@ -18,12 +18,30 @@ limitations under the License. * @module models/event-timeline */ -import { RoomState } from "./room-state"; +import { logger } from '../logger'; +import { RoomState, IMarkerFoundOptions } from "./room-state"; import { EventTimelineSet } from "./event-timeline-set"; import { MatrixEvent } from "./event"; import { Filter } from "../filter"; import { EventType } from "../@types/event"; +export interface IInitialiseStateOptions extends Pick { + // This is a separate interface without any extra stuff currently added on + // top of `IMarkerFoundOptions` just because it feels like they have + // different concerns. One shouldn't necessarily look to add to + // `IMarkerFoundOptions` just because they want to add an extra option to + // `initialiseState`. +} + +export interface IAddEventOptions extends Pick { + /** Whether to insert the new event at the start of the timeline where the + * oldest events are (timeline is in chronological order, oldest to most + * recent) */ + toStartOfTimeline: boolean; + /** The state events to reconcile metadata from */ + roomState?: RoomState; +} + export enum Direction { Backward = "b", Forward = "f", @@ -131,7 +149,7 @@ export class EventTimeline { * state with. * @throws {Error} if an attempt is made to call this after addEvent is called. */ - public initialiseState(stateEvents: MatrixEvent[]): void { + public initialiseState(stateEvents: MatrixEvent[], { timelineWasEmpty }: IInitialiseStateOptions = {}): void { if (this.events.length > 0) { throw new Error("Cannot initialise state after events are added"); } @@ -152,8 +170,12 @@ export class EventTimeline { Object.freeze(e); } - this.startState.setStateEvents(stateEvents); - this.endState.setStateEvents(stateEvents); + this.startState.setStateEvents(stateEvents, { + timelineWasEmpty, + }); + this.endState.setStateEvents(stateEvents, { + timelineWasEmpty, + }); } /** @@ -345,24 +367,60 @@ export class EventTimeline { * Add a new event to the timeline, and update the state * * @param {MatrixEvent} event new event - * @param {boolean} atStart true to insert new event at the start + * @param {IAddEventOptions} options addEvent options */ - public addEvent(event: MatrixEvent, atStart: boolean, stateContext?: RoomState): void { - if (!stateContext) { - stateContext = atStart ? this.startState : this.endState; + public addEvent( + event: MatrixEvent, + { + toStartOfTimeline, + roomState, + timelineWasEmpty, + }: IAddEventOptions, + ): void; + /** + * @deprecated In favor of the overload with `IAddEventOptions` + */ + public addEvent( + event: MatrixEvent, + toStartOfTimeline: boolean, + roomState?: RoomState + ): void; + public addEvent( + event: MatrixEvent, + toStartOfTimelineOrOpts: boolean | IAddEventOptions, + roomState?: RoomState, + ): void { + let toStartOfTimeline = !!toStartOfTimelineOrOpts; + let timelineWasEmpty: boolean; + if (typeof (toStartOfTimelineOrOpts) === 'object') { + ({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts); + } else if (toStartOfTimelineOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + 'Overload deprecated: ' + + '`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` ' + + 'is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`', + ); + } + + if (!roomState) { + roomState = toStartOfTimeline ? this.startState : this.endState; } const timelineSet = this.getTimelineSet(); if (timelineSet.room) { - EventTimeline.setEventMetadata(event, stateContext, atStart); + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); // modify state but only on unfiltered timelineSets if ( event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet ) { - stateContext.setStateEvents([event]); + roomState.setStateEvents([event], { + timelineWasEmpty, + }); // it is possible that the act of setting the state event means we // can set more metadata (specifically sender/target props), so try // it again if the prop wasn't previously set. It may also mean that @@ -373,22 +431,22 @@ export class EventTimeline { // back in time, else we'll set the .sender value for BEFORE the given // member event, whereas we want to set the .sender value for the ACTUAL // member event itself. - if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { - EventTimeline.setEventMetadata(event, stateContext, atStart); + if (!event.sender || (event.getType() === "m.room.member" && !toStartOfTimeline)) { + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); } } } - let insertIndex; + let insertIndex: number; - if (atStart) { + if (toStartOfTimeline) { insertIndex = 0; } else { insertIndex = this.events.length; } this.events.splice(insertIndex, 0, event); // insert element - if (atStart) { + if (toStartOfTimeline) { this.baseIndex++; } } diff --git a/src/models/event.ts b/src/models/event.ts index 227036be5..0ce12e068 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -514,13 +514,6 @@ export class MatrixEvent extends TypedEventEmitter