diff --git a/.bundler-audit.yml b/.bundler-audit.yml deleted file mode 100644 index f84ec80872..0000000000 --- a/.bundler-audit.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -ignore: - - CVE-2015-9284 # Mitigation following https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284#mitigating-in-rails-applications diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f991036add..b5e72a0973 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,11 +4,7 @@ FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bullseye # Install Rails # RUN gem install rails webdrivers -# Default value to allow debug server to serve content over GitHub Codespace's port forwarding service -# The value is a comma-separated list of allowed domains -ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev,.preview.app.github.dev,.app.github.dev" - -ARG NODE_VERSION="16" +ARG NODE_VERSION="20" RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1" # [Optional] Uncomment this section to install additional OS packages. @@ -19,6 +15,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ RUN gem install foreman # [Optional] Uncomment this line to install global node packages. -RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g yarn" 2>&1 +RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && corepack enable" 2>&1 COPY welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt diff --git a/.devcontainer/codespaces/devcontainer.json b/.devcontainer/codespaces/devcontainer.json new file mode 100644 index 0000000000..ca9156fdaa --- /dev/null +++ b/.devcontainer/codespaces/devcontainer.json @@ -0,0 +1,49 @@ +{ + "name": "Mastodon on GitHub Codespaces", + "dockerComposeFile": "../docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + }, + + "runServices": ["app", "db", "redis"], + + "forwardPorts": [3000, 4000], + + "portsAttributes": { + "3000": { + "label": "web", + "onAutoForward": "notify" + }, + "4000": { + "label": "stream", + "onAutoForward": "silent" + } + }, + + "otherPortsAttributes": { + "onAutoForward": "silent" + }, + + "remoteEnv": { + "LOCAL_DOMAIN": "${localEnv:CODESPACE_NAME}-3000.app.github.dev", + "LOCAL_HTTPS": "true", + "STREAMING_API_BASE_URL": "https://${localEnv:CODESPACE_NAME}-4000.app.github.dev", + "DISABLE_FORGERY_REQUEST_PROTECTION": "true", + "ES_ENABLED": "", + "LIBRE_TRANSLATE_ENDPOINT": "" + }, + + "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "postCreateCommand": ".devcontainer/post-create.sh", + "waitFor": "postCreateCommand", + + "customizations": { + "vscode": { + "settings": {}, + "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 17208a84e9..fa8d6542c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,31 +1,39 @@ -// For more details, see https://aka.ms/devcontainer.json. { - "name": "Mastodon", + "name": "Mastodon on local machine", "dockerComposeFile": "docker-compose.yml", "service": "app", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", - // Features to add to the dev container. More info: https://containers.dev/features. "features": { "ghcr.io/devcontainers/features/sshd:1": {} }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // This can be used to network with other containers or the host. "forwardPorts": [3000, 4000], - // Use 'postCreateCommand' to run commands after the container is created. + "portsAttributes": { + "3000": { + "label": "web", + "onAutoForward": "notify", + "requireLocalPort": true + }, + "4000": { + "label": "stream", + "onAutoForward": "silent", + "requireLocalPort": true + } + }, + + "otherPortsAttributes": { + "onAutoForward": "silent" + }, + "onCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "postCreateCommand": ".devcontainer/post-create.sh", "waitFor": "postCreateCommand", - // Configure tool-specific properties. "customizations": { - // Configure properties specific to VS Code. "vscode": { - // Set *default* container specific settings.json values on container create. "settings": {}, - // Add the IDs of extensions you want installed when the container is created. "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"] } } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a2658ea8ba..40dc72c12d 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -25,6 +25,7 @@ services: command: sleep infinity ports: - '127.0.0.1:3000:3000' + - '127.0.0.1:3035:3035' - '127.0.0.1:4000:4000' networks: - external_network @@ -69,7 +70,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.3.11 + image: libretranslate/libretranslate:v1.5.2 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index a075cc7b3b..82a2ccbb6c 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -11,7 +11,8 @@ bundle install git checkout -- Gemfile.lock # Fetch Javascript dependencies -yarn --frozen-lockfile +corepack prepare +yarn install --immutable # [re]create, migrate, and seed the test database RAILS_ENV=test ./bin/rails db:setup @@ -23,4 +24,4 @@ RAILS_ENV=development ./bin/rails db:setup RAILS_ENV=development ./bin/rails assets:precompile # Precompile assets for test -RAILS_ENV=test NODE_ENV=tests ./bin/rails assets:precompile +RAILS_ENV=test ./bin/rails assets:precompile diff --git a/.dockerignore b/.dockerignore index fedbea236d..41da718049 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ public/system public/assets public/packs +public/packs-test node_modules neo4j vendor/bundle diff --git a/.env.test b/.env.test index 761d0d9210..2f8c1afd6e 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ -# Node.js -NODE_ENV=tests +# In test, compile the NodeJS code as if we are in production +NODE_ENV=production # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true diff --git a/.env.vagrant b/.env.vagrant index 32ed9b9229..69c1bf1fb3 100644 --- a/.env.vagrant +++ b/.env.vagrant @@ -2,3 +2,7 @@ VAGRANT=true LOCAL_DOMAIN=mastodon.local BIND=0.0.0.0 DB_HOST=/var/run/postgresql/ + +ES_ENABLED=true +ES_HOST=localhost +ES_PORT=9200 \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 206faa1c7a..5adfa3bd70 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,7 @@ -module.exports = { +// @ts-check +const { defineConfig } = require('eslint-define-config'); + +module.exports = defineConfig({ root: true, extends: [ @@ -9,7 +12,6 @@ module.exports = { 'plugin:import/recommended', 'plugin:promise/recommended', 'plugin:jsdoc/recommended', - 'plugin:prettier/recommended', ], env: { @@ -63,7 +65,9 @@ module.exports = { 'consistent-return': 'error', 'dot-notation': 'error', eqeqeq: ['error', 'always', { 'null': 'ignore' }], + 'indent': ['error', 2], 'jsx-quotes': ['error', 'prefer-single'], + 'semi': ['error', 'always'], 'no-case-declarations': 'off', 'no-catch-shadow': 'error', 'no-console': [ @@ -116,7 +120,6 @@ module.exports = { 'react/jsx-uses-react': 'off', // not needed with new JSX transform 'react/jsx-wrap-multilines': 'error', 'react/no-deprecated': 'off', - 'react/no-unknown-property': 'off', 'react/react-in-jsx-scope': 'off', // not needed with new JSX transform 'react/self-closing-comp': 'error', @@ -192,6 +195,7 @@ module.exports = { 'error', { devDependencies: [ + '.eslintrc.js', 'config/webpack/**', 'app/javascript/mastodon/performance.js', 'app/javascript/mastodon/test_setup.js', @@ -235,7 +239,7 @@ module.exports = { }, // Common React utilities { - pattern: '{classnames,react-helmet,react-router-dom}', + pattern: '{classnames,react-helmet,react-router,react-router-dom}', group: 'external', position: 'before', }, @@ -247,7 +251,12 @@ module.exports = { }, // Internal packages { - pattern: '{mastodon/**,flavours/glitch-soc/**}', + pattern: '{mastodon/**}', + group: 'internal', + position: 'after', + }, + { + pattern: '{flavours/glitch-soc/**}', group: 'internal', position: 'after', }, @@ -256,6 +265,18 @@ module.exports = { }, ], + // Forbid imports from vanilla in glitch flavour + 'import/no-restricted-paths': [ + 'error', + { + zones: [{ + target: 'app/javascript/flavours/glitch/', + from: 'app/javascript/mastodon/', + message: 'Import from /flavours/glitch/ instead' + }] + } + ], + 'promise/always-return': 'off', 'promise/catch-or-return': [ 'error', @@ -279,7 +300,6 @@ module.exports = { 'formatjs/no-id': 'off', // IDs are used for translation keys 'formatjs/no-invalid-icu': 'error', 'formatjs/no-literal-string-in-jsx': 'off', // Should be looked at, but mainly flagging punctuation outside of strings - 'formatjs/no-multiple-plurals': 'off', // Only used by hashtag.jsx 'formatjs/no-multiple-whitespaces': 'error', 'formatjs/no-offset': 'error', 'formatjs/no-useless-message': 'error', @@ -298,6 +318,7 @@ module.exports = { overrides: [ { files: [ + '.eslintrc.js', '*.config.js', '.*rc.js', 'ide-helper.js', @@ -325,8 +346,8 @@ module.exports = { extends: [ 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:@typescript-eslint/strict-type-checked', + 'plugin:@typescript-eslint/stylistic-type-checked', 'plugin:react/recommended', 'plugin:react-hooks/recommended', 'plugin:jsx-a11y/recommended', @@ -338,7 +359,7 @@ module.exports = { ], parserOptions: { - project: './tsconfig.json', + project: true, tsconfigRootDir: __dirname, }, @@ -348,6 +369,7 @@ module.exports = { '@typescript-eslint/consistent-type-definitions': ['warn', 'interface'], '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/consistent-type-imports': 'error', + "@typescript-eslint/prefer-nullish-coalescing": ['error', { ignorePrimitives: { boolean: true } }], 'jsdoc/require-jsdoc': 'off', @@ -370,14 +392,6 @@ module.exports = { env: { jest: true, }, - }, - { - files: [ - 'streaming/**/*', - ], - rules: { - 'import/no-commonjs': 'off', - }, - }, + } ], -}; +}); diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index be750a5e41..0000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -patreon: mastodon -open_collective: mastodon -custom: https://sponsor.joinmastodon.org diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml deleted file mode 100644 index 22f51f7bdf..0000000000 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Bug Report -description: If something isn't working as expected -labels: [bug] -body: - - type: markdown - attributes: - value: | - Make sure that you are submitting a new bug that was not previously reported or already fixed. - - Please use a concise and distinct title for the issue. - - type: textarea - attributes: - label: Steps to reproduce the problem - description: What were you trying to do? - value: | - 1. - 2. - 3. - ... - validations: - required: true - - type: input - attributes: - label: Expected behaviour - description: What should have happened? - validations: - required: true - - type: input - attributes: - label: Actual behaviour - description: What happened? - validations: - required: true - - type: textarea - attributes: - label: Detailed description - validations: - required: false - - type: textarea - attributes: - label: Specifications - description: | - What version or commit hash of Mastodon did you find this bug in? - - If a front-end issue, what browser and operating systems were you using? - placeholder: | - Mastodon 3.5.3 (or Edge) - Ruby 2.7.6 (or v3.1.2) - Node.js 16.18.0 - - Google Chrome 106.0.5249.119 - Firefox 105.0.3 - - etc... - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/1.web_bug_report.yml b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml new file mode 100644 index 0000000000..20e27d103c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.web_bug_report.yml @@ -0,0 +1,76 @@ +name: Bug Report (Web Interface) +description: If you are using Mastodon's web interface and something is not working as expected +labels: [bug, 'status/to triage', 'area/web interface'] +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: true + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` + placeholder: v4.1.2 + validations: + required: true + - type: input + attributes: + label: Browser name and version + description: | + What browser are you using when getting this bug? Please specify the version as well. + placeholder: Firefox 105.0.3 + validations: + required: true + - type: input + attributes: + label: Operating system + description: | + What OS are you running? Please specify the version as well. + placeholder: macOS 13.4.1 + validations: + required: true + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have. This can include the full error log, inspector's output… + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2.server_bug_report.yml b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml new file mode 100644 index 0000000000..49d5f57209 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.server_bug_report.yml @@ -0,0 +1,65 @@ +name: Bug Report (server / API) +description: | + If something is not working as expected, but is not from using the web interface. +labels: [bug, 'status/to triage'] +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: true + - type: input + attributes: + label: Expected behaviour + description: What should have happened? + validations: + required: true + - type: input + attributes: + label: Actual behaviour + description: What happened? + validations: + required: true + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: input + attributes: + label: Mastodon instance + description: The address of the Mastodon instance where you experienced the issue + placeholder: mastodon.social + validations: + required: false + - type: input + attributes: + label: Mastodon version + description: | + This is displayed at the bottom of the About page, eg. `v4.1.2+nightly-20230627` + placeholder: v4.1.2 + validations: + required: false + - type: textarea + attributes: + label: Technical details + description: | + Any additional technical details you may have, like logs or error traces + value: | + If this is happening on your own Mastodon server, please fill out those: + - Ruby version: (from `ruby --version`, eg. v3.1.2) + - Node.js version: (from `node --version`, eg. v18.16.0) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/3.feature_request.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/2.feature_request.yml rename to .github/ISSUE_TEMPLATE/3.feature_request.yml diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml new file mode 100644 index 0000000000..07fd4d08d3 --- /dev/null +++ b/.github/actions/setup-javascript/action.yml @@ -0,0 +1,42 @@ +name: 'Setup Javascript' +description: 'Setup a Javascript environment ready to run the Mastodon code' +inputs: + onlyProduction: + description: Only install production dependencies + default: 'false' + +runs: + using: 'composite' + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + # The following is needed because we can not use `cache: true` for `setup-node`, as it does not support Corepack yet and mess up with the cache location if ran after Node is installed + - name: Enable corepack + shell: bash + run: corepack enable + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + shell: bash + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install all yarn packages + shell: bash + run: yarn install --immutable + if: inputs.onlyProduction == 'false' + + - name: Install all production yarn packages + shell: bash + run: yarn workspaces focus --production + if: inputs.onlyProduction != 'false' diff --git a/.github/actions/setup-ruby/action.yml b/.github/actions/setup-ruby/action.yml new file mode 100644 index 0000000000..3a6fba9402 --- /dev/null +++ b/.github/actions/setup-ruby/action.yml @@ -0,0 +1,23 @@ +name: 'Setup RUby' +description: 'Setup a Ruby environment ready to run the Mastodon code' +inputs: + ruby-version: + description: The Ruby version to install + default: '.ruby-version' + additional-system-dependencies: + description: 'Additional packages to install' + +runs: + using: 'composite' + steps: + - name: Install system dependencies + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libicu-dev libidn11-dev ${{ inputs.additional-system-dependencies }} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ inputs.ruby-version }} + bundler-cache: true diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000000..5532c49618 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,13 @@ +coverage: + status: + project: + default: + # Github status check is not blocking + informational: true + patch: + default: + # Github status check is not blocking + informational: true +comment: + # Only write a comment in PR if there are changes + require_changes: true diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1ae40d4161..dab99829a1 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,25 +1,28 @@ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: [ - 'config:base', - ':dependencyDashboard', + 'config:recommended', ':labels(dependencies)', - ':maintainLockFilesMonthly', // update non-direct dependencies monthly - ':prConcurrentLimit10', // only 10 open PRs at the same time + ':prConcurrentLimitNone', // Remove limit for open PRs at any time. + ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. ], - stabilityDays: 3, // Wait 3 days after the package has been published before upgrading it + minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it // packageRules order is important, they are applied from top to bottom and are merged, - // so for example grouping rules needs to be at the bottom + // meaning the most important ones must be at the bottom, for example grouping rules + // If we do not want a package to be grouped with others, we need to set its groupName + // to `null` after any other rule set it to something. + dependencyDashboardHeader: 'This issue lists Renovate updates and detected dependencies. Read the [Dependency Dashboard](https://docs.renovatebot.com/key-concepts/dashboard/) docs to learn more. Before approving any upgrade: read the description and comments in the [`renovate.json5` file](https://github.com/mastodon/mastodon/blob/main/.github/renovate.json5).', + postUpdateOptions: ['yarnDedupeHighest'], packageRules: [ { - // Ignore major version bumps for these node packages + // Require Dependency Dashboard Approval for major version bumps of these node packages matchManagers: ['npm'], matchPackageNames: [ - '@rails/ujs', // Needs to match the major Rails version 'tesseract.js', // Requires code changes 'react-hotkeys', // Requires code changes // Requires Webpacker upgrade or replacement + '@svgr/webpack', '@types/webpack', 'babel-loader', 'compression-webpack-plugin', @@ -40,26 +43,20 @@ 'react-router-dom', ], matchUpdateTypes: ['major'], - enabled: false, + dependencyDashboardApproval: true, }, { - // Ignore major version bumps for these Ruby packages + // Require Dependency Dashboard Approval for major version bumps of these Ruby packages matchManagers: ['bundler'], matchPackageNames: [ - 'sprockets', // Requires manual upgrade https://github.com/rails/sprockets/blob/master/UPGRADING.md#guide-to-upgrading-from-sprockets-3x-to-4x + 'rack', // Needs to be synced with Rails version 'strong_migrations', // Requires manual upgrade 'sidekiq', // Requires manual upgrade 'sidekiq-unique-jobs', // Requires manual upgrades and sync with Sidekiq version 'redis', // Requires manual upgrade and sync with Sidekiq version - 'fog-openstack', // TODO: was ignored in https://github.com/mastodon/mastodon/pull/13964 - - // Needs major Rails version bump - 'rack', - 'rails', - 'rails-i18n', ], matchUpdateTypes: ['major'], - enabled: false, + dependencyDashboardApproval: true, }, { // Update Github Actions and Docker images weekly @@ -67,36 +64,51 @@ extends: ['schedule:weekly'], }, { - // Ignore major & minor bumps for the ruby image, this needs to be synced with .ruby-version + // Require Dependency Dashboard Approval for major & minor bumps for the ruby image, this needs to be synced with .ruby-version matchManagers: ['dockerfile'], matchPackageNames: ['moritzheiber/ruby-jemalloc'], matchUpdateTypes: ['minor', 'major'], - enabled: false, + dependencyDashboardApproval: true, }, { - // Ignore major bump for the node image, this needs to be synced with .nvmrc + // Require Dependency Dashboard Approval for major bumps for the node image, this needs to be synced with .nvmrc matchManagers: ['dockerfile'], matchPackageNames: ['node'], matchUpdateTypes: ['major'], - enabled: false, + dependencyDashboardApproval: true, }, { - // Ignore major postgres bumps in the docker-compose file, as those break dev environments + // Require Dependency Dashboard Approval for major postgres bumps in the docker-compose file, as those break dev environments matchManagers: ['docker-compose'], matchPackageNames: ['postgres'], matchUpdateTypes: ['major'], - enabled: false, + dependencyDashboardApproval: true, }, { // Update devDependencies every week, with one grouped PR matchDepTypes: 'devDependencies', matchUpdateTypes: ['patch', 'minor'], - excludePackageNames: [ - 'typescript', // Typescript has many changes in minor versions, needs to be checked every time - ], groupName: 'devDependencies (non-major)', extends: ['schedule:weekly'], }, + { + // Group all eslint-related packages with `eslint` in the same PR + matchManagers: ['npm'], + matchPackageNames: ['eslint'], + matchPackagePrefixes: ['eslint-', '@typescript-eslint/'], + matchUpdateTypes: ['patch', 'minor'], + groupName: 'eslint (non-major)', + }, + { + // Group actions/*-artifact in the same PR + matchManagers: ['github-actions'], + matchPackageNames: [ + 'actions/download-artifact', + 'actions/upload-artifact', + ], + matchUpdateTypes: ['major'], + groupName: 'artifact actions (major)', + }, { // Update @types/* packages every week, with one grouped PR matchPackagePrefixes: '@types/', @@ -105,6 +117,14 @@ extends: ['schedule:weekly'], addLabels: ['typescript'], }, + { + // We want those packages to always have their own PR + matchManagers: ['npm'], + matchPackageNames: [ + 'typescript', // Typescript has code-impacting changes in minor versions + ], + groupName: null, // We dont want them to belong to any group + }, // Add labels depending on package manager { matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] }, { matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] }, diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml new file mode 100644 index 0000000000..e100e15821 --- /dev/null +++ b/.github/workflows/build-container-image.yml @@ -0,0 +1,102 @@ +on: + workflow_call: + inputs: + platforms: + required: true + type: string + cache: + type: boolean + default: true + use_native_arm64_builder: + type: boolean + push_to_images: + type: string + version_prerelease: + type: string + version_metadata: + type: string + flavor: + type: string + tags: + type: string + labels: + type: string + file_to_build: + type: string + +jobs: + build-image: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + if: contains(inputs.platforms, 'linux/arm64') && !inputs.use_native_arm64_builder + + - uses: docker/setup-buildx-action@v3 + id: buildx + if: ${{ !(inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64')) }} + + - name: Start a local Docker Builder + if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') + run: | + docker run --rm -d --name buildkitd -p 1234:1234 --privileged moby/buildkit:latest --addr tcp://0.0.0.0:1234 + + - uses: docker/setup-buildx-action@v3 + id: buildx-native + if: inputs.use_native_arm64_builder && contains(inputs.platforms, 'linux/arm64') + with: + driver: remote + endpoint: tcp://localhost:1234 + platforms: linux/amd64 + append: | + - endpoint: tcp://${{ vars.DOCKER_BUILDER_HETZNER_ARM64_01_HOST }}:13865 + platforms: linux/arm64 + name: mastodon-docker-builder-arm64-01 + driver-opts: + - servername=mastodon-docker-builder-arm64-01 + env: + BUILDER_NODE_1_AUTH_TLS_CACERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CACERT }} + BUILDER_NODE_1_AUTH_TLS_CERT: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_CERT }} + BUILDER_NODE_1_AUTH_TLS_KEY: ${{ secrets.DOCKER_BUILDER_HETZNER_ARM64_01_KEY }} + + - name: Log in to Docker Hub + if: contains(inputs.push_to_images, 'tootsuite') + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the Github Container registry + if: contains(inputs.push_to_images, 'ghcr.io') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + if: ${{ inputs.push_to_images != '' }} + with: + images: ${{ inputs.push_to_images }} + flavor: ${{ inputs.flavor }} + tags: ${{ inputs.tags }} + labels: ${{ inputs.labels }} + + - uses: docker/build-push-action@v5 + with: + context: . + file: ${{ inputs.file_to_build }} + build-args: | + MASTODON_VERSION_PRERELEASE=${{ inputs.version_prerelease }} + MASTODON_VERSION_METADATA=${{ inputs.version_metadata }} + platforms: ${{ inputs.platforms }} + provenance: false + builder: ${{ steps.buildx.outputs.name || steps.buildx-native.outputs.name }} + push: ${{ inputs.push_to_images != '' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: ${{ inputs.cache && 'type=gha' || '' }} + cache-to: ${{ inputs.cache && 'type=gha,mode=max' || '' }} diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml index f07f7447ca..eddcd80f26 100644 --- a/.github/workflows/build-nightly.yml +++ b/.github/workflows/build-nightly.yml @@ -3,58 +3,62 @@ on: workflow_dispatch: schedule: - cron: '0 2 * * *' # run at 2 AM UTC + permissions: contents: read packages: write jobs: - build-nightly-image: + compute-suffix: runs-on: ubuntu-latest - - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - + if: github.repository == 'glitch-soc/mastodon' steps: - - uses: actions/checkout@v3 - - uses: hadolint/hadolint-action@v3.1.0 - - uses: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 - - - name: Log in to the Github Container registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/metadata-action@v4 - id: meta - with: - images: | - ghcr.io/mastodon/mastodon - flavor: | - latest=auto - tags: | - type=raw,value=nightly - type=schedule,pattern=nightly-{{date 'YYYY-MM-DD' tz='Etc/UTC'}} - labels: | - org.opencontainers.image.description=Nightly build image used for testing purposes - - - name: Generate version suffix - id: version_vars + - id: version_vars + env: + TZ: Etc/UTC run: | - echo mastodon_version_suffix=+nightly-$(date +'%Y%m%d') >> $GITHUB_OUTPUT + echo mastodon_version_prerelease=nightly.$(date +'%Y-%m-%d')>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} - - uses: docker/build-push-action@v4 - with: - context: . - build-args: MASTODON_VERSION_SUFFIX=${{ steps.version_vars.outputs.mastodon_version_suffix }} - platforms: linux/amd64,linux/arm64 - provenance: false - builder: ${{ steps.buildx.outputs.name }} - push: ${{ github.repository == 'mastodon/mastodon' && github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + cache: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=true + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit + + build-image-streaming: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + cache: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon-streaming + version_prerelease: ${{ needs.compute-suffix.outputs.prerelease }} + labels: | + org.opencontainers.image.description=Nightly build image used for testing purposes + flavor: | + latest=true + tags: | + type=raw,value=edge + type=raw,value=nightly + type=schedule,pattern=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit diff --git a/.github/workflows/build-push-pr.yml b/.github/workflows/build-push-pr.yml new file mode 100644 index 0000000000..4505151e1a --- /dev/null +++ b/.github/workflows/build-push-pr.yml @@ -0,0 +1,58 @@ +name: Build container image for PR +on: + pull_request: + types: [labeled, synchronize, reopened, ready_for_review, opened] + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + # This is only allowed to run if: + # - the PR branch is in the `mastodon/mastodon` repository + # - the PR is not a draft + # - the PR has the "build-image" label + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'build-image') }} + steps: + # Repository needs to be cloned so `git rev-parse` below works + - name: Clone repository + uses: actions/checkout@v4 + - id: version_vars + run: | + echo mastodon_version_metadata=pr-${{ github.event.pull_request.number }}-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT + outputs: + metadata: ${{ steps.version_vars.outputs.mastodon_version_metadata }} + + build-image: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon + version_metadata: ${{ needs.compute-suffix.outputs.metadata }} + flavor: | + latest=auto + tags: | + type=ref,event=pr + secrets: inherit + + build-image-streaming: + needs: compute-suffix + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon-streaming + version_metadata: ${{ needs.compute-suffix.outputs.metadata }} + flavor: | + latest=auto + tags: | + type=ref,event=pr + secrets: inherit diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml new file mode 100644 index 0000000000..8e0fe5dfa8 --- /dev/null +++ b/.github/workflows/build-releases.yml @@ -0,0 +1,49 @@ +name: Build container release images +on: + push: + tags: + - '*' + +permissions: + contents: read + packages: write + +jobs: + build-image: + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon + # Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages + cache: false + # Only tag with latest when ran against the latest stable branch + # This needs to be updated after each minor version release + flavor: | + latest=${{ startsWith(github.ref, 'refs/tags/v4.2.') }} + tags: | + type=pep440,pattern={{raw}} + type=pep440,pattern=v{{major}}.{{minor}} + secrets: inherit + + build-image-streaming: + if: startsWith(github.ref, 'refs/tags/v4.3.') + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + platforms: linux/amd64,linux/arm64 + use_native_arm64_builder: false + push_to_images: | + ghcr.io/${{ github.repository_owner }}/mastodon-streaming + # Do not use cache when building releases, so apt update is always ran and the release always contain the latest packages + cache: false + # Only tag with latest when ran against the latest stable branch + # This needs to be updated after each minor version release + flavor: | + latest=${{ startsWith(github.ref, 'refs/tags/v4.3.') }} + tags: | + type=pep440,pattern={{raw}} + type=pep440,pattern=v{{major}}.{{minor}} + secrets: inherit diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml new file mode 100644 index 0000000000..bbc31598c7 --- /dev/null +++ b/.github/workflows/bundler-audit.yml @@ -0,0 +1,34 @@ +name: Bundler Audit +on: + push: + branches-ignore: + - 'dependabot/**' + paths: + - 'Gemfile*' + - '.ruby-version' + - '.bundler-audit.yml' + - '.github/workflows/bundler-audit.yml' + + pull_request: + paths: + - 'Gemfile*' + - '.ruby-version' + - '.bundler-audit.yml' + - '.github/workflows/bundler-audit.yml' + + schedule: + - cron: '0 5 * * 1' + +jobs: + security: + runs-on: ubuntu-latest + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Run bundler-audit + run: bundle exec bundler-audit diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index b67c503e95..ceb385933b 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -17,27 +17,13 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: Check for missing strings in English JSON run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8534501d4e..3b40c3fd07 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml new file mode 100644 index 0000000000..99271b127b --- /dev/null +++ b/.github/workflows/crowdin-download.yml @@ -0,0 +1,72 @@ +name: Crowdin / Download translations +on: + schedule: + - cron: '17 4 * * *' # Every day + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + download-translations: + runs-on: ubuntu-latest + if: github.repository == 'glitch-soc/mastodon' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Increase Git http.postBuffer + # This is needed due to a bug in Ubuntu's cURL version? + # See https://github.com/orgs/community/discussions/55820 + run: | + git config --global http.version HTTP/1.1 + git config --global http.postBuffer 157286400 + + # Download the translation files from Crowdin + - name: crowdin action + uses: crowdin/github-action@v1 + with: + config: crowdin-glitch.yml + upload_sources: false + upload_translations: false + download_translations: true + crowdin_branch_name: main + push_translations: false + create_pull_request: false + env: + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # As the files are extracted from a Docker container, they belong to root:root + # We need to fix this before the next steps + - name: Fix file permissions + run: sudo chown -R runner:docker . + + # This is needed to run the normalize step + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Run i18n normalize task + run: bundle exec i18n-tasks normalize + + # Create or update the pull request + - name: Create Pull Request + uses: peter-evans/create-pull-request@v5.0.2 + with: + commit-message: 'New Crowdin translations' + title: 'New Crowdin Translations (automated)' + author: 'GitHub Actions ' + body: | + New Crowdin translations, automated with Github Actions + + See `.github/workflows/crowdin-download.yml` + + This PR will be updated every day with new translations. + + Due to a limitation in Github Actions, checks are not running on this PR without manual action. + If you want to run the checks, then close and re-open it. + branch: i18n/crowdin/translations + base: main + labels: i18n diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml new file mode 100644 index 0000000000..75d66c2a6b --- /dev/null +++ b/.github/workflows/crowdin-upload.yml @@ -0,0 +1,36 @@ +name: Crowdin / Upload translations + +on: + push: + branches: + - main + paths: + - crowdin.yml + - app/javascript/mastodon/locales/en.json + - config/locales/en.yml + - config/locales/simple_form.en.yml + - config/locales/activerecord.en.yml + - config/locales/devise.en.yml + - config/locales/doorkeeper.en.yml + - .github/workflows/crowdin-upload.yml + +jobs: + upload-translations: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v1 + with: + config: crowdin-glitch.yml + upload_sources: true + upload_translations: false + download_translations: false + crowdin_branch_name: main + + env: + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 4d3c2ce5af..7229bec582 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -33,16 +33,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - uses: xt0rted/stylelint-problem-matcher@v1 diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index 56d817123a..8dcab845ee 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -28,18 +28,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install native Ruby dependencies - run: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby - name: Run haml-lint run: | diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 1f0cfd1e70..1c1ecc2b22 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -37,16 +37,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: ESLint run: yarn lint:js --max-warnings 0 diff --git a/.github/workflows/lint-json.yml b/.github/workflows/lint-json.yml index 8712d8bd80..7796bf92c4 100644 --- a/.github/workflows/lint-json.yml +++ b/.github/workflows/lint-json.yml @@ -29,16 +29,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: Prettier run: yarn lint:json diff --git a/.github/workflows/lint-md.yml b/.github/workflows/lint-md.yml index d19a0470db..51c59937a3 100644 --- a/.github/workflows/lint-md.yml +++ b/.github/workflows/lint-md.yml @@ -29,16 +29,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: Prettier run: yarn lint:md diff --git a/.github/workflows/lint-ruby.yml b/.github/workflows/lint-ruby.yml index 0395c8639f..411b323486 100644 --- a/.github/workflows/lint-ruby.yml +++ b/.github/workflows/lint-ruby.yml @@ -8,7 +8,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' - - '.bundler-audit.yml' + - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -18,7 +18,7 @@ on: - 'Gemfile*' - '.rubocop*.yml' - '.ruby-version' - - '.bundler-audit.yml' + - 'config/brakeman.ignore' - '**/*.rb' - '**/*.rake' - '.github/workflows/lint-ruby.yml' @@ -29,16 +29,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install native Ruby dependencies - run: sudo apt-get install -y libicu-dev libidn11-dev - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby - name: Set-up RuboCop Problem Matcher uses: r7kamura/rubocop-problem-matchers-action@v1 @@ -46,5 +40,6 @@ jobs: - name: Run rubocop run: bundle exec rubocop - - name: Run bundler-audit - run: bundle exec bundler-audit + - name: Run brakeman + if: always() # Run both checks, even if the first failed + run: bundle exec brakeman diff --git a/.github/workflows/lint-yml.yml b/.github/workflows/lint-yml.yml index 295e9610b3..908bdef5cc 100644 --- a/.github/workflows/lint-yml.yml +++ b/.github/workflows/lint-yml.yml @@ -31,16 +31,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: Prettier run: yarn lint:yml diff --git a/.github/workflows/rebase-needed.yml b/.github/workflows/rebase-needed.yml index 131a62a576..06d835c090 100644 --- a/.github/workflows/rebase-needed.yml +++ b/.github/workflows/rebase-needed.yml @@ -1,17 +1,8 @@ name: PR Needs Rebase on: - push: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - - 'l10n_main' - pull_request_target: - branches-ignore: - - 'dependabot/**' - - 'renovate/**' - - 'l10n_main' - types: [synchronize] + schedule: + - cron: '0 * * * *' permissions: pull-requests: write @@ -32,5 +23,5 @@ jobs: repoToken: '${{ secrets.GITHUB_TOKEN }}' commentOnClean: This pull request has resolved merge conflicts and is ready for review. commentOnDirty: This pull request has merge conflicts that must be resolved before it can be merged. - retryMax: 10 + retryMax: 30 continueOnMissingPermissions: false diff --git a/.github/workflows/test-image-build.yml b/.github/workflows/test-image-build.yml new file mode 100644 index 0000000000..980e071897 --- /dev/null +++ b/.github/workflows/test-image-build.yml @@ -0,0 +1,35 @@ +name: Test container image build +on: + pull_request: + paths: + - .github/workflows/build-nightly.yml + - .github/workflows/build-push-pr.yml + - .github/workflows/build-releases.yml + - .github/workflows/test-image-build.yml + - Dockerfile + - streaming/Dockerfile +permissions: + contents: read + +jobs: + build-image: + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: Dockerfile + platforms: linux/amd64 # Testing only on native platform so it is performant + cache: true + + build-image-streaming: + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-streaming + cancel-in-progress: true + + uses: ./.github/workflows/build-container-image.yml + with: + file_to_build: streaming/Dockerfile + platforms: linux/amd64 # Testing only on native platform so it is performant + cache: true diff --git a/.github/workflows/test-js.yml b/.github/workflows/test-js.yml index 3306105f9e..79622b6c1f 100644 --- a/.github/workflows/test-js.yml +++ b/.github/workflows/test-js.yml @@ -33,16 +33,10 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - cache: yarn - node-version-file: '.nvmrc' - - - name: Install all yarn packages - run: yarn --frozen-lockfile + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: Jest testing run: yarn jest --reporters github-actions summary diff --git a/.github/workflows/test-migrations-one-step.yml b/.github/workflows/test-migrations-one-step.yml index a91fd819a2..5dca8e376d 100644 --- a/.github/workflows/test-migrations-one-step.yml +++ b/.github/workflows/test-migrations-one-step.yml @@ -70,18 +70,10 @@ jobs: BUNDLE_RETRY: 3 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Install native Ruby dependencies - run: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev - - - name: Set up bundler cache - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby - name: Create database run: './bin/rails db:create' diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml index 50266fb8a0..59485d285d 100644 --- a/.github/workflows/test-migrations-two-step.yml +++ b/.github/workflows/test-migrations-two-step.yml @@ -69,18 +69,10 @@ jobs: BUNDLE_RETRY: 3 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Install native Ruby dependencies - run: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev - - - name: Set up bundler cache - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby - name: Create database run: './bin/rails db:create' diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 07cb1d41f8..ae25648a0b 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -32,38 +32,31 @@ jobs: SECRET_KEY_BASE: precompile_placeholder steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v3 + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript with: - cache: yarn - node-version-file: '.nvmrc' + onlyProduction: 'true' - - name: Install native Ruby dependencies - run: | - sudo apt-get update - sudo apt-get install -y libicu-dev libidn11-dev - - - name: Set up bundler cache - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - run: yarn --frozen-lockfile --production - name: Precompile assets # Previously had set this, but it's not supported # export NODE_OPTIONS=--openssl-legacy-provider run: |- ./bin/rails assets:precompile + - name: Archive asset artifacts + run: | + tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* + - uses: actions/upload-artifact@v3 if: matrix.mode == 'test' with: path: |- - ./public/assets - ./public/packs-test + ./artifacts.tar.gz name: ${{ github.sha }} retention-days: 0 @@ -101,14 +94,18 @@ jobs: DB_HOST: localhost DB_USER: postgres DB_PASS: postgres - DISABLE_SIMPLECOV: true + DISABLE_SIMPLECOV: ${{ matrix.ruby-version != '.ruby-version' }} RAILS_ENV: test ALLOW_NOPAM: true PAM_ENABLED: true PAM_DEFAULT_SERVICE: pam_test PAM_CONTROLLED_SERVICE: pam_test_controlled + OIDC_ENABLED: true + OIDC_SCOPE: read + SAML_ENABLED: true + CAS_ENABLED: true BUNDLE_WITH: 'pam_authentication test' - CI_JOBS: ${{ matrix.ci_job }}/4 + GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} strategy: fail-fast: false @@ -117,35 +114,218 @@ jobs: - '3.0' - '3.1' - '.ruby-version' - ci_job: - - 1 - - 2 - - 3 - - 4 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + with: + path: './' + name: ${{ github.sha }} + + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick libpam-dev + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bin/rspec + + - name: Upload coverage reports to Codecov + if: matrix.ruby-version == '.ruby-version' + uses: codecov/codecov-action@v3 + with: + files: coverage/lcov/mastodon.lcov + + test-e2e: + name: End to End testing + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_WITH: test + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.0' + - '3.1' + - '.ruby-version' + + steps: + - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: path: './public' name: ${{ github.sha }} - - name: Update package index - run: sudo apt-get update - - - name: Install native Ruby dependencies - run: sudo apt-get install -y libicu-dev libidn11-dev - - - name: Install additional system dependencies - run: sudo apt-get install -y ffmpeg imagemagick libpam-dev - - - name: Set up bundler cache - uses: ruby/setup-ruby@v1 + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby with: ruby-version: ${{ matrix.ruby-version}} - bundler-cache: true + additional-system-dependencies: ffmpeg imagemagick + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' - - run: bundle exec rake rspec_chunked + - run: bundle exec rake spec:system + + - name: Archive logs + uses: actions/upload-artifact@v3 + if: failure() + with: + name: e2e-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: e2e-screenshots + path: tmp/screenshots/ + + test-search: + name: Elastic Search integration testing + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + search: + image: ${{ matrix.search-image }} + env: + discovery.type: single-node + xpack.security.enabled: false + options: >- + --health-cmd "curl http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + - 9200:9200 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_WITH: test + ES_ENABLED: true + ES_HOST: localhost + ES_PORT: 9200 + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.0' + - '3.1' + - '.ruby-version' + search-image: + - docker.elastic.co/elasticsearch/elasticsearch:7.17.13 + include: + - ruby-version: '.ruby-version' + search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v3 + with: + path: './public' + name: ${{ github.sha }} + + - name: Set up Ruby environment + uses: ./.github/actions/setup-ruby + with: + ruby-version: ${{ matrix.ruby-version}} + additional-system-dependencies: ffmpeg imagemagick + + - name: Set up Javascript environment + uses: ./.github/actions/setup-javascript + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bin/rspec --tag search + + - name: Archive logs + uses: actions/upload-artifact@v3 + if: failure() + with: + name: test-search-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: test-search-screenshots + path: tmp/screenshots/ diff --git a/.gitignore b/.gitignore index 2bc8b18c8f..c5af8eb67f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,6 @@ # Ignore Vagrant files .vagrant/ -# Ignore Capistrano customizations -/config/deploy/* - # Ignore IDE files .vscode/ .idea/ @@ -58,6 +55,15 @@ npm-debug.log yarn-error.log yarn-debug.log +# From https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + # Ignore vagrant log files *-cloudimg-console.log diff --git a/.haml-lint.yml b/.haml-lint.yml index d1ed30b260..8cfcaec8d9 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -12,3 +12,5 @@ linters: enabled: true MiddleDot: enabled: true + LineLength: + max: 320 diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index c601683907..29646f28e8 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -1,106 +1,21 @@ # This configuration was generated by # `haml-lint --auto-gen-config` -# on 2023-03-15 00:55:01 -0400 using Haml-Lint version 0.45.0. +# on 2023-12-15 11:02:19 -0500 using Haml-Lint version 0.52.0. # The point is for the user to remove these configuration records # one by one as the lints are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of Haml-Lint, may require this file to be generated again. linters: - # Offense count: 63 - RuboCop: - exclude: - - 'app/views/accounts/_og.html.haml' - - 'app/views/admin/account_warnings/_account_warning.html.haml' - - 'app/views/admin/accounts/index.html.haml' - - 'app/views/admin/accounts/show.html.haml' - - 'app/views/admin/announcements/edit.html.haml' - - 'app/views/admin/announcements/new.html.haml' - - 'app/views/admin/disputes/appeals/_appeal.html.haml' - - 'app/views/admin/domain_blocks/edit.html.haml' - - 'app/views/admin/domain_blocks/new.html.haml' - - 'app/views/admin/ip_blocks/new.html.haml' - - 'app/views/admin/reports/actions/preview.html.haml' - - 'app/views/admin/reports/index.html.haml' - - 'app/views/admin/reports/show.html.haml' - - 'app/views/admin/roles/_form.html.haml' - - 'app/views/admin/settings/about/show.html.haml' - - 'app/views/admin/settings/appearance/show.html.haml' - - 'app/views/admin/settings/registrations/show.html.haml' - - 'app/views/admin/statuses/show.html.haml' - - 'app/views/auth/registrations/new.html.haml' - - 'app/views/disputes/strikes/show.html.haml' - - 'app/views/filters/_filter_fields.html.haml' - - 'app/views/invites/_form.html.haml' - - 'app/views/layouts/application.html.haml' - - 'app/views/layouts/error.html.haml' - - 'app/views/notification_mailer/_status.html.haml' - - 'app/views/settings/applications/_fields.html.haml' - - 'app/views/settings/imports/show.html.haml' - - 'app/views/settings/preferences/appearance/show.html.haml' - - 'app/views/settings/preferences/other/show.html.haml' - - 'app/views/statuses/_detailed_status.html.haml' - - 'app/views/statuses/_poll.html.haml' - - 'app/views/statuses/show.html.haml' - - 'app/views/statuses_cleanup/show.html.haml' - - 'app/views/user_mailer/warning.html.haml' - - # Offense count: 913 + # Offense count: 11 LineLength: - enabled: false - - # Offense count: 22 - UnnecessaryStringOutput: exclude: - - 'app/views/accounts/show.html.haml' - - 'app/views/admin/custom_emojis/_custom_emoji.html.haml' - - 'app/views/admin/relays/_relay.html.haml' - - 'app/views/admin/rules/_rule.html.haml' - - 'app/views/admin/statuses/index.html.haml' - - 'app/views/auth/registrations/_sessions.html.haml' - - 'app/views/disputes/strikes/show.html.haml' - - 'app/views/notification_mailer/_status.html.haml' - - 'app/views/settings/two_factor_authentication_methods/index.html.haml' - - 'app/views/statuses/_detailed_status.html.haml' - - 'app/views/statuses/_poll.html.haml' - - 'app/views/statuses/_simple_status.html.haml' - - 'app/views/user_mailer/suspicious_sign_in.html.haml' - - 'app/views/user_mailer/webauthn_credential_added.html.haml' - - 'app/views/user_mailer/webauthn_credential_deleted.html.haml' - - 'app/views/user_mailer/welcome.html.haml' - - # Offense count: 3 - ViewLength: - exclude: - - 'app/views/admin/accounts/show.html.haml' - - 'app/views/admin/reports/show.html.haml' - - 'app/views/disputes/strikes/show.html.haml' - - # Offense count: 41 - InstanceVariables: - exclude: - - 'app/views/admin/reports/_actions.html.haml' - 'app/views/admin/roles/_form.html.haml' - - 'app/views/admin/webhooks/_form.html.haml' - - 'app/views/auth/registrations/_sessions.html.haml' - - 'app/views/auth/registrations/_status.html.haml' - - 'app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml' - - 'app/views/authorize_interactions/_post_follow_actions.html.haml' - - 'app/views/invites/_form.html.haml' - - 'app/views/relationships/_account.html.haml' - - 'app/views/shared/_og.html.haml' - - 'app/views/statuses/_status.html.haml' - - # Offense count: 6 - ConsecutiveSilentScripts: - exclude: - - 'app/views/admin/settings/shared/_links.html.haml' - - 'app/views/settings/login_activities/_login_activity.html.haml' - - 'app/views/statuses/_poll.html.haml' - - # Offense count: 3 - IdNames: - exclude: - - 'app/views/authorize_interactions/error.html.haml' - - 'app/views/oauth/authorizations/error.html.haml' - - 'app/views/shared/_error_messages.html.haml' + - 'app/views/auth/registrations/edit.html.haml' + - 'app/views/auth/registrations/new.html.haml' + - 'app/views/media/player.html.haml' + - 'app/views/settings/applications/_fields.html.haml' + - 'app/views/settings/imports/index.html.haml' + - 'app/views/settings/preferences/appearance/show.html.haml' + - 'app/views/settings/preferences/notifications/show.html.haml' + - 'app/views/settings/preferences/other/show.html.haml' diff --git a/.nvmrc b/.nvmrc index 59ea99ee63..d4c3d320cc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.20 +20.10 diff --git a/.prettierignore b/.prettierignore index 27b6d5458a..07f83bb423 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,9 +31,6 @@ # Ignore Vagrant files .vagrant/ -# Ignore Capistrano customizations -/config/deploy/* - # Ignore IDE files .vscode/ .idea/ diff --git a/.rubocop.yml b/.rubocop.yml index eff89bdaee..61cb1164b4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -24,11 +24,11 @@ AllCops: Exclude: - db/schema.rb - 'bin/*' - - 'Rakefile' - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' - - 'lib/json_ld/*' # Generated files + - 'config/initializers/json_ld*' # Generated files + - 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab - 'lib/templates/**/*' # Reason: Prefer Hashes without extreme indentation @@ -39,14 +39,7 @@ Layout/FirstHashElementIndentation: # Reason: Currently disabled in .rubocop_todo.yml # https://docs.rubocop.org/rubocop/cops_layout.html#layoutlinelength Layout/LineLength: - AllowedPatterns: - # Allow comments to be long lines - - !ruby/regexp / \# .*$/ - - !ruby/regexp /^\# .*$/ - Exclude: - - 'lib/mastodon/cli/*.rb' - - db/*migrate/**/* - - db/seeds/**/* + Max: 320 # Default of 120 causes a duplicate entry in generated todo file # Reason: # https://docs.rubocop.org/rubocop/cops_lint.html#lintuselessaccessmodifier @@ -83,12 +76,6 @@ Metrics/AbcSize: - 'lib/mastodon/cli/*.rb' - db/*migrate/**/* -# Reason: -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting -Metrics/BlockNesting: - Exclude: - - 'lib/mastodon/cli/*.rb' - # Reason: Currently disabled in .rubocop_todo.yml # https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity Metrics/CyclomaticComplexity: @@ -118,26 +105,30 @@ Rails/Exit: - 'config/boot.rb' - 'lib/mastodon/cli/*.rb' +Rails/SkipsModelValidations: + Exclude: + - 'db/*migrate/**/*' + +# Reason: We want to preserve the ability to migrate from arbitrary old versions, +# and cannot guarantee that every installation has run every migration as they upgrade. +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsunusedignoredcolumns +Rails/UnusedIgnoredColumns: + Enabled: false + +# Reason: Prevailing style choice +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsnegateinclude +Rails/NegateInclude: + Enabled: false + # Reason: Some single letter camel case files shouldn't be split # https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecfilepath RSpec/FilePath: CustomTransform: - ActivityPub: activitypub # Ignore the snake_case due to the amount of files to rename + ActivityPub: activitypub DeepL: deepl FetchOEmbedService: fetch_oembed_service - JsonLdHelper: jsonld_helper OEmbedController: oembed_controller OStatus: ostatus - NodeInfoController: nodeinfo_controller # NodeInfo isn't snake_cased for any of the instances - Exclude: - - 'spec/config/initializers/rack_attack_spec.rb' # namespaces usually have separate folder - - 'spec/lib/sanitize_config_spec.rb' # namespaces usually have separate folder - - 'spec/controllers/concerns/account_controller_concern_spec.rb' # Concerns describe ApplicationController and don't fit naming - - 'spec/controllers/concerns/export_controller_concern_spec.rb' - - 'spec/controllers/concerns/localized_spec.rb' - - 'spec/controllers/concerns/rate_limit_headers_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - - 'spec/controllers/concerns/user_tracking_concern_spec.rb' # Reason: # https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject @@ -154,6 +145,16 @@ RSpec/NotToNot: RSpec/Rails/HttpStatus: EnforcedStyle: numeric +# Reason: Match overrides from Rspec/FilePath rule above +# https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecspecfilepathformat +RSpec/SpecFilePathFormat: + CustomTransform: + ActivityPub: activitypub + DeepL: deepl + FetchOEmbedService: fetch_oembed_service + OEmbedController: oembed_controller + OStatus: ostatus + # Reason: # https://docs.rubocop.org/rubocop/cops_style.html#styleclassandmodulechildren Style/ClassAndModuleChildren: @@ -192,6 +193,11 @@ Style/RedundantBegin: Style/RescueStandardError: EnforcedStyle: implicit +# Reason: Simplify some spec layouts +# https://docs.rubocop.org/rubocop/cops_style.html#stylesemicolon +Style/Semicolon: + AllowAsExpressionSeparator: true + # Reason: Originally disabled for CodeClimate, and no config consensus has been found # https://docs.rubocop.org/rubocop/cops_style.html#stylesymbolarray Style/SymbolArray: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index c1d580e515..bc66bc4add 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.52.1. +# using RuboCop version 1.57.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -14,145 +14,19 @@ Bundler/OrderedGems: - 'Gemfile' # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, IndentationWidth. -# SupportedStyles: with_first_argument, with_fixed_indentation -Layout/ArgumentAlignment: +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. +# URISchemes: http, https +Layout/LineLength: Exclude: - - 'config/initializers/cors.rb' - - 'config/initializers/session_store.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. -# SupportedHashRocketStyles: key, separator, table -# SupportedColonStyles: key, separator, table -# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit -Layout/HashAlignment: - Exclude: - - 'config/boot.rb' - - 'config/environments/production.rb' - - 'config/initializers/rack_attack.rb' - - 'config/routes.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowDoxygenCommentStyle, AllowGemfileRubyComment. -Layout/LeadingCommentSpace: - Exclude: - - 'config/application.rb' - - 'config/initializers/omniauth.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: require_no_space, require_space -Layout/SpaceInLambdaLiteral: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/content_security_policy.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowedMethods, AllowedPatterns. -Lint/AmbiguousBlockAssociation: - Exclude: - - 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/otp_authentication_controller_spec.rb' - - 'spec/services/activitypub/process_status_update_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - -# Configuration parameters: AllowComments, AllowEmptyLambdas. -Lint/EmptyBlock: - Exclude: - - 'spec/controllers/api/v2/search_controller_spec.rb' - - 'spec/fabricators/access_token_fabricator.rb' - - 'spec/fabricators/conversation_fabricator.rb' - - 'spec/fabricators/system_key_fabricator.rb' - - 'spec/helpers/admin/action_logs_helper_spec.rb' - - 'spec/lib/activitypub/adapter_spec.rb' - - 'spec/models/account_alias_spec.rb' - - 'spec/models/account_deletion_request_spec.rb' - - 'spec/models/account_moderation_note_spec.rb' - - 'spec/models/announcement_mute_spec.rb' - - 'spec/models/announcement_reaction_spec.rb' - - 'spec/models/announcement_spec.rb' - - 'spec/models/backup_spec.rb' - - 'spec/models/conversation_mute_spec.rb' - - 'spec/models/custom_filter_keyword_spec.rb' - - 'spec/models/custom_filter_spec.rb' - - 'spec/models/device_spec.rb' - - 'spec/models/encrypted_message_spec.rb' - - 'spec/models/featured_tag_spec.rb' - - 'spec/models/follow_recommendation_suppression_spec.rb' - - 'spec/models/list_account_spec.rb' - - 'spec/models/list_spec.rb' - - 'spec/models/login_activity_spec.rb' - - 'spec/models/mute_spec.rb' - - 'spec/models/preview_card_spec.rb' - - 'spec/models/preview_card_trend_spec.rb' - - 'spec/models/relay_spec.rb' - - 'spec/models/scheduled_status_spec.rb' - - 'spec/models/status_stat_spec.rb' - - 'spec/models/status_trend_spec.rb' - - 'spec/models/system_key_spec.rb' - - 'spec/models/tag_follow_spec.rb' - - 'spec/models/unavailable_domain_spec.rb' - - 'spec/models/user_invite_request_spec.rb' - - 'spec/models/user_role_spec.rb' - - 'spec/models/web/setting_spec.rb' + - 'app/models/account.rb' Lint/NonLocalExitFromIterator: Exclude: - 'app/helpers/jsonld_helper.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -Lint/OrAssignmentToConstant: - Exclude: - - 'lib/sanitize_ext/sanitize_config.rb' - -# This cop supports safe autocorrection (--autocorrect). -Lint/SendWithMixinArgument: - Exclude: - - 'config/application.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. -Lint/UnusedBlockArgument: - Exclude: - - 'config/initializers/content_security_policy.rb' - - 'config/initializers/doorkeeper.rb' - - 'config/initializers/paperclip.rb' - - 'config/initializers/simple_form.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -Lint/UselessAssignment: - Exclude: - - 'app/services/activitypub/process_status_update_service.rb' - - 'config/initializers/omniauth.rb' - - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - - 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb' - - 'spec/controllers/api/v1/bookmarks_controller_spec.rb' - - 'spec/controllers/api/v1/favourites_controller_spec.rb' - - 'spec/controllers/concerns/account_controller_concern_spec.rb' - - 'spec/helpers/jsonld_helper_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/domain_block_spec.rb' - - 'spec/models/status_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/models/webauthn_credentials_spec.rb' - - 'spec/services/account_search_service_spec.rb' - - 'spec/services/post_status_service_spec.rb' - - 'spec/services/precompute_feed_service_spec.rb' - - 'spec/services/resolve_url_service_spec.rb' - - 'spec/views/statuses/show.html.haml_spec.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: CheckForMethodsWithNoSideEffects. -Lint/Void: - Exclude: - - 'spec/services/resolve_account_service_spec.rb' - # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 150 - Exclude: - - 'app/serializers/initial_state_serializer.rb' + Max: 100 # Configuration parameters: CountBlocks, Max. Metrics/BlockNesting: @@ -167,224 +41,10 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 27 -Naming/AccessorMethodName: - Exclude: - - 'app/controllers/auth/sessions_controller.rb' - -# Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. -# CheckDefinitionPathHierarchyRoots: lib, spec, test, src -# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS -Naming/FileName: - Exclude: - - 'config/locales/sr-Latn.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyleForLeadingUnderscores. -# SupportedStylesForLeadingUnderscores: disallowed, required, optional -Naming/MemoizedInstanceVariableName: - Exclude: - - 'app/controllers/api/v1/bookmarks_controller.rb' - - 'app/controllers/api/v1/favourites_controller.rb' - - 'app/controllers/concerns/rate_limit_headers.rb' - - 'app/lib/activitypub/activity.rb' - - 'app/services/resolve_url_service.rb' - - 'app/services/search_service.rb' - - 'config/initializers/rack_attack.rb' - -# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. -# SupportedStyles: snake_case, normalcase, non_integer -# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 -Naming/VariableNumber: - Exclude: - - 'db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb' - - 'db/migrate/20180514140000_revert_index_change_on_statuses_for_api_v1_accounts_account_id_statuses.rb' - - 'db/migrate/20190820003045_update_statuses_index.rb' - - 'db/migrate/20190823221802_add_local_index_to_statuses.rb' - - 'db/migrate/20200119112504_add_public_index_to_statuses.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/domain_block_spec.rb' - - 'spec/models/user_spec.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -Performance/UnfreezeString: - Exclude: - - 'app/lib/rss/builder.rb' - - 'app/lib/text_formatter.rb' - - 'app/validators/status_length_validator.rb' - - 'lib/tasks/mastodon.rake' - -RSpec/AnyInstance: - Exclude: - - 'spec/controllers/activitypub/inboxes_controller_spec.rb' - - 'spec/controllers/admin/accounts_controller_spec.rb' - - 'spec/controllers/admin/resets_controller_spec.rb' - - 'spec/controllers/admin/settings/branding_controller_spec.rb' - - 'spec/controllers/api/v1/media_controller_spec.rb' - - 'spec/controllers/auth/sessions_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/recovery_codes_controller_spec.rb' - - 'spec/lib/request_spec.rb' - - 'spec/lib/status_filter_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/validators/blacklisted_email_validator_spec.rb' - - 'spec/validators/follow_limit_validator_spec.rb' - - 'spec/workers/activitypub/delivery_worker_spec.rb' - - 'spec/workers/web/push_notification_worker_spec.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -RSpec/EmptyExampleGroup: - Exclude: - - 'spec/helpers/admin/action_logs_helper_spec.rb' - - 'spec/models/account_alias_spec.rb' - - 'spec/models/account_deletion_request_spec.rb' - - 'spec/models/account_moderation_note_spec.rb' - - 'spec/models/announcement_mute_spec.rb' - - 'spec/models/announcement_reaction_spec.rb' - - 'spec/models/announcement_spec.rb' - - 'spec/models/backup_spec.rb' - - 'spec/models/conversation_mute_spec.rb' - - 'spec/models/custom_filter_keyword_spec.rb' - - 'spec/models/custom_filter_spec.rb' - - 'spec/models/device_spec.rb' - - 'spec/models/encrypted_message_spec.rb' - - 'spec/models/featured_tag_spec.rb' - - 'spec/models/follow_recommendation_suppression_spec.rb' - - 'spec/models/list_account_spec.rb' - - 'spec/models/list_spec.rb' - - 'spec/models/login_activity_spec.rb' - - 'spec/models/mute_spec.rb' - - 'spec/models/preview_card_spec.rb' - - 'spec/models/preview_card_trend_spec.rb' - - 'spec/models/relay_spec.rb' - - 'spec/models/scheduled_status_spec.rb' - - 'spec/models/status_stat_spec.rb' - - 'spec/models/status_trend_spec.rb' - - 'spec/models/system_key_spec.rb' - - 'spec/models/tag_follow_spec.rb' - - 'spec/models/unavailable_domain_spec.rb' - - 'spec/models/user_invite_request_spec.rb' - - 'spec/models/web/setting_spec.rb' - - 'spec/services/unmute_service_spec.rb' - # Configuration parameters: CountAsOne. RSpec/ExampleLength: Max: 22 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: implicit, each, example -RSpec/HookArgument: - Exclude: - - 'spec/controllers/api/v1/streaming_controller_spec.rb' - - 'spec/controllers/well_known/webfinger_controller_spec.rb' - - 'spec/helpers/instance_helper_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/rails_helper.rb' - - 'spec/serializers/activitypub/note_serializer_spec.rb' - - 'spec/serializers/activitypub/update_poll_serializer_spec.rb' - - 'spec/services/import_service_spec.rb' - - 'spec/spec_helper.rb' - -# Configuration parameters: AssignmentOnly. -RSpec/InstanceVariable: - Exclude: - - 'spec/controllers/api/v1/streaming_controller_spec.rb' - - 'spec/controllers/auth/confirmations_controller_spec.rb' - - 'spec/controllers/auth/passwords_controller_spec.rb' - - 'spec/controllers/auth/sessions_controller_spec.rb' - - 'spec/controllers/concerns/export_controller_concern_spec.rb' - - 'spec/controllers/home_controller_spec.rb' - - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb' - - 'spec/controllers/statuses_cleanup_controller_spec.rb' - - 'spec/models/concerns/account_finder_concern_spec.rb' - - 'spec/models/concerns/account_interactions_spec.rb' - - 'spec/models/public_feed_spec.rb' - - 'spec/serializers/activitypub/note_serializer_spec.rb' - - 'spec/serializers/activitypub/update_poll_serializer_spec.rb' - - 'spec/services/remove_status_service_spec.rb' - - 'spec/services/search_service_spec.rb' - - 'spec/services/unblock_domain_service_spec.rb' - -RSpec/LetSetup: - Exclude: - - 'spec/controllers/admin/accounts_controller_spec.rb' - - 'spec/controllers/admin/action_logs_controller_spec.rb' - - 'spec/controllers/admin/instances_controller_spec.rb' - - 'spec/controllers/admin/reports/actions_controller_spec.rb' - - 'spec/controllers/admin/statuses_controller_spec.rb' - - 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb' - - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb' - - 'spec/controllers/api/v1/filters_controller_spec.rb' - - 'spec/controllers/api/v1/followed_tags_controller_spec.rb' - - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' - - 'spec/controllers/api/v2/filters/keywords_controller_spec.rb' - - 'spec/controllers/api/v2/filters/statuses_controller_spec.rb' - - 'spec/controllers/api/v2/filters_controller_spec.rb' - - 'spec/controllers/auth/confirmations_controller_spec.rb' - - 'spec/controllers/auth/passwords_controller_spec.rb' - - 'spec/controllers/auth/sessions_controller_spec.rb' - - 'spec/controllers/follower_accounts_controller_spec.rb' - - 'spec/controllers/following_accounts_controller_spec.rb' - - 'spec/controllers/oauth/authorized_applications_controller_spec.rb' - - 'spec/controllers/oauth/tokens_controller_spec.rb' - - 'spec/controllers/settings/imports_controller_spec.rb' - - 'spec/lib/activitypub/activity/delete_spec.rb' - - 'spec/lib/vacuum/preview_cards_vacuum_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/account_statuses_cleanup_policy_spec.rb' - - 'spec/models/canonical_email_block_spec.rb' - - 'spec/models/status_spec.rb' - - 'spec/models/user_spec.rb' - - 'spec/services/account_statuses_cleanup_service_spec.rb' - - 'spec/services/activitypub/fetch_featured_collection_service_spec.rb' - - 'spec/services/activitypub/fetch_remote_status_service_spec.rb' - - 'spec/services/activitypub/process_account_service_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/batched_remove_status_service_spec.rb' - - 'spec/services/block_domain_service_spec.rb' - - 'spec/services/bulk_import_service_spec.rb' - - 'spec/services/delete_account_service_spec.rb' - - 'spec/services/import_service_spec.rb' - - 'spec/services/notify_service_spec.rb' - - 'spec/services/remove_status_service_spec.rb' - - 'spec/services/report_service_spec.rb' - - 'spec/services/resolve_account_service_spec.rb' - - 'spec/services/suspend_account_service_spec.rb' - - 'spec/services/unallow_domain_service_spec.rb' - - 'spec/services/unsuspend_account_service_spec.rb' - - 'spec/workers/scheduler/user_cleanup_scheduler_spec.rb' - -RSpec/MessageChain: - Exclude: - - 'spec/controllers/api/v1/media_controller_spec.rb' - - 'spec/models/concerns/remotable_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - -# Configuration parameters: EnforcedStyle. -# SupportedStyles: have_received, receive -RSpec/MessageSpies: - Exclude: - - 'spec/controllers/admin/accounts_controller_spec.rb' - - 'spec/controllers/api/base_controller_spec.rb' - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/helpers/admin/account_moderation_notes_helper_spec.rb' - - 'spec/helpers/application_helper_spec.rb' - - 'spec/lib/status_finder_spec.rb' - - 'spec/lib/webfinger_resource_spec.rb' - - 'spec/models/admin/account_action_spec.rb' - - 'spec/models/concerns/remotable_spec.rb' - - 'spec/models/follow_request_spec.rb' - - 'spec/models/identity_spec.rb' - - 'spec/models/session_activation_spec.rb' - - 'spec/models/setting_spec.rb' - - 'spec/services/activitypub/fetch_replies_service_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/spec_helper.rb' - - 'spec/validators/status_length_validator_spec.rb' - RSpec/MultipleExpectations: Max: 8 @@ -396,164 +56,26 @@ RSpec/MultipleMemoizedHelpers: RSpec/NestedGroups: Max: 6 -RSpec/PendingWithoutReason: - Exclude: - - 'spec/models/account_spec.rb' - -RSpec/StubbedMock: - Exclude: - - 'spec/controllers/api/base_controller_spec.rb' - - 'spec/controllers/api/v1/media_controller_spec.rb' - - 'spec/controllers/auth/registrations_controller_spec.rb' - - 'spec/helpers/application_helper_spec.rb' - - 'spec/lib/status_filter_spec.rb' - - 'spec/lib/status_finder_spec.rb' - - 'spec/lib/webfinger_resource_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - -RSpec/SubjectDeclaration: - Exclude: - - 'spec/controllers/admin/domain_blocks_controller_spec.rb' - - 'spec/models/account_migration_spec.rb' - - 'spec/models/account_spec.rb' - - 'spec/models/relationship_filter_spec.rb' - - 'spec/models/user_role_spec.rb' - - 'spec/policies/account_moderation_note_policy_spec.rb' - - 'spec/policies/account_policy_spec.rb' - - 'spec/policies/backup_policy_spec.rb' - - 'spec/policies/custom_emoji_policy_spec.rb' - - 'spec/policies/domain_block_policy_spec.rb' - - 'spec/policies/email_domain_block_policy_spec.rb' - - 'spec/policies/instance_policy_spec.rb' - - 'spec/policies/invite_policy_spec.rb' - - 'spec/policies/relay_policy_spec.rb' - - 'spec/policies/report_note_policy_spec.rb' - - 'spec/policies/report_policy_spec.rb' - - 'spec/policies/settings_policy_spec.rb' - - 'spec/policies/tag_policy_spec.rb' - - 'spec/policies/user_policy_spec.rb' - - 'spec/services/activitypub/process_account_service_spec.rb' - -RSpec/SubjectStub: - Exclude: - - 'spec/services/unallow_domain_service_spec.rb' - - 'spec/validators/blacklisted_email_validator_spec.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). Rails/ApplicationController: Exclude: - 'app/controllers/health_controller.rb' -# Configuration parameters: Database, Include. -# SupportedDatabases: mysql, postgresql -# Include: db/migrate/*.rb -Rails/BulkChangeTable: - Exclude: - - 'db/migrate/20160222143943_add_profile_fields_to_accounts.rb' - - 'db/migrate/20160223162837_add_metadata_to_statuses.rb' - - 'db/migrate/20160305115639_add_devise_to_users.rb' - - 'db/migrate/20160314164231_add_owner_to_application.rb' - - 'db/migrate/20160926213048_remove_owner_from_application.rb' - - 'db/migrate/20161003142332_add_confirmable_to_users.rb' - - 'db/migrate/20170112154826_migrate_settings.rb' - - 'db/migrate/20170127165745_add_devise_two_factor_to_users.rb' - - 'db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb' - - 'db/migrate/20170330021336_add_counter_caches.rb' - - 'db/migrate/20170425202925_add_oembed_to_preview_cards.rb' - - 'db/migrate/20170427011934_re_add_owner_to_application.rb' - - 'db/migrate/20170520145338_change_language_filter_to_opt_out.rb' - - 'db/migrate/20170624134742_add_description_to_session_activations.rb' - - 'db/migrate/20170718211102_add_activitypub_to_accounts.rb' - - 'db/migrate/20171006142024_add_uri_to_custom_emojis.rb' - - 'db/migrate/20180812123222_change_relays_enabled.rb' - - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - - 'db/migrate/20190805123746_add_capabilities_to_tags.rb' - - 'db/migrate/20190807135426_add_comments_to_domain_blocks.rb' - - 'db/migrate/20190815225426_add_last_status_at_to_tags.rb' - - 'db/migrate/20190901035623_add_max_score_to_tags.rb' - - 'db/migrate/20200417125749_add_storage_schema_version.rb' - - 'db/migrate/20200608113046_add_sign_in_token_to_users.rb' - - 'db/migrate/20211112011713_add_language_to_preview_cards.rb' - - 'db/migrate/20211231080958_add_category_to_reports.rb' - - 'db/migrate/20220202200743_add_trendable_to_accounts.rb' - - 'db/migrate/20220224010024_add_ips_to_email_domain_blocks.rb' - - 'db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb' - - 'db/migrate/20220303000827_add_ordered_media_attachment_ids_to_status_edits.rb' - - 'db/migrate/20220824164433_add_human_identifier_to_admin_action_logs.rb' - -# Configuration parameters: Include. -# Include: db/migrate/*.rb -Rails/CreateTableWithTimestamps: - Exclude: - - 'db/migrate/20170508230434_create_conversation_mutes.rb' - - 'db/migrate/20170823162448_create_status_pins.rb' - - 'db/migrate/20171116161857_create_list_accounts.rb' - - 'db/migrate/20180929222014_create_account_conversations.rb' - - 'db/migrate/20181007025445_create_pghero_space_stats.rb' - - 'db/migrate/20190103124649_create_scheduled_statuses.rb' - - 'db/migrate/20220824233535_create_status_trends.rb' - - 'db/migrate/20221006061337_create_preview_card_trends.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: Severity. -Rails/DuplicateAssociation: - Exclude: - - 'app/serializers/activitypub/collection_serializer.rb' - - 'app/serializers/activitypub/note_serializer.rb' - # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/HasAndBelongsToMany: Exclude: - - 'app/models/concerns/account_associations.rb' + - 'app/models/concerns/account/associations.rb' - 'app/models/preview_card.rb' - 'app/models/status.rb' - 'app/models/tag.rb' -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/HasManyOrHasOneDependent: - Exclude: - - 'app/models/concerns/account_counters.rb' - - 'app/models/conversation.rb' - - 'app/models/custom_emoji.rb' - - 'app/models/custom_emoji_category.rb' - - 'app/models/domain_block.rb' - - 'app/models/invite.rb' - - 'app/models/status.rb' - - 'app/models/user.rb' - - 'app/models/web/push_subscription.rb' - -Rails/I18nLocaleTexts: - Exclude: - - 'lib/tasks/mastodon.rake' - - 'spec/helpers/flashes_helper_spec.rb' - # Configuration parameters: Include. # Include: app/controllers/**/*.rb, app/mailers/**/*.rb Rails/LexicallyScopedActionFilter: Exclude: - 'app/controllers/auth/passwords_controller.rb' - 'app/controllers/auth/registrations_controller.rb' - - 'app/controllers/auth/sessions_controller.rb' - -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/NegateInclude: - Exclude: - - 'app/controllers/concerns/signature_verification.rb' - - 'app/helpers/jsonld_helper.rb' - - 'app/lib/activitypub/activity/create.rb' - - 'app/lib/activitypub/activity/move.rb' - - 'app/lib/feed_manager.rb' - - 'app/lib/link_details_extractor.rb' - - 'app/models/concerns/attachmentable.rb' - - 'app/models/concerns/remotable.rb' - - 'app/models/custom_filter.rb' - - 'app/services/activitypub/process_status_update_service.rb' - - 'app/services/fetch_link_card_service.rb' - - 'app/services/search_service.rb' - - 'app/workers/web/push_notification_worker.rb' - - 'lib/paperclip/color_extractor.rb' Rails/OutputSafety: Exclude: @@ -571,31 +93,13 @@ Rails/RakeEnvironment: - 'lib/tasks/repo.rake' - 'lib/tasks/statistics.rake' -# Configuration parameters: Include. -# Include: db/**/*.rb -Rails/ReversibleMigration: - Exclude: - - 'db/migrate/20160223164502_make_uris_nullable_in_statuses.rb' - - 'db/migrate/20161122163057_remove_unneeded_indexes.rb' - - 'db/migrate/20170205175257_remove_devices.rb' - - 'db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb' - - 'db/migrate/20170520145338_change_language_filter_to_opt_out.rb' - - 'db/migrate/20170609145826_remove_default_language_from_statuses.rb' - - 'db/migrate/20170711225116_fix_null_booleans.rb' - - 'db/migrate/20171129172043_add_index_on_stream_entries.rb' - - 'db/migrate/20171212195226_remove_duplicate_indexes_in_lists.rb' - - 'db/migrate/20171226094803_more_faster_index_on_notifications.rb' - - 'db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb' - - 'db/migrate/20180617162849_remove_unused_indexes.rb' - - 'db/migrate/20220827195229_change_canonical_email_blocks_nullable.rb' - # Configuration parameters: ForbiddenMethods, AllowedMethods. # ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all Rails/SkipsModelValidations: Exclude: - 'app/controllers/admin/invites_controller.rb' - 'app/controllers/concerns/session_tracking_concern.rb' - - 'app/models/concerns/account_merging.rb' + - 'app/models/concerns/account/merging.rb' - 'app/models/concerns/expireable.rb' - 'app/models/status.rb' - 'app/models/trends/links.rb' @@ -618,55 +122,12 @@ Rails/SkipsModelValidations: - 'app/workers/move_worker.rb' - 'app/workers/scheduler/ip_cleanup_scheduler.rb' - 'app/workers/scheduler/scheduled_statuses_scheduler.rb' - - 'db/migrate/20161203164520_add_from_account_id_to_notifications.rb' - - 'db/migrate/20170105224407_add_shortcode_to_media_attachments.rb' - - 'db/migrate/20170209184350_add_reply_to_statuses.rb' - - 'db/migrate/20170304202101_add_type_to_media_attachments.rb' - - 'db/migrate/20180528141303_fix_accounts_unique_index.rb' - - 'db/migrate/20180609104432_migrate_web_push_subscriptions2.rb' - - 'db/migrate/20181207011115_downcase_custom_emoji_domains.rb' - - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - - 'db/migrate/20191007013357_update_pt_locales.rb' - - 'db/migrate/20220316233212_update_kurdish_locales.rb' - - 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb' - - 'db/post_migrate/20200917193528_migrate_notifications_type.rb' - - 'db/post_migrate/20201017234926_fill_account_suspension_origin.rb' - - 'db/post_migrate/20220617202502_migrate_roles.rb' - - 'db/post_migrate/20221101190723_backfill_admin_action_logs.rb' - - 'db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb' - 'lib/mastodon/cli/accounts.rb' - - 'lib/mastodon/cli/main.rb' - 'lib/mastodon/cli/maintenance.rb' - - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb' - 'spec/lib/activitypub/activity/follow_spec.rb' - 'spec/services/follow_service_spec.rb' - 'spec/services/update_account_service_spec.rb' -# Configuration parameters: Include. -# Include: db/**/*.rb -Rails/ThreeStateBooleanColumn: - Exclude: - - 'db/migrate/20160325130944_add_admin_to_users.rb' - - 'db/migrate/20161123093447_add_sensitive_to_statuses.rb' - - 'db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb' - - 'db/migrate/20170127165745_add_devise_two_factor_to_users.rb' - - 'db/migrate/20170209184350_add_reply_to_statuses.rb' - - 'db/migrate/20170330163835_create_imports.rb' - - 'db/migrate/20170905165803_add_local_to_statuses.rb' - - 'db/migrate/20171210213213_add_local_only_flag_to_statuses.rb' - - 'db/migrate/20181203021853_add_discoverable_to_accounts.rb' - - 'db/migrate/20190509164208_add_by_moderator_to_tombstone.rb' - - 'db/migrate/20190805123746_add_capabilities_to_tags.rb' - - 'db/migrate/20191212163405_add_hide_collections_to_accounts.rb' - - 'db/migrate/20200309150742_add_forwarded_to_reports.rb' - - 'db/migrate/20210609202149_create_login_activities.rb' - - 'db/migrate/20210621221010_add_skip_sign_in_token_to_users.rb' - - 'db/migrate/20211031031021_create_preview_card_providers.rb' - - 'db/migrate/20211115032527_add_trendable_to_preview_cards.rb' - - 'db/migrate/20220202200743_add_trendable_to_accounts.rb' - - 'db/migrate/20220202200926_add_trendable_to_statuses.rb' - - 'db/migrate/20220303000827_add_ordered_media_attachment_ids_to_status_edits.rb' - # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/UniqueValidationWithoutIndex: @@ -676,19 +137,6 @@ Rails/UniqueValidationWithoutIndex: - 'app/models/identity.rb' - 'app/models/webauthn_credential.rb' -# Configuration parameters: Include. -# Include: app/models/**/*.rb -Rails/UnusedIgnoredColumns: - Exclude: - - 'app/models/account.rb' - - 'app/models/account_stat.rb' - - 'app/models/admin/action_log.rb' - - 'app/models/custom_filter.rb' - - 'app/models/email_domain_block.rb' - - 'app/models/report.rb' - - 'app/models/status_edit.rb' - - 'app/models/user.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyle. # SupportedStyles: exists, where @@ -702,7 +150,7 @@ Rails/WhereExists: - 'app/lib/feed_manager.rb' - 'app/lib/status_cache_hydrator.rb' - 'app/lib/suspicious_sign_in_detector.rb' - - 'app/models/concerns/account_interactions.rb' + - 'app/models/concerns/account/interactions.rb' - 'app/models/featured_tag.rb' - 'app/models/poll.rb' - 'app/models/session_activation.rb' @@ -730,7 +178,7 @@ Style/CaseEquality: Exclude: - 'config/initializers/trusted_proxies.rb' -# This cop supports safe autocorrection (--autocorrect). +# This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: AllowedMethods, AllowedPatterns. # AllowedMethods: ==, equal?, eql? Style/ClassEqualityComparison: @@ -742,12 +190,6 @@ Style/ClassVars: Exclude: - 'config/initializers/devise.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/CombinableLoops: - Exclude: - - 'app/models/form/custom_emoji_batch.rb' - - 'app/models/form/ip_block_batch.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedVars. Style/FetchEnvVar: @@ -756,15 +198,14 @@ Style/FetchEnvVar: - 'app/lib/translation_service.rb' - 'config/environments/development.rb' - 'config/environments/production.rb' - - 'config/initializers/2_whitelist_mode.rb' + - 'config/initializers/2_limited_federation_mode.rb' + - 'config/initializers/3_omniauth.rb' - 'config/initializers/blacklists.rb' - 'config/initializers/cache_buster.rb' - - 'config/initializers/content_security_policy.rb' - 'config/initializers/devise.rb' - - 'config/initializers/omniauth.rb' - 'config/initializers/paperclip.rb' - 'config/initializers/vapid.rb' - - 'lib/mastodon/premailer_webpack_strategy.rb' + - 'lib/premailer_webpack_strategy.rb' - 'lib/mastodon/redis_config.rb' - 'lib/tasks/repo.rake' - 'spec/features/profile_spec.rb' @@ -779,406 +220,6 @@ Style/FormatStringToken: - 'config/initializers/devise.rb' - 'lib/paperclip/color_extractor.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: always, always_true, never -Style/FrozenStringLiteralComment: - Exclude: - - 'app/views/accounts/show.rss.ruby' - - 'app/views/tags/show.rss.ruby' - - 'app/views/well_known/host_meta/show.xml.ruby' - - 'config/application.rb' - - 'config/boot.rb' - - 'config/environment.rb' - - 'config/environments/development.rb' - - 'config/environments/production.rb' - - 'config/environments/test.rb' - - 'config/initializers/0_post_deployment_migrations.rb' - - 'config/initializers/active_model_serializers.rb' - - 'config/initializers/application_controller_renderer.rb' - - 'config/initializers/assets.rb' - - 'config/initializers/backtrace_silencers.rb' - - 'config/initializers/cache_logging.rb' - - 'config/initializers/chewy.rb' - - 'config/initializers/content_security_policy.rb' - - 'config/initializers/cookies_serializer.rb' - - 'config/initializers/cors.rb' - - 'config/initializers/devise.rb' - - 'config/initializers/doorkeeper.rb' - - 'config/initializers/fast_blank.rb' - - 'config/initializers/ffmpeg.rb' - - 'config/initializers/filter_parameter_logging.rb' - - 'config/initializers/http_client_proxy.rb' - - 'config/initializers/httplog.rb' - - 'config/initializers/inflections.rb' - - 'config/initializers/mail_delivery_job.rb' - - 'config/initializers/makara.rb' - - 'config/initializers/mime_types.rb' - - 'config/initializers/oj.rb' - - 'config/initializers/omniauth.rb' - - 'config/initializers/open_uri_redirection.rb' - - 'config/initializers/permissions_policy.rb' - - 'config/initializers/pghero.rb' - - 'config/initializers/preload_link_headers.rb' - - 'config/initializers/premailer_rails.rb' - - 'config/initializers/rack_attack_logging.rb' - - 'config/initializers/redis.rb' - - 'config/initializers/session_store.rb' - - 'config/initializers/simple_form.rb' - - 'config/initializers/stoplight.rb' - - 'config/initializers/trusted_proxies.rb' - - 'config/initializers/twitter_regex.rb' - - 'config/initializers/webauthn.rb' - - 'config/initializers/wrap_parameters.rb' - - 'config/locales/sr-Latn.rb' - - 'config/locales/sr.rb' - - 'config/puma.rb' - - 'db/migrate/20160220174730_create_accounts.rb' - - 'db/migrate/20160220211917_create_statuses.rb' - - 'db/migrate/20160221003140_create_users.rb' - - 'db/migrate/20160221003621_create_follows.rb' - - 'db/migrate/20160222122600_create_stream_entries.rb' - - 'db/migrate/20160222143943_add_profile_fields_to_accounts.rb' - - 'db/migrate/20160223162837_add_metadata_to_statuses.rb' - - 'db/migrate/20160223164502_make_uris_nullable_in_statuses.rb' - - 'db/migrate/20160223165723_add_url_to_statuses.rb' - - 'db/migrate/20160223165855_add_url_to_accounts.rb' - - 'db/migrate/20160223171800_create_favourites.rb' - - 'db/migrate/20160224223247_create_mentions.rb' - - 'db/migrate/20160227230233_add_attachment_avatar_to_accounts.rb' - - 'db/migrate/20160305115639_add_devise_to_users.rb' - - 'db/migrate/20160306172223_create_doorkeeper_tables.rb' - - 'db/migrate/20160312193225_add_attachment_header_to_accounts.rb' - - 'db/migrate/20160314164231_add_owner_to_application.rb' - - 'db/migrate/20160316103650_add_missing_indices.rb' - - 'db/migrate/20160322193748_add_avatar_remote_url_to_accounts.rb' - - 'db/migrate/20160325130944_add_admin_to_users.rb' - - 'db/migrate/20160826155805_add_superapp_to_oauth_applications.rb' - - 'db/migrate/20160905150353_create_media_attachments.rb' - - 'db/migrate/20160919221059_add_subscription_expires_at_to_accounts.rb' - - 'db/migrate/20160920003904_remove_verify_token_from_accounts.rb' - - 'db/migrate/20160926213048_remove_owner_from_application.rb' - - 'db/migrate/20161003142332_add_confirmable_to_users.rb' - - 'db/migrate/20161003145426_create_blocks.rb' - - 'db/migrate/20161006213403_rails_settings_migration.rb' - - 'db/migrate/20161009120834_create_domain_blocks.rb' - - 'db/migrate/20161027172456_add_silenced_to_accounts.rb' - - 'db/migrate/20161104173623_create_tags.rb' - - 'db/migrate/20161105130633_create_statuses_tags_join_table.rb' - - 'db/migrate/20161116162355_add_locale_to_users.rb' - - 'db/migrate/20161119211120_create_notifications.rb' - - 'db/migrate/20161122163057_remove_unneeded_indexes.rb' - - 'db/migrate/20161123093447_add_sensitive_to_statuses.rb' - - 'db/migrate/20161128103007_create_subscriptions.rb' - - 'db/migrate/20161130142058_add_last_successful_delivery_at_to_subscriptions.rb' - - 'db/migrate/20161130185319_add_visibility_to_statuses.rb' - - 'db/migrate/20161202132159_add_in_reply_to_account_id_to_statuses.rb' - - 'db/migrate/20161203164520_add_from_account_id_to_notifications.rb' - - 'db/migrate/20161205214545_add_suspended_to_accounts.rb' - - 'db/migrate/20161221152630_add_hidden_to_stream_entries.rb' - - 'db/migrate/20161222201034_add_locked_to_accounts.rb' - - 'db/migrate/20161222204147_create_follow_requests.rb' - - 'db/migrate/20170105224407_add_shortcode_to_media_attachments.rb' - - 'db/migrate/20170109120109_create_web_settings.rb' - - 'db/migrate/20170112154826_migrate_settings.rb' - - 'db/migrate/20170114194937_add_application_to_statuses.rb' - - 'db/migrate/20170114203041_add_website_to_oauth_application.rb' - - 'db/migrate/20170119214911_create_preview_cards.rb' - - 'db/migrate/20170123162658_add_severity_to_domain_blocks.rb' - - 'db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb' - - 'db/migrate/20170125145934_add_spoiler_text_to_statuses.rb' - - 'db/migrate/20170127165745_add_devise_two_factor_to_users.rb' - - 'db/migrate/20170205175257_remove_devices.rb' - - 'db/migrate/20170209184350_add_reply_to_statuses.rb' - - 'db/migrate/20170214110202_create_reports.rb' - - 'db/migrate/20170217012631_add_reblog_of_id_foreign_key_to_statuses.rb' - - 'db/migrate/20170301222600_create_mutes.rb' - - 'db/migrate/20170303212857_add_last_emailed_at_to_users.rb' - - 'db/migrate/20170304202101_add_type_to_media_attachments.rb' - - 'db/migrate/20170317193015_add_search_index_to_accounts.rb' - - 'db/migrate/20170318214217_add_header_remote_url_to_accounts.rb' - - 'db/migrate/20170322021028_add_lowercase_index_to_accounts.rb' - - 'db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb' - - 'db/migrate/20170322162804_add_search_index_to_tags.rb' - - 'db/migrate/20170330021336_add_counter_caches.rb' - - 'db/migrate/20170330163835_create_imports.rb' - - 'db/migrate/20170330164118_add_attachment_data_to_imports.rb' - - 'db/migrate/20170403172249_add_action_taken_by_account_id_to_reports.rb' - - 'db/migrate/20170405112956_add_index_on_mentions_status_id.rb' - - 'db/migrate/20170406215816_add_notifications_and_favourites_indices.rb' - - 'db/migrate/20170409170753_add_last_webfingered_at_to_accounts.rb' - - 'db/migrate/20170414080609_add_devise_two_factor_backupable_to_users.rb' - - 'db/migrate/20170414132105_add_language_to_statuses.rb' - - 'db/migrate/20170418160728_add_indexes_to_reports_for_accounts.rb' - - 'db/migrate/20170423005413_add_allowed_languages_to_user.rb' - - 'db/migrate/20170424003227_create_account_domain_blocks.rb' - - 'db/migrate/20170424112722_add_status_id_index_to_statuses_tags.rb' - - 'db/migrate/20170425131920_add_media_attachment_meta.rb' - - 'db/migrate/20170425202925_add_oembed_to_preview_cards.rb' - - 'db/migrate/20170427011934_re_add_owner_to_application.rb' - - 'db/migrate/20170506235850_create_conversations.rb' - - 'db/migrate/20170507000211_add_conversation_id_to_statuses.rb' - - 'db/migrate/20170507141759_optimize_index_subscriptions.rb' - - 'db/migrate/20170508230434_create_conversation_mutes.rb' - - 'db/migrate/20170516072309_add_index_accounts_on_uri.rb' - - 'db/migrate/20170520145338_change_language_filter_to_opt_out.rb' - - 'db/migrate/20170601210557_add_index_on_media_attachments_account_id.rb' - - 'db/migrate/20170604144747_add_foreign_keys_for_accounts.rb' - - 'db/migrate/20170606113804_change_tag_search_index_to_btree.rb' - - 'db/migrate/20170609145826_remove_default_language_from_statuses.rb' - - 'db/migrate/20170610000000_add_statuses_index_on_account_id_id.rb' - - 'db/migrate/20170623152212_create_session_activations.rb' - - 'db/migrate/20170624134742_add_description_to_session_activations.rb' - - 'db/migrate/20170625140443_add_access_token_id_to_session_activations.rb' - - 'db/migrate/20170711225116_fix_null_booleans.rb' - - 'db/migrate/20170713112503_make_tag_search_case_insensitive.rb' - - 'db/migrate/20170713175513_create_web_push_subscriptions.rb' - - 'db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb' - - 'db/migrate/20170714184731_add_domain_to_subscriptions.rb' - - 'db/migrate/20170716191202_add_hide_notifications_to_mute.rb' - - 'db/migrate/20170718211102_add_activitypub_to_accounts.rb' - - 'db/migrate/20170720000000_add_index_favourites_on_account_id_and_id.rb' - - 'db/migrate/20170823162448_create_status_pins.rb' - - 'db/migrate/20170824103029_add_timestamps_to_status_pins.rb' - - 'db/migrate/20170829215220_remove_status_pins_account_index.rb' - - 'db/migrate/20170901141119_truncate_preview_cards.rb' - - 'db/migrate/20170901142658_create_join_table_preview_cards_statuses.rb' - - 'db/migrate/20170905044538_add_index_id_account_id_activity_type_on_notifications.rb' - - 'db/migrate/20170905165803_add_local_to_statuses.rb' - - 'db/migrate/20170913000752_create_site_uploads.rb' - - 'db/migrate/20170917153509_create_custom_emojis.rb' - - 'db/migrate/20170918125918_ids_to_bigints.rb' - - 'db/migrate/20170920024819_status_ids_to_timestamp_ids.rb' - - 'db/migrate/20170920032311_fix_reblogs_in_feeds.rb' - - 'db/migrate/20170924022025_ids_to_bigints2.rb' - - 'db/migrate/20170927215609_add_description_to_media_attachments.rb' - - 'db/migrate/20170928082043_create_email_domain_blocks.rb' - - 'db/migrate/20171005102658_create_account_moderation_notes.rb' - - 'db/migrate/20171005171936_add_disabled_to_custom_emojis.rb' - - 'db/migrate/20171006142024_add_uri_to_custom_emojis.rb' - - 'db/migrate/20171010023049_add_foreign_key_to_account_moderation_notes.rb' - - 'db/migrate/20171010025614_change_accounts_nonnullable_in_account_moderation_notes.rb' - - 'db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb' - - 'db/migrate/20171028221157_add_reblogs_to_follows.rb' - - 'db/migrate/20171107143332_add_memorial_to_accounts.rb' - - 'db/migrate/20171107143624_add_disabled_to_users.rb' - - 'db/migrate/20171109012327_add_moderator_to_accounts.rb' - - 'db/migrate/20171114080328_add_index_domain_to_email_domain_blocks.rb' - - 'db/migrate/20171114231651_create_lists.rb' - - 'db/migrate/20171116161857_create_list_accounts.rb' - - 'db/migrate/20171118012443_add_moved_to_account_id_to_accounts.rb' - - 'db/migrate/20171119172437_create_admin_action_logs.rb' - - 'db/migrate/20171122120436_add_index_account_and_reblog_of_id_to_statuses.rb' - - 'db/migrate/20171125024930_create_invites.rb' - - 'db/migrate/20171125031751_add_invite_id_to_users.rb' - - 'db/migrate/20171125185353_add_index_reblog_of_id_and_account_to_statuses.rb' - - 'db/migrate/20171125190735_remove_old_reblog_index_on_statuses.rb' - - 'db/migrate/20171129172043_add_index_on_stream_entries.rb' - - 'db/migrate/20171130000000_add_embed_url_to_preview_cards.rb' - - 'db/migrate/20171201000000_change_account_id_nonnullable_in_lists.rb' - - 'db/migrate/20171212195226_remove_duplicate_indexes_in_lists.rb' - - 'db/migrate/20171226094803_more_faster_index_on_notifications.rb' - - 'db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb' - - 'db/migrate/20180109143959_add_remember_token_to_users.rb' - - 'db/migrate/20180204034416_create_identities.rb' - - 'db/migrate/20180206000000_change_user_id_nonnullable.rb' - - 'db/migrate/20180211015820_create_backups.rb' - - 'db/migrate/20180304013859_add_featured_collection_url_to_accounts.rb' - - 'db/migrate/20180310000000_change_columns_in_notifications_nonnullable.rb' - - 'db/migrate/20180402031200_add_assigned_account_id_to_reports.rb' - - 'db/migrate/20180402040909_create_report_notes.rb' - - 'db/migrate/20180410204633_add_fields_to_accounts.rb' - - 'db/migrate/20180416210259_add_uri_to_relationships.rb' - - 'db/migrate/20180506221944_add_actor_type_to_accounts.rb' - - 'db/migrate/20180510214435_add_access_token_id_to_web_push_subscriptions.rb' - - 'db/migrate/20180510230049_migrate_web_push_subscriptions.rb' - - 'db/migrate/20180528141303_fix_accounts_unique_index.rb' - - 'db/migrate/20180608213548_reject_following_blocked_users.rb' - - 'db/migrate/20180609104432_migrate_web_push_subscriptions2.rb' - - 'db/migrate/20180615122121_add_autofollow_to_invites.rb' - - 'db/migrate/20180616192031_add_chosen_languages_to_users.rb' - - 'db/migrate/20180617162849_remove_unused_indexes.rb' - - 'db/migrate/20180628181026_create_custom_filters.rb' - - 'db/migrate/20180707154237_add_whole_word_to_custom_filter.rb' - - 'db/migrate/20180711152640_create_relays.rb' - - 'db/migrate/20180808175627_create_account_pins.rb' - - 'db/migrate/20180812123222_change_relays_enabled.rb' - - 'db/migrate/20180812162710_create_status_stats.rb' - - 'db/migrate/20180812173710_copy_status_stats.rb' - - 'db/migrate/20180814171349_add_confidential_to_doorkeeper_application.rb' - - 'db/migrate/20180831171112_create_bookmarks.rb' - - 'db/migrate/20180929222014_create_account_conversations.rb' - - 'db/migrate/20181007025445_create_pghero_space_stats.rb' - - 'db/migrate/20181010141500_add_silent_to_mentions.rb' - - 'db/migrate/20181017170937_add_reject_reports_to_domain_blocks.rb' - - 'db/migrate/20181018205649_add_unread_to_account_conversations.rb' - - 'db/migrate/20181024224956_migrate_account_conversations.rb' - - 'db/migrate/20181026034033_remove_faux_remote_account_duplicates.rb' - - 'db/migrate/20181116165755_create_account_stats.rb' - - 'db/migrate/20181116173541_copy_account_stats.rb' - - 'db/migrate/20181127130500_identity_id_to_bigint.rb' - - 'db/migrate/20181127165847_add_show_replies_to_lists.rb' - - 'db/migrate/20181203003808_create_accounts_tags_join_table.rb' - - 'db/migrate/20181203021853_add_discoverable_to_accounts.rb' - - 'db/migrate/20181204193439_add_last_status_at_to_account_stats.rb' - - 'db/migrate/20181204215309_create_account_tag_stats.rb' - - 'db/migrate/20181207011115_downcase_custom_emoji_domains.rb' - - 'db/migrate/20181213184704_create_account_warnings.rb' - - 'db/migrate/20181213185533_create_account_warning_presets.rb' - - 'db/migrate/20181219235220_add_created_by_application_id_to_users.rb' - - 'db/migrate/20181226021420_add_also_known_as_to_accounts.rb' - - 'db/migrate/20190103124649_create_scheduled_statuses.rb' - - 'db/migrate/20190103124754_add_scheduled_status_id_to_media_attachments.rb' - - 'db/migrate/20190117114553_create_tombstones.rb' - - 'db/migrate/20190201012802_add_overwrite_to_imports.rb' - - 'db/migrate/20190203180359_create_featured_tags.rb' - - 'db/migrate/20190225031541_create_polls.rb' - - 'db/migrate/20190225031625_create_poll_votes.rb' - - 'db/migrate/20190226003449_add_poll_id_to_statuses.rb' - - 'db/migrate/20190304152020_add_uri_to_poll_votes.rb' - - 'db/migrate/20190306145741_add_lock_version_to_polls.rb' - - 'db/migrate/20190307234537_add_approved_to_users.rb' - - 'db/migrate/20190314181829_migrate_open_registrations_setting.rb' - - 'db/migrate/20190316190352_create_account_identity_proofs.rb' - - 'db/migrate/20190317135723_add_uri_to_reports.rb' - - 'db/migrate/20190403141604_add_comment_to_invites.rb' - - 'db/migrate/20190409054914_create_user_invite_requests.rb' - - 'db/migrate/20190420025523_add_blurhash_to_media_attachments.rb' - - 'db/migrate/20190509164208_add_by_moderator_to_tombstone.rb' - - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb' - - 'db/migrate/20190627222225_create_custom_emoji_categories.rb' - - 'db/migrate/20190627222826_add_category_id_to_custom_emojis.rb' - - 'db/migrate/20190701022101_add_trust_level_to_accounts.rb' - - 'db/migrate/20190705002136_create_domain_allows.rb' - - 'db/migrate/20190715164535_add_instance_actor.rb' - - 'db/migrate/20190726175042_add_case_insensitive_index_to_tags.rb' - - 'db/migrate/20190729185330_add_score_to_tags.rb' - - 'db/migrate/20190805123746_add_capabilities_to_tags.rb' - - 'db/migrate/20190807135426_add_comments_to_domain_blocks.rb' - - 'db/migrate/20190815225426_add_last_status_at_to_tags.rb' - - 'db/migrate/20190819134503_add_deleted_at_to_statuses.rb' - - 'db/migrate/20190820003045_update_statuses_index.rb' - - 'db/migrate/20190823221802_add_local_index_to_statuses.rb' - - 'db/migrate/20190901035623_add_max_score_to_tags.rb' - - 'db/migrate/20190904222339_create_markers.rb' - - 'db/migrate/20190914202517_create_account_migrations.rb' - - 'db/migrate/20190915194355_create_account_aliases.rb' - - 'db/migrate/20190927232842_add_voters_count_to_polls.rb' - - 'db/migrate/20191001213028_add_lock_version_to_account_stats.rb' - - 'db/migrate/20191007013357_update_pt_locales.rb' - - 'db/migrate/20191031163205_change_list_account_follow_nullable.rb' - - 'db/migrate/20191212003415_increase_backup_size.rb' - - 'db/migrate/20191212163405_add_hide_collections_to_accounts.rb' - - 'db/migrate/20191218153258_create_announcements.rb' - - 'db/migrate/20200113125135_create_announcement_mutes.rb' - - 'db/migrate/20200114113335_create_announcement_reactions.rb' - - 'db/migrate/20200119112504_add_public_index_to_statuses.rb' - - 'db/migrate/20200126203551_add_published_at_to_announcements.rb' - - 'db/migrate/20200306035625_add_processing_to_media_attachments.rb' - - 'db/migrate/20200309150742_add_forwarded_to_reports.rb' - - 'db/migrate/20200312144258_add_title_to_account_warning_presets.rb' - - 'db/migrate/20200312162302_add_status_ids_to_announcements.rb' - - 'db/migrate/20200312185443_add_parent_id_to_email_domain_blocks.rb' - - 'db/migrate/20200317021758_add_expires_at_to_mutes.rb' - - 'db/migrate/20200407201300_create_unavailable_domains.rb' - - 'db/migrate/20200407202420_migrate_unavailable_inboxes.rb' - - 'db/migrate/20200417125749_add_storage_schema_version.rb' - - 'db/migrate/20200508212852_reset_unique_jobs_locks.rb' - - 'db/migrate/20200510110808_reset_web_app_secret.rb' - - 'db/migrate/20200510181721_remove_duplicated_indexes_pghero.rb' - - 'db/migrate/20200516180352_create_devices.rb' - - 'db/migrate/20200516183822_create_one_time_keys.rb' - - 'db/migrate/20200518083523_create_encrypted_messages.rb' - - 'db/migrate/20200521180606_encrypted_message_ids_to_timestamp_ids.rb' - - 'db/migrate/20200529214050_add_devices_url_to_accounts.rb' - - 'db/migrate/20200601222558_create_system_keys.rb' - - 'db/migrate/20200605155027_add_blurhash_to_preview_cards.rb' - - 'db/migrate/20200608113046_add_sign_in_token_to_users.rb' - - 'db/migrate/20200614002136_add_sensitized_to_accounts.rb' - - 'db/migrate/20200620164023_add_fixed_lowercase_index_to_accounts.rb' - - 'db/migrate/20200622213645_media_attachment_ids_to_timestamp_ids.rb' - - 'db/migrate/20200627125810_add_thumbnail_columns_to_media_attachments.rb' - - 'db/migrate/20200628133322_create_account_notes.rb' - - 'db/migrate/20200630190240_create_webauthn_credentials.rb' - - 'db/migrate/20200630190544_add_webauthn_id_to_users.rb' - - 'db/migrate/20200908193330_create_account_deletion_requests.rb' - - 'db/migrate/20200917192924_add_notify_to_follows.rb' - - 'db/migrate/20200917193034_add_type_to_notifications.rb' - - 'db/migrate/20200917222316_add_index_notifications_on_type.rb' - - 'db/migrate/20201008202037_create_ip_blocks.rb' - - 'db/migrate/20201008220312_add_sign_up_ip_to_users.rb' - - 'db/migrate/20201017233919_add_suspension_origin_to_accounts.rb' - - 'db/migrate/20201206004238_create_instances.rb' - - 'db/migrate/20201218054746_add_obfuscate_to_domain_blocks.rb' - - 'db/migrate/20210221045109_create_rules.rb' - - 'db/migrate/20210306164523_account_ids_to_timestamp_ids.rb' - - 'db/migrate/20210322164601_create_account_summaries.rb' - - 'db/migrate/20210323114347_create_follow_recommendations.rb' - - 'db/migrate/20210324171613_create_follow_recommendation_suppressions.rb' - - 'db/migrate/20210416200740_create_canonical_email_blocks.rb' - - 'db/migrate/20210421121431_add_case_insensitive_btree_index_to_tags.rb' - - 'db/migrate/20210425135952_add_index_on_media_attachments_account_id_status_id.rb' - - 'db/migrate/20210505174616_update_follow_recommendations_to_version_2.rb' - - 'db/migrate/20210609202149_create_login_activities.rb' - - 'db/migrate/20210616214526_create_user_ips.rb' - - 'db/migrate/20210621221010_add_skip_sign_in_token_to_users.rb' - - 'db/migrate/20210630000137_fix_canonical_email_blocks_foreign_key.rb' - - 'db/migrate/20210722120340_create_account_statuses_cleanup_policies.rb' - - 'db/migrate/20210904215403_add_edited_at_to_statuses.rb' - - 'db/migrate/20210908220918_create_status_edits.rb' - - 'db/migrate/20211031031021_create_preview_card_providers.rb' - - 'db/migrate/20211112011713_add_language_to_preview_cards.rb' - - 'db/migrate/20211115032527_add_trendable_to_preview_cards.rb' - - 'db/migrate/20211123212714_add_link_type_to_preview_cards.rb' - - 'db/migrate/20211213040746_update_account_summaries_to_version_2.rb' - - 'db/migrate/20211231080958_add_category_to_reports.rb' - - 'db/migrate/20220105163928_remove_mentions_status_id_index.rb' - - 'db/migrate/20220115125126_add_report_id_to_account_warnings.rb' - - 'db/migrate/20220115125341_fix_account_warning_actions.rb' - - 'db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb' - - 'db/migrate/20220124141035_create_appeals.rb' - - 'db/migrate/20220202200743_add_trendable_to_accounts.rb' - - 'db/migrate/20220202200926_add_trendable_to_statuses.rb' - - 'db/migrate/20220210153119_add_overruled_at_to_account_warnings.rb' - - 'db/migrate/20220224010024_add_ips_to_email_domain_blocks.rb' - - 'db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb' - - 'db/migrate/20220302232632_add_ordered_media_attachment_ids_to_statuses.rb' - - 'db/migrate/20220303000827_add_ordered_media_attachment_ids_to_status_edits.rb' - - 'db/migrate/20220304195405_migrate_hide_network_preference.rb' - - 'db/migrate/20220307094650_fix_featured_tags_constraints.rb' - - 'db/migrate/20220309213005_fix_reblog_deleted_at.rb' - - 'db/migrate/20220316233212_update_kurdish_locales.rb' - - 'db/migrate/20220428112511_add_index_statuses_on_account_id.rb' - - 'db/migrate/20220428112727_add_index_statuses_pins_on_status_id.rb' - - 'db/migrate/20220428114454_add_index_reports_on_assigned_account_id.rb' - - 'db/migrate/20220428114902_add_index_reports_on_action_taken_by_account_id.rb' - - 'db/migrate/20220606044941_create_webhooks.rb' - - 'db/migrate/20220611210335_create_user_roles.rb' - - 'db/migrate/20220611212541_add_role_id_to_users.rb' - - 'db/migrate/20220710102457_add_display_name_to_tags.rb' - - 'db/migrate/20220714171049_create_tag_follows.rb' - - 'db/migrate/20220824164433_add_human_identifier_to_admin_action_logs.rb' - - 'db/migrate/20220824233535_create_status_trends.rb' - - 'db/migrate/20220827195229_change_canonical_email_blocks_nullable.rb' - - 'db/migrate/20220829192633_add_languages_to_follows.rb' - - 'db/migrate/20220829192658_add_languages_to_follow_requests.rb' - - 'db/migrate/20221006061337_create_preview_card_trends.rb' - - 'db/migrate/20221012181003_add_blurhash_to_site_uploads.rb' - - 'db/migrate/20221021055441_add_index_featured_tags_on_account_id_and_tag_id.rb' - - 'db/migrate/20221025171544_add_index_ip_blocks_on_ip.rb' - - 'db/migrate/20221104133904_add_name_to_featured_tags.rb' - - 'db/post_migrate/20190519130537_remove_boosts_widening_audience.rb' - - 'db/post_migrate/20210308133107_remove_subscription_expires_at_from_accounts.rb' - - 'db/post_migrate/20220118183123_remove_rememberable_from_users.rb' - - 'db/seeds/01_web_app.rb' - - 'db/seeds/02_instance_actor.rb' - - 'db/seeds/03_roles.rb' - - 'db/seeds/04_admin.rb' - - 'lib/rails/engine_extensions.rb' - - 'lib/tasks/branding.rake' - - 'spec/fabricators_spec.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). Style/GlobalStdStream: Exclude: @@ -1199,8 +240,8 @@ Style/GuardClause: - 'app/lib/request_pool.rb' - 'app/lib/webfinger.rb' - 'app/lib/webfinger_resource.rb' - - 'app/models/concerns/account_counters.rb' - - 'app/models/concerns/ldap_authenticable.rb' + - 'app/models/concerns/account/counters.rb' + - 'app/models/concerns/user/ldap_authenticable.rb' - 'app/models/tag.rb' - 'app/models/user.rb' - 'app/services/fan_out_on_write_service.rb' @@ -1214,8 +255,8 @@ Style/GuardClause: - 'config/initializers/devise.rb' - 'db/migrate/20170901141119_truncate_preview_cards.rb' - 'db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb' - - 'lib/devise/two_factor_ldap_authenticatable.rb' - - 'lib/devise/two_factor_pam_authenticatable.rb' + - 'lib/devise/strategies/two_factor_ldap_authenticatable.rb' + - 'lib/devise/strategies/two_factor_pam_authenticatable.rb' - 'lib/mastodon/cli/accounts.rb' - 'lib/mastodon/cli/maintenance.rb' - 'lib/mastodon/cli/media.rb' @@ -1229,8 +270,8 @@ Style/HashAsLastArrayItem: Exclude: - 'app/controllers/admin/statuses_controller.rb' - 'app/controllers/api/v1/statuses_controller.rb' - - 'app/models/concerns/account_counters.rb' - - 'app/models/concerns/status_threading_concern.rb' + - 'app/models/concerns/account/counters.rb' + - 'app/models/concerns/status/threading_concern.rb' - 'app/models/status.rb' - 'app/services/batched_remove_status_service.rb' - 'app/services/notify_service.rb' @@ -1249,22 +290,6 @@ Style/IfUnlessModifier: - 'config/initializers/devise.rb' - 'config/initializers/ffmpeg.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: InverseMethods, InverseBlocks. -Style/InverseMethods: - Exclude: - - 'app/models/custom_filter.rb' - - 'app/services/update_account_service.rb' - - 'spec/controllers/activitypub/replies_controller_spec.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: line_count_dependent, lambda, literal -Style/Lambda: - Exclude: - - 'config/initializers/simple_form.rb' - - 'config/routes.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). Style/MapToHash: Exclude: @@ -1277,7 +302,6 @@ Style/MutableConstant: Exclude: - 'app/models/tag.rb' - 'app/services/delete_account_service.rb' - - 'config/initializers/twitter_regex.rb' - 'lib/mastodon/migration_warning.rb' # This cop supports safe autocorrection (--autocorrect). @@ -1319,8 +343,6 @@ Style/RedundantConstantBase: Exclude: - 'config/environments/production.rb' - 'config/initializers/sidekiq.rb' - - 'config/locales/sr-Latn.rb' - - 'config/locales/sr.rb' # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: SafeForConstants. @@ -1337,57 +359,21 @@ Style/RedundantFetchBlock: # AllowedMethods: present?, blank?, presence, try, try! Style/SafeNavigation: Exclude: - - 'app/models/concerns/account_finder_concern.rb' - - 'app/models/status.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowAsExpressionSeparator. -Style/Semicolon: - Exclude: - - 'spec/services/activitypub/process_status_update_service_spec.rb' - - 'spec/validators/blacklisted_email_validator_spec.rb' + - 'app/models/concerns/account/finder_concern.rb' # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: only_raise, only_fail, semantic Style/SignalException: Exclude: - - 'lib/devise/two_factor_ldap_authenticatable.rb' - - 'lib/devise/two_factor_pam_authenticatable.rb' + - 'lib/devise/strategies/two_factor_ldap_authenticatable.rb' + - 'lib/devise/strategies/two_factor_pam_authenticatable.rb' # This cop supports unsafe autocorrection (--autocorrect-all). Style/SingleArgumentDig: Exclude: - 'lib/webpacker/manifest_extensions.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -Style/SlicingWithRange: - Exclude: - - 'app/lib/emoji_formatter.rb' - - 'app/lib/text_formatter.rb' - - 'app/models/account_alias.rb' - - 'app/models/domain_block.rb' - - 'app/models/email_domain_block.rb' - - 'app/models/preview_card_provider.rb' - - 'app/validators/status_length_validator.rb' - - 'db/migrate/20190726175042_add_case_insensitive_index_to_tags.rb' - - 'lib/active_record/batches.rb' - - 'lib/mastodon/premailer_webpack_strategy.rb' - - 'lib/tasks/repo.rake' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle. -# SupportedStyles: require_parentheses, require_no_parentheses -Style/StabbyLambdaParentheses: - Exclude: - - 'config/environments/production.rb' - - 'config/initializers/content_security_policy.rb' - -# This cop supports safe autocorrection (--autocorrect). -Style/StderrPuts: - Exclude: - - 'config/boot.rb' - # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: Mode. Style/StringConcatenation: @@ -1406,13 +392,6 @@ Style/StringLiterals: - 'config/initializers/webauthn.rb' - 'config/routes.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, AllowComments. -# AllowedMethods: define_method, mail, respond_to -Style/SymbolProc: - Exclude: - - 'config/initializers/omniauth.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle, AllowSafeAssignment. # SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex @@ -1441,12 +420,3 @@ Style/TrailingCommaInHashLiteral: Style/WordArray: Exclude: - 'app/helpers/languages_helper.rb' - - 'config/initializers/cors.rb' - - 'spec/controllers/settings/imports_controller_spec.rb' - - 'spec/models/form/import_spec.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. -# URISchemes: http, https -Layout/LineLength: - Max: 701 diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000000..fbd0207bec --- /dev/null +++ b/.simplecov @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +if ENV['CI'] + require 'simplecov-lcov' + SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true + SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter +else + SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter +end + +SimpleCov.start 'rails' do + enable_coverage :branch + + add_filter 'lib/linter' + + add_group 'Libraries', 'lib' + add_group 'Policies', 'app/policies' + add_group 'Presenters', 'app/presenters' + add_group 'Serializers', 'app/serializers' + add_group 'Services', 'app/services' + add_group 'Validators', 'app/validators' +end diff --git a/.watchmanconfig b/.watchmanconfig new file mode 100644 index 0000000000..29e4f231e9 --- /dev/null +++ b/.watchmanconfig @@ -0,0 +1,3 @@ +{ + "ignore_dirs": ["node_modules/", "public/"] +} diff --git a/.yarn/.gitkeep b/.yarn/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch b/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch new file mode 100644 index 0000000000..0b3f94d09e --- /dev/null +++ b/.yarn/patches/babel-plugin-lodash-npm-3.3.4-c7161075b6.patch @@ -0,0 +1,13 @@ +diff --git a/lib/index.js b/lib/index.js +index 16ed6be8be8f555cc99096c2ff60954b42dc313d..d009c069770d066ad0db7ad02de1ea473a29334e 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -99,7 +99,7 @@ function lodash(_ref) { + + var node = _ref3; + +- if ((0, _types.isModuleDeclaration)(node)) { ++ if ((0, _types.isImportDeclaration)(node) || (0, _types.isExportDeclaration)(node)) { + isModule = true; + break; + } diff --git a/.yarn/patches/compression-webpack-plugin-npm-6.1.1-3a2a65987e.patch b/.yarn/patches/compression-webpack-plugin-npm-6.1.1-3a2a65987e.patch new file mode 100644 index 0000000000..30d5db2739 --- /dev/null +++ b/.yarn/patches/compression-webpack-plugin-npm-6.1.1-3a2a65987e.patch @@ -0,0 +1,22 @@ +diff --git a/dist/index.js b/dist/index.js +index 57e375592d984e9a429bcd9f800fa2d15cd662e4..0c47d96df3608e23adfd77d887a8f72abbd501c0 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", { + }); + exports.default = void 0; + +-var _crypto = _interopRequireDefault(require("crypto")); ++var _createHash = _interopRequireDefault(require("webpack/lib/util/createHash")); + + var _path = _interopRequireDefault(require("path")); + +@@ -227,7 +227,7 @@ class CompressionPlugin { + originalAlgorithm: this.options.algorithm, + compressionOptions: this.options.compressionOptions, + name, +- contentHash: _crypto.default.createHash("md4").update(input).digest("hex") ++ contentHash: _createHash.default("md4").update(input).digest("hex") + }; + } else { + cacheData.name = (0, _serializeJavascript.default)({ diff --git a/.yarnclean b/.yarnclean deleted file mode 100644 index 21eb734a6c..0000000000 --- a/.yarnclean +++ /dev/null @@ -1,49 +0,0 @@ -# test directories -__tests__ -test -tests -powered-test - -# asset directories -docs -doc -website -images -# assets - -# examples -example -examples - -# code coverage directories -coverage -.nyc_output - -# build scripts -Makefile -Gulpfile.js -Gruntfile.js - -# configs -.tern-project -.gitattributes -.editorconfig -.*ignore -.eslintrc -.jshintrc -.flowconfig -.documentup.json -.yarn-metadata.json -.*.yml -*.yml - -# misc -*.gz -*.md - -# for specific ignore -!.svgo.yml -!sass-lint/**/*.yml - -# breaks lint-staged or generally anything using https://github.com/eemeli/yaml/issues/384 -!**/yaml/dist/**/doc diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000000..3186f3f079 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/AUTHORS.md b/AUTHORS.md index 18b9f2d708..78cc37a17b 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -9,33 +9,40 @@ and provided thanks to the work of the following contributors: * [ClearlyClaire](https://github.com/ClearlyClaire) * [dependabot-preview[bot]](https://github.com/apps/dependabot-preview) * [ykzts](https://github.com/ykzts) -* [akihikodaki](https://github.com/akihikodaki) * [mjankowski](https://github.com/mjankowski) +* [akihikodaki](https://github.com/akihikodaki) +* [nschonni](https://github.com/nschonni) +* [renovate[bot]](https://github.com/apps/renovate) * [unarist](https://github.com/unarist) * [noellabo](https://github.com/noellabo) +* [tribela](https://github.com/tribela) * [abcang](https://github.com/abcang) * [yiskah](https://github.com/yiskah) -* [tribela](https://github.com/tribela) * [mayaeh](https://github.com/mayaeh) * [nolanlawson](https://github.com/nolanlawson) * [ysksn](https://github.com/ysksn) * [sorin-davidoi](https://github.com/sorin-davidoi) +* [renchap](https://github.com/renchap) * [lynlynlynx](https://github.com/lynlynlynx) * [m4sk1n](mailto:me@m4sk.in) * [Marcin Mikołajczak](mailto:me@m4sk.in) +* [danielmbrasil](https://github.com/danielmbrasil) * [shleeable](https://github.com/shleeable) +* [c960657](https://github.com/c960657) * [renatolond](https://github.com/renatolond) * [zunda](https://github.com/zunda) +* [ineffyble](https://github.com/ineffyble) +* [takayamaki](https://github.com/takayamaki) * [alpaca-tc](https://github.com/alpaca-tc) * [nclm](https://github.com/nclm) -* [ineffyble](https://github.com/ineffyble) +* [trwnh](https://github.com/trwnh) * [ariasuni](https://github.com/ariasuni) * [Masoud Abkenar](mailto:ampbox@gmail.com) * [blackle](https://github.com/blackle) +* [ThisIsMissEm](https://github.com/ThisIsMissEm) * [Quent-in](https://github.com/Quent-in) -* [Brawaru](https://github.com/Brawaru) +* [brawaru](https://github.com/brawaru) * [JantsoP](https://github.com/JantsoP) -* [trwnh](https://github.com/trwnh) * [nullkal](https://github.com/nullkal) * [yookoala](https://github.com/yookoala) * [dunn](https://github.com/dunn) @@ -46,10 +53,8 @@ and provided thanks to the work of the following contributors: * [danhunsaker](https://github.com/danhunsaker) * [eramdam](https://github.com/eramdam) * [Jeroen](mailto:jeroenpraat@users.noreply.github.com) -* [takayamaki](https://github.com/takayamaki) * [masarakki](https://github.com/masarakki) * [ticky](https://github.com/ticky) -* [ThisIsMissEm](https://github.com/ThisIsMissEm) * [hinaloe](https://github.com/hinaloe) * [hcmiya](https://github.com/hcmiya) * [stephenburgess8](https://github.com/stephenburgess8) @@ -61,14 +66,14 @@ and provided thanks to the work of the following contributors: * [rkarabut](https://github.com/rkarabut) * [jeroenpraat](mailto:jeroenpraat@users.noreply.github.com) * [marek-lach](https://github.com/marek-lach) +* [krainboltgreene](https://github.com/krainboltgreene) * [Artoria2e5](https://github.com/Artoria2e5) * [rinsuki](https://github.com/rinsuki) * [marrus-sh](https://github.com/marrus-sh) -* [krainboltgreene](https://github.com/krainboltgreene) -* [pfigel](https://github.com/pfigel) -* [BoFFire](https://github.com/BoFFire) -* [Aldarone](https://github.com/Aldarone) * [deepy](https://github.com/deepy) +* [pfigel](https://github.com/pfigel) +* [Aldarone](https://github.com/Aldarone) +* [BoFFire](https://github.com/BoFFire) * [clworld](https://github.com/clworld) * [MasterGroosha](https://github.com/MasterGroosha) * [dracos](https://github.com/dracos) @@ -76,19 +81,25 @@ and provided thanks to the work of the following contributors: * [SerCom_KC](mailto:sercom-kc@users.noreply.github.com) * [Sylvhem](https://github.com/Sylvhem) * [koyuawsmbrtn](https://github.com/koyuawsmbrtn) +* [taichi221228](https://github.com/taichi221228) * [MitarashiDango](https://github.com/MitarashiDango) * [angristan](https://github.com/angristan) * [JeanGauthier](https://github.com/JeanGauthier) * [kschaper](https://github.com/kschaper) * [beatrix-bitrot](https://github.com/beatrix-bitrot) +* [github-actions[bot]](https://github.com/apps/github-actions) * [BenLubar](https://github.com/BenLubar) * [mkljczk](https://github.com/mkljczk) * [adbelle](https://github.com/adbelle) * [evanminto](https://github.com/evanminto) * [MightyPork](https://github.com/MightyPork) * [ashleyhull-versent](https://github.com/ashleyhull-versent) +* [gunchleoc](https://github.com/gunchleoc) +* [kedamaDQ](https://github.com/kedamaDQ) * [yhirano55](https://github.com/yhirano55) * [mashirozx](https://github.com/mashirozx) +* [dariusk](https://github.com/dariusk) +* [mgmn](https://github.com/mgmn) * [devkral](https://github.com/devkral) * [camponez](https://github.com/camponez) * [Hugo Gameiro](mailto:hmgameiro@gmail.com) @@ -96,12 +107,14 @@ and provided thanks to the work of the following contributors: * [SerCom_KC](mailto:szescxz@gmail.com) * [aschmitz](https://github.com/aschmitz) * [mfmfuyu](https://github.com/mfmfuyu) -* [kedamaDQ](https://github.com/kedamaDQ) +* [mistydemeo](https://github.com/mistydemeo) * [fpiesche](https://github.com/fpiesche) * [gandaro](https://github.com/gandaro) * [johnsudaar](https://github.com/johnsudaar) * [trebmuh](https://github.com/trebmuh) * [rmhasan](https://github.com/rmhasan) +* [Trevor Wolf](mailto:teeerevor@gmail.com) +* [jsgoldstein](https://github.com/jsgoldstein) * [lindwurm](https://github.com/lindwurm) * [victorhck](mailto:victorhck@geeko.site) * [voidsatisfaction](https://github.com/voidsatisfaction) @@ -109,49 +122,58 @@ and provided thanks to the work of the following contributors: * [seefood](https://github.com/seefood) * [jackjennings](https://github.com/jackjennings) * [sunny](https://github.com/sunny) +* [VyrCossont](https://github.com/VyrCossont) +* [Izorkin](https://github.com/Izorkin) * [puckipedia](https://github.com/puckipedia) * [splaGit](https://github.com/splaGit) * [tateisu](https://github.com/tateisu) * [walf443](https://github.com/walf443) +* [progval](https://github.com/progval) * [JoelQ](https://github.com/JoelQ) -* [mistydemeo](https://github.com/mistydemeo) * [Ashley](mailto:expenses@airmail.cc) * [xqus](https://github.com/xqus) +* [CSDUMMI](https://github.com/CSDUMMI) * [pfm-eyesightjp](https://github.com/pfm-eyesightjp) +* [S-H-GAMELINKS](https://github.com/S-H-GAMELINKS) * [fakenine](https://github.com/fakenine) +* [Signez](https://github.com/Signez) * [tsuwatch](https://github.com/tsuwatch) -* [progval](https://github.com/progval) * [victorhck](https://github.com/victorhck) -* [Izorkin](https://github.com/Izorkin) +* [luzpaz](https://github.com/luzpaz) * [manuelviens](mailto:manuelviens@users.noreply.github.com) * [fvh-P](https://github.com/fvh-P) * [lfuelling](https://github.com/lfuelling) * [rtucker](https://github.com/rtucker) * [Anna e só](mailto:contraexemplos@gmail.com) * [danieljakots](https://github.com/danieljakots) -* [dariusk](https://github.com/dariusk) * [Gomasy](https://github.com/Gomasy) -* [kazu9su](https://github.com/kazu9su) -* [komic](https://github.com/komic) +* [j-f1](https://github.com/j-f1) +* [kescherCode](https://github.com/kescherCode) +* [tooooooooomy](https://github.com/tooooooooomy) +* [Komic](mailto:contact@komic.eu) * [lmorchard](https://github.com/lmorchard) * [diomed](https://github.com/diomed) * [Neetshin](mailto:neetshin@neetsh.in) * [rainyday](https://github.com/rainyday) +* [rgroothuijsen](https://github.com/rgroothuijsen) +* [rrgeorge](https://github.com/rrgeorge) * [tcitworld](https://github.com/tcitworld) +* [timetinytim](https://github.com/timetinytim) * [valentin2105](https://github.com/valentin2105) * [yuntan](https://github.com/yuntan) * [goofy-bz](mailto:goofy@babelzilla.org) * [kadiix](https://github.com/kadiix) * [kodacs](https://github.com/kodacs) -* [luzpaz](https://github.com/luzpaz) * [marcin mikołajczak](mailto:me@m4sk.in) -* [berkes](https://github.com/berkes) +* [prplecake](https://github.com/prplecake) * [KScl](https://github.com/KScl) -* [sterdev](https://github.com/sterdev) +* [deprecated-acct](https://github.com/deprecated-acct) * [TheKinrar](https://github.com/TheKinrar) * [AA4ch1](https://github.com/AA4ch1) * [alexgleason](https://github.com/alexgleason) +* [berkes](https://github.com/berkes) * [cpytel](https://github.com/cpytel) +* [connorshea](https://github.com/connorshea) * [cutls](https://github.com/cutls) * [northerner](https://github.com/northerner) * [weex](https://github.com/weex) @@ -159,21 +181,22 @@ and provided thanks to the work of the following contributors: * [fhemberger](https://github.com/fhemberger) * [greysteil](https://github.com/greysteil) * [henrycatalinismith](https://github.com/henrycatalinismith) +* [hs4man21](https://github.com/hs4man21) * [HolgerHuo](https://github.com/HolgerHuo) * [d6rkaiz](https://github.com/d6rkaiz) * [ladyisatis](https://github.com/ladyisatis) * [JMendyk](https://github.com/JMendyk) -* [kescherCode](https://github.com/kescherCode) * [JohnD28](https://github.com/JohnD28) +* [casaper](https://github.com/casaper) * [znz](https://github.com/znz) * [saper](https://github.com/saper) * [Naouak](https://github.com/Naouak) * [pawelngei](https://github.com/pawelngei) -* [rgroothuijsen](https://github.com/rgroothuijsen) * [reneklacan](https://github.com/reneklacan) * [ekiru](https://github.com/ekiru) * [unasuke](https://github.com/unasuke) * [geta6](https://github.com/geta6) +* [gol-cha](https://github.com/gol-cha) * [happycoloredbanana](https://github.com/happycoloredbanana) * [joenepraat](https://github.com/joenepraat) * [leopku](https://github.com/leopku) @@ -184,6 +207,7 @@ and provided thanks to the work of the following contributors: * [aji-su](https://github.com/aji-su) * [ikuradon](https://github.com/ikuradon) * [nzws](https://github.com/nzws) +* [moritzheiber](https://github.com/moritzheiber) * [SuperSandro2000](https://github.com/SuperSandro2000) * [178inaba](https://github.com/178inaba) * [acid-chicken](https://github.com/acid-chicken) @@ -192,17 +216,24 @@ and provided thanks to the work of the following contributors: * [aablinov](https://github.com/aablinov) * [stalker314314](https://github.com/stalker314314) * [cohosh](https://github.com/cohosh) +* [muffinista](https://github.com/muffinista) * [huertanix](https://github.com/huertanix) +* [consideRatio](https://github.com/consideRatio) * [eleboucher](https://github.com/eleboucher) +* [FrancisMurillo](https://github.com/FrancisMurillo) * [halkeye](https://github.com/halkeye) * [Hanage999](https://github.com/Hanage999) * [treby](https://github.com/treby) +* [eltociear](https://github.com/eltociear) * [jpdevries](https://github.com/jpdevries) * [gdpelican](https://github.com/gdpelican) * [pbzweihander](https://github.com/pbzweihander) * [MonaLisaOverrdrive](https://github.com/MonaLisaOverrdrive) * [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name) +* [Tak](https://github.com/Tak) * [panarom](https://github.com/panarom) +* [MFTabriz](https://github.com/MFTabriz) +* [vmstan](https://github.com/vmstan) * [Dar13](https://github.com/Dar13) * [nevillepark](https://github.com/nevillepark) * [ornithocoder](https://github.com/ornithocoder) @@ -211,8 +242,10 @@ and provided thanks to the work of the following contributors: * [qguv](https://github.com/qguv) * [Ram Lmn](mailto:ramlmn@users.noreply.github.com) * [Sascha](mailto:sascha@serenitylabs.cloud) +* [SISheogorath](https://github.com/SISheogorath) * [harukasan](https://github.com/harukasan) * [stamak](https://github.com/stamak) +* [OmmyZhang](https://github.com/OmmyZhang) * [Technowix](https://github.com/Technowix) * [Zoeille](https://github.com/Zoeille) * [Thorwegian](https://github.com/Thorwegian) @@ -220,30 +253,31 @@ and provided thanks to the work of the following contributors: * [gled-rs](https://github.com/gled-rs) * [Valentin_NC](mailto:valentin.ouvrard@nautile.sarl) * [R0ckweb](https://github.com/R0ckweb) +* [alfe](https://github.com/alfe) * [caasi](https://github.com/caasi) * [chandrn7](https://github.com/chandrn7) * [chr-1x](https://github.com/chr-1x) * [esetomo](https://github.com/esetomo) * [foxiehkins](https://github.com/foxiehkins) -* [gol-cha](https://github.com/gol-cha) * [highemerly](https://github.com/highemerly) * [hoodie](mailto:hoodiekitten@outlook.com) * [kaiyou](https://github.com/kaiyou) * [007lva](https://github.com/007lva) * [luzi82](https://github.com/luzi82) -* [prplecake](https://github.com/prplecake) * [duxovni](https://github.com/duxovni) * [slice](https://github.com/slice) * [tmm576](https://github.com/tmm576) * [unsmell](mailto:unsmell@users.noreply.github.com) * [valerauko](https://github.com/valerauko) * [Grawl](https://github.com/Grawl) -* [chriswmartin](https://github.com/chriswmartin) +* [minacle](https://github.com/minacle) * [AndreLewin](https://github.com/AndreLewin) * [0xflotus](https://github.com/0xflotus) * [redtachyons](https://github.com/redtachyons) * [thurloat](https://github.com/thurloat) +* [Akkiesoft](https://github.com/Akkiesoft) * [aaribaud](https://github.com/aaribaud) +* [Saiv46](https://github.com/Saiv46) * [pointlessone](https://github.com/pointlessone) * [Andrew](mailto:andrewlchronister@gmail.com) * [arielrodrigues](https://github.com/arielrodrigues) @@ -254,29 +288,30 @@ and provided thanks to the work of the following contributors: * [dissolve](https://github.com/dissolve) * [PurpleBooth](https://github.com/PurpleBooth) * [bradurani](https://github.com/bradurani) -* [wavebeem](https://github.com/wavebeem) +* [bramus](https://github.com/bramus) +* [Brian Mock](mailto:brian@mockbrian.com) * [thermosflasche](https://github.com/thermosflasche) * [LottieVixen](https://github.com/LottieVixen) +* [chriswmartin](https://github.com/chriswmartin) * [wchristian](https://github.com/wchristian) -* [muffinista](https://github.com/muffinista) * [cdutson](https://github.com/cdutson) * [farlistener](https://github.com/farlistener) * [baby-gnu](https://github.com/baby-gnu) * [divergentdave](https://github.com/divergentdave) +* [lochiiconnectivity](https://github.com/lochiiconnectivity) * [DavidLibeau](https://github.com/DavidLibeau) * [dmerejkowsky](https://github.com/dmerejkowsky) * [ddevault](https://github.com/ddevault) * [emilyst](https://github.com/emilyst) -* [consideRatio](https://github.com/consideRatio) * [Fjoerfoks](https://github.com/Fjoerfoks) * [fmauNeko](https://github.com/fmauNeko) * [gloaec](https://github.com/gloaec) * [unstabler](https://github.com/unstabler) * [potato4d](https://github.com/potato4d) * [h-izumi](https://github.com/h-izumi) +* [HeitorMC](https://github.com/HeitorMC) * [ErikXXon](https://github.com/ErikXXon) * [ian-kelling](https://github.com/ian-kelling) -* [eltociear](https://github.com/eltociear) * [immae](https://github.com/immae) * [J0WI](https://github.com/J0WI) * [koboldunderlord](https://github.com/koboldunderlord) @@ -285,7 +320,9 @@ and provided thanks to the work of the following contributors: * [raggi](https://github.com/raggi) * [jasonrhodes](https://github.com/jasonrhodes) * [Jason Snell](mailto:jason@newrelic.com) +* [casperisfine](https://github.com/casperisfine) * [jviide](https://github.com/jviide) +* [joshuap](https://github.com/joshuap) * [YuleZ](https://github.com/YuleZ) * [jtracey](https://github.com/jtracey) * [crakaC](https://github.com/crakaC) @@ -294,14 +331,12 @@ and provided thanks to the work of the following contributors: * [Kazhnuz](https://github.com/Kazhnuz) * [mkody](https://github.com/mkody) * [connyduck](https://github.com/connyduck) -* [Tak](https://github.com/Tak) * [LindseyB](https://github.com/LindseyB) * [Lorenz Diener](mailto:halcyon@icosahedron.website) * [Markus Amalthea Magnuson](mailto:markus.magnuson@gmail.com) * [madmath03](https://github.com/madmath03) * [mig5](https://github.com/mig5) * [mohe2015](https://github.com/mohe2015) -* [moritzheiber](https://github.com/moritzheiber) * [Nathaniel Suchy](mailto:me@lunorian.is) * [ndarville](https://github.com/ndarville) * [NimaBoscarino](https://github.com/NimaBoscarino) @@ -312,21 +347,24 @@ and provided thanks to the work of the following contributors: * [xPaw](https://github.com/xPaw) * [petzah](https://github.com/petzah) * [PeterDaveHello](https://github.com/PeterDaveHello) +* [sidp](https://github.com/sidp) * [ignisf](https://github.com/ignisf) * [postmodern](https://github.com/postmodern) * [lumenwrites](https://github.com/lumenwrites) * [remram44](https://github.com/remram44) * [sts10](https://github.com/sts10) +* [arbolitoloco1](https://github.com/arbolitoloco1) * [u1-liquid](https://github.com/u1-liquid) -* [SISheogorath](https://github.com/SISheogorath) * [rosylilly](https://github.com/rosylilly) * [withshubh](https://github.com/withshubh) * [sim6](https://github.com/sim6) * [Sir-Boops](https://github.com/Sir-Boops) * [stemid](https://github.com/stemid) * [sumdog](https://github.com/sumdog) -* [OmmyZhang](https://github.com/OmmyZhang) +* [shuuji3](https://github.com/shuuji3) +* [edent](https://github.com/edent) * [ThomasLeister](https://github.com/ThomasLeister) +* [timothyjrogers](https://github.com/timothyjrogers) * [Tom McAtee](mailto:a1608768@student.adelaide.edu.au) * [tototoshi](https://github.com/tototoshi) * [TrashMacNugget](https://github.com/TrashMacNugget) @@ -343,11 +381,13 @@ and provided thanks to the work of the following contributors: * [aus-social](https://github.com/aus-social) * [bsky](mailto:git@bsky.moe) * [bsky](mailto:me@imbsky.net) +* [cadars](https://github.com/cadars) * [codl](https://github.com/codl) * [cpsdqs](https://github.com/cpsdqs) * [dogelover911](https://github.com/dogelover911) +* [emilweth](https://github.com/emilweth) * [barzamin](https://github.com/barzamin) -* [gunchleoc](https://github.com/gunchleoc) +* [forsamori](https://github.com/forsamori) * [fhalna](https://github.com/fhalna) * [haoyayoi](https://github.com/haoyayoi) * [helloworldstack](https://github.com/helloworldstack) @@ -358,6 +398,7 @@ and provided thanks to the work of the following contributors: * [mbajur](https://github.com/mbajur) * [matsurai25](https://github.com/matsurai25) * [mecab](https://github.com/mecab) +* [nametoolong](https://github.com/nametoolong) * [nicobz25](https://github.com/nicobz25) * [niwatori24](https://github.com/niwatori24) * [noiob](https://github.com/noiob) @@ -374,14 +415,15 @@ and provided thanks to the work of the following contributors: * [vjackson725](https://github.com/vjackson725) * [wxcafe](https://github.com/wxcafe) * [新都心(Neet Shin)](mailto:nucx@dio-vox.com) -* [clarfonthey](https://github.com/clarfonthey) -* [cygnan](https://github.com/cygnan) -* [Awea](https://github.com/Awea) +* [tenderlove](https://github.com/tenderlove) +* [raboof](https://github.com/raboof) * [single-right-quote](https://github.com/single-right-quote) * [8398a7](https://github.com/8398a7) * [857b](https://github.com/857b) +* [9p4](https://github.com/9p4) * [insom](https://github.com/insom) * [tachyons](https://github.com/tachyons) +* [AcesFullOfKings](https://github.com/AcesFullOfKings) * [Esteth](https://github.com/Esteth) * [unascribed](https://github.com/unascribed) * [Aguay-val](https://github.com/Aguay-val) @@ -389,13 +431,14 @@ and provided thanks to the work of the following contributors: * [h3poteto](https://github.com/h3poteto) * [unleashed](https://github.com/unleashed) * [alxrcs](https://github.com/alxrcs) +* [alexstine](https://github.com/alexstine) * [console-cowboy](https://github.com/console-cowboy) -* [Saiv46](https://github.com/Saiv46) * [Alkarex](https://github.com/Alkarex) * [a2](https://github.com/a2) * [Alfie John](mailto:33c6c91f3bb4a391082e8a29642cafaf@alfie.wtf) * [0xa](https://github.com/0xa) * [ashpieboop](https://github.com/ashpieboop) +* [alisonw](https://github.com/alisonw) * [virtualpain](https://github.com/virtualpain) * [sapphirus](https://github.com/sapphirus) * [amandavisconti](https://github.com/amandavisconti) @@ -406,88 +449,120 @@ and provided thanks to the work of the following contributors: * [schas002](https://github.com/schas002) * [contraexemplo](https://github.com/contraexemplo) * [abackstrom](https://github.com/abackstrom) +* [AntoninDelFabbro](https://github.com/AntoninDelFabbro) * [orlea](https://github.com/orlea) * [armandfardeau](https://github.com/armandfardeau) -* [raboof](https://github.com/raboof) * [v-aisac](https://github.com/v-aisac) -* [gi-yt](https://github.com/gi-yt) -* [boahc077](https://github.com/boahc077) -* [aldatsa](https://github.com/aldatsa) -* [jumbosushi](https://github.com/jumbosushi) -* [acuteaura](https://github.com/acuteaura) -* [ayumin](https://github.com/ayumin) -* [bzg](https://github.com/bzg) -* [BastienDurel](https://github.com/BastienDurel) -* [bearice](https://github.com/bearice) -* [li-bei](https://github.com/li-bei) -* [hardillb](https://github.com/hardillb) +* [Arya K](mailto:73596856+gi-yt@users.noreply.github.com) +* [Ashish Kurmi](mailto:100655670+boahc077@users.noreply.github.com) +* [Asier Iturralde Sarasola](mailto:asier.iturralde@gmail.com) +* [Atsushi Yamamoto](mailto:yamaatsushi927@gmail.com) +* [Aurelia](mailto:aurelia@schittler.dev) +* [Avdi Grimm](mailto:avdi@users.noreply.github.com) +* [Ayumu AIZAWA](mailto:ayumu.aizawa@gmail.com) +* [Bastien](mailto:bzg@users.noreply.github.com) +* [Bastien Durel](mailto:bastien@durel.org) +* [Bearice Ren](mailto:bearice@gmail.com) +* [Bei Li](mailto:kylinroc@gmail.com) +* [Ben Hardill](mailto:b.hardill@gmail.com) * [Benedikt Geißler](mailto:benedikt@g5r.eu) -* [BenisonSebastian](https://github.com/BenisonSebastian) +* [BenisonSebastian](mailto:33474422+benisonsebastian@users.noreply.github.com) * [Blake](mailto:blake.barnett@postmates.com) +* [Botao Wang](mailto:wxt2005@gmail.com) * [Brad Janke](mailto:brad.janke@gmail.com) -* [braydofficial](https://github.com/braydofficial) -* [bclindner](https://github.com/bclindner) -* [brycied00d](https://github.com/brycied00d) -* [carlosjs23](https://github.com/carlosjs23) -* [WyriHaximus](https://github.com/WyriHaximus) -* [cgxxx](https://github.com/cgxxx) -* [kibitan](https://github.com/kibitan) -* [cdzombak](https://github.com/cdzombak) -* [chrisheninger](https://github.com/chrisheninger) -* [chris-martin](https://github.com/chris-martin) -* [offbyone](https://github.com/offbyone) -* [cclauss](https://github.com/cclauss) -* [DoubleMalt](https://github.com/DoubleMalt) -* [Moosh-be](https://github.com/Moosh-be) -* [cchoi12](https://github.com/cchoi12) -* [Motoma](https://github.com/Motoma) +* [Brayd](mailto:byronfroehlich@proton.me) +* [Brian C. Lindner](mailto:cslindner@gmail.com) +* [Brian Campbell](mailto:unlambda@gmail.com) +* [Bryce Chidester](mailto:bryce@cobryce.com) +* [BtbN](mailto:btbn@btbn.de) +* [ButterflyOfFire](mailto:42316180+boffire@users.noreply.github.com) +* [Bèr Kessels](mailto:github@berk.es) +* [Carl Schwan](mailto:carl@carlschwan.eu) +* [Carlos A. Escobar](mailto:ingcarlosandresescobar@gmail.com) +* [Cees-Jan Kiewiet](mailto:ceesjank@gmail.com) +* [CgX](mailto:github@cgx.me) +* [Chikahiro Tokoro](mailto:uzukifirst@gmail.com) +* [Chike Nwaenie](mailto:chikenwaenie@gmail.com) +* [Chris](mailto:cmarti14@artic.edu) +* [Chris Dzombak](mailto:chris@chrisdzombak.net) +* [Chris Funderburg](mailto:chris@funderburg.me) +* [Chris Heninger](mailto:heninger@gmail.com) +* [Chris Johnson](mailto:49479599+workeffortwaste@users.noreply.github.com) +* [Chris Martin](mailto:ch.martin@gmail.com) +* [Chris Rose](mailto:offbyone@github.com) +* [Christian Clauss](mailto:cclauss@me.com) +* [Christoph Witzany](mailto:christoph@web.crofting.com) +* [Christophe Gesché](mailto:moosh@php.net) +* [Christopher Choi](mailto:cdddchris@gmail.com) +* [Christopher Gilbert](mailto:motoma@gmail.com) * [Christopher Kolstad](mailto:christopher.kolstad@finn.no) -* [csu](https://github.com/csu) -* [kklleemm](https://github.com/kklleemm) -* [colindean](https://github.com/colindean) -* [CommanderRoot](https://github.com/CommanderRoot) -* [connorshea](https://github.com/connorshea) -* [DeeUnderscore](https://github.com/DeeUnderscore) -* [dachinat](https://github.com/dachinat) +* [Christopher Nethercott](mailto:ccnethercott@gmail.com) +* [Christopher Su](mailto:christophersu9@gmail.com) +* [Clar Charr](mailto:clar@charr.xyz) +* [Clar Fon](mailto:them@lightdark.xyz) +* [Clément D](mailto:kklleemm@users.noreply.github.com) +* [Colette Kerr](mailto:colette.m.y.kerr@gmail.com) +* [Colin Dean](mailto:colindean@users.noreply.github.com) +* [CommanderRoot](mailto:commanderroot@users.noreply.github.com) +* [Cygnan](mailto:email@cygnan.com) +* [Cygnan](mailto:mail@cygnan.com) +* [D Anzorge](mailto:d.anzorge@gmail.com) +* [Dachi Natsvlishvili](mailto:dachinat@gmail.com) * [Daggertooth](mailto:dev@monsterpit.net) -* [watilde](https://github.com/watilde) -* [dalehenries](https://github.com/dalehenries) -* [daprice](https://github.com/daprice) -* [da2x](https://github.com/da2x) -* [codesections](https://github.com/codesections) -* [dar5hak](https://github.com/dar5hak) -* [kant](https://github.com/kant) -* [maxolasersquad](https://github.com/maxolasersquad) +* [Daijiro Wachi](mailto:daijiro.wachi@gmail.com) +* [Dale Henries](mailto:dalehenries@gmail.com) +* [Dale Price](mailto:daprice@users.noreply.github.com) +* [Dan Peterson](mailto:danp@danp.net) +* [Daniel Aleksandersen](mailto:code@daniel.priv.no) +* [Daniel Axtens](mailto:daniel@axtens.net) +* [Daniel Sockwell](mailto:dsockwell@gmail.com) +* [Darshak Parikh](mailto:dar5hak@users.noreply.github.com) +* [Darío Hereñú](mailto:magallania@gmail.com) +* [David Authier](mailto:aweaoftheworld@gmail.com) +* [David Baucum](mailto:maxolasersquad@gmail.com) * [David Baumgold](mailto:david@davidbaumgold.com) * [David Caldwell](mailto:david+github@porkrind.org) * [David Celis](mailto:me@davidcel.is) * [David Hewitt](mailto:davidmhewitt@users.noreply.github.com) +* [David Leadbeater](mailto:dgl@dgl.cx) * [David Underwood](mailto:davefp@gmail.com) +* [David Vega](mailto:david-vega@users.noreply.github.com) * [David Yip](mailto:yipdw@member.fsf.org) +* [Dean Bassett](mailto:dean@dbassett.dev) * [Debanshu Kundu](mailto:debanshu.kundu@joshtechnologygroup.com) * [Denis Teyssier](mailto:admin@mascali.ovh) * [Derek Lewis](mailto:derekcecillewis@gmail.com) * [Devon Blandin](mailto:dblandin@gmail.com) +* [Douglas Blank](mailto:doug.blank@gmail.com) * [Drew Gates](mailto:aranaur@users.noreply.github.com) * [Drew Schuster](mailto:dtschust@gmail.com) * [Dryusdan](mailto:dryusdan@dryusdan.fr) * [Eai](mailto:eai@mizle.net) +* [Eashwar Ranganathan](mailto:eranganathan@lyft.com) * [Ed Knutson](mailto:knutsoned@gmail.com) -* [Effy Elden](mailto:effy@effy.space) +* [Elizabeth Martín Campos](mailto:me@elizabeth.sh) * [Elizabeth Myers](mailto:elizabeth@interlinked.me) +* [Ell Bradshaw](mailto:cincodenada@gmail.com) * [Eric](mailto:enewhuis@gmail.com) * [Eric Blade](mailto:blade.eric@gmail.com) * [Eshin Kunishima](mailto:mikoim@users.noreply.github.com) * [Espen Rønnevik](mailto:espen@ronnevik.net) +* [Essem](mailto:smswessem@gmail.com) +* [Evan](mailto:35814742+evanphilip@users.noreply.github.com) * [Expenses](mailto:expenses@airmail.cc) * [Fabian Schlenz](mailto:mail@fabianonline.de) * [Faye Duxovni](mailto:duxovni@duxovni.org) * [Filipe Rodrigues](mailto:shello@shello.org) * [Finariel](mailto:finariel@gmail.com) +* [Florin](mailto:csflorin@users.noreply.github.com) +* [Foritus](mailto:rich@aornis.com) * [Francis Chong](mailto:francis@ignition.hk) * [Franck Zoccolo](mailto:franck@zoccolo.com) +* [Frankie Roberto](mailto:frankie@frankieroberto.com) * [Fred Wenzel](mailto:fwenzel@users.noreply.github.com) +* [Fries](mailto:40834252+ayefries@users.noreply.github.com) * [Gabriel Rubens](mailto:gabrielrumiranda@gmail.com) +* [Gabriel Simmer](mailto:github@gmem.ca) * [Gaelan Steele](mailto:gbs@canishe.com) * [Genbu Hase](mailto:hasegenbu@gmail.com) * [Georg Gadinger](mailto:nilsding@nilsding.org) @@ -509,48 +584,65 @@ and provided thanks to the work of the following contributors: * [Hiroe Jun](mailto:jun.hiroe@gmail.com) * [Hiromi Kai](mailto:pie05041008@gmail.com) * [Hisham Muhammad](mailto:hisham@gobolinux.org) +* [HonkingGoose](mailto:34918129+honkinggoose@users.noreply.github.com) * [Hugo "Slaynash" Flores](mailto:hugoflores@hotmail.fr) * [INAGAKI Hiroshi](mailto:musashino205@users.noreply.github.com) * [IWAI, Masaharu](mailto:iwaim.sub@gmail.com) +* [Ian](mailto:ian@devolute.net) * [Ian McCowan](mailto:imccowan@gmail.com) * [Ian McDowell](mailto:me@ianmcdowell.net) * [Iijima Yasushi](mailto:kurage.cc@gmail.com) * [Ingo Blechschmidt](mailto:iblech@web.de) * [Irie Aoi](mailto:eai@mizle.net) +* [Ivan Rodriguez](mailto:104603218+irod22@users.noreply.github.com) * [J Yeary](mailto:usbsnowcrash@users.noreply.github.com) +* [JT Olio](mailto:hello@jtolio.com) * [Jack Michaud](mailto:jack-michaud@users.noreply.github.com) +* [Jaehong Kang](mailto:sinoru@me.com) * [Jakub Mendyk](mailto:jakubmendyk.szkola@gmail.com) * [James](mailto:james.allen.vaughan@gmail.com) +* [James Adney](mailto:jfadney@gmail.com) * [James Smith](mailto:james@floppy.org.uk) +* [Jamie Hoyle](mailto:j@jamiehoyle.com) * [Jarek Lipski](mailto:pub@loomchild.net) +* [Jay Prakash Kalia](mailto:jaykalia047@gmail.com) * [Jennifer Glauche](mailto:=^.^=@github19.jglauche.de) * [Jennifer Kruse](mailto:jenkr55@gmail.com) * [Jeremy Rose](mailto:nornagon@nornagon.net) * [Jessica](mailto:46502909+hyenagirl64@users.noreply.github.com) * [Jessica K. Litwin](mailto:jessica@litw.in) +* [Jim Myhrberg](mailto:contact@jimeh.me) * [Jo Decker](mailto:trolldecker@users.noreply.github.com) * [Joan Montané](mailto:jmontane@users.noreply.github.com) * [Joe](mailto:401283+htmlbyjoe@users.noreply.github.com) * [Joe Friedl](mailto:stuff@joefriedl.net) +* [Jonathan Hawkes](mailto:jonathan@thoughtbuilt.com) * [Jonathan Klee](mailto:klee.jonathan@gmail.com) * [Jordan Guerder](mailto:jguerder@fr.pulseheberg.net) * [Joseph Mingrone](mailto:jehops@users.noreply.github.com) * [Josh Leeb-du Toit](mailto:mail@joshleeb.com) +* [Josh McKinney](mailto:joshka@users.noreply.github.com) * [Josh Soref](mailto:2119212+jsoref@users.noreply.github.com) -* [Joshua Wood](mailto:josh@joshuawood.net) +* [João Pedro Marques](mailto:64037198+thedevjoao@users.noreply.github.com) +* [Juan Xavier Gomez](mailto:jgomez@codecademy.com) * [Julien](mailto:tiwy57@users.noreply.github.com) * [Julien Deswaef](mailto:juego@requiem4tv.com) +* [Jullan-M](mailto:42940512+jullan-m@users.noreply.github.com) * [June Sallou](mailto:jnsll@users.noreply.github.com) +* [Justin Hutchings](mailto:jhutchings1@users.noreply.github.com) * [Justin Thomas](mailto:justin@jdt.io) * [Jérémy Benoist](mailto:j0k3r@users.noreply.github.com) * [KEINOS](mailto:github@keinos.com) +* [Kai](mailto:2644614+schweinepriester@users.noreply.github.com) * [Kairui Song | 宋恺睿](mailto:ryncsn@gmail.com) * [Keiji Matsuzaki](mailto:futoase@gmail.com) * [Kevin Liu](mailto:kevin@potatofrom.space) * [Kit Redgrave](mailto:qwertyitis@gmail.com) * [Knut Erik](mailto:abjectio@users.noreply.github.com) +* [Kohei Ota (inductor)](mailto:kela@inductor.me) * [Kota Ouchi](mailto:k0ta0uchi@gmail.com) * [Krzysztof Jurewicz](mailto:krzysztof.jurewicz@gmail.com) +* [Kuba Suder](mailto:mackuba@users.noreply.github.com) * [Leo Wzukw](mailto:leowzukw@users.noreply.github.com) * [Leonie](mailto:62470640+bubblineyuri@users.noreply.github.com) * [Lex Alexander](mailto:l.alexander10@gmail.com) @@ -558,12 +650,17 @@ and provided thanks to the work of the following contributors: * [Lorenz Diener](mailto:lorenzd@gmail.com) * [Luc Didry](mailto:ldidry@users.noreply.github.com) * [Lukas Burk](mailto:jemus42@users.noreply.github.com) +* [Lukas Martini](mailto:lutoma@ohai.su) +* [Luxiaba](mailto:5391976+luxiaba@users.noreply.github.com) * [Manato Kameya](mailto:grabacr07+github@gmail.com) * [Mantas](mailto:mistermantas@users.noreply.github.com) * [Mareena Kunjachan](mailto:mareenakunjachan@gmail.com) * [Marek Lach](mailto:marek.brohatwack.lach@gmail.com) +* [Mark Roszko](mailto:mark.roszko@gmail.com) * [Markus Petzsch](mailto:markus@petzsch.eu) * [Markus R](mailto:wirehack7@users.noreply.github.com) +* [Markus Unterwaditzer](mailto:markus-honeypot@unterwaditzer.net) +* [Markus Unterwaditzer](mailto:markus@unterwaditzer.net) * [Marty McGuire](mailto:schmartissimo@gmail.com) * [Marvin Kopf](mailto:marvinkopf@posteo.de) * [Masafumi Otsune](mailto:info@otsune.com) @@ -571,21 +668,24 @@ and provided thanks to the work of the following contributors: * [Mateusz Bugowski](mailto:23140767+mbugowski@users.noreply.github.com) * [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com) * [Mathieu Brunot](mailto:mb.mathieu.brunot@gmail.com) +* [Matias Lago Evia](mailto:matiaslagoevia@gmail.com) * [Matt](mailto:matt-auckland@users.noreply.github.com) * [Matt Corallo](mailto:649246+thebluematt@users.noreply.github.com) +* [Matt Hodges](mailto:hodgesmr1@gmail.com) * [Matt Sweetman](mailto:webroo@gmail.com) +* [Matt Williams](mailto:matt@makeable.co.uk) * [Matthias Bethke](mailto:matthias@towiski.de) * [Matthias Beyer](mailto:mail@beyermatthias.de) * [Matthias Jouan](mailto:matthias.jouan@gmail.com) * [Matthieu Paret](mailto:matthieuparet69@gmail.com) +* [Matthías Páll Gissurarson](mailto:mpg@mpg.is) * [Maxime BORGES](mailto:maxime.borges@gmail.com) -* [Mayu Laierlence](mailto:minacle@live.com) -* [Meisam](mailto:39205857+mftabriz@users.noreply.github.com) * [Michael Deeb](mailto:michaeldeeb@me.com) * [Michael Vieira](mailto:dtox94@gmail.com) * [Michel](mailto:michel@cyweo.com) * [Midgard](mailto:m1dgard@users.noreply.github.com) * [Mike Burns](mailto:mburns@thoughtbot.com) +* [Mikhail Paulyshka](mailto:me@mixaill.net) * [Milan](mailto:me@petabyteboy.de) * [Milan*](mailto:tchncs@vivaldi.net) * [Milton Mazzarri](mailto:milmazz@gmail.com) @@ -602,9 +702,12 @@ and provided thanks to the work of the following contributors: * [Nanamachi](mailto:town7.haruki@gmail.com) * [Nathaniel Ekoniak](mailto:nekoniak@ennate.tech) * [NecroTechno](mailto:necrotechno@riseup.net) +* [Neil Matatall](mailto:448516+oreoshake@users.noreply.github.com) * [Nicholas La Roux](mailto:larouxn@gmail.com) * [Nick Gerakines](mailto:nick@gerakines.net) +* [Nicolai Søborg](mailto:nicolaisoeborg@users.noreply.github.com) * [Nicolai von Neudeck](mailto:nicolai@vonneudeck.com) +* [Nikita Karamov](mailto:me@kytta.dev) * [Ninetailed](mailto:ninetailed@gmail.com) * [Nishi, Keisuke](mailto:k24@users.noreply.github.com) * [Noiob](mailto:noiob@users.noreply.github.com) @@ -616,34 +719,49 @@ and provided thanks to the work of the following contributors: * [Oskari Noppa](mailto:noppa@users.noreply.github.com) * [Otakan](mailto:otakan951@gmail.com) * [Padraig Fahy](mailto:tech@padraigfahy.com) +* [Partho Ghosh](mailto:partho.ghosh24@gmail.com) * [Patrice Ferlet](mailto:metal3d@gmail.com) * [PatrickRWells](mailto:32802366+patrickrwells@users.noreply.github.com) * [Paul](mailto:naydex.mc+github@gmail.com) +* [PauloVilarinho](mailto:33267902+paulovilarinho@users.noreply.github.com) * [Pete Keen](mailto:pete@petekeen.net) * [Pierre Bourdon](mailto:delroth@gmail.com) * [Pierre-Morgan Gate](mailto:pgate@users.noreply.github.com) +* [Plastikmensch](mailto:plastikmensch@users.noreply.github.com) +* [Pleclown](mailto:pleclown+github@gmail.com) +* [Ramūns Usovs](mailto:ramuuns@enkurs.org) * [Ratmir Karabut](mailto:rkarabut@sfmodern.ru) * [Reto Kromer](mailto:retokromer@users.noreply.github.com) +* [Riedler](mailto:github@riedler.wien) +* [Rin](mailto:36845451+ateliersnek@users.noreply.github.com) * [Rob Petti](mailto:rob.petti@gmail.com) +* [Rob Thomas](mailto:xrobau@gmail.com) * [Rob Watson](mailto:rfwatson@users.noreply.github.com) * [Robert Laurenz](mailto:8169746+laurenzcodes@users.noreply.github.com) +* [Rodion Borisov](mailto:vintprox@gmail.com) * [Rohan Sharma](mailto:i.am.lone.survivor@protonmail.com) * [Roni Laukkarinen](mailto:roni@laukkarinen.info) +* [Rose](mailto:83477269+ataridreams@users.noreply.github.com) +* [Rubicon Rowe](mailto:thislight@users.noreply.github.com) * [Ryan Freebern](mailto:ryan@freebern.org) * [Ryan Wade](mailto:ryan.wade@protonmail.com) * [Ryo Kajiwara](mailto:kfe-fecn6.prussian@s01.info) -* [S.H](mailto:gamelinks007@gmail.com) * [SJang1](mailto:git@sjang.dev) * [Sadiq Saif](mailto:staticsafe@users.noreply.github.com) +* [Sai](mailto:github@saizai.com) * [Sam Hewitt](mailto:hewittsamuel@gmail.com) +* [Samruddhi Khandale](mailto:samruddhikhandale@github.com) * [Samuel Kaiser](mailto:sk22@mailbox.org) +* [Santiago Kozak](mailto:santikzk1406@gmail.com) * [Sara Aimée Smiseth](mailto:51710585+sarasmiseth@users.noreply.github.com) * [Sara Golemon](mailto:pollita@php.net) * [Satoshi KOJIMA](mailto:skoji@mac.com) * [ScienJus](mailto:i@scienjus.com) * [Scott Larkin](mailto:scott@codeclimate.com) * [Scott Sweeny](mailto:scott@ssweeny.net) +* [Sean](mailto:64788907+seano-vs@users.noreply.github.com) * [Sean](mailto:sean@sean.taipei) +* [Sean Whalen](mailto:44679+seanthegeek@users.noreply.github.com) * [Sebastian Hübner](mailto:imolein@users.noreply.github.com) * [Sebastian Morr](mailto:sebastian@morr.cc) * [Sergei Č](mailto:noiwex1911@gmail.com) @@ -652,14 +770,19 @@ and provided thanks to the work of the following contributors: * [Shin Adachi](mailto:shn@glucose.jp) * [Shin Kojima](mailto:shin@kojima.org) * [Shouko Yu](mailto:imshouko@gmail.com) +* [Simon Elvery](mailto:simon@elvery.net) * [Sina Mashek](mailto:sina@mashek.xyz) +* [Skyler Hawthorne](mailto:skyler@dead10ck.com) * [Soft. Dev](mailto:24978+nileshkumar@users.noreply.github.com) * [Sophie Parker](mailto:dev@cortices.me) * [Soshi Kato](mailto:mail@sossii.com) * [Spanky](mailto:2788886+spankyworks@users.noreply.github.com) +* [Stan Hu](mailto:stanhu@gmail.com) * [Stanislas](mailto:stanislas.lange@pm.me) +* [Stanislav Dobrovolschii](mailto:uusername@protonmail.ch) * [StefOfficiel](mailto:pichard.stephane@free.fr) * [Stefano Pigozzi](mailto:ste.pigozzi@gmail.com) +* [Steven Munn](mailto:stevenjlm@users.noreply.github.com) * [Steven Tappert](mailto:admin@dark-it.net) * [Stéphane Guillou](mailto:stephane.guillou@member.fsf.org) * [Su Yang](mailto:soulteary@users.noreply.github.com) @@ -672,11 +795,14 @@ and provided thanks to the work of the following contributors: * [TakesxiSximada](mailto:takesxi.sximada@gmail.com) * [Tao Bror Bojlén](mailto:brortao@users.noreply.github.com) * [Taras Gogol](mailto:taras2358@gmail.com) +* [Terry Garcia](mailto:10190993+terrygarcia@users.noreply.github.com) * [The Stranjer](mailto:791672+thestranjer@users.noreply.github.com) * [TheInventrix](mailto:theinventrix@users.noreply.github.com) * [TheMainOne](mailto:50847364+theevilskeleton@users.noreply.github.com) +* [Thijs Kinkhorst](mailto:thijs@kinkhorst.com) * [Thomas Alberola](mailto:thomas@needacoffee.fr) * [Thomas Citharel](mailto:github@tcit.fr) +* [Tim Lucas](mailto:t@toolmantim.com) * [Toby Deshane](mailto:fortyseven@users.noreply.github.com) * [Toby Pinder](mailto:gigitrix@gmail.com) * [Tomonori Murakami](mailto:crosslife777@gmail.com) @@ -684,13 +810,14 @@ and provided thanks to the work of the following contributors: * [Tony Jiang](mailto:yujiang99@gmail.com) * [Treyssat-Vincent Nino](mailto:treyssatvincent@users.noreply.github.com) * [Truong Nguyen](mailto:truongnmt.dev@gmail.com) +* [Tyler Deitz](mailto:tylerdeitz@gmail.com) * [Udo Kramer](mailto:optik@fluffel.io) * [Una](mailto:una@unascribed.com) * [Ushitora Anqou](mailto:ushitora@anqou.net) * [Ushitora Anqou](mailto:ushitora_anqou@yahoo.co.jp) * [Valentin Lorentz](mailto:progval+git@progval.net) +* [Varun Sharma](mailto:varun999sharma@gmail.com) * [Vladimir Mincev](mailto:vladimir@canicinteractive.com) -* [Vyr Cossont](mailto:vyrcossont@users.noreply.github.com) * [Waldir Pimenta](mailto:waldyrious@gmail.com) * [Wenceslao Páez Chávez](mailto:wcpaez@gmail.com) * [Wesley Ellis](mailto:tahnok@gmail.com) @@ -714,8 +841,11 @@ and provided thanks to the work of the following contributors: * [Zach Neill](mailto:neillz@berea.edu) * [Zachary Spector](mailto:logicaldash@gmail.com) * [ZiiX](mailto:ziix@users.noreply.github.com) -* [asria-jp](mailto:is@alicematic.com) +* [aaaaalbert](mailto:aaaaalbert@users.noreply.github.com) +* [afontenot](mailto:adam.m.fontenot@gmail.com) +* [alfe](mailto:alfe10251+github@gmail.com) * [ava](mailto:vladooku@users.noreply.github.com) +* [awea](mailto:aweaoftheworld@gmail.com) * [benklop](mailto:benklop@gmail.com) * [bobbyd0g](mailto:93697464+bobbyd0g@users.noreply.github.com) * [bsky](mailto:git@imbsky.net) @@ -727,10 +857,10 @@ and provided thanks to the work of the following contributors: * [d0p1](mailto:dopi-sama@hush.com) * [dxwc](mailto:dxwc@users.noreply.github.com) * [eai04191](mailto:eai@mizle.net) +* [eggplants](mailto:w10776e8w@yahoo.co.jp) * [evilny0](mailto:evilny0@moomoocamp.net) * [febrezo](mailto:felixbrezo@gmail.com) * [fsubal](mailto:fsubal@users.noreply.github.com) -* [fusagiko / takayamaki](mailto:24884114+takayamaki@users.noreply.github.com) * [fusshi-](mailto:dikky1218@users.noreply.github.com) * [gentaro](mailto:gentaroooo@gmail.com) * [guigeekz](mailto:pattusg@gmail.com) @@ -753,20 +883,27 @@ and provided thanks to the work of the following contributors: * [kedama](mailto:32974885+kedamadq@users.noreply.github.com) * [keiya](mailto:keiya_21@yahoo.co.jp) * [kuro5hin](mailto:rusty@kuro5hin.org) +* [kyori19](mailto:kyori@accelf.net) +* [lenore gilbert](mailto:lenore@lenoregilbert.net) * [leo60228](mailto:leo@60228.dev) * [matildepark](mailto:matilde.park@pm.me) * [maxypy](mailto:maxime@mpigou.fr) * [mhe](mailto:mail@marcus-herrmann.com) +* [mhkhung](mailto:mhkhung@gmail.com) * [mickkael](mailto:19755421+mickkael@users.noreply.github.com) * [mike castleman](mailto:m@mlcastle.net) * [mimikun](mailto:dzdzble_effort_311@outlook.jp) * [mohemohe](mailto:mohemohe@users.noreply.github.com) +* [mon1kasenpai](mailto:ballaticseal@gmail.com) * [mshrtkch](mailto:mshrtkch@users.noreply.github.com) * [muan](mailto:muan@github.com) +* [n0toose](mailto:git@n0toose.net) * [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com) * [neetshin](mailto:neetshin@neetsh.in) +* [nemobis](mailto:federicoleva@tiscali.it) * [notozeki](mailto:notozeki@users.noreply.github.com) * [ntl-purism](mailto:57806346+ntl-purism@users.noreply.github.com) +* [nyura123dev](mailto:58617294+nyura123dev@users.noreply.github.com) * [nzws](mailto:git-yuzu@svk.jp) * [pea-sys](mailto:49807271+pea-sys@users.noreply.github.com) * [potpro](mailto:pptppctt@gmail.com) @@ -775,6 +912,7 @@ and provided thanks to the work of the following contributors: * [rcombs](mailto:rcombs@rcombs.me) * [roikale](mailto:roikale@users.noreply.github.com) * [rysiekpl](mailto:rysiek@hackerspace.pl) +* [s0](mailto:s0@s0.is) * [sasanquaneuf](mailto:sasanquaneuf@gmail.com) * [saturday06](mailto:dyob@lunaport.net) * [scd31](mailto:57571338+scd31@users.noreply.github.com) @@ -794,9 +932,13 @@ and provided thanks to the work of the following contributors: * [vpzomtrrfrt](mailto:vpzomtrrfrt@gmail.com) * [walfie](mailto:walfington@gmail.com) * [y-temp4](mailto:y.temp4@gmail.com) +* [y.takahashi](mailto:eai@mizle.net) +* [ymmtmdk](mailto:ymmtmdk@gmail.com) * [ymmtmdk](mailto:ymmtmdk@gmail.com) * [yoshipc](mailto:yoooo@yoshipc.net) +* [yufushiro](mailto:62991447+yufushiro@users.noreply.github.com) * [Özcan Zafer AYAN](mailto:ozcanzaferayan@gmail.com) +* [наб](mailto:nabijaczleweli@nabijaczleweli.xyz) * [ばん](mailto:detteiu0321@gmail.com) * [ふるふる](mailto:frfs@users.noreply.github.com) * [りんすき](mailto:6533808+rinsuki@users.noreply.github.com) @@ -815,951 +957,933 @@ This document is provided for informational purposes only. Since it is only upda Following people have contributed to translation of Mastodon: - GunChleoc (*Scottish Gaelic*) -- ケインツロ ⚧️👾🛸 (KNTRO) (*Spanish, Argentina*) -- Hồ Nhất Duy (honhatduy) (*Vietnamese*) -- Sveinn í Felli (sveinki) (*Icelandic*) -- Kristaps (Kristaps_M) (*Latvian*) +- KNTRO (*Spanish, Argentina*) +- honhatduy (*Vietnamese*) +- sveinki (*Icelandic*) +- Kristaps_M (*Latvian*) - NCAA (*Danish, French*) -- Zoltán Gera (gerazo) (*Hungarian*) -- ghose (XoseM) (*Galician, Spanish*) -- Jeong Arm (Kjwon15) (*Korean, Esperanto, Japanese, Spanish*) -- Emanuel Pina (emanuelpina) (*Portuguese*) +- gerazo (*Hungarian*) +- ghose (*Galician, Spanish*) +- Kjwon15 (*Esperanto, Japanese, Korean, Spanish*) +- emanuelpina (*Portuguese*) - Reyzadren (*Ido, Malay*) -- Thai Localization (thl10n) (*Thai*) +- thl10n (*Thai*) - Besnik_b (*Albanian*) -- Joene (joenepraat) (*Dutch*) -- Cyax (Cyaxares) (*Kurmanji (Kurdish)*) +- joenepraat (*Dutch*) +- Cyaxares (*Kurmanji (Kurdish)*) - adrmzz (*Sardinian*) -- Ramdziana F Y (rafeyu) (*Indonesian*) +- rafeyu (*Indonesian*) - xatier (*Chinese Traditional, Chinese Traditional, Hong Kong*) -- qezwan (*Sorani (Kurdish), Persian*) +- qezwan (*Persian, Sorani (Kurdish)*) - spla (*Catalan, Spanish*) -- ButterflyOfFire (BoFFire) (*Arabic, French, Kabyle*) -- Martin (miles) (*Slovenian*) -- නාමල් ජයසිංහ (nimnaya) (*Sinhala*) -- Asier Iturralde Sarasola (aldatsa) (*Basque*) -- Ondřej Pokorný (unextro) (*Czech*) +- BoFFire (*Arabic, French, Kabyle*) +- miles (*Slovenian*) +- nimnaya (*Sinhala*) +- aldatsa (*Basque*) +- unextro (*Czech*) - Roboron (*Spanish*) - taicv (*Vietnamese*) - koyu (*German*) -- Daniele Lira Mereb (danilmereb) (*Portuguese, Brazilian*) -- T. E. Kalaycı (tekrei) (*Turkish*) -- Evert Prants (IcyDiamond) (*Estonian*) -- Yair Mahalalel (yairm) (*Hebrew*) -- Ihor Hordiichuk (ihor_ck) (*Ukrainian*) -- Alessandro Levati (Oct326) (*Italian*) -- Kimmo Kujansuu (mrkujansuu) (*Finnish*) -- Alix Rossi (palindromordnilap) (*Corsican, Esperanto, French*) -- Danial Behzadi (danialbehzadi) (*Persian*) -- stan ionut (stanionut12) (*Romanian*) -- Mastodon 中文译者 (mastodon-linguist) (*Chinese Simplified*) -- Kristijan Tkalec (lapor) (*Slovenian*) -Alexander Sorokin (Brawaru) (*Russian, Vietnamese, Swedish, Portuguese, Tamil, Kabyle, Polish, Italian, Catalan, Armenian, Hungarian, Albanian, Greek, Galician, Korean, Ukrainian, German, Danish, French*) +- danilmereb (*Portuguese, Brazilian*) +- tekrei (*Turkish*) +- IcyDiamond (*Estonian*) +- yairm (*Hebrew*) +- ihor_ck (*Ukrainian*) +- Oct326 (*Italian*) +- mrkujansuu (*Finnish*) +- palindromordnilap (*Corsican, Esperanto, French*) +- danialbehzadi (*Persian*) +- stanionut12 (*Romanian*) +- mastodon-linguist (*Chinese Simplified*) +- lapor (*Slovenian*) +- Brawaru (*Albanian, Armenian, Catalan, Danish, French, Galician, German, Greek, Hungarian, Italian, Kabyle, Korean, Polish, Portuguese, Russian, Swedish, Tamil, Ukrainian, Vietnamese*) - ManeraKai (*Arabic*) -- мачко (ma4ko) (*Bulgarian*) +- ma4ko (*Bulgarian*) +- Rhoslyn (*Welsh*) - kamee (*Armenian*) -- Yamagishi Kazutoshi (ykzts) (*Japanese, Icelandic, Sorani (Kurdish), Albanian, Vietnamese, Chinese Simplified*) -- Takeçi (polygoat) (*French, Italian*) +- ykzts (*Albanian, Chinese Simplified, Icelandic, Japanese, Sorani (Kurdish), Vietnamese*) +- polygoat (*French, Italian*) - REMOVED_USER (*Czech*) - borys_sh (*Ukrainian*) -- Imre Kristoffer Eilertsen (DandelionSprout) (*Norwegian*) -- Marek Ľach (mareklach) (*Slovak, Polish*) -- yeft (*Chinese Traditional, Hong Kong, Chinese Traditional*) -- D. Cederberg (cederberget) (*Swedish*) -- Miguel Mayol (mitcoes) (*Spanish, Catalan*) +- DandelionSprout (*Norwegian*) +- mareklach (*Polish, Slovak*) +- yeft (*Chinese Traditional, Chinese Traditional, Hong Kong*) +- cederberget (*Swedish*) +- mitcoes (*Catalan, Spanish*) - enolp (*Asturian*) -- Manuel Viens (manuelviens) (*French*) +- manuelviens (*French*) - cybergene (*Japanese*) - REMOVED_USER (*Turkish*) - xpil (*Polish, Scottish Gaelic*) -- Balázs Meskó (mesko.balazs) (*Hungarian, Czech*) -- Koala Yeung (yookoala) (*Chinese Traditional, Hong Kong*) +- mesko.balazs (*Czech, Hungarian*) +- yookoala (*Chinese Traditional, Hong Kong*) - Osoitz (*Basque*) -- Amir Rubinstein - TAU (AmirrTAU) (*Hebrew, Indonesian*) -- Maya Minatsuki (mayaeh) (*Japanese*) -- Peterandre (*Norwegian Nynorsk, Norwegian*) -Mélanie Chauvel (ariasuni) (*French, Esperanto, Norwegian Nynorsk, Persian, Kabyle, Sardinian, Corsican, Breton, Portuguese, Brazilian, Arabic, Chinese Simplified, Ukrainian, Slovenian, Greek, German, Czech, Hungarian*) +- AmirrTAU (*Hebrew, Indonesian*) +- mayaeh (*Japanese*) +- Peterandre (*Norwegian, Norwegian Nynorsk*) +- ariasuni (*Arabic, Breton, Chinese Simplified, Corsican, Czech, Esperanto, French, German, Greek, Hungarian, Kabyle, Norwegian Nynorsk, Persian, Portuguese, Brazilian, Sardinian, Slovenian, Ukrainian*) - tzium (*Sardinian*) - Diluns (*Occitan*) -- Galician Translator (Galician_translator) (*Galician*) -- Marcin Mikołajczak (mkljczkk) (*Polish, Czech, Russian*) -- Jeff Huang (s8321414) (*Chinese Traditional*) -- Pixelcode (realpixelcode) (*German*) -- Allen Zhong (AstroProfundis) (*Chinese Simplified*) +- REMOVED_USER (*Galician*) +- mkljczkk (*Czech, Polish, Russian*) +- s8321414 (*Chinese Traditional*) +- realpixelcode (*German*) - lamnatos (*Greek*) -- Sean Young (assanges) (*Chinese Traditional*) +- AstroProfundis (*Chinese Simplified*) +- assanges (*Chinese Traditional*) - retiolus (*Catalan, French, Spanish*) - tolstoevsky (*Russian*) -- Ali Demirtaş (alidemirtas) (*Turkish*) -- J. Cam Andrever-Wright (gourmas) (*Cornish*) +- alidemirtas (*Turkish*) +- gourmas (*Cornish*) - coxde (*Chinese Simplified*) - Dremski (*Bulgarian*) - gagik_ (*Armenian*) -- Masoud Abkenar (mabkenar) (*Persian*) +- mabkenar (*Persian*) - arshat (*Kazakh*) -- Ira (seefood) (*Hebrew*) +- seefood (*Hebrew*) - Linerly (*Indonesian*) -- Blak Ouille (BlakOuille16) (*French*) -- e (diveedd) (*Kurmanji (Kurdish)*) -- Em St Cenydd (cancennau) (*Welsh*) -- Tigran (tigransimonyan) (*Armenian*) +- BlakOuille16 (*French*) +- diveedd (*Kurmanji (Kurdish)*) +- cancennau (*Welsh*) +- lisawe (*Norwegian*) +- tigransimonyan (*Armenian*) - Draacoun (*Portuguese, Brazilian*) -- REMOVED_USER (*Turkish*) -- Nurul Azeera Hidayah @ Muhammad Nur Hidayat Yasuyoshi (MNH48.moe) (mnh48) (*Malay*) -- Tagomago (tagomago) (*Spanish, French*) -- Ashun (ashune) (*Croatian*) +- mnh48 (*Malay*) +- tagomago (*French, Spanish*) +- ashune (*Croatian*) - Aditoo17 (*Czech*) - vishnuvaratharajan (*Tamil*) - pulmonarycosignerkindness (*Swedish*) - calypsoopenmail (*French*) - REMOVED_USER (*Kabyle*) - snerk (*Norwegian Nynorsk*) -- Sebastian (SebastianBerlin) (*German*) -- lisawe (*Norwegian*) +- TranslatorDE (*German*) - serratrad (*Catalan*) - Bran_Ruz (*Breton*) -- ViktorOn (*Russian, Danish*) +- ViktorOn (*Danish, Russian*) - Gearguy (*Finnish*) -- Andi Chandler (andibing) (*English, United Kingdom*) -- Tor Egil Hoftun Kvæstad (Taloran) (*Norwegian Nynorsk*) +- andibing (*English, United Kingdom*) +- Taloran (*Norwegian Nynorsk*) - GiorgioHerbie (*Italian*) -- හෙළබස සමූහය (HelaBasa) (*Sinhala*) -- kat (katktv) (*Ukrainian, Russian*) -- Yi-Jyun Pan (pan93412) (*Chinese Traditional*) -- Fjoerfoks (fryskefirefox) (*Frisian, Dutch*) -- Eshagh (eshagh79) (*Persian*) +- HelaBasa (*Sinhala*) +- philip-khor (*English, United Kingdom, Malay*) +- katktv (*Russian, Ukrainian*) +- pan93412 (*Chinese Traditional*) +- fryskefirefox (*Dutch, Frisian*) +- eshagh79 (*Persian*) - regulartranslator (*Portuguese, Brazilian*) - Saederup92 (*Danish*) -- ozzii (Serbian (Cyrillic), French) -- Irfan (Irfan_Radz) (*Malay*) +- ozzii (*French, Serbian (Cyrillic)*) +- Irfan_Radz (*Malay*) - ClearlyClaire (*French, Icelandic*) -- Sokratis Alichanidis (alichani) (*Greek*) -- Jiří Podhorecký (trendspotter) (*Czech*) -- Akarshan Biswas (biswasab) (*Bengali, Sanskrit*) -- Robert Wolniak (Szkodnix) (*Polish*) -- Jan Lindblom (janlindblom) (*Swedish*) -- Dewi (Unkorneg) (*Breton, French*) -- Kristoffer Grundström (Umeaboy) (*Swedish*) -- Rafael H L Moretti (Moretti) (*Portuguese, Brazilian*) +- alichani (*Greek*) +- trendspotter (*Czech*) +- biswasab (*Bengali, Sanskrit*) +- Szkodnix (*Polish*) +- janlindblom (*Swedish*) +- Unkorneg (*Breton, French*) +- Umeaboy (*Swedish*) +- Moretti (*Portuguese, Brazilian*) - d5Ziif3K (*Ukrainian*) -- Nemu (Dormemulo) (*Esperanto, French, Italian, Ido, Afrikaans*) -- Johan Mynhardt (johanmynhardt) (*Afrikaans*) -- Rojdayek (Kurmanji (Kurdish)) +- Dormemulo (*Afrikaans, Esperanto, French, Ido, Italian*) +- johanmynhardt (*Afrikaans*) +- Rojdayek (*Kurmanji (Kurdish)*) - REMOVED_USER (*Portuguese, Brazilian*) - GCardo (*Portuguese, Brazilian*) - christalleras (*Norwegian Nynorsk*) - diorama (*Italian*) -- Jaz-Michael King (jazmichaelking) (*Welsh*) -- Catalina (catalina.st) (*Romanian*) -- Ryo (DrRyo) (*Korean*) -- otrapersona (*Spanish, Mexico, Spanish*) -- Frontier Translation Ltd. (frontier-translation) (*Chinese Simplified*) -- Mauzi (*Swedish, German*) +- jazmichaelking (*Welsh*) +- catalina.st (*Romanian*) +- DrRyo (*Korean*) +- otrapersona (*Spanish, Spanish, Mexico*) +- frontier-translation (*Chinese Simplified*) +- Mauzi (*German, Swedish*) - Clopsy87 (*Italian*) - atarashiako (*Chinese Simplified*) +- 101010pl (*Polish*) - erictapen (*German*) -- zhen liao (az0189re) (*Chinese Simplified*) -- 101010 (101010pl) (*Polish*) +- az0189re (*Chinese Simplified*) - REMOVED_USER (*Norwegian*) - axi (*Finnish*) - silkevicious (*Italian*) -- Floxu (fredrikdim1) (*Norwegian Nynorsk*) -- Nic Dafis (nicdafis) (*Welsh*) -- NadieAishi (*Spanish, Mexico, Spanish*) -- 戸渡生野 (aomyouza2543) (*Thai*) -- Tjipke van der Heide (vancha) (*Frisian*) -- Erik Mogensen (mogsie) (*Norwegian*) +- nicdafis (*Welsh*) +- fredrikdim1 (*Norwegian Nynorsk*) +- NadieAishi (*Spanish, Spanish, Mexico*) +- aomyouza2543 (*Thai*) +- vancha (*Frisian*) +- mogsie (*Norwegian*) - pomoch (*Chinese Traditional, Hong Kong*) -- Alexandre Brito (alexbrito) (*Portuguese, Brazilian*) -- Bertil Hedkvist (Berrahed) (*Swedish*) -- William(ѕ)ⁿ (wmlgr) (*Spanish*) +- alexbrito (*Portuguese, Brazilian*) +- Berrahed (*Swedish*) +- wmlgr (*Spanish*) - LNDDYL (*Chinese Traditional*) - tanketom (*Norwegian Nynorsk*) - norayr (*Armenian*) -- l3ycle (*German*) - strubbl (*German*) -- Satnam S Virdi (pika10singh) (*Punjabi*) -- Tiago Epifânio (tfve) (*Portuguese*) -- Mentor Gashi (mentorgashi.com) (*Albanian*) -- Sid (autinerd1) (*Dutch, German*) +- l3ycle (*German*) +- pika10singh (*Punjabi*) +- tfve (*Portuguese*) +- mentorgashi.com (*Albanian*) +- autinerd1 (*Dutch, German*) - carolinagiorno (*Portuguese, Brazilian*) -- Em_i (emiliencoss) (*French*) -- Liam O (liamoshan) (*Irish*) -- Hayk Khachatryan (brutusromanus123) (*Armenian*) -- Roby Thomas (roby.thomas) (*Malayalam*) +- emiliencoss (*French*) +- liamoshan (*Irish*) +- Gim_Garam (*Korean*) +- brutusromanus123 (*Armenian*) +- roby.thomas (*Malayalam*) - ThonyVezbe (*Breton*) -- Percy (kecrily) (*Chinese Simplified*) -- Bharat Kumar (Marwari) (*Hindi*) -- Austra Muizniece (aus_m) (*Latvian*) -- Urubu Lageano (urubulageano) (*Portuguese, Brazilian*) -- Just Spanish (7_7) (*Spanish, Mexico*) +- kecrily (*Chinese Simplified*) +- Marwari (*Hindi*) +- aus_m (*Latvian*) +- urubulageano (*Portuguese, Brazilian*) +- 7_7 (*Spanish, Mexico*) - v4vachan (*Malayalam*) - bilfri (*Danish*) -- IamHappy (mrmx2013) (*Ukrainian*) +- mrmx2013 (*Ukrainian*) - dkdarshan760 (*Sanskrit*) -- Timur Seber (seber) (*Tatar*) -- Slimane Selyan AMIRI (SelyanKab) (*Kabyle*) +- seber (*Tatar*) +- SelyanKab (*Kabyle*) - VaiTon (*Italian*) - tykayn (*French*) -- Abdulaziz Aljaber (kuwaitna) (*Arabic*) +- kuwaitna (*Arabic*) - taoxvx (*Danish*) -- Hrach Mkrtchyan (hrachmk) (*Armenian*) -- sabri (thetomatoisavegetable) (*Spanish, Spanish, Argentina*) -- CoelacanthusHex (*Chinese Simplified*) -- Rhys Harrison (rhedders) (*Esperanto*) -- syncopams (*Chinese Traditional, Hong Kong, Chinese Traditional, Chinese Simplified*) +- hrachmk (*Armenian*) +- thetomatoisavegetable (*Spanish, Spanish, Argentina*) +- Coelacanthus (*Chinese Simplified*) +- rhedders (*Esperanto*) +- syncopams (*Chinese Simplified, Chinese Traditional, Chinese Traditional, Hong Kong*) - SteinarK (*Norwegian Nynorsk*) +- vagnes (*Norwegian, Norwegian Nynorsk*) - REMOVED_USER (*Standard Moroccan Tamazight*) -- Maxine B. Vågnes (vagnes) (*Norwegian, Norwegian Nynorsk*) -- Rikard Linde (rikardlinde) (*Swedish*) +- rikardlinde (*Swedish*) - ahangarha (*Persian*) -- Lalo Tafolla (lalotafo) (*Spanish, Spanish, Mexico*) -- Larissa Cruz (larissacruz) (*Portuguese, Brazilian*) -- dashersyed (Urdu (Pakistan)) +- lalotafo (*Spanish, Spanish, Mexico*) +- larissacruz (*Portuguese, Brazilian*) +- dashersyed (*Urdu (Pakistan)*) - camerongreer21 (*English, United Kingdom*) - REMOVED_USER (*Ukrainian*) -- Conight Wang (xfddwhh) (*Chinese Simplified*) +- xfddwhh (*Chinese Simplified*) - liffon (*Swedish*) -- Damjan Dimitrioski (gnud) (*Macedonian*) +- gnud (*Macedonian*) - rondnunes (*Portuguese, Brazilian*) - PPNplus (*Thai*) -- Steven Ritchie (Steaph38) (*Scottish Gaelic*) -- 游荡 (MamaShip) (*Chinese Simplified*) -- Edward Navarro (EdwardNavarro) (*Spanish*) +- Steaph38 (*Scottish Gaelic*) +- MamaShip (*Chinese Simplified*) +- EdwardNavarro (*Spanish*) - shioko (*Chinese Simplified*) - gnu-ewm (*Polish*) -- Kahina Mess (K_hina) (*Kabyle*) -- Hexandcube (hexandcube) (*Polish*) -- Scott Starkey (yekrats) (*Esperanto*) +- K_hina (*Kabyle*) +- hexandcube (*Polish*) +- yekrats (*Esperanto*) - ZiriSut (*Kabyle*) - FreddyG (*Esperanto*) -- mynameismonkey (*Welsh*) -- Groosha (groosha) (*Russian*) -- Gwenn (Belvar) (*Breton*) +- jmking (*Welsh*) +- groosha (*Russian*) +- toba (*German*) +- Belvar (*Breton*) - StanleyFrew (*French*) - cathalgarvey (*Irish*) -- Nikita Epifanov (Nikets) (*Russian*) +- Nikets (*Russian*) - REMOVED_USER (*Finnish*) - jaranta (*Finnish*) -- Slobodan Simić (Слободан Симић) (slsimic) (*Serbian (Cyrillic)*) -- iVampireSP (*Chinese Traditional, Chinese Simplified*) -- Felicia Jongleur (midsommar) (*Swedish*) -- Denys (dector) (*Ukrainian*) -- Mo_der Steven (SakuraPuare) (*Chinese Simplified*) +- slsimic (*Serbian (Cyrillic)*) +- iVampireSP (*Chinese Simplified, Chinese Traditional*) +- midsommar (*Swedish*) +- dector (*Ukrainian*) +- SakuraPuare (*Chinese Simplified*) - REMOVED_USER (*German*) -- Kishin Sagume (kishinsagi) (*Chinese Simplified*) +- kishinsagi (*Chinese Simplified*) - bennepharaoh (*Chinese Simplified*) - Vanege (*Esperanto*) -- hibiya inemuri (hibiya) (*Korean*) -- Jess Rafn (therealyez) (*Danish*) -- Stasiek Michalski (hellcp) (*Polish*) +- hibiya (*Korean*) +- therealyez (*Danish*) +- hellcp (*Polish*) - dxwc (*Bengali*) -- Heran Membingung (heranmembingung) (*Indonesian*) - Parodper (*Galician*) +- filbert (*Indonesian*) - rbnval (*Catalan*) +- jmontane (*Catalan*) - Liboide (*Spanish*) - hemnaren (*Norwegian Nynorsk*) -- jmontane (*Catalan*) -- Andy Kleinert (AndyKl) (*German*) -- Chris Kay (chriskarasoulis) (*Greek*) +- AndyKl (*German*) +- Acursen (*German*) +- chriskarasoulis (*Greek*) - CrowdinBRUH (*Vietnamese*) -- Rhoslyn Prys (Rhoslyn) (*Welsh*) -- abidin toumi (Zet24) (*Arabic*) -- Johan Schiff (schyffel) (*Swedish*) -- Rex_sa (rex07) (*Arabic*) +- Zet24 (*Arabic*) +- schyffel (*Swedish*) +- rex07 (*Arabic*) - amedcj (*Kurmanji (Kurdish)*) -- Arunmozhi (tecoholic) (*Tamil*) -- zer0-x (ZER0-X) (*Arabic*) +- tecoholic (*Tamil*) +- zer0-x (*Arabic*) - staticnoisexyz (*Czech*) -- Lauren Liberda (selfisekai) (*Polish*) -- Michael Zeevi (maze88) (*Hebrew*) +- cuu508 (*Latvian*) +- selfisekai (*Polish*) +- maze88 (*Hebrew*) - oti4500 (*Hungarian, Ukrainian*) -- Delta (Delta-Time) (*Japanese*) -- Marc Antoine Thevenet (MATsxm) (*French*) -- AlexKoala (alexkoala) (*Korean*) +- Delta-Time (*Japanese*) +- MATsxm (*French*) +- alexkoala (*Korean*) - SarfarazAhmed (*Urdu (Pakistan)*) -- Ahmad Dakhlallah (MIUIArabia) (*Arabic*) -- Mats Gunnar Ahlqvist (goqbi) (*Swedish*) +- ahmadafef (*Arabic*) +- goqbi (*Swedish*) - diazepan (*Spanish, Spanish, Argentina*) -- Tiger:blank (tsagaanbar) (*Chinese Simplified*) -- REMOVED_USER (*Chinese Simplified*) +- tsagaanbar (*Chinese Simplified*) - marzuquccen (*Kabyle*) +- REMOVED_USER (*Chinese Simplified*) - atriix (*Swedish*) -- Laur (melaur) (*Romanian*) -- VictorCorreia (victorcorreia1984) (*Afrikaans*) -- Remito (remitocat) (*Japanese*) -- Juan José Salvador Piedra (JuanjoSalvador) (*Spanish*) -- REMOVED_USER (*Norwegian*) -- 森の子リスのミーコの大冒険 (Phroneris) (*Japanese*) -- Gim_Garam (*Korean*) +- melaur (*Romanian*) +- victorcorreia1984 (*Afrikaans*) +- remitocat (*Japanese*) +- JuanjoSalvador (*Spanish*) +- Phroneris (*Japanese*) - BurekzFinezt (*Serbian (Cyrillic)*) -- Pēteris Caune (cuu508) (*Latvian*) +- lancet (*Irish*) - asnomgtu (*Hungarian*) - bendigeidfran (*Welsh*) - SHeija (*Finnish*) -- Врабац (Slovorad) (*Serbian (Cyrillic)*) -- Dženan (Dzenan) (*Swedish*) -- Gabriel Beecham (lancet) (*Irish*) +- Dzenan (*Swedish*) +- Slovorad (*Serbian (Cyrillic)*) +- isaac.97_WT (*Spanish*) - antonyho (*Chinese Traditional, Hong Kong*) -- Jack R (isaac.97_WT) (*Spanish*) -- Henrik Mattsson-Mårn (rchk) (*Swedish*) -- Oguzhan Aydin (aoguzhan) (*Turkish*) -- Soran730 (*Chinese Simplified*) -- andruhov (*Ukrainian, Russian*) -- 北䑓如法 (Nyoho) (*Japanese*) +- rchk (*Swedish*) +- aoguzhan (*Turkish*) +- andruhov (*Russian, Ukrainian*) +- Nyoho (*Japanese*) - phena109 (*Chinese Traditional, Hong Kong*) -- Aryamik Sharma (Aryamik) (*Hindi, Swedish*) +- Aryamik (*Hindi, Swedish*) - Unmual (*Spanish*) -- Tobias Bannert (toba) (*German*) -- Adrián Graña (alaris83) (*Spanish*) +- agrana (*Spanish*) - vpei (*Chinese Simplified*) - cruz2020 (*Portuguese*) -- papapep (h9f2ycHh-ktOd6_Y) (*Catalan*) -- Roj (roj1512) (*Sorani (Kurdish), Kurmanji (Kurdish)*) -- るいーね (ruine) (*Japanese*) -- aujawindar (*Norwegian Nynorsk*) +- h9f2ycHh-ktOd6_Y (*Catalan*) +- roj1512 (*Kurmanji (Kurdish), Sorani (Kurdish)*) +- ruine (*Japanese*) - irithys (*Chinese Simplified*) -- Sam Tux (imahbub) (*Bengali*) +- aujawindar (*Norwegian Nynorsk*) +- imahbub (*Bengali*) - igordrozniak (*Polish*) -- Johannes Nilsson (nlssn) (*Swedish*) -- Michał Sidor (michcioperz) (*Polish*) -- Isaac Huang (caasih) (*Chinese Traditional*) -- AW Unad (awcodify) (*Indonesian*) +- nlssn (*Swedish*) +- michcioperz (*Polish*) +- caasih (*Chinese Traditional*) +- stromholm (*Swedish*) +- awcodify (*Indonesian*) - 1Alino (*Slovak*) -- Cutls (cutls) (*Japanese*) -- Goudarz Jafari (GoudarzJafari) (*Persian*) -- Daniel Strömholm (stromholm) (*Swedish*) -- 1 (Ipsumry) (*Spanish*) -- Falling Snowdin (tghgg) (*Vietnamese*) -- Paulino Michelazzo (pmichelazzo) (*Portuguese, Brazilian*) -- Y.Yamashiro (uist1idrju3i) (*Japanese*) -- Rasmus Lindroth (RasmusLindroth) (*Swedish*) -- Gianfranco Fronteddu (gianfro.gianfro) (*Sardinian*) -- Andrea Lo Iacono (niels0n) (*Italian*) +- cutls (*Japanese*) +- GoudarzJafari (*Persian*) +- Ipsumry (*Spanish*) +- tghgg (*Vietnamese*) +- pmichelazzo (*Portuguese, Brazilian*) +- uist1idrju3i (*Japanese*) +- RasmusLindroth (*Swedish*) +- gianfro.gianfro (*Sardinian*) +- niels0n (*Italian*) - fucsia (*Italian*) -- Vedran Serbu (SerenoXGen) (*Croatian*) -- Raphael Das Gupta (das-g) (*Esperanto, German*) +- SerenoXGen (*Croatian*) +- das-g (*Esperanto, German*) - yanchan09 (*Estonian*) - ainmeolai (*Irish*) -- REMOVED_USER (*Norwegian*) +- kinshuksunil (*Hindi*) - mian42 (*Bulgarian*) -- Kinshuk Sunil (kinshuksunil) (*Hindi*) +- ullasjoseph (*Malayalam*) - al_._ (*German, Russian*) -- Ullas Joseph (ullasjoseph) (*Malayalam*) - sanoth (*Swedish*) -- Aftab Alam (iaftabalam) (*Hindi*) +- iaftabalam (*Hindi*) - frumble (*German*) -- juanda097 (juanda-097) (*Spanish*) -- Matthías Páll Gissurarson (icetritlo) (*Icelandic*) -- Russian Retro (retrograde) (*Russian*) +- juanda-097 (*Spanish*) +- icetritlo (*Icelandic*) +- retrograde (*Russian*) +- tedliou (*Chinese Traditional*) - KcKcZi (*Chinese Simplified*) -- Yu-Pai Liu (tedliou) (*Chinese Traditional*) -- Amarin Cemthong (acitmaster) (*Thai*) +- acitmaster (*Thai*) - Etinew (*Hebrew*) - xsml (*Chinese Simplified*) -- S.J. L. (samijuhanilii) (*Finnish*) +- samijuhanilii (*Finnish*) - Anunnakey (*Macedonian*) - erikkemp (*Dutch*) -- Tsl (muun) (*Chinese Simplified*) -- Renato "Lond" Cerqueira (renatolond) (*Portuguese, Brazilian*) -- Úna-Minh Kavanagh (yunitex) (*Irish*) +- renatolond (*Portuguese, Brazilian*) +- muun (*Chinese Simplified*) +- yunitex (*Irish*) - kongk (*Norwegian Nynorsk*) - erikstl (*Esperanto*) - twpenguin (*Chinese Traditional*) +- bobchao (*Chinese Traditional*) - JeremyStarTM (*German*) -- Po-chiang Chao (bobchao) (*Chinese Traditional*) -- Marcus Myge (mygg-priv) (*Norwegian*) -- Esther (esthermations) (*Portuguese*) -- Jiri Grönroos (spammemoreplease) (*Finnish*) +- IetsMooi (*Norwegian*) - MadeInSteak (*Finnish*) +- esthermations (*Portuguese*) +- spammemoreplease (*Finnish*) - witoharmuth (*Swedish*) -- MESHAL45 (*Arabic*) - mcdutchie (*Dutch*) -- Michal Špondr (michalspondr) (*Czech*) +- MESHAL45 (*Arabic*) +- michalspondr (*Czech*) - t_aus_m (*German*) -- kaki7777 (*Japanese, Chinese Traditional*) -- Heimen Stoffels (Vistaus) (*Dutch*) -- serapolis (*Chinese Traditional, Hong Kong, Chinese Traditional, Japanese, Chinese Simplified*) -- Rajarshi Guha (rajarshiguha) (*Bengali*) -- Amir Reza (ElAmir) (*Persian*) -- REMOVED_USER (*Norwegian*) -- MohammadSaleh Kamyab (mskf1383) (*Persian*) +- Vistaus (*Dutch*) +- serapolis (*Chinese Simplified, Chinese Traditional, Chinese Traditional, Hong Kong, Japanese*) +- kaki7777 (*Chinese Traditional, Japanese*) +- rajarshiguha (*Bengali*) +- ElAmir (*Persian*) - REMOVED_USER (*Romanian*) -- Gopal Sharma (gopalvirat) (*Hindi*) -- Вероніка Някшу (pampushkaveronica) (*Russian, Romanian*) -- Linnéa (lesbian_subnet) (*Swedish*) -- Valentin (HDValentin) (*German*) +- mskf1383 (*Persian*) +- gopalvirat (*Hindi*) +- lesbian_subnet (*Swedish*) +- pampushkaveronica (*Romanian, Russian*) +- HDValentin (*German*) - dragnucs2 (*Arabic*) -- Carlos Solís (csolisr) (*Esperanto*) -- Tofiq Abdula (Xwla) (*Sorani (Kurdish)*) +- csolisr (*Esperanto*) +- Xwla (*Sorani (Kurdish)*) - halcek (*Slovak*) -- Tobias Kunze (rixxian) (*German*) -- Parthan S Ramanujam (parthan) (*Tamil*) -- Kasper Nymand (KasperNymand) (*Danish*) -- TS (morte) (*Finnish*) -- REMOVED_USER (*German*) +- parthan (*Tamil*) +- rixxian (*German*) +- KasperNymand (*Danish*) - REMOVED_USER (*Basque*) +- morte (*Finnish*) - subram (*Turkish*) -- Gudwin (*Spanish, Mexico, Spanish*) -- Ptrcmd (ptrcmd) (*Chinese Traditional*) -- shmuelHal (*Hebrew*) +- Gudwin (*Spanish, Spanish, Mexico*) - SensDeViata (*Ukrainian*) +- ptrcmd (*Chinese Traditional*) +- shmuelHal (*Hebrew*) - megaleo (*Portuguese, Brazilian*) -- Acursen (*German*) -- NurKai Kai (nurkaiyttv) (*German*) -- Guttorm (ghveem) (*Norwegian Nynorsk*) +- nurkaiyttv (*German*) - SergioFMiranda (*Portuguese, Brazilian*) -- Danni Lundgren (dannilundgren) (*Danish*) -- Vivek K J (Vivekkj) (*Malayalam*) +- ghveem (*Norwegian Nynorsk*) +- dannilundgren (*Danish*) - hiroTS (*Chinese Traditional*) -- teadesu (*Portuguese, Brazilian*) +- Vivekkj (*Malayalam*) +- fnogcps (*Portuguese, Brazilian*) - petartrajkov (*Macedonian*) -- Ariel Costas (arielcostas3) (*Galician*) -- Ch. (sftblw) (*Korean*) +- arielcostas (*Galician*) +- sftblw (*Korean*) - Rintan (*Japanese*) -- Jair Henrique (jairhenrique) (*Portuguese, Brazilian*) - sorcun (*Turkish*) +- jairhenrique (*Portuguese, Brazilian*) - filippodb (*Italian*) - johne32rus23 (*Russian*) -- OctolinGamer (octolingamer) (*Portuguese, Brazilian*) +- octolingamer (*Portuguese, Brazilian*) - AzureNya (*Chinese Simplified*) -- Ram varma (ram4varma) (*Tamil*) -- REMOVED_USER (Sorani (Kurdish)) -- REMOVED_USER (*Portuguese, Brazilian*) +- ram4varma (*Tamil*) +- REMOVED_USER (*Sorani (Kurdish)*) - seanmhade (*Irish*) - sanser (*Russian*) -- Vijay (vijayatmin) (*Tamil*) +- vijayatmin (*Tamil*) - Anomalion (*German*) -- Pukima (Pukimaa) (*German*) -- Curtis Lee (CansCurtis) (*Chinese Traditional*) -- โบโลน่าไวรัส (nullxyz_) (*Thai*) -- ふぁーらんど (farland1717) (*Japanese*) +- Pukimaa (*German*) +- nullxyz_ (*Thai*) +- CansCurtis (*Chinese Traditional*) +- farland1717 (*Japanese*) - 3wen (*Breton*) +- rahmatullinailzira53 (*Tatar*) - rlafuente (*Portuguese*) -- Ильзира Рахматуллина (rahmatullinailzira53) (*Tatar*) -- Code Man (codemansrc) (*Russian*) -- Philip Gillißen (guerda) (*German*) -- Daniel Dimitrov (daniel.dimitrov) (*Bulgarian*) -- Anton (atjn) (*Danish*) +- codemansrc (*Russian*) +- guerda (*German*) +- daniel.dimitrov (*Bulgarian*) +- atjn (*Danish*) - kekkepikkuni (*Tamil*) - MODcraft (*Chinese Simplified*) - oorsutri (*Tamil*) +- NeoChen1024 (*Chinese Traditional*) - wortfeld (*German*) -- Neo_Chen (NeoChen1024) (*Chinese Traditional*) -- Stereopolex (*Polish*) - NxOne14 (*Bulgarian*) -- Juan Ortiz (Kloido) (*Spanish, Catalan*) -- Nithin V (Nithin896) (*Tamil*) +- Stereopolex (*Polish*) +- Kloido (*Catalan, Spanish*) +- Nithin896 (*Tamil*) - strikeCunny2245 (*Icelandic*) -- Miro Rauhala (mirorauhala) (*Finnish*) -- nicoduesing (duconi) (*German, Esperanto*) -- Gnonthgol (*Norwegian Nynorsk*) -- WKobes (*Dutch*) +- mirorauhala (*Finnish*) +- duconi (*Esperanto, German*) - Oymate (*Bengali*) +- WKobes (*Dutch*) +- Gnonthgol (*Norwegian Nynorsk*) +- EzigboOmenana (*Cornish, Igbo*) - mikwee (*Hebrew*) -- EzigboOmenana (*Igbo, Cornish*) -- yan Wato (janWato) (*Hindi*) +- janWato (*Hindi*) - Papuass (*Latvian*) -- Vincent Orback (vincentorback) (*Swedish*) +- vincentorback (*Swedish*) +- nineteen (*Chinese Simplified*) - chettoy (*Chinese Simplified*) -- 19 (nineteen) (*Chinese Simplified*) -- ಚಿರಾಗ್ ನಟರಾಜ್ (chiraag-nataraj) (*Kannada*) -- Layik Hama (layik) (*Sorani (Kurdish)*) -- Guillaume Turchini (orion78fr) (*French*) -- Andri Yngvason (andryng) (*Icelandic*) -- Aswin C (officialcjunior) (*Malayalam*) -- Yuval Nehemia (yuvalne) (*Hebrew*) -- mawoka-myblock (mawoka) (*German*) -- Ganesh D (auntgd) (*Marathi*) -- Lens0021 (lens0021) (*Korean*) -- An Gafraíoch (angafraioch) (*Irish*) -- Michael Smith (michaelshmitty) (*Dutch*) -- Ryan Ho (koungho) (*Chinese Traditional*) +- chiraag-nataraj (*Kannada*) +- layik (*Sorani (Kurdish)*) +- orion78fr (*French*) +- officialcjunior (*Malayalam*) +- andryng (*Icelandic*) +- auntgd (*Marathi*) +- mawoka (*German*) +- yuvalne (*Hebrew*) +- lens0021 (*Korean*) +- angafraioch (*Irish*) +- koungho (*Chinese Traditional*) +- michaelshmitty (*Dutch*) - tunisiano187 (*French*) -- Peter van Mever (SpacemanSpiff) (*Dutch*) -- Pedro Henrique (exploronauta) (*Portuguese, Brazilian*) +- h_tejas (*Marathi*) +- meskobalazs (*Hungarian*) +- exploronauta (*Portuguese, Brazilian*) - REMOVED_USER (*Esperanto, Italian, Japanese*) -- Tejas Harad (h_tejas) (*Marathi*) -- Balázs Meskó (meskobalazs) (*Hungarian*) -- Vasanthan (vasanthan) (*Tamil*) -- Tatsuto "Laminne" Yamamoto (laminne) (*Japanese*) -- slbtty (shenlebantongying) (*Chinese Simplified*) -- 硫酸鶏 (acid_chicken) (*Japanese*) +- SpacemanSpiff (*Dutch*) +- vasanthan (*Tamil*) +- laminne (*Japanese*) +- shenlebantongying (*Chinese Simplified*) +- acid_chicken (*Japanese*) +- clarminb8 (*Sorani (Kurdish)*) - programizer (*German*) - guessimmaterialgrl (*Chinese Simplified*) -- clarmin b8 (clarminb8) (*Sorani (Kurdish)*) -- Maria Riegler (riegler3m) (*German*) - manukp (*Malayalam*) -- earth dweller (sanethoughtyt) (*Marathi*) +- riegler3m (*German*) +- sanethoughtyt (*Marathi*) - psymyn (*Hebrew*) -- Aaraon Thomas (aaraon) (*Portuguese, Brazilian*) -- Rafael Viana (rafacnec) (*Portuguese, Brazilian*) -- Marek Ľach (marek-lach) (*Slovak*) -- meijerivoi (toilet) (*Finnish*) +- aaraon (*Portuguese, Brazilian*) +- toilet (*Finnish*) +- marek-lach (*Slovak*) +- rafacnec (*Portuguese, Brazilian*) +- GenialMeg (*Spanish*) - essaar (*Tamil*) - serubeena (*Swedish*) - RqndomHax (*French*) - REMOVED_USER (*Polish*) -- ギャラ (gyara) (*Chinese Simplified, Japanese*) -- Khó͘ Tiatlêng (khotiatleng) (*Chinese Traditional, Taigi*) -- revarioba (*Spanish*) -- friedbeans (*Croatian*) -- An (AnTheMaker) (*German*) -- kuchengrab (*German*) -- Hernik (hernik27) (*Czech*) +- gyara (*Chinese Simplified, Japanese*) - valarivan (*Tamil*) -- אדם לוין (adamlevin) (*Hebrew*) -- Vít Horčička (legvita123) (*Czech*) -- Abi Turi (abi123) (*Georgian*) -- Thomas Munkholt (munkholt) (*Danish*) +- khotiatleng (*Chinese Traditional, Taigi*) +- hernik27 (*Czech*) +- kuchengrab (*German*) +- friedbeans (*Croatian*) +- revarioba (*Spanish*) +- AnTheMaker (*German*) +- adamlevin (*Hebrew*) +- abi123 (*Georgian*) +- munkholt (*Danish*) - pparescasellas (*Catalan*) -- Hinaloe (hinaloe) (*Japanese*) +- hinaloe (*Japanese*) +- Selrond (*Slovak*) - Ifnuth (*German*) -- Sebastián Andil (Selrond) (*Slovak*) -- boni777 (*Chinese Simplified*) +- ddgulledge (*Esperanto*) - KEINOS (*Japanese*) -- Asbjørn Olling (a2) (*Danish*) +- a2 (*Danish*) +- boni777 (*Chinese Simplified*) - REMOVED_USER (*Chinese Traditional, Hong Kong*) -- DarkShy Community (ponyfrost.mc) (*Russian*) -- Dennis Reimund (reimunddennis7) (*German*) +- reimunddennis7 (*German*) +- ponyfrost.mc (*Russian*) - jocafeli (*Spanish, Mexico*) -- Wrya ali (John12) (*Sorani (Kurdish)*) -- Bottle (suryasalem2010) (*Tamil*) -- Algustionesa Yoshi (algustionesa) (*Indonesian*) - JzshAC (*Chinese Simplified*) -- Artem Mikhalitsin (artemmikhalitsin) (*Russian*) -- siamano (*Thai, Esperanto*) -- KARARTI44 (kararti44) (*Turkish*) +- suryasalem2010 (*Tamil*) +- John12 (*Sorani (Kurdish)*) +- algustionesa (*Indonesian*) +- artemmikhalitsin (*Russian*) +- mbootsman (*Dutch*) +- siamano (*Esperanto, Thai*) +- kararti44 (*Turkish*) - c0c (*Irish*) -- Stefano S. (Sting1_JP) (*Italian*) +- Sting1_JP (*Italian*) +- sammy8806 (*German*) +- antillion99 (*Spanish*) +- ilis (*Galician*) - tommil (*Finnish*) -- Ignacio Lis (ilis) (*Galician*) -- Steven Tappert (sammy8806) (*German*) -- Antillion (antillion99) (*Spanish*) -- K.B.Dharun Krishna (kbdharun) (*Tamil*) -- Wassim EL BOUHAMIDI (elbouhamidiw) (*Arabic*) - Reg3xp (*Persian*) +- elbouhamidiw (*Arabic*) +- kbdharun (*Tamil*) +- mble (*Polish*) +- Exbu (*Dutch*) - florentVgn (*French*) -- Matt (Exbu) (*Dutch*) -- Maciej Błędkowski (mble) (*Polish*) -- gowthamanb (*Tamil*) - hiphipvargas (*Portuguese*) +- gowthamanb (*Tamil*) - GabuVictor (*Portuguese, Brazilian*) +- REMOVED_USER (*Spanish*) - Pverte (*French*) -- REMOVED_USER (*Spanish*) - Surindaku (*Chinese Simplified*) -- Arttu Ylhävuori (arttu.ylhavuori) (*Finnish*) -- Pabllo Soares (pabllosoarez) (*Portuguese, Brazilian*) -- Jona (88wcJoWl) (*Spanish*) -- Ka2n (kaanmetu) (*Turkish*) +- arttu.ylhavuori (*Finnish*) +- samiti3d (*Thai*) - tctovsli (*Norwegian Nynorsk*) -- Timo Tijhof (Krinkle) (*Dutch*) -- SamitiMed (samiti3d) (*Thai*) -- Mikkel B. Goldschmidt (mikkelbjoern) (*Danish*) -- Odyssey346 (alexader612) (*Norwegian*) -- mecqor labi (mecqorlabi) (*Persian*) -- Cù Huy Phúc Khang (taamee) (*Vietnamese*) -- Oskari Lavinto (olavinto) (*Finnish*) -- Philippe Lemaire (philippe-lemaire) (*Esperanto*) +- Krinkle (*Dutch*) +- mikkelbjoern (*Danish*) +- kaanmetu (*Turkish*) +- pabllosoarez (*Portuguese, Brazilian*) +- mecqorlabi (*Persian*) - vjasiegd (*Polish*) -- Eban (ebanDev) (*Esperanto, French*) -- Nícolas Lavinicki (nclavinicki) (*Portuguese, Brazilian*) -- REMOVED_USER (*Portuguese, Brazilian*) -- Rekan Adl (rekan-adl1) (*Sorani (Kurdish)*) -- VSx86 (*Russian*) +- ebanDev (*Esperanto, French*) +- philippe-lemaire (*Esperanto*) +- olavinto (*Finnish*) +- taamee (*Vietnamese*) +- nclavinicki (*Portuguese, Brazilian*) +- rekan-adl1 (*Sorani (Kurdish)*) - umelard (*Hebrew*) -- Antara2Cinta (Se7enTime) (*Indonesian*) +- Se7enTime (*Indonesian*) +- VSx86 (*Russian*) +- yaitelmouden (*Standard Moroccan Tamazight*) - Lucas_NL (*Dutch*) -- Yassine Aït-El-Mouden (yaitelmouden) (*Standard Moroccan Tamazight*) -- Mathieu Marquer (slasherfun) (*French*) -- Haerul Fuad (Dokuwiki) (*Indonesian*) +- Dokuwiki (*Indonesian*) +- slasherfun (*French*) - parnikkapore (*Thai*) -- Michelle M (MichelleMMM) (*Dutch*) +- MichelleMMM (*Dutch*) +- sherwanothman11 (*Sorani (Kurdish)*) +- lagash (*Esperanto*) - malbona (*Esperanto*) -- Sherwan Othman (sherwanothman11) (*Sorani (Kurdish)*) -- Lagash (lagash) (*Esperanto*) -- Chine Sebastien (chine.sebastien) (*French*) -- bgme (*Chinese Simplified*) -- Rafael V. (Rafaeeel) (*Portuguese, Brazilian*) - SKELET (*Danish*) -- A A (sebastien.chine) (*French*) -- Project Z (projectz.1338) (*German*) -- Fei Yang (Fei1Yang) (*Chinese Traditional*) -- Ğani (freegnu) (*Tatar*) -- musix (*Persian*) -- REMOVED_USER (*German*) -- ALEM FARID (faridatcemlulaqbayli) (*Kabyle*) -- Jean-Pierre MÉRESSE (Jipem) (*French*) +- chine.sebastien (*French*) +- bgme (*Chinese Simplified*) +- Rafaael (*Portuguese, Brazilian*) +- Fei1Yang (*Chinese Traditional*) +- freegnu (*Tatar*) +- sebastien.chine (*French*) +- projectz.1338 (*German*) - enipra (*Armenian*) -- Serhiy Dmytryshyn (dies) (*Ukrainian*) -- Eric Brulatout (ebrulato) (*Esperanto*) -- Hougo (hougo) (*French*) +- faridatcemlulaqbayli (*Kabyle*) +- musix (*Persian*) +- Jipem (*French*) +- hougo (*French*) +- dies (*Ukrainian*) +- djprmf (*Portuguese*) - Sonstwer (*German*) -- Pedro Fernandes (djprmf) (*Portuguese*) -- REMOVED_USER (*Norwegian*) -- Tigran's Tips (tigrank08) (*Armenian*) -- 亜緯丹穂 (ayiniho) (*Japanese*) -- maisui (*Chinese Simplified*) -- Trinsec (*Dutch*) -- Adrián Lattes (haztecaso) (*Spanish*) -- webkinzfrog (*Polish*) +- ebrulato (*Esperanto*) +- haztecaso (*Spanish*) - ybardapurkar (*Marathi*) -- Mordi Sacks (MordiSacks) (*Hebrew*) -- Manuel Tassi (Mannivu) (*Italian*) -- Szabolcs Gál (galszabolcs810624) (*Hungarian*) -- rikrise (*Swedish*) -- when_hurts (*German*) -- Wojciech Bigosinski (wbigos2) (*Polish*) -- Vladislav S (vladislavs) (*Romanian*) -- mikslatvis (*Latvian*) -- MartinAlstad (*Norwegian*) +- MordiSacks (*Hebrew*) +- ayiniho (*Japanese*) +- tigrank08 (*Armenian*) +- Trinsec (*Dutch*) +- webkinzfrog (*Polish*) +- Mannivu (*Italian*) +- maisui (*Chinese Simplified*) - TracyJacks (*Chinese Simplified*) +- galszabolcs810624 (*Hungarian*) +- vladislavs (*Romanian*) +- rikrise (*Swedish*) +- MartinAlstad (*Norwegian*) +- when_hurts (*German*) +- wbigos2 (*Polish*) +- mikslatvis (*Latvian*) - rasheedgm (*Kannada*) -- Cirelli (cirelli94) (*Italian*) - danreznik (*Hebrew*) +- cirelli94 (*Italian*) - iraline (*Portuguese, Brazilian*) -- Seán Mór (seanmor3) (*Irish*) +- seanmor3 (*Irish*) +- sidharastro (*Spanish, Mexico*) - vianaweb (*Portuguese, Brazilian*) -- Siddharastro Doraku (sidharastro) (*Spanish, Mexico*) -- REMOVED_USER (*Spanish*) +- nspeaks (*Hindi*) +- belkacem77 (*Kabyle*) - omquylzu (*Latvian*) -- Arthegor (*French*) -- Navjot Singh (nspeaks) (*Hindi*) - mkljczk (*Polish*) -- Belkacem Mohammed (belkacem77) (*Kabyle*) +- c6ristian (*German*) +- lexxai (*Ukrainian*) - Showfom (*Chinese Simplified*) - xemyst (*Catalan*) -- lexxai (*Ukrainian*) -- c6ristian (*German*) -- svetlozaurus (*Bulgarian*) +- Arthegor (*French*) +- petrosyan (*Armenian*) - Ozai (*German*) +- MetehanOzyurek (*Turkish*) - damascene (*Arabic*) -- Jan Ainali (Ainali) (*Swedish*) -- Sahak Petrosyan (petrosyan) (*Armenian*) -- Metehan Özyürek (MetehanOzyurek) (*Turkish*) -- Сау Рэмсон (sawrams) (*Russian*) +- svetlozaurus (*Bulgarian*) +- Ainali (*Swedish*) +- rapiteanu (*Romanian*) +- sawrams (*Russian*) +- kscanne (*Irish*) +- sebastienserre (*French*) - metehan-arslan (*Turkish*) -- Viorel-Cătălin Răpițeanu (rapiteanu) (*Romanian*) -- Sébastien SERRE (sebastienserre) (*French*) -- Eugen Caruntu (eugencaruntu) (*Romanian*) -- Kevin Scannell (kscanne) (*Irish*) -- Pachara Chantawong (pachara2202) (*Thai*) -- bensch.dev (*German*) +- eugencaruntu (*Romanian*) +- quinoa_biryani (*Bengali*) +- pachara2202 (*Thai*) - LIZH (*French*) -- Siddhartha Sarathi Basu (quinoa_biryani) (*Bengali*) -- Overflow Cat (OverflowCat) (*Chinese Traditional, Chinese Simplified*) -- Stephan Voeth (svoeth) (*German*) -- Zijian Zhao (jobs2512821228) (*Chinese Simplified*) -- bugboy-20 (*Esperanto, Italian*) -- SouthFox (*Chinese Simplified*) -- Noan (SkewRam) (*French*) +- bensch.dev (*German*) +- SkewRam (*French*) +- jobs2512821228 (*Chinese Simplified*) - dbeaver (*German*) +- OverflowCat (*Chinese Simplified, Chinese Traditional*) +- svoeth (*German*) +- SouthFox (*Chinese Simplified*) +- bugboy-20 (*Esperanto, Italian*) +- guruprasath (*Tamil*) - turtle836 (*German*) -- Guru Prasath Anandapadmanaban (guruprasath) (*Tamil*) - zordsdavini (*Lithuanian*) -- Susanna Ånäs (susanna.anas) (*Finnish*) -- Alessandro (alephoto85) (*Italian*) -- Marcepanek_ (thekingmarcepan) (*Polish*) -- Choi Younsoo (usagicore) (*Korean*) -- Yann Aguettaz (yann-a) (*French*) -- zylosophe (*French*) -- Celso Fernandes (Celsof) (*Portuguese, Brazilian*) -- Feruz Oripov (FeruzOripov) (*Russian*) +- susanna.anas (*Finnish*) +- thekingmarcepan (*Polish*) +- alephoto85 (*Italian*) +- FeruzOripov (*Russian*) +- yann-a (*French*) +- usagicore (*Korean*) +- Celsof (*Portuguese, Brazilian*) - REMOVED_USER (*French*) -- Bui Huy Quang (bhuyquang1) (*Vietnamese*) +- zylosophe (*French*) +- bhuyquang1 (*Vietnamese*) - bogomilshopov (*Bulgarian*) +- kaedech (*Japanese*) +- xgc.redes (*Asturian*) - REMOVED_USER (*Burmese*) -- Kaede (kaedech) (*Japanese*) -- Mick Onio (xgc.redes) (*Asturian*) -- Malik Mann (dermalikmann) (*German*) +- dermalikmann (*German*) +- hg6 (*Hindi*) - padulafacundo (*Spanish*) +- tina.zhang040609 (*Chinese Simplified*) - r3dsp1 (*Chinese Traditional, Hong Kong*) - dadosch (*German*) -- Tianqi Zhang (tina.zhang040609) (*Chinese Simplified*) -- HybridGlucose (*Chinese Traditional*) - vmichalak (*French*) -- hg6 (*Hindi*) +- HybridGlucose (*Chinese Traditional*) - marivisales (*Portuguese, Brazilian*) -- Orlando Murcio (Atos20) (*Spanish, Mexico*) +- Atos20 (*Spanish, Mexico*) +- J0hsHH (*Norwegian*) - maa123 (*Japanese*) -- Julian Doser (julian21) (*English, United Kingdom, German*) -- johannes hove-henriksen (J0hsHH) (*Norwegian*) -- Alexander Ivanov (Saiv46) (*Russian*) -- unstable.icu (*Chinese Simplified*) -- Padraic Calpin (padraic-padraic) (*Slovenian*) -- Youngeon Lee (YoungeonLee) (*Korean*) -- LeJun (le-jun) (*French*) -- shdy (*German*) -- REMOVED_USER (*French*) -- Yonjae Lee (yonjlee) (*Korean*) +- julian21 (*English, United Kingdom, German*) - cenegd (*Chinese Simplified*) +- padraic-padraic (*Slovenian*) - piupiupiudiu (*Chinese Simplified*) -- Umi (mtrumi) (*Chinese Traditional, Hong Kong, Chinese Simplified*) -- Yogesh K S (yogi) (*Kannada*) +- shdy (*German*) +- mtrumi (*Chinese Simplified, Chinese Traditional, Hong Kong*) +- YoungeonLee (*Korean*) +- unstable.icu (*Chinese Simplified*) +- yonjlee (*Korean*) +- le-jun (*French*) +- Saiv46 (*Russian*) +- youloveonlymeh (*Chinese Simplified*) +- yogi (*Kannada*) +- adithyak04 (*Malayalam*) +- daijie (*Chinese Simplified*) +- milli.pretili (*Croatian*) - Ulong32 (*Japanese*) -- Adithya K (adithyak04) (*Malayalam*) -- DAI JIE (daijie) (*Chinese Simplified*) -- Mihael Budeč (milli.pretili) (*Croatian*) -- Hugh Liu (youloveonlymeh) (*Chinese Simplified*) +- rakino (*Chinese Simplified*) - ZQYD (*Chinese Simplified*) -- X.M (kimonoki) (*Chinese Simplified*) -- Rakino (rakino) (*Chinese Simplified*) -- paziy Georgi (paziygeorgi4) (*Dutch*) -- Komeil Parseh (mmdbalkhi) (*Persian*) -- Jothipazhani Nagarajan (jothipazhani.n) (*Tamil*) -- tikky9 (*Portuguese, Brazilian*) -- horsm (*Finnish*) -- BenJule (*German*) -- Stanisław Jelnicki (JelNiSlaw) (*Polish*) -- Yananas (wangyanyan.hy) (*Chinese Simplified*) -- Vivamus (elaaksu) (*Turkish*) -- ihealyou (*Italian*) +- kimonoki (*Chinese Simplified*) +- jothipazhani.n (*Tamil*) - AmazighNM (*Kabyle*) -- Miquel Sabaté Solà (mssola) (*Catalan*) +- mssola (*Catalan*) +- JelNiSlaw (*Polish*) +- BenJule (*German*) +- wangyanyan.hy (*Chinese Simplified*) - residuum (*German*) -- nua_kr (*Korean*) -- Andrea Mazzilli (andreamazzilli) (*Italian*) -- Paula SIMON (EncoreEutIlFalluQueJeLeSusse) (*French*) +- mmdbalkhi (*Persian*) +- paziygeorgi4 (*Dutch*) +- tikky9 (*Portuguese, Brazilian*) +- ihealyou (*Italian*) +- elaaksu (*Turkish*) +- horsm (*Finnish*) - hallomaurits (*Dutch*) -- Erfan Kheyrollahi Qaroğlu (ekm507) (*Persian*) - REMOVED_USER (*Galician, Spanish*) -- alnd hezh (alndhezh) (*Sorani (Kurdish)*) -- Clash Clans (KURD12345) (*Sorani (Kurdish)*) +- SolidRhino (*Dutch*) +- KURD12345 (*Sorani (Kurdish)*) +- alndhezh (*Sorani (Kurdish)*) +- nua_kr (*Korean*) +- EncoreEutIlFalluQueJeLeSusse (*French*) +- CloudSet (*Chinese Simplified*) - ruok (*Chinese Simplified*) - Frederik-FJ (*German*) -- CloudSet (*Chinese Simplified*) -- Solid Rhino (SolidRhino) (*Dutch*) +- andreamazzilli (*Italian*) +- ekm507 (*Persian*) +- noellabo (*Japanese*) - hussama (*Portuguese, Brazilian*) -- jazzynico (*French*) -- k_taka (peaceroad) (*Japanese*) -- 林水溶 (shuiRong) (*Chinese Simplified*) -- Peter Lutz (theellutzo) (*German*) -- Sébastien Feugère (smonff) (*French*) -- AnalGoddess770 (*Hebrew*) -- Sven Goller (svengoller) (*German*) -- Ahmet (ahmetlii) (*Turkish*) +- shuiRong (*Chinese Simplified*) +- smonff (*French*) +- peaceroad (*Japanese*) +- hallo_hamza12 (*Sorani (Kurdish)*) +- ahmetlii (*Turkish*) +- theellutzo (*German*) - hosted22 (*German*) -- Hallo Abdullah (hallo_hamza12) (*Sorani (Kurdish)*) -- Karam Hamada (TheKaram) (*Arabic*) -- Takeshi Umeda (noellabo) (*Japanese*) +- svengoller (*German*) +- TheKaram (*Arabic*) +- jazzynico (*French*) +- AnalGoddess770 (*Hebrew*) - SnDer (*Dutch*) -- Robert Yano (throwcalmbobaway) (*Spanish, Mexico*) -- Gustav Lindqvist (Reedyn) (*Swedish*) -- Dagur Ammendrup (dagurp) (*Icelandic*) -- shafouz (*Portuguese, Brazilian*) -- Miguel Branco (mglbranco) (*Galician*) -- Sergey Panteleev (saundefined) (*Russian*) -- Tom_ (*Czech*) -- Zlr- (cZeler) (*French*) -- Ashok314 (ashok314) (*Hindi*) -- PifyZ (*French*) -- Zeyi Fan (fanzeyi) (*Chinese Simplified*) -- OminousCry (*Russian, Ukrainian*) -- Adam Sapiński (Adamos9898) (*Polish*) - eichkat3r (*German*) -- Yasin İsa YILDIRIM (redsfyre) (*Turkish*) -- Tagada (Tagadda) (*French*) +- PifyZ (*French*) +- OminousCry (*Russian, Ukrainian*) +- shafouz (*Portuguese, Brazilian*) +- Tom_ (*Czech*) +- Tagadda (*French*) +- ashok314 (*Hindi*) +- cZeler (*French*) +- Iriep (*Breton*) +- throwcalmbobaway (*Spanish, Mexico*) +- redsfyre (*Turkish*) +- Adamos9898 (*Polish*) +- Reedyn (*Swedish*) +- saundefined (*Russian*) +- fanzeyi (*Chinese Simplified*) +- mglbranco (*Galician*) +- dagurp (*Icelandic*) - gasrios (*Portuguese, Brazilian*) -- 夜楓Yoka (Yoka2627) (*Chinese Simplified*) -- AniCommieDDR (*Russian*) -- Nathaël Noguès (NatNgs) (*French*) -- Daniel M. (daniconil) (*Catalan*) -- César Daniel Cavanzo Quintero (LeinadCQ) (*Esperanto*) -- Noam Tamim (noamtm) (*Hebrew*) +- saccharin23 (*Japanese*) +- tshrinivasan (*Tamil*) +- REMOVED_USER (*Urdu (Pakistan)*) +- kishorkumara3 (*Kannada*) +- swatisani (*Urdu (Pakistan)*) +- daniconil (*Catalan*) +- NatNgs (*French*) +- Yoka2627 (*Chinese Simplified*) - papayaisnotafood (*Chinese Traditional*) -- さっかりんにーさん (saccharin23) (*Japanese*) -- Marcin Wolski (martinwolski) (*Polish*) -- REMOVED_USER (*Chinese Simplified*) -- Kk (kishorkumara3) (*Kannada*) -- Shrinivasan T (tshrinivasan) (*Tamil*) -- REMOVED_USER (Urdu (Pakistan)) -- Kakarico Bra (kakarico20) (*Portuguese, Brazilian*) -- Swati Sani (swatisani) (*Urdu (Pakistan)*) -- 快乐的老鼠宝宝 (LaoShuBaby) (*Chinese Simplified, Chinese Traditional*) -- Mt Front (mtfront) (*Chinese Simplified*) -- SusVersiva (*Catalan*) -- REMOVED_USER (*Portuguese, Brazilian*) -- Avinash Mg (hatman290) (*Malayalam*) -- kruijs (*Dutch*) -- Artem (Artem4ik) (*Russian*) +- LeinadCQ (*Esperanto*) +- kakarico20 (*Portuguese, Brazilian*) +- AniCommieDDR (*Russian*) +- martinwolski (*Polish*) +- noamtm (*Hebrew*) +- tradjincal (*French*) - Zinkokooo (*Basque*) -- 劉昌賢 (twcctz500) (*Chinese Traditional*) - Vikatakavi (*Kannada*) -- Tradjincal (tradjincal) (*French*) -- Robin van der Vliet (RobinvanderVliet) (*Esperanto*) -- Marvin (magicmarvman) (*German*) +- SusVersiva (*Catalan*) +- RobinvanderVliet (*Esperanto*) +- Artem4ik (*Russian*) - pullopen (*Chinese Simplified*) -- Tealk (*German*) -- tibequadorian (*German*) -- Henk Bulder (henkbulder) (*Dutch*) -- Edison Lee (edisonlee55) (*Chinese Traditional*) -- mpdude (*German*) -- Rijk van Geijtenbeek (rvangeijtenbeek) (*Dutch*) -- Entelekheia-ousia (*Chinese Simplified*) -- REMOVED_USER (*Spanish*) -- sergioaraujo1 (*Portuguese, Brazilian*) -- Livingston Samuel (livingston) (*Tamil*) +- magicmarvman (*German*) +- mtfront (*Chinese Simplified*) +- twcctz500 (*Chinese Traditional*) +- LaoShuBaby (*Chinese Simplified, Chinese Traditional*) +- kruijs (*Dutch*) +- hatman290 (*Malayalam*) - mmokhi (*Persian*) +- sergioaraujo1 (*Portuguese, Brazilian*) - tsundoker (*Malayalam*) -- CyberAmoeba (pseudoobscura) (*Chinese Simplified*) - prabhjot (*Hindi*) -- Ikka Putri (ikka240290) (*Indonesian, Danish, English, United Kingdom*) -- Paz Galindo (paz.almendra.g) (*Spanish*) -- Ricardo Colin (rysard) (*Spanish*) -- Pierre Morvan (Iriep) (*Breton*) -- oscfd (*Spanish*) -- Thies Mueller (thies00) (*German*) -- Lyra (teromene) (*French*) -- Kedr (lava20121991) (*Esperanto*) -- mkljczk (mykylyjczyk) (*Polish*) +- livingston (*Tamil*) +- pseudoobscura (*Chinese Simplified*) +- Entelekheia-ousia (*Chinese Simplified*) +- tibequadorian (*German*) +- edisonlee55 (*Chinese Traditional*) +- Tealk (*German*) +- rvangeijtenbeek (*Dutch*) +- henkbulder (*Dutch*) +- mpdude (*German*) - fedot (*Russian*) -- Philipp Fischbeck (PFischbeck) (*German*) -- Hasan Berkay Çağır (berkaycagir) (*Turkish*) -- Silvestri Nicola (nick99silver) (*Italian*) - skaaarrr (*German*) -- Mo Rijndael (mo_rijndael) (*Russian*) -- tsesunnaallun (orezraey) (*Portuguese, Brazilian*) -- Lukas Fülling (lfuelling) (*German*) -- Algo (algovigura) (*Indonesian*) -- REMOVED_USER (*Spanish*) -- setthemfree (*Ukrainian*) -- i fly (ifly3years) (*Chinese Simplified*) -- ralozkolya (*Georgian*) -- Zoé Bőle (zoe1337) (*German*) -- Ville Rantanen (vrntnn) (*Finnish*) +- rysard (*Spanish*) +- paz.almendra.g (*Spanish*) +- mykylyjczyk (*Polish*) +- PFischbeck (*German*) +- berkaycagir (*Turkish*) +- thies00 (*German*) +- lava20121991 (*Esperanto*) +- nick99silver (*Italian*) +- teromene (*French*) +- ikka240290 (*Danish, English, United Kingdom, Indonesian*) +- Merman-Jack (*Chinese Simplified*) +- zoe1337 (*German*) +- lfuelling (*German*) +- REMOVED_USER (*Georgian*) - GaggiX (*Italian*) -- JackXu (Merman-Jack) (*Chinese Simplified*) -- ceonia (*Chinese Traditional, Hong Kong*) -- Emirhan Yavuz (takomlii) (*Turkish*) +- orezraey (*Portuguese, Brazilian*) - teezeh (*German*) -- MevLyshkin (Leinnan) (*Polish*) -- Apple (blackteaovo) (*Chinese Simplified*) -- qwerty287 (*German*) -- Tangcuyu (*Chinese Simplified*) +- takomlii (*Turkish*) +- ceonia (*Chinese Traditional, Hong Kong*) +- mo_rijndael (*Russian*) +- vrntnn (*Finnish*) +- ifly3years (*Chinese Simplified*) +- Leinnan (*Polish*) +- algovigura (*Indonesian*) +- setthemfree (*Ukrainian*) +- anoopp (*Malayalam*) +- samir_t7 (*Kabyle*) +- AymBroussier (*French*) +- albjeremias (*Portuguese*) - Nocta (*French*) -- ru_mactunnag (*Scottish Gaelic*) -- Lilian Nabati (Lilounab49) (*French*) -- lokalisoija (*Finnish*) -- Dennis Reimund (reimund_dennis) (*German*) -- ronee (*Kurmanji (Kurdish)*) -- EricVogt_ (*Spanish*) -- yu miao (metaxx.dev) (*Chinese Simplified*) -- Anoop (anoopp) (*Malayalam*) -- Samir Tighzert (samir_t7) (*Kabyle*) -- sn02 (*German*) -- Yui Karasuma (yui87) (*Japanese*) -- asala4544 (*Basque*) -- Thibaut Rousseau (thiht44) (*French*) -- Jason Gibson (barberpike606) (*Slovenian, Chinese Simplified*) -- Sugar NO (g1024116707) (*Chinese Simplified*) -- Aymeric (AymBroussier) (*French*) - pezcurrel (*Italian*) -- Xurxo Guerra (xguerrap) (*Galician*) -- nicosomb (*French*) -- Albatroz Jeremias (albjeremias) (*Portuguese*) -- María José Vera (mjverap) (*Spanish*) - mashirozx (*Chinese Simplified*) +- blackteaovo (*Chinese Simplified*) +- xguerrap (*Galician*) +- reimund_dennis (*German*) +- asala4544 (*Basque*) +- qwerty287 (*German*) +- ru_mactunnag (*Scottish Gaelic*) +- Lilounab49 (*French*) +- ronee (*Kurmanji (Kurdish)*) +- barberpike606 (*Chinese Simplified, Slovenian*) +- lokalisoija (*Finnish*) +- Tangcuyu (*Chinese Simplified*) - codl (*French*) -- Doug (douglasalvespe) (*Portuguese, Brazilian*) -- Matias Lavik (matiaslavik) (*Norwegian Nynorsk*) -- random_person (*Spanish*) -- whoeta (wh0eta) (*Russian*) -- xpac1985 (xpac) (*German*) -- thisdudeisvegan (braydofficial) (*German*) -- Fleva (*Sardinian*) -- Anonymous (Anonymous666) (*Russian*) -- Mohammad Adnan Mahmood (adnanmig) (*Arabic*) -- ÀŘǾŚ PÀŚĦÀÍ (arospashai) (*Sorani (Kurdish)*) -- mikel (mikelalas) (*Spanish*) -- Trond Boksasp (boksasp) (*Norwegian*) -- asretro (*Chinese Traditional, Hong Kong*) -- Holger Huo (holgerhuo) (*Chinese Simplified*) -- Aman Alam (aalam) (*Punjabi*) -- smedvedev (*Russian*) -- Jay Lonnquist (crowkeep) (*Japanese*) -- mimikun (*Japanese*) -- Mohd Bilal (mdb571) (*Malayalam*) -- veer66 (*Thai*) -- OpenAlgeria (*Arabic*) -- Rave (nayumi-464812844) (*Vietnamese*) -- ReavedNetwork (*German*) -- Michael (Discostu36) (*German*) +- mjverap (*Spanish*) +- metaxx.dev (*Chinese Simplified*) +- g1024116707 (*Chinese Simplified*) +- EricVogt_ (*Spanish*) +- yui87 (*Japanese*) +- sn02 (*German*) +- nicosomb (*French*) +- thiht44 (*French*) - tamaina (*Japanese*) +- OpenAlgeria (*Arabic*) +- Saislakshmanan (*Tamil*) +- amithraj1989 (*Kannada*) +- adnanmig (*Arabic*) +- smedvedev (*Russian*) +- boksasp (*Norwegian*) +- mikelalas (*Spanish*) +- random_person (*Spanish*) +- matiaslavik (*Norwegian Nynorsk*) +- douglasalvespe (*Portuguese, Brazilian*) +- Fleva (*Sardinian*) +- arospashai (*Sorani (Kurdish)*) +- xpac (*German*) +- asretro (*Chinese Traditional, Hong Kong*) +- aalam (*Punjabi*) +- mimikun (*Japanese*) +- holgerhuo (*Chinese Simplified*) +- mdb571 (*Malayalam*) +- braydofficial (*German*) +- rmegg1933 (*Latvian*) +- nayumi-464812844 (*Vietnamese*) +- ReavedNetwork (*German*) +- Discostu36 (*German*) +- veer66 (*Thai*) - sk22 (*German*) -- Ragnars Eggerts (rmegg1933) (*Latvian*) -- Sais Lakshmanan (Saislakshmanan) (*Tamil*) -- Amith Raj Shetty (amithraj1989) (*Kannada*) -- Bartek Fijałkowski (brateq) (*Polish*) -- Asbeltrion (*Spanish*) -- Michael Horstmann (mhrstmnn) (*German*) -- Joffrey Abeilard (Abeilard14) (*French*) -- capiscuas (*Spanish*) +- crowkeep (*Japanese*) +- wh0eta (*Russian*) +- Anonymous666 (*Russian*) - djoerd (*Dutch*) -- REMOVED_USER (*Spanish*) -- NeverMine17 (*Russian*) -- songxianj (songxian_jiang) (*Chinese Simplified*) -- Ács Zoltán (zoli111) (*Hungarian*) -- haaninjo (*Swedish*) - REMOVED_USER (*Esperanto*) -- Philip Molares (DerMolly) (*German*) -- ChalkPE (amato0617) (*Korean*) -- ebrezhoneg (*Breton*) -- 디떱 (diddub) (*Korean*) -- Hans (hansj) (*German*) -- Nithya Mary (nithyamary25) (*Tamil*) -- kavitha129 (*Tamil*) +- Abijeet (*Basque*) +- benjamincobb (*German*) - waweic (*German*) -- Aries (orlea) (*Japanese*) -- おさ (osapon) (*Japanese*) -- Abijeet Patro (Abijeet) (*Basque*) -- centumix (*Japanese*) -- Martin Müller (muellermartin) (*German*) +- kavitha129 (*Tamil*) +- nithyamary25 (*Tamil*) +- ebrezhoneg (*Breton*) +- argxentakato (*Japanese*) - tateisu (*Japanese*) -- Arĝentakato (argxentakato) (*Japanese*) -- Benjamin Cobb (benjamincobb) (*German*) -- deanerschnitzel (*German*) -- Jill H. (kokakiwi) (*French*) -- maksutheam (*Finnish*) -- d0p1 (d0p1s4m4) (*French*) +- osapon (*Japanese*) +- centumix (*Japanese*) +- orlea (*Japanese*) +- NeverMine17 (*Russian*) +- capiscuas (*Spanish*) +- brateq (*Polish*) +- zoli111 (*Hungarian*) +- Jiniux (*Italian*) +- Aniqueper1 (*Chinese Simplified*) +- SamOak (*Portuguese, Brazilian*) +- dobrado (*Portuguese, Brazilian*) +- dcapillae (*Spanish*) +- xissshawww (*Chinese Simplified*) +- kuraking202 (*Sorani (Kurdish)*) +- RanjAhmed (*Sorani (Kurdish)*) +- Salh_haji6 (*Sorani (Kurdish)*) +- dashty (*Sorani (Kurdish)*) +- Kurdish.boy (*Sorani (Kurdish)*) +- herrero.maty (*Spanish*) +- umonaca (*Chinese Simplified*) +- ronchaine (*Finnish*) +- atomicmind (*Slovenian*) +- futchitwo (*Japanese*) +- brodi1 (*Dutch*) +- soheilkhanalipur (*Persian*) +- hud5634j (*Spanish*) +- kvdbve34 (*Russian*) +- jiangshanghan (*Chinese Simplified*) +- patriceboivin58 (*French*) - majorblazr (*Danish*) -- Patrice Boivin (patriceboivin58) (*French*) -- 江尚寒 (jiangshanghan) (*Chinese Simplified*) -- HSD Channel (kvdbve34) (*Russian*) -- alwyn joe (iomedivh200) (*Chinese Simplified*) -- ZHY (sheepzh) (*Chinese Simplified*) -- Bei Li (libei) (*Chinese Simplified*) -- Aluo (Aluo_rbszd) (*Chinese Simplified*) -- clarkzjw (*Chinese Simplified*) -- Noah Luppe (noahlup) (*German*) +- maksutheam (*Finnish*) +- kokakiwi (*French*) - araghunde (*Galician*) +- noahlup (*German*) +- clarkzjw (*Chinese Simplified*) +- Aluo_rbszd (*Chinese Simplified*) +- libei (*Chinese Simplified*) +- sheepzh (*Chinese Simplified*) +- iomedivh200 (*Chinese Simplified*) +- fyuodchiodmoiidiiduh86 (*Chinese Simplified*) - BratishkaErik (*Russian*) - Bunny9568 (*Chinese Simplified*) -- SamOak (*Portuguese, Brazilian*) -- Ranj A Abdulqadir (RanjAhmed) (*Sorani (Kurdish)*) -- Amir Kurdo (kuraking202) (*Sorani (Kurdish)*) -- 于晚霞 (xissshawww) (*Chinese Simplified*) -- Fyuoxyjidyho Moiodyyiodyhi (fyuodchiodmoiidiiduh86) (*Chinese Simplified*) -- RPD0911 (*Hungarian*) -- dcapillae (*Spanish*) -- dobrado (*Portuguese, Brazilian*) -- Hannah (Aniqueper1) (*Chinese Simplified*) -- Azad ahmad (dashty) (*Sorani (Kurdish)*) -- Uri Chachick (urich.404) (*Hebrew*) -- Bnoru (*Portuguese, Brazilian*) -- Jiniux (*Italian*) -- REMOVED_USER (*German*) -- Salh_haji6 (Sorani (Kurdish)) -- Kurdish Translator (*Kurdish.boy) (Sorani (Kurdish)*) -- Beagle (beagleworks) (*Japanese*) -- hud5634j (*Spanish*) -- Kisaragi Hiu (flyingfeather1501) (*Chinese Traditional*) -- Dominik Ziegler (dodomedia) (*German*) -- soheilkhanalipur (*Persian*) -- Brodi (brodi1) (*Dutch*) -- Savarín Electrográfico Marmota Intergalactica (herrero.maty) (*Spanish*) -- Ni Futchi (futchitwo) (*Japanese*) -- Zois Lee (gcnwm) (*Chinese Simplified*) -- Arnold Marko (atomicmind) (*Slovenian*) +- d0p1s4m4 (*French*) +- flyingfeather1501 (*Chinese Traditional*) +- dodomedia (*German*) +- beagleworks (*Japanese*) +- gcnwm (*Chinese Simplified*) - scholzco (*German*) -- Jari Ronkainen (ronchaine) (*Finnish*) -- umonaca (*Chinese Simplified*) +- RPD0911 (*Hungarian*) +- urich.404 (*Hebrew*) +- Bnoru (*Portuguese, Brazilian*) +- deanerschnitzel (*German*) +- haaninjo (*Swedish*) +- Asbeltrion (*Spanish*) +- songxian_jiang (*Chinese Simplified*) +- hansj (*German*) +- amato0617 (*Korean*) +- diddub (*Korean*) +- muellermartin (*German*) +- DerMolly (*German*) +- Abeilard14 (*French*) +- mhrstmnn (*German*) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f1b7bcb3..6f775fcfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,394 @@ All notable changes to this project will be documented in this file. +## [4.2.1] - 2023-10-10 + +### Added + +- Add redirection on `/deck` URLs for logged-out users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27128)) +- Add support for v4.2.0 migrations to `tootctl maintenance fix-duplicates` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27147)) + +### Changed + +- Change some worker lock TTLs to be shorter-lived ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27246)) +- Change user archive export allowed period from 7 days to 6 days ([suddjian](https://github.com/mastodon/mastodon/pull/27200)) + +### Fixed + +- Fix duplicate reports being sent when reporting some remote posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27355)) +- Fix clicking on already-opened thread post scrolling to the top of the thread ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27331), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27338), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27350)) +- Fix some remote posts getting truncated ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27307)) +- Fix some cases of infinite scroll code trying to fetch inaccessible posts in a loop ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27286)) +- Fix `Vary` headers not being set on some redirects ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27272)) +- Fix mentions being matched in some URL query strings ([mjankowski](https://github.com/mastodon/mastodon/pull/25656)) +- Fix unexpected linebreak in version string in the Web UI ([vmstan](https://github.com/mastodon/mastodon/pull/26986)) +- Fix double scroll bars in some columns in advanced interface ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27187)) +- Fix boosts of local users being filtered in account timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27204)) +- Fix multiple instances of the trend refresh scheduler sometimes running at once ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27253)) +- Fix importer returning negative row estimates ([jgillich](https://github.com/mastodon/mastodon/pull/27258)) +- Fix incorrectly keeping outdated update notices absent from the API endpoint ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27021)) +- Fix import progress not updating on certain failures ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27247)) +- Fix websocket connections being incorrectly decremented twice on errors ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/27238)) +- Fix explore prompt appearing because of posts being received out of order ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27211)) +- Fix explore prompt sometimes showing up when the home TL is loading ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27062)) +- Fix link handling of mentions in user profiles when logged out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27185)) +- Fix filtering audit log for entries about disabling 2FA ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27186)) +- Fix notification toasts not respecting reduce-motion ([c960657](https://github.com/mastodon/mastodon/pull/27178)) +- Fix retention dashboard not displaying correct month ([vmstan](https://github.com/mastodon/mastodon/pull/27180)) +- Fix tIME chunk not being properly removed from PNG uploads ([TheEssem](https://github.com/mastodon/mastodon/pull/27111)) +- Fix division by zero in video in bitrate computation code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27129)) +- Fix inefficient queries in “Follows and followers” as well as several admin pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27116), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27306)) +- Fix ActiveRecord using two connection pools when no replica is defined ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27061)) +- Fix the search documentation URL in system checks ([renchap](https://github.com/mastodon/mastodon/pull/27036)) + +## [4.2.0] - 2023-09-21 + +The following changelog entries focus on changes visible to users, administrators, client developers or federated software developers, but there has also been a lot of code modernization, refactoring, and tooling work, in particular by [@danielmbrasil](https://github.com/danielmbrasil), [@mjankowski](https://github.com/mjankowski), [@nschonni](https://github.com/nschonni), [@renchap](https://github.com/renchap), and [@takayamaki](https://github.com/takayamaki). + +### Added + +- **Add full-text search of opted-in public posts and rework search operators** ([Gargron](https://github.com/mastodon/mastodon/pull/26485), [jsgoldstein](https://github.com/mastodon/mastodon/pull/26344), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26657), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26650), [jsgoldstein](https://github.com/mastodon/mastodon/pull/26659), [Gargron](https://github.com/mastodon/mastodon/pull/26660), [Gargron](https://github.com/mastodon/mastodon/pull/26663), [Gargron](https://github.com/mastodon/mastodon/pull/26688), [Gargron](https://github.com/mastodon/mastodon/pull/26689), [Gargron](https://github.com/mastodon/mastodon/pull/26686), [Gargron](https://github.com/mastodon/mastodon/pull/26687), [Gargron](https://github.com/mastodon/mastodon/pull/26692), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26697), [Gargron](https://github.com/mastodon/mastodon/pull/26699), [Gargron](https://github.com/mastodon/mastodon/pull/26701), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26710), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26739), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26754), [Gargron](https://github.com/mastodon/mastodon/pull/26662), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26755), [Gargron](https://github.com/mastodon/mastodon/pull/26781), [Gargron](https://github.com/mastodon/mastodon/pull/26782), [Gargron](https://github.com/mastodon/mastodon/pull/26760), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26756), [Gargron](https://github.com/mastodon/mastodon/pull/26784), [Gargron](https://github.com/mastodon/mastodon/pull/26807), [Gargron](https://github.com/mastodon/mastodon/pull/26835), [Gargron](https://github.com/mastodon/mastodon/pull/26847), [Gargron](https://github.com/mastodon/mastodon/pull/26834), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26893), [tribela](https://github.com/mastodon/mastodon/pull/26896), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26927), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27014)) + This introduces a new `public_statuses` Elasticsearch index for public posts by users who have opted in to their posts being searchable (`toot#indexable` flag). + This also revisits the other indexes to provide more useful indexing, and adds new search operators such as `from:me`, `before:2022-11-01`, `after:2022-11-01`, `during:2022-11-01`, `language:fr`, `has:poll`, or `in:library` (for searching only in posts you have written or interacted with). + Results are now ordered chronologically. +- **Add admin notifications for new Mastodon versions** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26582)) + This is done by querying `https://api.joinmastodon.org/update-check` every 30 minutes in a background job. + That URL can be changed using the `UPDATE_CHECK_URL` environment variable, and the feature outright disabled by setting that variable to an empty string (`UPDATE_CHECK_URL=`). +- **Add “Privacy and reach” tab in profile settings** ([Gargron](https://github.com/mastodon/mastodon/pull/26484), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26508)) + This reorganized scattered privacy and reach settings to a single place, as well as improve their wording. +- **Add display of out-of-band hashtags in the web interface** ([Gargron](https://github.com/mastodon/mastodon/pull/26492), [arbolitoloco1](https://github.com/mastodon/mastodon/pull/26497), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26506), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26525), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26606), [Gargron](https://github.com/mastodon/mastodon/pull/26666), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26960)) +- **Add role badges to the web interface** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25649), [Gargron](https://github.com/mastodon/mastodon/pull/26281)) +- **Add ability to pick domains to forward reports to using the `forward_to_domains` parameter in `POST /api/v1/reports`** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25866), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26636)) + The `forward_to_domains` REST API parameter is a list of strings. If it is empty or omitted, the previous behavior is maintained. + The `forward` parameter still needs to be set for `forward_to_domains` to be taken into account. + The forwarded-to domains can only include that of the original author and people being replied to. +- **Add forwarding of reported replies to servers being replied to** ([Gargron](https://github.com/mastodon/mastodon/pull/25341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26189)) +- Add `ONE_CLICK_SSO_LOGIN` environment variable to directly link to the Single-Sign On provider if there is only one sign up method available ([CSDUMMI](https://github.com/mastodon/mastodon/pull/26083), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26368), [CSDUMMI](https://github.com/mastodon/mastodon/pull/26857), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26901)) +- **Add webhook templating** ([Gargron](https://github.com/mastodon/mastodon/pull/23289)) +- **Add webhooks for local `status.created`, `status.updated`, `account.updated` and `report.updated`** ([VyrCossont](https://github.com/mastodon/mastodon/pull/24133), [VyrCossont](https://github.com/mastodon/mastodon/pull/24243), [VyrCossont](https://github.com/mastodon/mastodon/pull/24211)) +- **Add exclusive lists** ([dariusk, necropolina](https://github.com/mastodon/mastodon/pull/22048), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25324)) +- **Add a confirmation screen when suspending a domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25144), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25603)) +- **Add support for importing lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25203), [mgmn](https://github.com/mastodon/mastodon/pull/26120), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26372)) +- **Add optional hCaptcha support** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25019), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25057), [Gargron](https://github.com/mastodon/mastodon/pull/25395), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26388)) +- **Add lines to threads in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24549), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24677), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24696), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24711), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24713), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24715), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24800), [teeerevor](https://github.com/mastodon/mastodon/pull/25706), [renchap](https://github.com/mastodon/mastodon/pull/25807)) +- **Add new onboarding flow to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24619), [Gargron](https://github.com/mastodon/mastodon/pull/24646), [Gargron](https://github.com/mastodon/mastodon/pull/24705), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24872), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24883), [Gargron](https://github.com/mastodon/mastodon/pull/24954), [stevenjlm](https://github.com/mastodon/mastodon/pull/24959), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25010), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25275), [Gargron](https://github.com/mastodon/mastodon/pull/25559), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25561)) +- **Add auto-refresh of accounts we get new messages/edits of** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26510)) +- **Add Elasticsearch cluster health check and indexes mismatch check to dashboard** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26448), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26605), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26658)) +- Add `hide_collections`, `discoverable` and `indexable` attributes to credentials API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26998)) +- Add `S3_ENABLE_CHECKSUM_MODE` environment variable to enable checksum verification on compatible S3-providers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26435)) +- Add admin API for managing tags ([rrgeorge](https://github.com/mastodon/mastodon/pull/26872)) +- Add a link to hashtag timelines from the Trending hashtags moderation interface ([gunchleoc](https://github.com/mastodon/mastodon/pull/26724)) +- Add timezone to datetimes in e-mails ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26822)) +- Add `authorized_fetch` server setting in addition to env var ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25798), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26958)) +- Add avatar image to webfinger responses ([tvler](https://github.com/mastodon/mastodon/pull/26558)) +- Add debug logging on signature verification failure ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26637), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26812)) +- Add explicit error messages when DeepL quota is exceeded ([lutoma](https://github.com/mastodon/mastodon/pull/26704)) +- Add Elasticsearch/OpenSearch version to “Software” in admin dashboard ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26652)) +- Add `data-nosnippet` attribute to remote posts and local posts with `noindex` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26648)) +- Add support for federating `memorial` attribute ([rrgeorge](https://github.com/mastodon/mastodon/pull/26583)) +- Add Cherokee and Kalmyk to languages dropdown ([gunchleoc](https://github.com/mastodon/mastodon/pull/26012), [gunchleoc](https://github.com/mastodon/mastodon/pull/26013)) +- Add `DELETE /api/v1/profile/avatar` and `DELETE /api/v1/profile/header` to the REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25124), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26573)) +- Add `ES_PRESET` option to customize numbers of shards and replicas ([Gargron](https://github.com/mastodon/mastodon/pull/26483), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26489)) + This can have a value of `single_node_cluster` (default), `small_cluster` (uses one replica) or `large_cluster` (uses one replica and a higher number of shards). +- Add `CACHE_BUSTER_HTTP_METHOD` environment variable ([renchap](https://github.com/mastodon/mastodon/pull/26528), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26542)) +- Add support for `DB_PASS` when using `DATABASE_URL` ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26295)) +- Add `GET /api/v1/instance/languages` to REST API ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24443)) +- Add primary key to `preview_cards_statuses` join table ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25243), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26384), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26447), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26737), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26979)) +- Add client-side timeout on resend confirmation button ([Gargron](https://github.com/mastodon/mastodon/pull/26300)) +- Add published date and author to news on the explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26155)) +- Add `lang` attribute to various UI components ([c960657](https://github.com/mastodon/mastodon/pull/23869), [c960657](https://github.com/mastodon/mastodon/pull/23891), [c960657](https://github.com/mastodon/mastodon/pull/26111), [c960657](https://github.com/mastodon/mastodon/pull/26149)) +- Add stricter protocol fields validation for accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25937)) +- Add support for Azure blob storage ([mistydemeo](https://github.com/mastodon/mastodon/pull/23607), [mistydemeo](https://github.com/mastodon/mastodon/pull/26080)) +- Add toast with option to open post after publishing in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25564), [Signez](https://github.com/mastodon/mastodon/pull/25919), [Gargron](https://github.com/mastodon/mastodon/pull/26664)) +- Add canonical link tags in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25715)) +- Add button to see results for polls in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25726)) +- Add at-symbol prepended to mention span title ([forsamori](https://github.com/mastodon/mastodon/pull/25684)) +- Add users index on `unconfirmed_email` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25672), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25702)) +- Add superapp index on `oauth_applications` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25670)) +- Add index to backups on `user_id` column ([mjankowski](https://github.com/mastodon/mastodon/pull/25647)) +- Add onboarding prompt when home feed too slow in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25267), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25556), [Gargron](https://github.com/mastodon/mastodon/pull/25579), [renchap](https://github.com/mastodon/mastodon/pull/25580), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25581), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25617), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25917), [Gargron](https://github.com/mastodon/mastodon/pull/26829), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26935)) +- Add `POST /api/v1/conversations/:id/unread` API endpoint to mark a conversation as unread ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25509)) +- Add `translate="no"` to outgoing mentions and links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25524)) +- Add unsubscribe link and headers to e-mails ([Gargron](https://github.com/mastodon/mastodon/pull/25378), [c960657](https://github.com/mastodon/mastodon/pull/26085)) +- Add logging of websocket send errors ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25280)) +- Add time zone preference ([Gargron](https://github.com/mastodon/mastodon/pull/25342), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26025)) +- Add `legal` as report category ([Gargron](https://github.com/mastodon/mastodon/pull/23941), [renchap](https://github.com/mastodon/mastodon/pull/25400), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26509)) +- Add `data-nosnippet` so Google doesn't use trending posts in snippets for `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25279)) +- Add card with who invited you to join when displaying rules on sign-up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23475)) +- Add missing primary keys to `accounts_tags` and `statuses_tags` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25210)) +- Add support for custom sign-up URLs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25014), [renchap](https://github.com/mastodon/mastodon/pull/25108), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25190), [mgmn](https://github.com/mastodon/mastodon/pull/25531)) + This is set using `SSO_ACCOUNT_SIGN_UP` and reflected in the REST API by adding `registrations.sign_up_url` to the `/api/v2/instance` endpoint. +- Add polling and automatic redirection to `/start` on email confirmation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25013)) +- Add ability to block sign-ups from IP using the CLI ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24870)) +- Add ALT badges to media that has alternative text in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24782), [c960657](https://github.com/mastodon/mastodon/pull/26166) +- Add ability to include accounts with pending follow requests in lists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/19727), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24810)) +- Add trend management to admin API ([rrgeorge](https://github.com/mastodon/mastodon/pull/24257)) + - `POST /api/v1/admin/trends/statuses/:id/approve` + - `POST /api/v1/admin/trends/statuses/:id/reject` + - `POST /api/v1/admin/trends/links/:id/approve` + - `POST /api/v1/admin/trends/links/:id/reject` + - `POST /api/v1/admin/trends/tags/:id/approve` + - `POST /api/v1/admin/trends/tags/:id/reject` + - `GET /api/v1/admin/trends/links/publishers` + - `POST /api/v1/admin/trends/links/publishers/:id/approve` + - `POST /api/v1/admin/trends/links/publishers/:id/reject` +- Add user handle to notification mail recipient address ([HeitorMC](https://github.com/mastodon/mastodon/pull/24240)) +- Add progress indicator to sign-up flow ([Gargron](https://github.com/mastodon/mastodon/pull/24545)) +- Add client-side validation for taken username in sign-up form ([Gargron](https://github.com/mastodon/mastodon/pull/24546)) +- Add `--approve` option to `tootctl accounts create` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24533)) +- Add “In Memoriam” banner back to profiles ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23591), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23614)) + This adds the `memorial` attribute to the `Account` REST API entity. +- Add colour to follow button when hashtag is being followed ([c960657](https://github.com/mastodon/mastodon/pull/24361)) +- Add further explanations to the profile link verification instructions ([drzax](https://github.com/mastodon/mastodon/pull/19723)) +- Add a link to Identity provider's account settings from the account settings ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24628)) +- Add support for streaming server to connect to postgres with self-signed certs through the `sslmode` URL parameter ([ramuuns](https://github.com/mastodon/mastodon/pull/21431)) +- Add support for specifying S3 storage classes through the `S3_STORAGE_CLASS` environment variable ([hyl](https://github.com/mastodon/mastodon/pull/22480)) +- Add support for incoming rich text ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23913)) +- Add support for Ruby 3.2 ([tenderlove](https://github.com/mastodon/mastodon/pull/22928), [casperisfine](https://github.com/mastodon/mastodon/pull/24142), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24202), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26934)) +- Add API parameter to safeguard unexpected mentions in new posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18350)) + +### Changed + +- **Change hashtags to be displayed separately when they are the last line of a post** ([renchap](https://github.com/mastodon/mastodon/pull/26499), [renchap](https://github.com/mastodon/mastodon/pull/26614), [renchap](https://github.com/mastodon/mastodon/pull/26615)) +- **Change reblogs to be excluded from "Posts and replies" tab in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26302)) +- **Change interaction modal in web interface** ([Gargron, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26075), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26268), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26267), [mgmn](https://github.com/mastodon/mastodon/pull/26459), [tribela](https://github.com/mastodon/mastodon/pull/26461), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26593), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26795)) +- **Change design of link previews in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/26136), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26151), [Gargron](https://github.com/mastodon/mastodon/pull/26153), [Gargron](https://github.com/mastodon/mastodon/pull/26250), [Gargron](https://github.com/mastodon/mastodon/pull/26287), [Gargron](https://github.com/mastodon/mastodon/pull/26286), [c960657](https://github.com/mastodon/mastodon/pull/26184)) +- **Change "direct message" nomenclature to "private mention" in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/24248)) +- **Change translation feature to cover Content Warnings, poll options and media descriptions** ([c960657](https://github.com/mastodon/mastodon/pull/24175), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25251), [c960657](https://github.com/mastodon/mastodon/pull/26168), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26452)) +- **Change account search to match by text when opted-in** ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25599), [Gargron](https://github.com/mastodon/mastodon/pull/26378)) +- **Change import feature to be clearer, less error-prone and more reliable** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21054), [mgmn](https://github.com/mastodon/mastodon/pull/24874)) +- **Change local and federated timelines to be tabs of a single “Live feeds” column** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25641), [Gargron](https://github.com/mastodon/mastodon/pull/25683), [mgmn](https://github.com/mastodon/mastodon/pull/25694), [Plastikmensch](https://github.com/mastodon/mastodon/pull/26247), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26633)) +- **Change user archive export to be faster and more reliable, and export `.zip` archives instead of `.tar.gz` ones** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23360), [TheEssem](https://github.com/mastodon/mastodon/pull/25034)) +- **Change `mastodon-streaming` systemd unit files to be templated** ([e-nomem](https://github.com/mastodon/mastodon/pull/24751)) +- **Change `statsd` integration to disable sidekiq metrics by default** ([mjankowski](https://github.com/mastodon/mastodon/pull/25265), [mjankowski](https://github.com/mastodon/mastodon/pull/25336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26310)) + This deprecates `statsd` support and disables the sidekiq integration unless `STATSD_SIDEKIQ` is set to `true`. + This is because the `nsa` gem is unmaintained, and its sidekiq integration is known to add very significant overhead. + Later versions of Mastodon will have other ways to get the same metrics. +- **Change replica support to native Rails adapter** ([krainboltgreene](https://github.com/mastodon/mastodon/pull/25693), [Gargron](https://github.com/mastodon/mastodon/pull/25849), [Gargron](https://github.com/mastodon/mastodon/pull/25874), [Gargron](https://github.com/mastodon/mastodon/pull/25851), [Gargron](https://github.com/mastodon/mastodon/pull/25977), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26074), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26386), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26856)) + This is a breaking change, dropping `makara` support, and requiring you to update your database configuration if you are using replicas. + To tell Mastodon to use a read replica, you can either set the `REPLICA_DB_NAME` environment variable (along with `REPLICA_DB_USER`, `REPLICA_DB_PASS`, `REPLICA_DB_HOST`, and `REPLICA_DB_PORT`, if they differ from the primary database), or the `REPLICA_DATABASE_URL` environment variable if your configuration is based on `DATABASE_URL`. +- Change DCT method used for JPEG encoding to float ([electroCutie](https://github.com/mastodon/mastodon/pull/26675)) +- Change from `node-redis` to `ioredis` for streaming ([gmemstr](https://github.com/mastodon/mastodon/pull/26581)) +- Change private statuses index to index without crutches ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26713)) +- Change video compression parameters ([Gargron](https://github.com/mastodon/mastodon/pull/26631), [Gargron](https://github.com/mastodon/mastodon/pull/26745), [Gargron](https://github.com/mastodon/mastodon/pull/26766), [Gargron](https://github.com/mastodon/mastodon/pull/26970)) +- Change admin e-mail notification settings to be their own settings group ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26596)) +- Change opacity of the delete icon in the search field to be more visible ([AntoninDelFabbro](https://github.com/mastodon/mastodon/pull/26449)) +- Change Account Search to prioritize username over display name ([jsgoldstein](https://github.com/mastodon/mastodon/pull/26623)) +- Change follow recommendation materialized view to be faster in most cases ([renchap, ClearlyClaire](https://github.com/mastodon/mastodon/pull/26545)) +- Change `robots.txt` to block GPTBot ([Foritus](https://github.com/mastodon/mastodon/pull/26396)) +- Change header of hashtag timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26362), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26416)) +- Change streaming `/metrics` to include additional metrics ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26299), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26945)) +- Change indexing frequency from 5 minutes to 1 minute, add locks to schedulers ([Gargron](https://github.com/mastodon/mastodon/pull/26304)) +- Change column link to add a better keyboard focus indicator ([teeerevor](https://github.com/mastodon/mastodon/pull/26278)) +- Change poll form element colors to fit with the rest of the ui ([teeerevor](https://github.com/mastodon/mastodon/pull/26139), [teeerevor](https://github.com/mastodon/mastodon/pull/26162), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26164)) +- Change 'favourite' to 'favorite' for American English ([marekr](https://github.com/mastodon/mastodon/pull/24667), [gunchleoc](https://github.com/mastodon/mastodon/pull/26009), [nabijaczleweli](https://github.com/mastodon/mastodon/pull/26109)) +- Change ActivityStreams representation of suspended accounts to not use a blank `name` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25276)) +- Change focus UI for keyboard only input ([teeerevor](https://github.com/mastodon/mastodon/pull/25935), [Gargron](https://github.com/mastodon/mastodon/pull/26125), [Gargron](https://github.com/mastodon/mastodon/pull/26767)) +- Change thread view to scroll to the selected post rather than the post being replied to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24685)) +- Change links in multi-column mode so tabs are open in single-column mode ([Signez](https://github.com/mastodon/mastodon/pull/25893), [Signez](https://github.com/mastodon/mastodon/pull/26070), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25973), [Signez](https://github.com/mastodon/mastodon/pull/26019), [Signez](https://github.com/mastodon/mastodon/pull/26759)) +- Change searching with `#` to include account index ([jsgoldstein](https://github.com/mastodon/mastodon/pull/25638)) +- Change label and design of sensitive and unavailable media in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25712), [Gargron](https://github.com/mastodon/mastodon/pull/26135), [Gargron](https://github.com/mastodon/mastodon/pull/26330)) +- Change button colors to increase hover/focus contrast and consistency ([teeerevor](https://github.com/mastodon/mastodon/pull/25677), [Gargron](https://github.com/mastodon/mastodon/pull/25679)) +- Change dropdown icon above compose form from ellipsis to bars in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25661)) +- Change header backgrounds to use fewer different colors in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25577)) +- Change files to be deleted in batches instead of one-by-one ([Gargron](https://github.com/mastodon/mastodon/pull/23302), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/25586), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25587)) +- Change emoji picker icon ([iparr](https://github.com/mastodon/mastodon/pull/25479)) +- Change edit profile page ([Gargron](https://github.com/mastodon/mastodon/pull/25413), [c960657](https://github.com/mastodon/mastodon/pull/26538)) +- Change "bot" label to "automated" ([Gargron](https://github.com/mastodon/mastodon/pull/25356)) +- Change design of dropdowns in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25107)) +- Change wording of “Content cache retention period” setting to highlight destructive implications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23261)) +- Change autolinking to allow carets in URL search params ([renchap](https://github.com/mastodon/mastodon/pull/25216)) +- Change share action from being in action bar to being in dropdown in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25105)) +- Change sessions to be ordered from most-recent to least-recently updated ([frankieroberto](https://github.com/mastodon/mastodon/pull/25005)) +- Change vacuum scheduler to also delete expired tokens and unused application records ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24868), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24871)) +- Change "Sign in" to "Login" ([Gargron](https://github.com/mastodon/mastodon/pull/24942)) +- Change domain suspensions to also be checked before trying to fetch unknown remote resources ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24535)) +- Change media components to use aspect-ratio rather than compute height themselves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24686), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24943), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26801)) +- Change logo version in header based on screen size in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24707)) +- Change label from "For you" to "People" on explore screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24706)) +- Change logged-out WebUI HTML pages to be cached for a few seconds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24708)) +- Change unauthenticated responses to be cached in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/24348), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24662), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24665)) +- Change HTTP caching logic ([Gargron](https://github.com/mastodon/mastodon/pull/24347), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24604)) +- Change hashtags and mentions in bios to open in-app in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24643)) +- Change styling of the recommended accounts to allow bio to be more visible ([chike00](https://github.com/mastodon/mastodon/pull/24480)) +- Change account search in moderation interface to allow searching by username including the leading `@` ([HeitorMC](https://github.com/mastodon/mastodon/pull/24242)) +- Change all components to use the same error page in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24512)) +- Change search pop-out in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24305)) +- Change user settings to be stored in a more optimal way ([Gargron](https://github.com/mastodon/mastodon/pull/23630), [c960657](https://github.com/mastodon/mastodon/pull/24321), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24453), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24460), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24558), [Gargron](https://github.com/mastodon/mastodon/pull/24761), [Gargron](https://github.com/mastodon/mastodon/pull/24783), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25508), [jsgoldstein](https://github.com/mastodon/mastodon/pull/25340), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26884), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/27012)) +- Change media upload limits and remove client-side resizing ([Gargron](https://github.com/mastodon/mastodon/pull/23726)) +- Change design of account rows in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24247), [Gargron](https://github.com/mastodon/mastodon/pull/24343), [Gargron](https://github.com/mastodon/mastodon/pull/24956), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25131)) +- Change log-out to use Single Logout when using external log-in through OIDC ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24020)) +- Change sidekiq-bulk's batch size from 10,000 to 1,000 jobs in one Redis call ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24034)) +- Change translation to only be offered for supported languages ([c960657](https://github.com/mastodon/mastodon/pull/23879), [c960657](https://github.com/mastodon/mastodon/pull/24037)) + This adds the `/api/v1/instance/translation_languages` REST API endpoint that returns an object with the supported translation language pairs in the form: + ```json + { + "fr": ["en", "de"] + } + ``` + (where `fr` is a supported source language and `en` and `de` or supported output language when translating a `fr` string) +- Change compose form checkbox to native input with `appearance: none` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/22949)) +- Change posts' clickable area to be larger ([c960657](https://github.com/mastodon/mastodon/pull/23621)) +- Change `followed_by` link to `location=all` if account is local on /admin/accounts/:id page ([tribela](https://github.com/mastodon/mastodon/pull/23467)) + +### Removed + +- **Remove support for Node.js 14** ([renchap](https://github.com/mastodon/mastodon/pull/25198)) +- **Remove support for Ruby 2.7** ([nschonni](https://github.com/mastodon/mastodon/pull/24237)) +- **Remove clustering from streaming API** ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/24655)) +- **Remove anonymous access to the streaming API** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23989)) +- Remove obfuscation of reply count in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26768)) +- Remove `kmr` from language selection, as it was a duplicate for `ku` ([gunchleoc](https://github.com/mastodon/mastodon/pull/26014), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26787)) +- Remove 16:9 cropping from web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26132)) +- Remove back button from bookmarks, favourites and lists screens in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26126)) +- Remove display name input from sign-up form ([Gargron](https://github.com/mastodon/mastodon/pull/24704)) +- Remove `tai` locale ([c960657](https://github.com/mastodon/mastodon/pull/23880)) +- Remove empty Kushubian (csb) local files ([nschonni](https://github.com/mastodon/mastodon/pull/24151)) +- Remove `Permissions-Policy` header from all responses ([Gargron](https://github.com/mastodon/mastodon/pull/24124)) + +### Fixed + +- **Fix filters not being applying in the explore page** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25887)) +- **Fix being unable to load past a full page of filtered posts in Home timeline** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24930)) +- **Fix log-in flow when involving both OAuth and external authentication** ([CSDUMMI](https://github.com/mastodon/mastodon/pull/24073)) +- **Fix broken links in account gallery** ([c960657](https://github.com/mastodon/mastodon/pull/24218)) +- **Fix migration handler not updating lists** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24808)) +- Fix crash when viewing a moderation appeal and the moderator account has been deleted ([xrobau](https://github.com/mastodon/mastodon/pull/25900)) +- Fix error in Web UI when server rules cannot be fetched ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26957)) +- Fix paragraph margins resulting in irregular read-more cut-off in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26828)) +- Fix notification permissions being requested immediately after login ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26472)) +- Fix performances of profile directory ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26840), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26842)) +- Fix mute button and volume slider feeling disconnected in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26827), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26860)) +- Fix “Scoped order is ignored, it's forced to be batch order.” warnings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26793)) +- Fix blocked domain appearing in account feeds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26823)) +- Fix invalid `Content-Type` header for WebP images ([c960657](https://github.com/mastodon/mastodon/pull/26773)) +- Fix minor inefficiencies in `tootctl search deploy` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26721)) +- Fix filter form in profiles directory overflowing instead of wrapping ([arbolitoloco1](https://github.com/mastodon/mastodon/pull/26682)) +- Fix sign up steps progress layout in right-to-left locales ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26728)) +- Fix bug with “favorited by” and “reblogged by“ view on posts only showing up to 40 items ([timothyjrogers](https://github.com/mastodon/mastodon/pull/26577), [timothyjrogers](https://github.com/mastodon/mastodon/pull/26574)) +- Fix bad search type heuristic ([Gargron](https://github.com/mastodon/mastodon/pull/26673)) +- Fix not being able to negate prefix clauses in search ([Gargron](https://github.com/mastodon/mastodon/pull/26672)) +- Fix timeout on invalid set of exclusionary parameters in `/api/v1/timelines/public` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/26239)) +- Fix adding column with default value taking longer on Postgres >= 11 ([Gargron](https://github.com/mastodon/mastodon/pull/26375)) +- Fix light theme select option for hashtags ([teeerevor](https://github.com/mastodon/mastodon/pull/26311)) +- Fix AVIF attachments ([c960657](https://github.com/mastodon/mastodon/pull/26264)) +- Fix incorrect URL normalization when fetching remote resources ([c960657](https://github.com/mastodon/mastodon/pull/26219), [c960657](https://github.com/mastodon/mastodon/pull/26285)) +- Fix being unable to filter posts for individual Chinese languages ([gunchleoc](https://github.com/mastodon/mastodon/pull/26066)) +- Fix preview card sometimes linking to 4xx error pages ([c960657](https://github.com/mastodon/mastodon/pull/26200)) +- Fix emoji picker button scrolling with textarea content in single-column view ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25304)) +- Fix missing border on error screen in light theme in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/26152)) +- Fix UI overlap with the loupe icon in the Explore Tab ([gol-cha](https://github.com/mastodon/mastodon/pull/26113)) +- Fix unexpected redirection to `/explore` after sign-in ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26143)) +- Fix `/api/v1/statuses/:id/unfavourite` and `/api/v1/statuses/:id/unreblog` returning non-updated counts ([c960657](https://github.com/mastodon/mastodon/pull/24365)) +- Fix clicking the “Back” button sometimes leading out of Mastodon ([c960657](https://github.com/mastodon/mastodon/pull/23953), [CSFlorin](https://github.com/mastodon/mastodon/pull/24835), [S-H-GAMELINKS](https://github.com/mastodon/mastodon/pull/24867), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25281)) +- Fix processing of `null` ActivityPub activities ([tribela](https://github.com/mastodon/mastodon/pull/26021)) +- Fix hashtag posts not being removed from home feed on hashtag unfollow ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26028)) +- Fix for "follows you" indicator in light web UI not readable ([vmstan](https://github.com/mastodon/mastodon/pull/25993)) +- Fix incorrect line break between icon and number of reposts & favourites ([edent](https://github.com/mastodon/mastodon/pull/26004)) +- Fix sounds not being loaded from assets host ([Signez](https://github.com/mastodon/mastodon/pull/25931)) +- Fix buttons showing inconsistent styles ([teeerevor](https://github.com/mastodon/mastodon/pull/25903), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25965), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26341), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/26482)) +- Fix trend calculation working on too many items at a time ([Gargron](https://github.com/mastodon/mastodon/pull/25835)) +- Fix dropdowns being disabled for logged out users in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25964)) +- Fix explore page being inaccessible when opted-out of trends in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25716)) +- Fix re-activated accounts possibly getting deleted by `AccountDeletionWorker` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25711)) +- Fix `/api/v2/search` not working with following query param ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25681)) +- Fix inefficient query when requesting a new confirmation email from a logged-in account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25669)) +- Fix unnecessary concurrent calls to `/api/*/instance` in web UI ([mgmn](https://github.com/mastodon/mastodon/pull/25663)) +- Fix resolving local URL for remote content ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25637)) +- Fix search not being easily findable on smaller screens in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25576), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/25631)) +- Fix j/k keyboard shortcuts on some status lists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25554)) +- Fix missing validation on `default_privacy` setting ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25513)) +- Fix incorrect pagination headers in `/api/v2/admin/accounts` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/25477)) +- Fix non-interactive upload container being given a `button` role and tabIndex ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25462)) +- Fix always redirecting to onboarding in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/25396)) +- Fix inconsistent use of middle dot (·) instead of bullet (•) to separate items ([j-f1](https://github.com/mastodon/mastodon/pull/25248)) +- Fix spacing of middle dots in the detailed status meta section ([j-f1](https://github.com/mastodon/mastodon/pull/25247)) +- Fix prev/next buttons color in media viewer ([renchap](https://github.com/mastodon/mastodon/pull/25231)) +- Fix email addresses not being properly updated in `tootctl maintenance fix-duplicates` ([mjankowski](https://github.com/mastodon/mastodon/pull/25118)) +- Fix unicode surrogate pairs sometimes being broken in page title ([eai04191](https://github.com/mastodon/mastodon/pull/25148)) +- Fix various inefficient queries against account domains ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25126)) +- Fix video player offering to expand in a lightbox when it's in an `iframe` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25067)) +- Fix post embed previews ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25071)) +- Fix inadequate error handling in several API controllers when given invalid parameters ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24947), [danielmbrasil](https://github.com/mastodon/mastodon/pull/24958), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25063), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25072), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25386), [danielmbrasil](https://github.com/mastodon/mastodon/pull/25595)) +- Fix uncaught `ActiveRecord::StatementInvalid` in Mastodon::IpBlocksCLI ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24861)) +- Fix various edge cases with local moves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24812)) +- Fix `tootctl accounts cull` crashing when encountering a domain resolving to a private address ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23378)) +- Fix `tootctl accounts approve --number N` not aproving the N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605)) +- Fix being unable to clear media description when editing posts ([c960657](https://github.com/mastodon/mastodon/pull/24720)) +- Fix unavailable translations not falling back to English ([mgmn](https://github.com/mastodon/mastodon/pull/24727)) +- Fix anonymous visitors getting a session cookie on first visit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24584), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24650), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24664)) +- Fix cutting off first letter of hashtag links sometimes in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/24623)) +- Fix crash in `tootctl accounts create --reattach --force` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24557), [danielmbrasil](https://github.com/mastodon/mastodon/pull/24680)) +- Fix characters being emojified even when using Variation Selector 15 (text) ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/20949), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24615)) +- Fix uncaught ActiveRecord::StatementInvalid exception in `Mastodon::AccountsCLI#approve` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24590)) +- Fix email confirmation skip option in `tootctl accounts modify USERNAME --email EMAIL --confirm` ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24578)) +- Fix tooltip for dates without time ([c960657](https://github.com/mastodon/mastodon/pull/24244)) +- Fix missing loading spinner and loading more on scroll in Private Mentions column ([c960657](https://github.com/mastodon/mastodon/pull/24446)) +- Fix account header image missing from `/settings/profile` on narrow screens ([c960657](https://github.com/mastodon/mastodon/pull/24433)) +- Fix height of announcements not being updated when using reduced animations ([c960657](https://github.com/mastodon/mastodon/pull/24354)) +- Fix inconsistent radius in advanced interface drawer ([thislight](https://github.com/mastodon/mastodon/pull/24407)) +- Fix loading more trending posts on scroll in the advanced interface ([OmmyZhang](https://github.com/mastodon/mastodon/pull/24314)) +- Fix poll ending notification for edited polls ([c960657](https://github.com/mastodon/mastodon/pull/24311)) +- Fix max width of media in `/about` and `/privacy-policy` ([mgmn](https://github.com/mastodon/mastodon/pull/24180)) +- Fix streaming API not being usable without `DATABASE_URL` ([Gargron](https://github.com/mastodon/mastodon/pull/23960)) +- Fix external authentication not running onboarding code for new users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/23458)) + +## [4.1.8] - 2023-09-19 + +### Fixed + +- Fix post edits not being forwarded as expected ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26936)) +- Fix moderator rights inconsistencies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26729)) +- Fix crash when encountering invalid URL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26814)) +- Fix cached posts including stale stats ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26409)) +- Fix uploading of video files for which `ffprobe` reports `0/0` average framerate ([NicolaiSoeborg](https://github.com/mastodon/mastodon/pull/26500)) +- Fix unexpected audio stream transcoding when uploaded video is eligible to passthrough ([yufushiro](https://github.com/mastodon/mastodon/pull/26608)) + +### Security + +- Fix missing HTML sanitization in translation API (CVE-2023-42452, [GHSA-2693-xr3m-jhqr](https://github.com/mastodon/mastodon/security/advisories/GHSA-2693-xr3m-jhqr)) +- Fix incorrect domain name normalization (CVE-2023-42451, [GHSA-v3xf-c9qf-j667](https://github.com/mastodon/mastodon/security/advisories/GHSA-v3xf-c9qf-j667)) + +## [4.1.7] - 2023-09-05 + +### Changed + +- Change remote report processing to accept reports with long comments, but truncate them ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25028)) + +### Fixed + +- **Fix blocking subdomains of an already-blocked domain** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26392)) +- Fix `/api/v1/timelines/tag/:hashtag` allowing for unauthenticated access when public preview is disabled ([danielmbrasil](https://github.com/mastodon/mastodon/pull/26237)) +- Fix inefficiencies in `PlainTextFormatter` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26727)) + +## [4.1.6] - 2023-07-31 + +### Fixed + +- Fix memory leak in streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26228)) +- Fix wrong filters sometimes applying in streaming ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26159), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26213), [renchap](https://github.com/mastodon/mastodon/pull/26233)) +- Fix incorrect connect timeout in outgoing requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26116)) + +## [4.1.5] - 2023-07-21 + +### Added + +- Add check preventing Sidekiq workers from running with Makara configured ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25850)) + +### Changed + +- Change request timeout handling to use a longer deadline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26055)) + +### Fixed + +- Fix moderation interface for remote instances with a .zip TLD ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25885)) +- Fix remote accounts being possibly persisted to database with incomplete protocol values ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25886)) +- Fix trending publishers table not rendering correctly on narrow screens ([vmstan](https://github.com/mastodon/mastodon/pull/25945)) + +### Security + +- Fix CSP headers being unintentionally wide ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26105)) + ## [4.1.4] - 2023-07-07 ### Fixed @@ -143,7 +531,7 @@ All notable changes to this project will be documented in this file. - Add instance activity API endpoint toggle back to the admin interface ([dariusk](https://github.com/mastodon/mastodon/pull/22833)) - Add setting for status page URL ([Gargron](https://github.com/mastodon/mastodon/pull/23390), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/23499)) - REST API changes: - - Add `configuration.urls.status` attribute to the object returned by `GET /api/v1/instance` + - Add `configuration.urls.status` attribute to the object returned by `GET /api/v2/instance` - Add `account.approved` webhook ([Saiv46](https://github.com/mastodon/mastodon/pull/22938)) - Add 12 hours option to polls ([Pleclown](https://github.com/mastodon/mastodon/pull/21131)) - Add dropdown menu item to open admin interface for remote domains ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/21895)) @@ -550,2331 +938,4 @@ Some of the features in this release have been funded through the [NGI0 Discover - Fix rate limiting for paths with formats ([Gargron](https://github.com/mastodon/mastodon/pull/20675)) - Fix out-of-bound reads in blurhash transcoder ([delroth](https://github.com/mastodon/mastodon/pull/20388)) -## [3.5.3] - 2022-05-26 - -### Added - -- **Add language dropdown to compose form in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/18420), [ykzts](https://github.com/mastodon/mastodon/pull/18460)) -- **Add warning for limited accounts in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/18344)) -- Add `limited` attribute to accounts in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/18344)) - -### Changed - -- **Change RSS feeds** ([Gargron](https://github.com/mastodon/mastodon/pull/18356), [tribela](https://github.com/mastodon/mastodon/pull/18406)) - - Titles are now date and time of post - - Bodies now render all content faithfully, including polls and emojis - - All media attachments are included with Media RSS -- Change "dangerous" to "sensitive" in privacy policy and web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18515)) -- Change unconfirmed accounts to not be visible in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17530)) -- Change `tootctl search deploy` to improve performance ([Gargron](https://github.com/mastodon/mastodon/pull/18463), [Gargron](https://github.com/mastodon/mastodon/pull/18514)) -- Change search indexing to use batches to minimize resource usage ([Gargron](https://github.com/mastodon/mastodon/pull/18451)) - -### Fixed - -- Fix follower and other counters being able to go negative ([Gargron](https://github.com/mastodon/mastodon/pull/18517)) -- Fix unnecessary query on when creating a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17901)) -- Fix warning an account outside of a report closing all reports for that account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18387)) -- Fix error when resolving a link that redirects to a local post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18314)) -- Fix preferred posting language returning unusable value in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/18428)) -- Fix race condition error when external status is reblogged ([ykzts](https://github.com/mastodon/mastodon/pull/18424)) -- Fix missing string for appeal validation error ([Gargron](https://github.com/mastodon/mastodon/pull/18410)) -- Fix block/mute lists showing a follow button in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18364)) -- Fix Redis configuration not being changed by `mastodon:setup` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18383)) -- Fix streaming notifications not using quick filter logic in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18316)) -- Fix ambiguous wording on appeal actions in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18328)) -- Fix floating action button obscuring last element in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18332)) -- Fix account warnings not being recorded in audit log ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18338)) -- Fix leftover icons for direct visibility statuses ([Steffo99](https://github.com/mastodon/mastodon/pull/18305)) -- Fix link verification requiring case sensitivity on links ([sgolemon](https://github.com/mastodon/mastodon/pull/18320)) -- Fix embeds not setting their height correctly ([rinsuki](https://github.com/mastodon/mastodon/pull/18301)) - -### Security - -- Fix concurrent unfollowing decrementing follower count more than once ([Gargron](https://github.com/mastodon/mastodon/pull/18527)) -- Fix being able to appeal a strike unlimited times ([Gargron](https://github.com/mastodon/mastodon/pull/18529)) -- Fix being able to report otherwise inaccessible statuses ([Gargron](https://github.com/mastodon/mastodon/pull/18528)) -- Fix empty votes arbitrarily increasing voters count in polls ([Gargron](https://github.com/mastodon/mastodon/pull/18526)) -- Fix moderator identity leak when approving appeal of sensitive marked statuses ([Gargron](https://github.com/mastodon/mastodon/pull/18525)) -- Fix suspended users being able to access APIs that don't require a user ([Gargron](https://github.com/mastodon/mastodon/pull/18524)) -- Fix confirmation redirect to app without `Location` header ([Gargron](https://github.com/mastodon/mastodon/pull/18523)) - -## [3.5.2] - 2022-05-04 - -### Added - -- Add warning on direct messages screen in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18289)) - - We already had a warning when composing a direct message, it has now been reworded to be more clear - - Same warning is now displayed when viewing sent and received direct messages -- Add ability to set approval-based registration through tootctl ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18248)) -- Add pre-filling of domain from search filter in domain allow/block admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18172)) - -## Changed - -- Change name of “Direct” visibility to “Mentioned people only” in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18146), [Gargron](https://github.com/mastodon/mastodon/pull/18289), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18291)) -- Change trending posts to only show one post from each account ([Gargron](https://github.com/mastodon/mastodon/pull/18181)) -- Change half-life of trending posts from 6 hours to 2 hours ([Gargron](https://github.com/mastodon/mastodon/pull/18182)) -- Change full-text search feature to also include polls you have voted in ([tribela](https://github.com/mastodon/mastodon/pull/18070)) -- Change Redis from using one connection per process, to using a connection pool ([Gargron](https://github.com/mastodon/mastodon/pull/18135), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18160), [Gargron](https://github.com/mastodon/mastodon/pull/18171)) - - Different threads no longer have to wait on a mutex over a single connection - - However, this does increase the number of Redis connections by a fair amount - - We are planning to optimize Redis use so that the pool can be made smaller in the future - -## Removed - -- Remove IP matching from e-mail domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/18190)) - - The IPs of the blocked e-mail domain or its MX records are no longer checked - - Previously it was too easy to block e-mail providers by mistake - -## Fixed - -- Fix compatibility with Friendica's pinned posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18254), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/18260)) -- Fix error when looking up handle with surrounding spaces in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18225)) -- Fix double render error when authorizing interaction ([Gargron](https://github.com/mastodon/mastodon/pull/18203)) -- Fix error when a post references an invalid media attachment ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18211)) -- Fix error when trying to revoke OAuth token without supplying a token ([Gargron](https://github.com/mastodon/mastodon/pull/18205)) -- Fix error caused by missing subject in Webfinger response ([Gargron](https://github.com/mastodon/mastodon/pull/18204)) -- Fix error on attempting to delete an account moderation note ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18196)) -- Fix light-mode emoji borders in web UI ([Gaelan](https://github.com/mastodon/mastodon/pull/18131)) -- Fix being able to scroll away from the loading bar in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/18170)) -- Fix error when a bookmark or favorite has been reported and deleted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18174)) -- Fix being offered empty “Server rules violation” report option in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18165)) -- Fix temporary network errors preventing from authorizing interactions with remote accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18161)) -- Fix incorrect link in "new trending tags" email ([cdzombak](https://github.com/mastodon/mastodon/pull/18156)) -- Fix missing indexes on some foreign keys ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18157)) -- Fix n+1 query on feed merge and populate operations ([Gargron](https://github.com/mastodon/mastodon/pull/18111)) -- Fix feed unmerge worker being exceptionally slow in some conditions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18110)) -- Fix PeerTube videos appearing with an erroneous “Edited at” marker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18100)) -- Fix instance actor being created incorrectly when running through migrations ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18109)) -- Fix web push notifications containing HTML entities ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18071)) -- Fix inconsistent parsing of `TRUSTED_PROXY_IP` ([ykzts](https://github.com/mastodon/mastodon/pull/18051)) -- Fix error when fetching pinned posts ([tribela](https://github.com/mastodon/mastodon/pull/18030)) -- Fix wrong optimization in feed populate operation ([dogelover911](https://github.com/mastodon/mastodon/pull/18009)) -- Fix error in alias settings page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18004)) - -## [3.5.1] - 2022-04-08 - -### Added - -- Add pagination for trending statuses in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17976)) - -### Changed - -- Change e-mail notifications to only be sent when recipient is offline ([Gargron](https://github.com/mastodon/mastodon/pull/17984)) - - Send e-mails for mentions and follows by default again - - But only when recipient does not have push notifications through an app -- Change `website` attribute to be nullable on `Application` entity in REST API ([rinsuki](https://github.com/mastodon/mastodon/pull/17962)) - -### Removed - -- Remove sign-in token authentication, instead send e-mail about new sign-in ([Gargron](https://github.com/mastodon/mastodon/pull/17970)) - - You no longer need to enter a security code sent through e-mail - - Instead you get an e-mail about a new sign-in from an unfamiliar IP address - -### Fixed - -- Fix error responses for `from` search prefix ([single-right-quote](https://github.com/mastodon/mastodon/pull/17963)) -- Fix dangling language-specific trends ([Gargron](https://github.com/mastodon/mastodon/pull/17997)) -- Fix extremely rare race condition when deleting a status or account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17994)) -- Fix trends returning less results per page when filtered in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17996)) -- Fix pagination header on empty trends responses in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17986)) -- Fix cookies secure flag being set when served over Tor ([Gargron](https://github.com/mastodon/mastodon/pull/17992)) -- Fix migration error handling ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17991)) -- Fix error when re-running some migrations if they get interrupted at the wrong moment ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17989)) -- Fix potentially missing statuses when reconnecting to streaming API in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17981), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17987), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17980)) -- Fix error when sending warning emails with custom text ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17983)) -- Fix unset `SMTP_RETURN_PATH` environment variable causing e-mail not to send ([Gargron](https://github.com/mastodon/mastodon/pull/17982)) -- Fix possible duplicate statuses in timelines in some edge cases in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17971)) -- Fix spurious edits and require incoming edits to be explicitly marked as such ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17918)) -- Fix error when encountering invalid pinned statuses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17964)) -- Fix inconsistency in error handling when removing a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17974)) -- Fix admin API unconditionally requiring CSRF token ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17975)) -- Fix trending tags endpoint missing `offset` param in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17973)) -- Fix unusual number formatting in some locales ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17929)) -- Fix `S3_FORCE_SINGLE_REQUEST` environment variable not working ([HolgerHuo](https://github.com/mastodon/mastodon/pull/17922)) -- Fix failure to build assets with OpenSSL 3 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17930)) -- Fix PWA manifest using outdated routes ([HolgerHuo](https://github.com/mastodon/mastodon/pull/17921)) -- Fix error when indexing statuses into Elasticsearch ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17912)) - -## [3.5.0] - 2022-03-30 - -### Added - -- **Add support for incoming edited posts** ([Gargron](https://github.com/mastodon/mastodon/pull/16697), [Gargron](https://github.com/mastodon/mastodon/pull/17727), [Gargron](https://github.com/mastodon/mastodon/pull/17728), [Gargron](https://github.com/mastodon/mastodon/pull/17320), [Gargron](https://github.com/mastodon/mastodon/pull/17404), [Gargron](https://github.com/mastodon/mastodon/pull/17390), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17335), [Gargron](https://github.com/mastodon/mastodon/pull/17696), [Gargron](https://github.com/mastodon/mastodon/pull/17745), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17740), [Gargron](https://github.com/mastodon/mastodon/pull/17697), [Gargron](https://github.com/mastodon/mastodon/pull/17648), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17531), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17499), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17498), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17380), [Gargron](https://github.com/mastodon/mastodon/pull/17373), [Gargron](https://github.com/mastodon/mastodon/pull/17334), [Gargron](https://github.com/mastodon/mastodon/pull/17333), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17699), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17748)) - - Previous versions remain available for perusal and comparison - - People who reblogged a post are notified when it's edited - - New REST APIs: - - `PUT /api/v1/statuses/:id` - - `GET /api/v1/statuses/:id/history` - - `GET /api/v1/statuses/:id/source` - - New streaming API event: - - `status.update` -- **Add appeals for moderator decisions** ([Gargron](https://github.com/mastodon/mastodon/pull/17364), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17725), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17566), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17652), [Gargron](https://github.com/mastodon/mastodon/pull/17616), [Gargron](https://github.com/mastodon/mastodon/pull/17615), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17554), [Gargron](https://github.com/mastodon/mastodon/pull/17523)) - - All default moderator decisions now notify the affected user by e-mail - - They now link to an appeal page instead of suggesting replying to the e-mail - - They can now be found in account settings and not just e-mail - - Users can submit one appeal within 20 days of the decision - - Moderators can approve or reject the appeal -- **Add notifications for posts deleted by moderators** ([Gargron](https://github.com/mastodon/mastodon/pull/17204), [Gargron](https://github.com/mastodon/mastodon/pull/17668), [Gargron](https://github.com/mastodon/mastodon/pull/17746), [Gargron](https://github.com/mastodon/mastodon/pull/17679), [Gargron](https://github.com/mastodon/mastodon/pull/17487)) - - New, redesigned report view in admin UI - - Common report actions now only take one click to complete - - Deleting posts or marking as sensitive from report now notifies user - - Reports can be categorized by reason and specific rules violated - - The reasons are automatically cited in the notifications, except for spam - - Marking posts as sensitive now federates using post editing -- **Add explore page with trending posts and links** ([Gargron](https://github.com/mastodon/mastodon/pull/17123), [Gargron](https://github.com/mastodon/mastodon/pull/17431), [Gargron](https://github.com/mastodon/mastodon/pull/16917), [Gargron](https://github.com/mastodon/mastodon/pull/17677), [Gargron](https://github.com/mastodon/mastodon/pull/16938), [Gargron](https://github.com/mastodon/mastodon/pull/17044), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16978), [Gargron](https://github.com/mastodon/mastodon/pull/16979), [tribela](https://github.com/mastodon/mastodon/pull/17066), [Gargron](https://github.com/mastodon/mastodon/pull/17072), [Gargron](https://github.com/mastodon/mastodon/pull/17403), [noiob](https://github.com/mastodon/mastodon/pull/17624), [mayaeh](https://github.com/mastodon/mastodon/pull/17755), [mayaeh](https://github.com/mastodon/mastodon/pull/17757), [Gargron](https://github.com/mastodon/mastodon/pull/17760), [mayaeh](https://github.com/mastodon/mastodon/pull/17762)) - - Hashtag trends algorithm is extended to work for posts and links - - Links are only considered if they have an adequate preview card - - Preview card generation has been improved to support structured data - - Links can only trend if the publisher (domain) has been approved - - Posts can only trend if the author has been approved - - Individual approval and rejection for posts and links is also available - - Moderators are notified about pending trends at most once every 2 hours - - Posts and link trends are language-specific - - Search page is redesigned into explore page in web UI - - Discovery tab is coming soon in official iOS and Android apps - - New REST APIs: - - `GET /api/v1/trends/links` - - `GET /api/v1/trends/statuses` - - `GET /api/v1/trends/tags` (alias of `GET /api/v1/trends`) - - `GET /api/v1/admin/trends/links` - - `GET /api/v1/admin/trends/statuses` - - `GET /api/v1/admin/trends/tags` -- **Add graphs and retention metrics to admin dashboard** ([Gargron](https://github.com/mastodon/mastodon/pull/16829), [Gargron](https://github.com/mastodon/mastodon/pull/17617), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17570), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16910), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16909), [mashirozx](https://github.com/mastodon/mastodon/pull/16884), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16854)) - - Dashboard shows more numbers with development over time - - Other data such as most used interface languages and sign-up sources - - User retention graph shows how many new users stick around - - New REST APIs: - - `POST /api/v1/admin/measures` - - `POST /api/v1/admin/dimensions` - - `POST /api/v1/admin/retention` -- Add `GET /api/v1/accounts/familiar_followers` to REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17700)) -- Add `POST /api/v1/accounts/:id/remove_from_followers` to REST API ([noellabo](https://github.com/mastodon/mastodon/pull/16864)) -- Add `category` and `rule_ids` params to `POST /api/v1/reports` IN REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17492), [Gargron](https://github.com/mastodon/mastodon/pull/17682), [Gargron](https://github.com/mastodon/mastodon/pull/17713)) - - `category` can be one of: `spam`, `violation`, `other` (default) - - `rule_ids` must reference `rules` returned in `GET /api/v1/instance` -- Add global `lang` param to REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17464), [Gargron](https://github.com/mastodon/mastodon/pull/17592)) -- Add `types` param to `GET /api/v1/notifications` in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17767)) -- **Add notifications for moderators about new sign-ups** ([Gargron](https://github.com/mastodon/mastodon/pull/16953), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17629)) - - When a new user confirms e-mail, moderators receive a notification - - New notification type: - - `admin.sign_up` -- Add authentication history ([Gargron](https://github.com/mastodon/mastodon/pull/16408), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16428), [baby-gnu](https://github.com/mastodon/mastodon/pull/16654)) -- Add ability to automatically delete old posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16529), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17691), [tribela](https://github.com/mastodon/mastodon/pull/16653)) -- Add ability to pin private posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16954), [tribela](https://github.com/mastodon/mastodon/pull/17326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17304), [MitarashiDango](https://github.com/mastodon/mastodon/pull/17647)) -- Add ability to filter search results by author using `from:` syntax ([tribela](https://github.com/mastodon/mastodon/pull/16526)) -- Add ability to delete canonical email blocks in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16644)) -- Add ability to purge undeliverable domains in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16686), [tribela](https://github.com/mastodon/mastodon/pull/17210), [tribela](https://github.com/mastodon/mastodon/pull/17741), [tribela](https://github.com/mastodon/mastodon/pull/17209)) -- Add ability to disable e-mail token authentication for specific users in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/16427)) -- **Add ability to suspend accounts in batches in admin UI** ([Gargron](https://github.com/mastodon/mastodon/pull/17009), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17301), [Gargron](https://github.com/mastodon/mastodon/pull/17444)) - - New, redesigned accounts list in admin UI - - Batch suspensions are meant to help clean up spam and bot accounts - - They do not generate notifications -- Add ability to filter reports by origin of target account in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/16487)) -- Add support for login through OpenID Connect ([chandrn7](https://github.com/mastodon/mastodon/pull/16221)) -- Add lazy loading for emoji picker in web UI ([mashirozx](https://github.com/mastodon/mastodon/pull/16907), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17011)) -- Add single option votes tooltip in polls in web UI ([Brawaru](https://github.com/mastodon/mastodon/pull/16849)) -- Add confirmation modal when closing media edit modal with unsaved changes in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16518)) -- Add hint about missing media attachment description in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17845)) -- Add support for fetching Create and Announce activities by URI in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16383)) -- Add `S3_FORCE_SINGLE_REQUEST` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16866)) -- Add `OMNIAUTH_ONLY` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17288), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17345)) -- Add `ES_USER` and `ES_PASS` environment variables for Elasticsearch authentication ([tribela](https://github.com/mastodon/mastodon/pull/16890)) -- Add `CAS_SECURITY_ASSUME_EMAIL_IS_VERIFIED` environment variable ([baby-gnu](https://github.com/mastodon/mastodon/pull/16655)) -- Add ability to pass specific domains to `tootctl accounts cull` ([tribela](https://github.com/mastodon/mastodon/pull/16511)) -- Add `--by-uri` option to `tootctl domains purge` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16434)) -- Add `--batch-size` option to `tootctl search deploy` ([aquarla](https://github.com/mastodon/mastodon/pull/17049)) -- Add `--remove-orphans` option to `tootctl statuses remove` ([noellabo](https://github.com/mastodon/mastodon/pull/17067)) - -### Changed - -- Change design of federation pages in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/17704), [noellabo](https://github.com/mastodon/mastodon/pull/17735), [Gargron](https://github.com/mastodon/mastodon/pull/17765)) -- Change design of account cards in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17689)) -- Change `follow` scope to be covered by `read` and `write` scopes in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17678)) -- Change design of authorized applications page ([Gargron](https://github.com/mastodon/mastodon/pull/17656), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17686)) -- Change e-mail domain blocks to block IPs dynamically ([Gargron](https://github.com/mastodon/mastodon/pull/17635), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17650), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17649)) -- Change report modal to include category selection in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17565), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17734), [Gargron](https://github.com/mastodon/mastodon/pull/17654), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17632)) -- Change reblogs to not count towards hashtag trends anymore ([Gargron](https://github.com/mastodon/mastodon/pull/17501)) -- Change languages to be listed under standard instead of native name in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/17485)) -- Change routing paths to use usernames in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/16171), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16772), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16773), [mashirozx](https://github.com/mastodon/mastodon/pull/16793), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17060)) -- Change list title input design in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17092)) -- Change "Opt-in to profile directory" preference to be general discoverability preference ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16637)) -- Change API rate limits to use /64 masking on IPv6 addresses ([tribela](https://github.com/mastodon/mastodon/pull/17588), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17600), [zunda](https://github.com/mastodon/mastodon/pull/17590)) -- Change allowed formats for locally uploaded custom emojis to include GIF ([rgroothuijsen](https://github.com/mastodon/mastodon/pull/17706), [Gargron](https://github.com/mastodon/mastodon/pull/17759)) -- Change error message when chosen password is too long ([rgroothuijsen](https://github.com/mastodon/mastodon/pull/17082)) -- Change minimum required Elasticsearch version from 6 to 7 ([noellabo](https://github.com/mastodon/mastodon/pull/16915)) - -### Removed - -- Remove profile directory link from main navigation panel in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17688)) -- **Remove language detection through cld3** ([Gargron](https://github.com/mastodon/mastodon/pull/17478), [ykzts](https://github.com/mastodon/mastodon/pull/17539), [Gargron](https://github.com/mastodon/mastodon/pull/17496), [Gargron](https://github.com/mastodon/mastodon/pull/17722)) - - cld3 is very inaccurate on short-form content even with unique alphabets - - Post language can be overridden individually using `language` param - - Otherwise, it defaults to the user's interface language -- Remove support for `OAUTH_REDIRECT_AT_SIGN_IN` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17287)) - - Use `OMNIAUTH_ONLY` instead -- Remove Keybase integration ([Gargron](https://github.com/mastodon/mastodon/pull/17045)) -- Remove old columns and indexes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17245), [Gargron](https://github.com/mastodon/mastodon/pull/16409), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17191)) -- Remove shortcodes from newly-created media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16730), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16763)) - -### Deprecated - -- `GET /api/v1/trends` → `GET /api/v1/trends/tags` -- OAuth `follow` scope → `read` and/or `write` -- `text` attribute on `DELETE /api/v1/statuses/:id` → `GET /api/v1/statuses/:id/source` - -### Fixed - -- Fix IDN domains not being rendered correctly in a few left-over places ([Gargron](https://github.com/mastodon/mastodon/pull/17848)) -- Fix Sanskrit translation not being used in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17820)) -- Fix Kurdish languages having the wrong language codes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17812)) -- Fix pghero making database schema suggestions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17807)) -- Fix encoding glitch in the OpenGraph description of a profile page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17821)) -- Fix web manifest not permitting PWA usage from alternate domains ([HolgerHuo](https://github.com/mastodon/mastodon/pull/16714)) -- Fix not being able to edit media attachments for scheduled posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17690)) -- Fix subscribed relay activities being recorded as boosts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17571)) -- Fix streaming API server error messages when JSON parsing fails not specifying the source ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17559)) -- Fix browsers autofilling new password field with old password ([mashirozx](https://github.com/mastodon/mastodon/pull/17702)) -- Fix text being invisible before fonts load in web UI ([tribela](https://github.com/mastodon/mastodon/pull/16330)) -- Fix public profile pages of unconfirmed users being accessible ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17385), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17457)) -- Fix nil error when trying to fetch key for signature verification ([Gargron](https://github.com/mastodon/mastodon/pull/17747)) -- Fix null values being included in some indexes ([Gargron](https://github.com/mastodon/mastodon/pull/17711)) -- Fix `POST /api/v1/emails/confirmations` not being available after sign-up ([Gargron](https://github.com/mastodon/mastodon/pull/17743)) -- Fix rare race condition when reblogged post is deleted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17693), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17730)) -- Fix being able to add more than 4 hashtags to hashtag column in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17729)) -- Fix data integrity of featured tags ([Gargron](https://github.com/mastodon/mastodon/pull/17712)) -- Fix performance of account timelines ([Gargron](https://github.com/mastodon/mastodon/pull/17709)) -- Fix returning empty `

` tag for blank account `note` in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17687)) -- Fix leak of existence of otherwise inaccessible posts in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/17684)) -- Fix not showing loading indicator when searching in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17655)) -- Fix media modal footer's “external link” not being a link ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17561)) -- Fix reply button on media modal not giving focus to compose form ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17626)) -- Fix some media attachments being converted with too high framerates ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17619)) -- Fix sign in token and warning emails failing to send when contact e-mail address is malformed ([helloworldstack](https://github.com/mastodon/mastodon/pull/17589)) -- Fix opening the emoji picker scrolling the single-column view to the top ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17579)) -- Fix edge case where settings/admin page sidebar would be incorrectly hidden ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17580)) -- Fix performance of server-side filtering ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17575)) -- Fix privacy policy link not being visible on small screens ([Gargron](https://github.com/mastodon/mastodon/pull/17533)) -- Fix duplicate accounts when searching by IP range in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/17524), [tribela](https://github.com/mastodon/mastodon/pull/17150)) -- Fix error when performing a batch action on posts in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17532)) -- Fix deletes not being signed in authorized fetch mode ([Gargron](https://github.com/mastodon/mastodon/pull/17484)) -- Fix Undo Announce sometimes inlining the originally Announced status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17516)) -- Fix localization of cold-start follow recommendations ([Gargron](https://github.com/mastodon/mastodon/pull/17479), [Gargron](https://github.com/mastodon/mastodon/pull/17486)) -- Fix replies collection incorrectly looping ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17462)) -- Fix errors when multiple Delete are received for a given actor ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17460)) -- Fixed prototype pollution bug and only allow trusted origin ([r0hanSH](https://github.com/mastodon/mastodon/pull/17420)) -- Fix text being incorrectly pre-selected in composer textarea on /share ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17339)) -- Fix SMTP_ENABLE_STARTTLS_AUTO/SMTP_TLS/SMTP_SSL environment variables don't work ([kgtkr](https://github.com/mastodon/mastodon/pull/17216)) -- Fix media upload specific rate limits only being applied to v1 endpoint in REST API ([tribela](https://github.com/mastodon/mastodon/pull/17272)) -- Fix media descriptions not being used for client-side filtering ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17206)) -- Fix cold-start follow recommendation favouring older accounts due to wrong sorting ([noellabo](https://github.com/mastodon/mastodon/pull/17126)) -- Fix not redirect to the right page after authenticating with WebAuthn ([heguro](https://github.com/mastodon/mastodon/pull/17098)) -- Fix searching for additional hashtags in hashtag column ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17054)) -- Fix color of hashtag column settings inputs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17058)) -- Fix performance of `tootctl statuses remove` ([noellabo](https://github.com/mastodon/mastodon/pull/17052)) -- Fix `tootctl accounts cull` not excluding domains on timeouts and certificate issues ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16433)) -- Fix 404 error when filtering admin action logs by non-existent target account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16643)) -- Fix error when accessing streaming API without any OAuth scopes ([Brawaru](https://github.com/mastodon/mastodon/pull/16823)) -- Fix follow request count not updating when new follow requests arrive over streaming API in web UI ([matildepark](https://github.com/mastodon/mastodon/pull/16652)) -- Fix error when unsuspending a local account ([HolgerHuo](https://github.com/mastodon/mastodon/pull/16605)) -- Fix crash when a notification contains a not yet processed media attachment in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16573)) -- Fix wrong color of download button in audio player in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16572)) -- Fix notes for others accounts not being deleted when an account is deleted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16579)) -- Fix error when logging occurrence of unsupported video file ([noellabo](https://github.com/mastodon/mastodon/pull/16581)) -- Fix wrong elements in trends widget being hidden on smaller screens in web UI ([tribela](https://github.com/mastodon/mastodon/pull/16570)) -- Fix link to about page being displayed in limited federation mode ([weex](https://github.com/mastodon/mastodon/pull/16432)) -- Fix styling of boost button in media modal not reflecting ability to boost ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16387)) -- Fix OCR failure when erroneous lang data is in cache ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16386)) -- Fix downloading media from blocked domains in `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/16914)) -- Fix login form being displayed on landing page when already logged in ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17348)) -- Fix polling for media processing status too frequently in web UI ([tribela](https://github.com/mastodon/mastodon/pull/17271)) -- Fix hashtag autocomplete overriding user-typed case ([weex](https://github.com/mastodon/mastodon/pull/16460)) -- Fix WebAuthn authentication setup to not prompt for PIN ([truongnmt](https://github.com/mastodon/mastodon/pull/16545)) - -### Security - -- Fix being able to post URLs longer than 4096 characters ([Gargron](https://github.com/mastodon/mastodon/pull/17908)) -- Fix being able to bypass e-mail restrictions ([Gargron](https://github.com/mastodon/mastodon/pull/17909)) - -## [3.4.6] - 2022-02-03 - -### Fixed - -- Fix `mastodon:webpush:generate_vapid_key` task requiring a functional environment ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17338)) -- Fix spurious errors when receiving an Add activity for a private post ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17425)) - -### Security - -- Fix error-prone SQL queries ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15828)) -- Fix not compacting incoming signed JSON-LD activities ([puckipedia](https://github.com/mastodon/mastodon/pull/17426), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17428)) (CVE-2022-24307) -- Fix insufficient sanitization of report comments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17430)) -- Fix stop condition of a Common Table Expression ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17427)) -- Disable legacy XSS filtering ([Wonderfall](https://github.com/mastodon/mastodon/pull/17289)) - -## [3.4.5] - 2022-01-31 - -### Added - -- Add more advanced migration tests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17393)) -- Add github workflow to build Docker images ([unasuke](https://github.com/mastodon/mastodon/pull/16973), [Gargron](https://github.com/mastodon/mastodon/pull/16980), [Gargron](https://github.com/mastodon/mastodon/pull/17000)) - -### Fixed - -- Fix some old migrations failing when skipping releases ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17394)) -- Fix migrations script failing in certain edge cases ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17398)) -- Fix Docker build ([tribela](https://github.com/mastodon/mastodon/pull/17188)) -- Fix Ruby 3.0 dependencies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16723)) -- Fix followers synchronization mechanism ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16510)) - -## [3.4.4] - 2021-11-26 - -### Fixed - -- Fix error when suspending user with an already blocked canonical email ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17036)) -- Fix overflow of long profile fields in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17010)) -- Fix confusing error when WebFinger request returns empty document ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16986)) -- Fix upload of remote media with OpenStack Swift sometimes failing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16998)) -- Fix logout link not working in Safari ([noellabo](https://github.com/mastodon/mastodon/pull/16574)) -- Fix “open” link of media modal not closing modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16524)) -- Fix replying from modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16516)) -- Fix `mastodon:setup` command crashing in some circumstances ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16976)) - -### Security - -- Fix filtering DMs from non-followed users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17042)) -- Fix handling of recursive toots in WebUI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17041)) - -## [3.4.3] - 2021-11-06 - -### Fixed - -- Fix login being broken due to inaccurately applied backport fix in 3.4.2 ([Gargron](https://github.com/mastodon/mastodon/commit/5c47a18c8df3231aa25c6d1f140a71a7fac9cbf9)) - -## [3.4.2] - 2021-11-06 - -### Added - -- Add `configuration` attribute to `GET /api/v1/instance` ([Gargron](https://github.com/mastodon/mastodon/pull/16485)) - -### Fixed - -- Fix handling of back button with modal windows in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16499)) -- Fix pop-in player when author has long username in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16468)) -- Fix crash when a status with a playing video gets deleted in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16384)) -- Fix crash with Microsoft Translate in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16525)) -- Fix PWA not being usable from alternate domains ([HolgerHuo](https://github.com/mastodon/mastodon/pull/16714)) -- Fix locale-specific number rounding errors ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16469)) -- Fix scheduling a status decreasing status count ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16791)) -- Fix user's canonical email address being blocked when user deletes own account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16503)) -- Fix not being able to suspend users that already have their canonical e-mail blocked ([Gargron](https://github.com/mastodon/mastodon/pull/16455)) -- Fix anonymous access to outbox not being cached by the reverse proxy ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16458)) -- Fix followers synchronization mechanism not working when URI has empty path ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16744)) -- Fix serialization of counts in REST API when user hides their network ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16418)) -- Fix inefficiencies in auto-linking code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16506)) -- Fix `tootctl self-destruct` not sending delete activities for recently-suspended accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16688)) -- Fix suspicious sign-in e-mail text being out of date ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16690)) -- Fix some frameworks being unnecessarily loaded ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16725)) -- Fix canonical e-mail blocks missing foreign key constraints ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16448)) -- Fix inconsistent order on account's statuses page in admin UI ([tribela](https://github.com/mastodon/mastodon/pull/16937)) -- Fix media from blocked domains being redownloaded by `tootctl media refresh` ([tribela](https://github.com/mastodon/mastodon/pull/16914)) -- Fix `mastodon:setup` generated env-file syntax ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16896)) -- Fix link previews being incorrectly generated from earlier links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16885)) -- Fix wrong `to`/`cc` values for remote groups in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16700)) -- Fix mentions with non-ascii TLDs not being processed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16689)) -- Fix authentication failures halfway through a sign-in attempt ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16792)) -- Fix suspended accounts statuses being merged back into timelines ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16628)) -- Fix crash when encountering invalid account fields ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16598)) -- Fix invalid blurhash handling for remote activities ([noellabo](https://github.com/mastodon/mastodon/pull/16583)) -- Fix newlines being added to account notes when an account moves ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16415), [noellabo](https://github.com/mastodon/mastodon/pull/16576)) -- Fix crash when creating an announcement with links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16941)) -- Fix logging out from one browser logging out all other sessions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16943)) - -### Security - -- Fix user notes not having a length limit ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16942)) -- Fix revoking a specific session not working ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16943)) - -## [3.4.1] - 2021-06-03 - -### Added - -- Add new emoji assets from Twemoji 13.1.0 ([Gargron](https://github.com/mastodon/mastodon/pull/16345)) - -### Fixed - -- Fix some ActivityPub identifiers in server actor outbox ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16343)) -- Fix custom CSS path setting cookies and being uncacheable due to it ([tribela](https://github.com/mastodon/mastodon/pull/16314)) -- Fix unread notification count when polling in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16272)) -- Fix health check not being accessible through localhost ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16270)) -- Fix some redis locks auto-releasing too fast ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16276), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16291)) -- Fix e-mail confirmations API not working correctly ([Gargron](https://github.com/mastodon/mastodon/pull/16348)) -- Fix migration script not being able to run if it fails midway ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16312)) -- Fix account deletion sometimes failing because of optimistic locks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16317)) -- Fix deprecated slash as division in SASS files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16347)) -- Fix `tootctl search deploy` compatibility error on Ruby 3 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16346)) -- Fix mailer jobs for deleted notifications erroring out ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16294)) - -## [3.4.0] - 2021-05-16 - -### Added - -- **Add follow recommendations for onboarding** ([Gargron](https://github.com/mastodon/mastodon/pull/15945), [Gargron](https://github.com/mastodon/mastodon/pull/16161), [Gargron](https://github.com/mastodon/mastodon/pull/16060), [Gargron](https://github.com/mastodon/mastodon/pull/16077), [Gargron](https://github.com/mastodon/mastodon/pull/16078), [Gargron](https://github.com/mastodon/mastodon/pull/16160), [Gargron](https://github.com/mastodon/mastodon/pull/16079), [noellabo](https://github.com/mastodon/mastodon/pull/16044), [noellabo](https://github.com/mastodon/mastodon/pull/16045), [Gargron](https://github.com/mastodon/mastodon/pull/16152), [Gargron](https://github.com/mastodon/mastodon/pull/16153), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16082), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16173), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16159), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16189)) - - Tutorial on first web UI launch has been replaced with follow suggestions - - Follow suggestions take user locale into account and are a mix of accounts most followed by currently active local users, and accounts that wrote the most shared/favourited posts in the last 30 days - - Only accounts that have opted-in to being discoverable from their profile settings, and that do not require follow requests, will be suggested - - Moderators can review suggestions for every supported locale and suppress specific suggestions from appearing and admins can ensure certain accounts always show up in suggestions from the settings area - - New users no longer automatically follow admins -- **Add server rules** ([Gargron](https://github.com/mastodon/mastodon/pull/15769), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15778)) - - Admins can create and edit itemized server rules - - They are available through the REST API and on the about page -- **Add canonical e-mail blocks for suspended accounts** ([Gargron](https://github.com/mastodon/mastodon/pull/16049)) - - Normally, people can make multiple accounts using the same e-mail address using the `+` trick or by inserting or removing `.` characters from the first part of their address - - Once an account is suspended, it will no longer be possible for the e-mail address used by that account to be used for new sign-ups in any of its forms -- Add management of delivery availability in admin UI ([noellabo](https://github.com/mastodon/mastodon/pull/15771)) -- **Add system checks to dashboard in admin UI** ([Gargron](https://github.com/mastodon/mastodon/pull/15989), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15954), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16002)) - - The dashboard will now warn you if you some Sidekiq queues are not being processed, if you have not defined any server rules, or if you forgot to run database migrations from the latest Mastodon upgrade -- Add inline description of moderation actions in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15792)) -- Add "recommended" label to activity/peers API toggles in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/16081)) -- Add joined date to profiles in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/16169), [rinsuki](https://github.com/mastodon/mastodon/pull/16186)) -- Add transition to media modal background in web UI ([mkljczk](https://github.com/mastodon/mastodon/pull/15843)) -- Add option to opt-out of unread notification markers in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15842)) -- Add borders to 📱, 🚲, and 📲 emojis in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15794), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16035)) -- Add dropdown for boost privacy in boost confirmation modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15704)) -- Add support for Ruby 3.0 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16046), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16174)) -- Add `Message-ID` header to outgoing emails ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16076)) - - Some e-mail spam filters penalize e-mails that have a `Message-ID` header that uses a different domain name than the sending e-mail address. Now, the same domain will be used -- Add `af`, `gd` and `si` locales ([Gargron](https://github.com/mastodon/mastodon/pull/16090)) -- Add guard against DNS rebinding attacks ([noellabo](https://github.com/mastodon/mastodon/pull/16087), [noellabo](https://github.com/mastodon/mastodon/pull/16095)) -- Add HTTP header to explicitly opt-out of FLoC by default ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16036)) -- Add missing push notification title for polls and statuses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15929), [mkljczk](https://github.com/mastodon/mastodon/pull/15564), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15931)) -- Add `POST /api/v1/emails/confirmations` to REST API ([Gargron](https://github.com/mastodon/mastodon/pull/15816), [Gargron](https://github.com/mastodon/mastodon/pull/15949)) - - This method allows an app through which a user signed-up to request a new confirmation e-mail to be sent, or to change the e-mail of the account before it is confirmed -- Add `GET /api/v1/accounts/lookup` to REST API ([Gargron](https://github.com/mastodon/mastodon/pull/15740), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15750)) - - This method allows to quickly convert a username of a known account to an ID that can be used with the REST API, or to check if a username is available - for sign-up -- Add `policy` param to `POST /api/v1/push/subscriptions` in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/16040)) - - This param allows an app to control from whom notifications should be delivered as push notifications to the app -- Add `details` to error response for `POST /api/v1/accounts` in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/15803)) - - This attribute allows an app to display more helpful information to the user about why the sign-up did not succeed -- Add `SIDEKIQ_REDIS_URL` and related environment variables to optionally use a separate Redis server for Sidekiq ([noellabo](https://github.com/mastodon/mastodon/pull/16188)) - -### Changed - -- Change trending hashtags to be affected be reblogs ([Gargron](https://github.com/mastodon/mastodon/pull/16164)) - - Previously, only original posts contributed to a hashtag's trending score - - Now, reblogs of posts will also contribute to that hashtag's trending score -- Change e-mail confirmation link to always redirect to web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16151)) -- Change log level of worker lifecycle to WARN in streaming API ([Gargron](https://github.com/mastodon/mastodon/pull/16110)) - - Since running with INFO log level in production is not always desirable, it is easy to miss when a worker is shutdown and a new one is started -- Change the nouns "toot" and "status" to "post" in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/16080), [Gargron](https://github.com/mastodon/mastodon/pull/16089)) - - To be clear, the button still says "Toot!" -- Change order of dropdown menu on posts to be more intuitive in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/15647)) -- Change description of keyboard shortcuts in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/16129)) -- Change option labels on edit profile page ([Gargron](https://github.com/mastodon/mastodon/pull/16041)) - - "Lock account" is now "Require follow requests" - - "List this account on the directory" is now "Suggest account to others" - - "Hide your network" is now "Hide your social graph" -- Change newly generated account IDs to not be enumerable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15844)) -- Change Web Push API deliveries to use request pooling ([Gargron](https://github.com/mastodon/mastodon/pull/16014)) -- Change multiple mentions with same username to render with domain ([Gargron](https://github.com/mastodon/mastodon/pull/15718), [noellabo](https://github.com/mastodon/mastodon/pull/16038)) - - When a post contains mentions of two or more users who have the same username, but on different domains, render their names with domain to help disambiguate them - - Always render the domain of usernames used in profile metadata -- Change health check endpoint to reveal less information ([Gargron](https://github.com/mastodon/mastodon/pull/15988)) -- Change account counters to use upsert (requires Postgres >= 9.5) ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15913)) -- Change `mastodon:setup` to not call `assets:precompile` in Docker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13942)) -- **Change max. image dimensions to 1920x1080px (1080p)** ([Gargron](https://github.com/mastodon/mastodon/pull/15690)) - - Previously, this was 1280x1280px - - This is the amount of pixels that original images get downsized to -- Change custom emoji to be animated when hovering container in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15637)) -- Change streaming API from deprecated ClusterWS/cws to ws ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15932)) -- Change systemd configuration to add sandboxing features ([Izorkin](https://github.com/mastodon/mastodon/pull/15937), [Izorkin](https://github.com/mastodon/mastodon/pull/16103), [Izorkin](https://github.com/mastodon/mastodon/pull/16127)) -- Change nginx configuration to make running Onion service easier ([cohosh](https://github.com/mastodon/mastodon/pull/15498)) -- Change Helm configuration ([dunn](https://github.com/mastodon/mastodon/pull/15722), [dunn](https://github.com/mastodon/mastodon/pull/15728), [dunn](https://github.com/mastodon/mastodon/pull/15748), [dunn](https://github.com/mastodon/mastodon/pull/15749), [dunn](https://github.com/mastodon/mastodon/pull/15767)) -- Change Docker configuration ([SuperSandro2000](https://github.com/mastodon/mastodon/pull/10823), [mashirozx](https://github.com/mastodon/mastodon/pull/15978)) - -### Removed - -- Remove PubSubHubbub-related columns from accounts table ([Gargron](https://github.com/mastodon/mastodon/pull/16170), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15857)) -- Remove dependency on @babel/plugin-proposal-class-properties ([ykzts](https://github.com/mastodon/mastodon/pull/16155)) -- Remove dependency on pluck_each gem ([Gargron](https://github.com/mastodon/mastodon/pull/16012)) -- Remove spam check and dependency on nilsimsa gem ([Gargron](https://github.com/mastodon/mastodon/pull/16011)) -- Remove MySQL-specific code from Mastodon::MigrationHelpers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15924)) -- Remove IE11 from supported browsers target ([gol-cha](https://github.com/mastodon/mastodon/pull/15779)) - -### Fixed - -- Fix "You might be interested in" flashing while searching in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/16162)) -- Fix display of posts without text content in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15665)) -- Fix Google Translate breaking web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15610), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15611)) -- Fix web UI crashing when SVG support is disabled ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15809)) -- Fix web UI crash when a status opened in the media modal is deleted ([kaias1jp](https://github.com/mastodon/mastodon/pull/15701)) -- Fix OCR language data failing to load in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15519)) -- Fix footer links not being clickable in Safari in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/15496)) -- Fix autofocus/autoselection not working on mobile in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15555), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15985)) -- Fix media redownload worker retrying on unexpected response codes ([Gargron](https://github.com/mastodon/mastodon/pull/16111)) -- Fix thread resolve worker retrying when status no longer exists ([Gargron](https://github.com/mastodon/mastodon/pull/16109)) -- Fix n+1 queries when rendering statuses in REST API ([abcang](https://github.com/mastodon/mastodon/pull/15641)) -- Fix n+1 queries when rendering notifications in REST API ([abcang](https://github.com/mastodon/mastodon/pull/15640)) -- Fix delete of local reply to local parent not being forwarded ([Gargron](https://github.com/mastodon/mastodon/pull/16096)) -- Fix remote reporters not receiving suspend/unsuspend activities ([Gargron](https://github.com/mastodon/mastodon/pull/16050)) -- Fix understanding (not fully qualified) `as:Public` and `Public` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15948)) -- Fix actor update not being distributed on profile picture deletion ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15461)) -- Fix processing of incoming Delete activities ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16084)) -- Fix processing of incoming Block activities ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15546)) -- Fix processing of incoming Update activities of unknown accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15514)) -- Fix URIs of repeat follow requests not being recorded ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15662)) -- Fix error on requests with no `Digest` header ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15782)) -- Fix activity object not requiring signature in secure mode ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15592)) -- Fix database serialization failure returning HTTP 500 ([Gargron](https://github.com/mastodon/mastodon/pull/16101)) -- Fix media processing getting stuck on too much stdin/stderr ([Gargron](https://github.com/mastodon/mastodon/pull/16136)) -- Fix some inefficient array manipulations ([007lva](https://github.com/mastodon/mastodon/pull/15513), [007lva](https://github.com/mastodon/mastodon/pull/15527)) -- Fix some inefficient regex matching ([007lva](https://github.com/mastodon/mastodon/pull/15528)) -- Fix some inefficient SQL queries ([abcang](https://github.com/mastodon/mastodon/pull/16104), [abcang](https://github.com/mastodon/mastodon/pull/16106), [abcang](https://github.com/mastodon/mastodon/pull/16105)) -- Fix trying to fetch key from empty URI when verifying HTTP signature ([Gargron](https://github.com/mastodon/mastodon/pull/16100)) -- Fix `tootctl maintenance fix-duplicates` failures ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15923), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15515)) -- Fix error when removing status caused by race condition ([Gargron](https://github.com/mastodon/mastodon/pull/16099)) -- Fix blocking someone not clearing up list feeds ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16205)) -- Fix misspelled URLs character counting ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15382)) -- Fix Sidekiq hanging forever due to a Resolv bug in Ruby 2.7.3 ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16157)) -- Fix edge case where follow limit interferes with accepting a follow ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16098)) -- Fix inconsistent lead text style in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/16052), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/16086)) -- Fix reports of already suspended accounts being recorded ([Gargron](https://github.com/mastodon/mastodon/pull/16047)) -- Fix sign-up restrictions based on IP addresses not being enforced ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15607)) -- Fix YouTube embeds failing due to YouTube serving wrong OEmbed URLs ([Gargron](https://github.com/mastodon/mastodon/pull/15716)) -- Fix error when rendering public pages with media without meta ([Gargron](https://github.com/mastodon/mastodon/pull/16112)) -- Fix misaligned logo on follow button on public pages ([noellabo](https://github.com/mastodon/mastodon/pull/15458)) -- Fix video modal not working on public pages ([noellabo](https://github.com/mastodon/mastodon/pull/15469)) -- Fix race conditions on account migration creation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15597)) -- Fix not being able to change world filter expiration back to “Never” ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15858)) -- Fix `.env.vagrant` not setting `RAILS_ENV` variable ([chandrn7](https://github.com/mastodon/mastodon/pull/15709)) -- Fix error when muting users with `duration` in REST API ([Tak](https://github.com/mastodon/mastodon/pull/15516)) -- Fix border padding on front page in light theme ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15926)) -- Fix wrong URL to custom CSS when `CDN_HOST` is used ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15927)) -- Fix `tootctl accounts unfollow` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15639)) -- Fix `tootctl emoji import` wasting time on MacOS shadow files ([cortices](https://github.com/mastodon/mastodon/pull/15430)) -- Fix `tootctl emoji import` not treating shortcodes as case-insensitive ([angristan](https://github.com/mastodon/mastodon/pull/15738)) -- Fix some issues with SAML account creation ([Gargron](https://github.com/mastodon/mastodon/pull/15222), [kaiyou](https://github.com/mastodon/mastodon/pull/15511)) -- Fix MX validation applying for explicitly allowed e-mail domains ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15930)) -- Fix share page not using configured custom mascot ([tribela](https://github.com/mastodon/mastodon/pull/15687)) -- Fix instance actor not being automatically created if it wasn't seeded properly ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15693)) -- Fix HTTPS enforcement preventing Mastodon from being run as an Onion service ([cohosh](https://github.com/mastodon/mastodon/pull/15560), [jtracey](https://github.com/mastodon/mastodon/pull/15741), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15712), [cohosh](https://github.com/mastodon/mastodon/pull/15725)) -- Fix app name, website and redirect URIs not having a maximum length ([Gargron](https://github.com/mastodon/mastodon/pull/16042)) - -## [3.3.0] - 2020-12-27 - -### Added - -- **Add hotkeys for audio/video control in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/15158), [Gargron](https://github.com/mastodon/mastodon/pull/15198)) - - `Space` and `k` to toggle playback - - `m` to toggle mute - - `f` to toggle fullscreen - - `j` and `l` to go back and forward by 10 seconds - - `.` and `,` to go back and forward by a frame (video only) -- Add expand/compress button on media modal in web UI ([mashirozx](https://github.com/mastodon/mastodon/pull/15068), [mashirozx](https://github.com/mastodon/mastodon/pull/15088), [mashirozx](https://github.com/mastodon/mastodon/pull/15094)) -- Add border around 🕺 emoji in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14769)) -- Add border around 🐞 emoji in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14712)) -- Add home link to the getting started column when home isn't mounted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14707)) -- Add option to disable swiping motions across the web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13885)) -- **Add pop-out player for audio/video in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/14870), [Gargron](https://github.com/mastodon/mastodon/pull/15157), [Gargron](https://github.com/mastodon/mastodon/pull/14915), [noellabo](https://github.com/mastodon/mastodon/pull/15309)) - - Continue watching/listening when you scroll away - - Action bar to interact with/open toot from the pop-out player -- Add unread notification markers in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14818), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14960), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14954), [noellabo](https://github.com/mastodon/mastodon/pull/14897), [noellabo](https://github.com/mastodon/mastodon/pull/14907)) -- Add paragraph about browser add-ons when encountering errors in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14801)) -- Add import and export for bookmarks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14956)) -- Add cache buster feature for media files ([Gargron](https://github.com/mastodon/mastodon/pull/15155)) - - If you have a proxy cache in front of object storage, deleted files will persist until the cache expires - - If enabled, cache buster will make a special request to the proxy to signal a cache reset -- Add duration option to the mute function ([aquarla](https://github.com/mastodon/mastodon/pull/13831)) -- Add replies policy option to the list function ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9205), [trwnh](https://github.com/mastodon/mastodon/pull/15304)) -- Add `og:published_time` OpenGraph tags on toots ([nornagon](https://github.com/mastodon/mastodon/pull/14865)) -- **Add option to be notified when a followed user posts** ([Gargron](https://github.com/mastodon/mastodon/pull/13546), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14896), [Gargron](https://github.com/mastodon/mastodon/pull/14822)) - - If you don't want to miss a toot, click the bell button! -- Add client-side validation in password change forms ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14564)) -- Add client-side validation in the registration form ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14560), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14599)) -- Add support for Gemini URLs ([joshleeb](https://github.com/mastodon/mastodon/pull/15013)) -- Add app shortcuts to web app manifest ([mkljczk](https://github.com/mastodon/mastodon/pull/15234)) -- Add WebAuthn as an alternative 2FA method ([santiagorodriguez96](https://github.com/mastodon/mastodon/pull/14466), [jiikko](https://github.com/mastodon/mastodon/pull/14806)) -- Add honeypot fields and minimum fill-out time for sign-up form ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15276)) -- Add icon for mutual relationships in relationship manager ([noellabo](https://github.com/mastodon/mastodon/pull/15149)) -- Add follow selected followers button in relationship manager ([noellabo](https://github.com/mastodon/mastodon/pull/15148)) -- **Add subresource integrity for JS and CSS assets** ([Gargron](https://github.com/mastodon/mastodon/pull/15096)) - - If you use a CDN for static assets (JavaScript, CSS, and so on), you have to trust that the CDN does not modify the assets maliciously - - Subresource integrity compares server-generated asset digests with what's actually served from the CDN and prevents such attacks -- Add `ku`, `sa`, `sc`, `zgh` to available locales ([ykzts](https://github.com/mastodon/mastodon/pull/15138)) -- Add ability to force an account to mark media as sensitive ([noellabo](https://github.com/mastodon/mastodon/pull/14361)) -- **Add ability to block access or limit sign-ups from chosen IPs** ([Gargron](https://github.com/mastodon/mastodon/pull/14963), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15263)) - - Add rules for IPs or CIDR ranges that automatically expire after a configurable amount of time - - Choose the severity of the rule, either blocking all access or merely limiting sign-ups -- **Add support for reversible suspensions through ActivityPub** ([Gargron](https://github.com/mastodon/mastodon/pull/14989)) - - Servers can signal that one of their accounts has been suspended - - During suspension, the account can only delete its own content - - A reversal of the suspension can be signalled the same way - - A local suspension always overrides a remote one -- Add indication to admin UI of whether a report has been forwarded ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13237)) -- Add display of reasons for joining of an account in admin UI ([mashirozx](https://github.com/mastodon/mastodon/pull/15265)) -- Add option to obfuscate domain name in public list of domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/15355)) -- Add option to make reasons for joining required on sign-up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15326), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15358), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15385), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15405)) -- Add ActivityPub follower synchronization mechanism ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14510), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15026)) -- Add outbox attribute to instance actor ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14721)) -- Add featured hashtags as an ActivityPub collection ([Gargron](https://github.com/mastodon/mastodon/pull/11595), [noellabo](https://github.com/mastodon/mastodon/pull/15277)) -- Add support for dereferencing objects through bearcaps ([Gargron](https://github.com/mastodon/mastodon/pull/14683), [noellabo](https://github.com/mastodon/mastodon/pull/14981)) -- Add `S3_READ_TIMEOUT` environment variable ([tateisu](https://github.com/mastodon/mastodon/pull/14952)) -- Add `ALLOWED_PRIVATE_ADDRESSES` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14722)) -- Add `--fix-permissions` option to `tootctl media remove-orphans` ([Gargron](https://github.com/mastodon/mastodon/pull/14383), [uist1idrju3i](https://github.com/mastodon/mastodon/pull/14715)) -- Add `tootctl accounts merge` ([Gargron](https://github.com/mastodon/mastodon/pull/15201), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15264), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15256)) - - Has someone changed their domain or subdomain thereby creating two accounts where there should be one? - - This command will fix it on your end -- Add `tootctl maintenance fix-duplicates` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14860), [Gargron](https://github.com/mastodon/mastodon/pull/15223), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15373)) - - Index corruption in the database? - - This command is for you -- **Add support for managing multiple stream subscriptions in a single connection** ([Gargron](https://github.com/mastodon/mastodon/pull/14524), [Gargron](https://github.com/mastodon/mastodon/pull/14566), [mfmfuyu](https://github.com/mastodon/mastodon/pull/14859), [zunda](https://github.com/mastodon/mastodon/pull/14608)) - - Previously, getting live updates for multiple timelines required opening a HTTP or WebSocket connection for each - - More connections means more resource consumption on both ends, not to mention the (ever so slight) delay when establishing a new connection - - Now, with just a single WebSocket connection you can subscribe and unsubscribe to and from multiple streams -- Add support for limiting results by both `min_id` and `max_id` at the same time in REST API ([tateisu](https://github.com/mastodon/mastodon/pull/14776)) -- Add `GET /api/v1/accounts/:id/featured_tags` to REST API ([noellabo](https://github.com/mastodon/mastodon/pull/11817), [noellabo](https://github.com/mastodon/mastodon/pull/15270)) -- Add stoplight for object storage failures, return HTTP 503 in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/13043)) -- Add optional `tootctl remove media` cronjob in Helm chart ([dunn](https://github.com/mastodon/mastodon/pull/14396)) -- Add clean error message when `RAILS_ENV` is unset ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15381)) - -### Changed - -- **Change media modals look in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/15217), [Gargron](https://github.com/mastodon/mastodon/pull/15221), [Gargron](https://github.com/mastodon/mastodon/pull/15284), [Gargron](https://github.com/mastodon/mastodon/pull/15283), [Kjwon15](https://github.com/mastodon/mastodon/pull/15308), [noellabo](https://github.com/mastodon/mastodon/pull/15305), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15417)) - - Background of the overlay matches the color of the image - - Action bar to interact with or open the toot from the modal -- Change order of announcements in admin UI to be newest-first ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15091)) -- **Change account suspensions to be reversible by default** ([Gargron](https://github.com/mastodon/mastodon/pull/14726), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15152), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15106), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15100), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15099), [noellabo](https://github.com/mastodon/mastodon/pull/14855), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15380), [Gargron](https://github.com/mastodon/mastodon/pull/15420), [Gargron](https://github.com/mastodon/mastodon/pull/15414)) - - Suspensions no longer equal deletions - - A suspended account can be unsuspended with minimal consequences for 30 days - - Immediate deletion of data is still available as an explicit option - - Suspended accounts can request an archive of their data through the UI -- Change REST API to return empty data for suspended accounts (14765) -- Change web UI to show empty profile for suspended accounts ([Gargron](https://github.com/mastodon/mastodon/pull/14766), [Gargron](https://github.com/mastodon/mastodon/pull/15345)) -- Change featured hashtag suggestions to be recently used instead of most used ([abcang](https://github.com/mastodon/mastodon/pull/14760)) -- Change direct toots to appear in the home feed again ([Gargron](https://github.com/mastodon/mastodon/pull/14711), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15182), [noellabo](https://github.com/mastodon/mastodon/pull/14727)) - - Return to treating all toots the same instead of trying to retrofit direct visibility into an instant messaging model -- Change email address validation to return more specific errors ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14565)) -- Change HTTP signature requirements to include `Digest` header on `POST` requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15069)) -- Change click area of video/audio player buttons to be bigger in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/15049)) -- Change order of filters by alphabetic by "keyword or phrase" ([ariasuni](https://github.com/mastodon/mastodon/pull/15050)) -- Change suspension of remote accounts to also undo outgoing follows ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15188)) -- Change string "Home" to "Home and lists" in the filter creation screen ([ariasuni](https://github.com/mastodon/mastodon/pull/15139)) -- Change string "Boost to original audience" to "Boost with original visibility" in web UI ([3n-k1](https://github.com/mastodon/mastodon/pull/14598)) -- Change string "Show more" to "Show newer" and "Show older" on public pages ([ariasuni](https://github.com/mastodon/mastodon/pull/15052)) -- Change order of announcements to be reverse chronological in web UI ([dariusk](https://github.com/mastodon/mastodon/pull/15065), [dariusk](https://github.com/mastodon/mastodon/pull/15070)) -- Change RTL detection to rely on unicode-bidi paragraph by paragraph in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/14573)) -- Change visibility icon next to timestamp to be clickable in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/15053), [mayaeh](https://github.com/mastodon/mastodon/pull/15055)) -- Change public thread view to hide "Show thread" link ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15266)) -- Change number format on about page from full to shortened ([Gargron](https://github.com/mastodon/mastodon/pull/15327)) -- Change how scheduled tasks run in multi-process environments ([noellabo](https://github.com/mastodon/mastodon/pull/15314)) - - New dedicated queue `scheduler` - - Runs by default when Sidekiq is executed with no options - - Has to be added manually in a multi-process environment - -### Removed - -- Remove fade-in animation from modals in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/15199)) -- Remove auto-redirect to direct messages in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/15142)) -- Remove obsolete IndexedDB operations from web UI ([Gargron](https://github.com/mastodon/mastodon/pull/14730)) -- Remove dependency on unused and unmaintained http_parser.rb gem ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14574)) - -### Fixed - -- Fix layout on about page when contact account has a long username ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15357)) -- Fix follow limit preventing re-following of a moved account ([Gargron](https://github.com/mastodon/mastodon/pull/14207), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15384)) -- **Fix deletes not reaching every server that interacted with toot** ([Gargron](https://github.com/mastodon/mastodon/pull/15200)) - - Previously, delete of a toot would be primarily sent to the followers of its author, people mentioned in the toot, and people who reblogged the toot - - Now, additionally, it is ensured that it is sent to people who replied to it, favourited it, and to the person it replies to even if that person is not mentioned -- Fix resolving an account through its non-canonical form (i.e. alternate domain) ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15187)) -- Fix sending redundant ActivityPub events when processing remote account deletion ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15104)) -- Fix Move handler not being triggered when failing to fetch target account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15107)) -- Fix downloading remote media files when server returns empty filename ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14867)) -- Fix account processing failing because of large collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15027)) -- Fix not being able to unfavorite toots one has lost access to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15192)) -- Fix not being able to unbookmark toots one has lost access to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14604)) -- Fix possible casing inconsistencies in hashtag search ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14906)) -- Fix updating account counters when association is not yet created ([Gargron](https://github.com/mastodon/mastodon/pull/15108)) -- Fix cookies not having a SameSite attribute ([Gargron](https://github.com/mastodon/mastodon/pull/15098)) -- Fix poll ending notifications being created for each vote ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15071)) -- Fix multiple boosts of a same toot erroneously appearing in TL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14759)) -- Fix asset builds not picking up `CDN_HOST` change ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14381)) -- Fix desktop notifications permission prompt in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/14985), [Gargron](https://github.com/mastodon/mastodon/pull/15141), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13543), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15176)) - - Some time ago, browsers added a requirement that desktop notification prompts could only be displayed in response to a user-generated event (such as a click) - - This means that for some time, users who haven't already given the permission before were not getting a prompt and as such were not receiving desktop notifications -- Fix "Mark media as sensitive" string not supporting pluralizations in other languages in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/15051)) -- Fix glitched image uploads when canvas read access is blocked in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15180)) -- Fix some account gallery items having empty labels in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15073)) -- Fix alt-key hotkeys activating while typing in a text field in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14942)) -- Fix wrong seek bar width on media player in web UI ([mfmfuyu](https://github.com/mastodon/mastodon/pull/15060)) -- Fix logging out on mobile in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14901)) -- Fix wrong click area for GIFVs in media modal in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/14615)) -- Fix unreadable placeholder text color in high contrast theme in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/14803)) -- Fix scrolling issues when closing some dropdown menus in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14606)) -- Fix notification filter bar incorrectly filtering gaps in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14808)) -- Fix disabled boost icon being replaced by private boost icon on hover in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14456)) -- Fix hashtag detection in compose form being different to server-side in web UI ([kedamaDQ](https://github.com/mastodon/mastodon/pull/14484), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14513)) -- Fix home last read marker mishandling gaps in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14809)) -- Fix unnecessary re-rendering of various components when typing in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/15286)) -- Fix notifications being unnecessarily re-rendered in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15312)) -- Fix column swiping animation logic in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15301)) -- Fix inefficiency when fetching hashtag timeline ([noellabo](https://github.com/mastodon/mastodon/pull/14861), [akihikodaki](https://github.com/mastodon/mastodon/pull/14662)) -- Fix inefficiency when fetching bookmarks ([akihikodaki](https://github.com/mastodon/mastodon/pull/14674)) -- Fix inefficiency when fetching favourites ([akihikodaki](https://github.com/mastodon/mastodon/pull/14673)) -- Fix inefficiency when fetching media-only account timeline ([akihikodaki](https://github.com/mastodon/mastodon/pull/14675)) -- Fix inefficiency when deleting accounts ([Gargron](https://github.com/mastodon/mastodon/pull/15387), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15409), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15407), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15408), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15402), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/15416), [Gargron](https://github.com/mastodon/mastodon/pull/15421)) -- Fix redundant query when processing batch actions on custom emojis ([niwatori24](https://github.com/mastodon/mastodon/pull/14534)) -- Fix slow distinct queries where grouped queries are faster ([Gargron](https://github.com/mastodon/mastodon/pull/15287)) -- Fix performance on instances list in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/15282)) -- Fix server actor appearing in list of accounts in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14567)) -- Fix "bootstrap timeline accounts" toggle in site settings in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15325)) -- Fix PostgreSQL secret name for cronjob in Helm chart ([metal3d](https://github.com/mastodon/mastodon/pull/15072)) -- Fix Procfile not being compatible with herokuish ([acuteaura](https://github.com/mastodon/mastodon/pull/12685)) -- Fix installation of tini being split into multiple steps in Dockerfile ([ryncsn](https://github.com/mastodon/mastodon/pull/14686)) - -### Security - -- Fix streaming API allowing connections to persist after access token invalidation ([Gargron](https://github.com/mastodon/mastodon/pull/15111)) -- Fix 2FA/sign-in token sessions being valid after password change ([Gargron](https://github.com/mastodon/mastodon/pull/14802)) -- Fix resolving accounts sometimes creating duplicate records for a given ActivityPub identifier ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15364)) - -## [3.2.2] - 2020-12-19 - -### Added - -- Add `tootctl maintenance fix-duplicates` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14860), [Gargron](https://github.com/mastodon/mastodon/pull/15223)) - - Index corruption in the database? - - This command is for you - -### Removed - -- Remove dependency on unused and unmaintained http_parser.rb gem ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14574)) - -### Fixed - -- Fix Move handler not being triggered when failing to fetch target account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15107)) -- Fix downloading remote media files when server returns empty filename ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14867)) -- Fix possible casing inconsistencies in hashtag search ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14906)) -- Fix updating account counters when association is not yet created ([Gargron](https://github.com/mastodon/mastodon/pull/15108)) -- Fix account processing failing because of large collections ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15027)) -- Fix resolving an account through its non-canonical form (i.e. alternate domain) ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15187)) -- Fix slow distinct queries where grouped queries are faster ([Gargron](https://github.com/mastodon/mastodon/pull/15287)) - -### Security - -- Fix 2FA/sign-in token sessions being valid after password change ([Gargron](https://github.com/mastodon/mastodon/pull/14802)) -- Fix resolving accounts sometimes creating duplicate records for a given ActivityPub identifier ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/15364)) - -## [3.2.1] - 2020-10-19 - -### Added - -- Add support for latest HTTP Signatures spec draft ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14556)) -- Add support for inlined objects in ActivityPub `to`/`cc` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14514)) - -### Changed - -- Change actors to not be served at all without authentication in limited federation mode ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14800)) - - Previously, a bare version of an actor was served when not authenticated, i.e. username and public key - - Because all actor fetch requests are signed using a separate system actor, that is no longer required - -### Fixed - -- Fix `tootctl media` commands not recognizing very large IDs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14536)) -- Fix crash when failing to load emoji picker in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14525)) -- Fix contrast requirements in thumbnail color extraction ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14464)) -- Fix audio/video player not using `CDN_HOST` on public pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14486)) -- Fix private boost icon not being used on public pages ([OmmyZhang](https://github.com/mastodon/mastodon/pull/14471)) -- Fix audio player on Safari in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14485), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14465)) -- Fix dereferencing remote statuses not using the correct account for signature when receiving a targeted inbox delivery ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14656)) -- Fix nil error in `tootctl media remove` ([noellabo](https://github.com/mastodon/mastodon/pull/14657)) -- Fix videos with near-60 fps being rejected ([Gargron](https://github.com/mastodon/mastodon/pull/14684)) -- Fix reported statuses not being included in warning e-mail ([Gargron](https://github.com/mastodon/mastodon/pull/14778)) -- Fix `Reject` activities of `Follow` objects not correctly destroying a follow relationship ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14479)) -- Fix inefficiencies in fan-out-on-write service ([Gargron](https://github.com/mastodon/mastodon/pull/14682), [noellabo](https://github.com/mastodon/mastodon/pull/14709)) -- Fix timeout errors when trying to webfinger some IPv6 configurations ([Gargron](https://github.com/mastodon/mastodon/pull/14919)) -- Fix files served as `application/octet-stream` being rejected without attempting mime type detection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14452)) - -## [3.2.0] - 2020-07-27 - -### Added - -- Add `SMTP_SSL` environment variable ([OmmyZhang](https://github.com/mastodon/mastodon/pull/14309)) -- Add hotkey for toggling content warning input in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13987)) -- **Add e-mail-based sign in challenge for users with disabled 2FA** ([Gargron](https://github.com/mastodon/mastodon/pull/14013)) - - If user tries signing in after: - - Being inactive for a while - - With a previously unknown IP - - Without 2FA being enabled - - Require to enter a token sent via e-mail before sigining in -- Add `limit` param to RSS feeds ([noellabo](https://github.com/mastodon/mastodon/pull/13743)) -- Add `visibility` param to share page ([noellabo](https://github.com/mastodon/mastodon/pull/13023)) -- Add blurhash to link previews ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13984), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14143), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13985), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/14267), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/14278), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14126), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14261), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14260)) - - In web UI, toots cannot be marked as sensitive unless there is media attached - - However, it's possible to do via API or ActivityPub - - Thumbnails of link previews of such posts now use blurhash in web UI - - The Card entity in REST API has a new `blurhash` attribute -- Add support for `summary` field for media description in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13763)) -- Add hints about incomplete remote content to web UI ([Gargron](https://github.com/mastodon/mastodon/pull/14031), [noellabo](https://github.com/mastodon/mastodon/pull/14195)) -- **Add personal notes for accounts** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14148), [Gargron](https://github.com/mastodon/mastodon/pull/14208), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/14251)) - - To clarify, these are notes only you can see, to help you remember details - - Notes can be viewed and edited from profiles in web UI - - New REST API: `POST /api/v1/accounts/:id/note` with `comment` param - - The Relationship entity in REST API has a new `note` attribute -- Add Helm chart ([dunn](https://github.com/mastodon/mastodon/pull/14090), [dunn](https://github.com/mastodon/mastodon/pull/14256), [dunn](https://github.com/mastodon/mastodon/pull/14245)) -- **Add customizable thumbnails for audio and video attachments** ([Gargron](https://github.com/mastodon/mastodon/pull/14145), [Gargron](https://github.com/mastodon/mastodon/pull/14244), [Gargron](https://github.com/mastodon/mastodon/pull/14273), [Gargron](https://github.com/mastodon/mastodon/pull/14203), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14255), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14306), [noellabo](https://github.com/mastodon/mastodon/pull/14358), [noellabo](https://github.com/mastodon/mastodon/pull/14357)) - - Metadata (album, artist, etc) is no longer stripped from audio files - - Album art is automatically extracted from audio files - - Thumbnail can be manually uploaded for both audio and video attachments - - Media upload APIs now support `thumbnail` param - - On `POST /api/v1/media` and `POST /api/v2/media` - - And on `PUT /api/v1/media/:id` - - ActivityPub representation of media attachments represents custom thumbnails with an `icon` attribute - - The Media Attachment entity in REST API now has a `preview_remote_url` to its `preview_url`, equivalent to `remote_url` to its `url` -- **Add color extraction for thumbnails** ([Gargron](https://github.com/mastodon/mastodon/pull/14209), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14264)) - - The `meta` attribute on the Media Attachment entity in REST API can now have a `colors` attribute which in turn contains three hex colors: `background`, `foreground`, and `accent` - - The background color is chosen from the most dominant color around the edges of the thumbnail - - The foreground and accent colors are chosen from the colors that are the most different from the background color using the CIEDE2000 algorithm - - The most saturated color of the two is designated as the accent color - - The one with the highest W3C contrast is designated as the foreground color - - If there are not enough colors in the thumbnail, new ones are generated using a monochrome pattern -- Add a visibility indicator to toots in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/14123), [highemerly](https://github.com/mastodon/mastodon/pull/14292)) -- Add `tootctl email_domain_blocks` ([tateisu](https://github.com/mastodon/mastodon/pull/13589), [Gargron](https://github.com/mastodon/mastodon/pull/14147)) -- Add "Add new domain block" to header of federation page in admin UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13934)) -- Add ability to keep emoji picker open with ctrl+click in web UI ([bclindner](https://github.com/mastodon/mastodon/pull/13896), [noellabo](https://github.com/mastodon/mastodon/pull/14096)) -- Add custom icon for private boosts in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14380)) -- Add support for Create and Update activities that don't inline objects in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14359)) -- Add support for Undo activities that don't inline activities in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14346)) - -### Changed - -- Change `.env.production.sample` to be leaner and cleaner ([Gargron](https://github.com/mastodon/mastodon/pull/14206)) - - It was overloaded as de-facto documentation and getting quite crowded - - Defer to the actual documentation while still giving a minimal example -- Change `tootctl search deploy` to work faster and display progress ([Gargron](https://github.com/mastodon/mastodon/pull/14300)) -- Change User-Agent of link preview fetching service to include "Bot" ([Gargron](https://github.com/mastodon/mastodon/pull/14248)) - - Some websites may not render OpenGraph tags into HTML if that's not the case -- Change behaviour to carry blocks over when someone migrates their followers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14144)) -- Change volume control and download buttons in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/14122)) -- **Change design of audio players in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/14095), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14281), [Gargron](https://github.com/mastodon/mastodon/pull/14282), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14118), [Gargron](https://github.com/mastodon/mastodon/pull/14199), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14338)) -- Change reply filter to never filter own toots in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14128)) -- Change boost button to no longer serve as visibility indicator in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/14132), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14373)) -- Change contrast of flash messages ([cchoi12](https://github.com/mastodon/mastodon/pull/13892)) -- Change wording from "Hide media" to "Hide image/images" in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13834)) -- Change appearance of settings pages to be more consistent ([ariasuni](https://github.com/mastodon/mastodon/pull/13938)) -- Change "Add media" tooltip to not include long list of formats in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13954)) -- Change how badly contrasting emoji are rendered in web UI ([leo60228](https://github.com/mastodon/mastodon/pull/13773), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13772), [mfmfuyu](https://github.com/mastodon/mastodon/pull/14020), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14015)) -- Change structure of unavailable content section on about page ([ariasuni](https://github.com/mastodon/mastodon/pull/13930)) -- Change behaviour to accept ActivityPub activities relayed through group actor ([noellabo](https://github.com/mastodon/mastodon/pull/14279)) -- Change amount of processing retries for ActivityPub activities ([noellabo](https://github.com/mastodon/mastodon/pull/14355)) - -### Removed - -- Remove the terms "blacklist" and "whitelist" from UX ([Gargron](https://github.com/mastodon/mastodon/pull/14149), [mayaeh](https://github.com/mastodon/mastodon/pull/14192)) - - Environment variables changed (old versions continue to work): - - `WHITELIST_MODE` → `LIMITED_FEDERATION_MODE` - - `EMAIL_DOMAIN_BLACKLIST` → `EMAIL_DOMAIN_DENYLIST` - - `EMAIL_DOMAIN_WHITELIST` → `EMAIL_DOMAIN_ALLOWLIST` - - CLI option changed: - - `tootctl domains purge --whitelist-mode` → `tootctl domains purge --limited-federation-mode` -- Remove some unnecessary database indexes ([lfuelling](https://github.com/mastodon/mastodon/pull/13695), [noellabo](https://github.com/mastodon/mastodon/pull/14259)) -- Remove unnecessary Node.js version upper bound ([ykzts](https://github.com/mastodon/mastodon/pull/14139)) - -### Fixed - -- Fix `following` param not working when exact match is found in account search ([noellabo](https://github.com/mastodon/mastodon/pull/14394)) -- Fix sometimes occurring duplicate mention notifications ([noellabo](https://github.com/mastodon/mastodon/pull/14378)) -- Fix RSS feeds not being cacheable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14368)) -- Fix lack of locking around processing of Announce activities in ActivityPub ([noellabo](https://github.com/mastodon/mastodon/pull/14365)) -- Fix boosted toots from blocked account not being retroactively removed from TL ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14339)) -- Fix large shortened numbers (like 1.2K) using incorrect pluralization ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/14061)) -- Fix streaming server trying to use empty password to connect to Redis when `REDIS_PASSWORD` is given but blank ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14135)) -- Fix being unable to unboost posts when blocked by their author ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14308)) -- Fix account domain block not properly unfollowing accounts from domain ([Gargron](https://github.com/mastodon/mastodon/pull/14304)) -- Fix removing a domain allow wiping known accounts in open federation mode ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14298)) -- Fix blocks and mutes pagination in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14275)) -- Fix new posts pushing down origin of opened dropdown in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14271), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14348)) -- Fix timeline markers not being saved sometimes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13887), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13889), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/14155)) -- Fix CSV uploads being rejected ([noellabo](https://github.com/mastodon/mastodon/pull/13835)) -- Fix incompatibility with Elasticsearch 7.x ([noellabo](https://github.com/mastodon/mastodon/pull/13828)) -- Fix being able to search posts where you're in the target audience but not actively mentioned ([noellabo](https://github.com/mastodon/mastodon/pull/13829)) -- Fix non-local posts appearing on local-only hashtag timelines in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/13827)) -- Fix `tootctl media remove-orphans` choking on unknown files in storage ([Gargron](https://github.com/mastodon/mastodon/pull/13765)) -- Fix `tootctl upgrade storage-schema` misbehaving ([Gargron](https://github.com/mastodon/mastodon/pull/13761), [angristan](https://github.com/mastodon/mastodon/pull/13768)) - - Fix it marking records as upgraded even though no files were moved - - Fix it not working with S3 storage - - Fix it not working with custom emojis -- Fix GIF reader raising incorrect exceptions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13760)) -- Fix hashtag search performing account search as well ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13758)) -- Fix Webfinger returning wrong status code on malformed or missing param ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13759)) -- Fix `rake mastodon:setup` error when some environment variables are set ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13928)) -- Fix admin page crashing when trying to block an invalid domain name in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13884)) -- Fix unsent toot confirmation dialog not popping up in single column mode in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13888)) -- Fix performance of follow import ([noellabo](https://github.com/mastodon/mastodon/pull/13836)) - - Reduce timeout of Webfinger requests to that of other requests - - Use circuit breakers to stop hitting unresponsive servers - - Avoid hitting servers that are already known to be generally unavailable -- Fix filters ignoring media descriptions ([BenLubar](https://github.com/mastodon/mastodon/pull/13837)) -- Fix some actions on custom emojis leading to cryptic errors in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13951)) -- Fix ActivityPub serialization of replies when some of them are URIs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13957)) -- Fix `rake mastodon:setup` choking on environment variables containing `%` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13940)) -- Fix account redirect confirmation message talking about moved followers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13950)) -- Fix avatars having the wrong size on public detailed status pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14140)) -- Fix various issues around OpenGraph representation of media ([Gargron](https://github.com/mastodon/mastodon/pull/14133)) - - Pages containing audio no longer say "Attached: 1 image" in description - - Audio attachments now represented as OpenGraph `og:audio` - - The `twitter:player` page now uses Mastodon's proper audio/video player - - Audio/video buffered bars now display correctly in audio/video player - - Volume and progress bars now respond to movement/move smoother -- Fix audio/video/images/cards not reacting to window resizes in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/14130)) -- Fix very wide media attachments resulting in too thin a thumbnail in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14127)) -- Fix crash when merging posts into home feed after following someone ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14129)) -- Fix unique username constraint for local users not being enforced in database ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14099)) -- Fix unnecessary gap under video modal in web UI ([mfmfuyu](https://github.com/mastodon/mastodon/pull/14098)) -- Fix 2FA and sign in token pages not respecting user locale ([mfmfuyu](https://github.com/mastodon/mastodon/pull/14087)) -- Fix unapproved users being able to view profiles when in limited-federation mode _and_ requiring approval for sign-ups ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14093)) -- Fix initial audio volume not corresponding to what's displayed in audio player in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14057)) -- Fix timelines sometimes jumping when closing modals in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14019)) -- Fix memory usage of downloading remote files ([Gargron](https://github.com/mastodon/mastodon/pull/14184), [Gargron](https://github.com/mastodon/mastodon/pull/14181), [noellabo](https://github.com/mastodon/mastodon/pull/14356)) - - Don't read entire file (up to 40 MB) into memory - - Read and write it to temp file in small chunks -- Fix inconsistent account header padding in web UI ([trwnh](https://github.com/mastodon/mastodon/pull/14179)) -- Fix Thai being skipped from language detection ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/13989)) - - Since Thai has its own alphabet, it can be detected more reliably -- Fix broken hashtag column options styling in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14247)) -- Fix pointer cursor being shown on toots that are not clickable in web UI ([arielrodrigues](https://github.com/mastodon/mastodon/pull/14185)) -- Fix lock icon not being shown when locking account in profile settings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14190)) -- Fix domain blocks doing work the wrong way around ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13424)) - - Instead of suspending accounts one by one, mark all as suspended first (quick) - - Only then proceed to start removing their data (slow) - - Clear out media attachments in a separate worker (slow) - -## [3.1.5] - 2020-07-07 - -### Security - -- Fix media attachment enumeration ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/14254)) -- Change rate limits for various paths ([Gargron](https://github.com/mastodon/mastodon/pull/14253)) -- Fix other sessions not being logged out on password change ([Gargron](https://github.com/mastodon/mastodon/pull/14252)) - -## [3.1.4] - 2020-05-14 - -### Added - -- Add `vi` to available locales ([taicv](https://github.com/mastodon/mastodon/pull/13542)) -- Add ability to remove identity proofs from account ([Gargron](https://github.com/mastodon/mastodon/pull/13682)) -- Add ability to exclude local content from federated timeline ([noellabo](https://github.com/mastodon/mastodon/pull/13504), [noellabo](https://github.com/mastodon/mastodon/pull/13745)) - - Add `remote` param to `GET /api/v1/timelines/public` REST API - - Add `public/remote` / `public:remote` variants to streaming API - - "Remote only" option in federated timeline column settings in web UI -- Add ability to exclude remote content from hashtag timelines in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/13502)) - - No changes to REST API - - "Local only" option in hashtag column settings in web UI -- Add Capistrano tasks that reload the services after deploying ([berkes](https://github.com/mastodon/mastodon/pull/12642)) -- Add `invites_enabled` attribute to `GET /api/v1/instance` in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13501)) -- Add `tootctl emoji export` command ([lfuelling](https://github.com/mastodon/mastodon/pull/13534)) -- Add separate cache directory for non-local uploads ([Gargron](https://github.com/mastodon/mastodon/pull/12821), [Hanage999](https://github.com/mastodon/mastodon/pull/13593), [mayaeh](https://github.com/mastodon/mastodon/pull/13551)) - - Add `tootctl upgrade storage-schema` command to move old non-local uploads to the cache directory -- Add buttons to delete header and avatar from profile settings ([sternenseemann](https://github.com/mastodon/mastodon/pull/13234)) -- Add emoji graphics and shortcodes from Twemoji 12.1.5 ([DeeUnderscore](https://github.com/mastodon/mastodon/pull/13021)) - -### Changed - -- Change error message when trying to migrate to an account that does not have current account set as an alias to be more clear ([TheEvilSkeleton](https://github.com/mastodon/mastodon/pull/13746)) -- Change delivery failure tracking to work with hostnames instead of URLs ([Gargron](https://github.com/mastodon/mastodon/pull/13437), [noellabo](https://github.com/mastodon/mastodon/pull/13481), [noellabo](https://github.com/mastodon/mastodon/pull/13482), [noellabo](https://github.com/mastodon/mastodon/pull/13535)) -- Change Content-Security-Policy to not need unsafe-inline style-src ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13679), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13692), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13576), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13575), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13438)) -- Change how RSS items are titled and formatted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13592), [ykzts](https://github.com/mastodon/mastodon/pull/13591)) - -### Fixed - -- Fix dropdown of muted and followed accounts offering option to hide boosts in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13748)) -- Fix "You are already signed in" alert being shown at wrong times ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13547)) -- Fix retrying of failed-to-download media files not actually working ([noellabo](https://github.com/mastodon/mastodon/pull/13741)) -- Fix first poll option not being focused when adding a poll in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13740)) -- Fix `sr` locale being selected over `sr-Latn` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13693)) -- Fix error within error when limiting backtrace to 3 lines ([Gargron](https://github.com/mastodon/mastodon/pull/13120)) -- Fix `tootctl media remove-orphans` crashing on "Import" files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13685)) -- Fix regression in `tootctl media remove-orphans` ([Gargron](https://github.com/mastodon/mastodon/pull/13405)) -- Fix old unique jobs digests not having been cleaned up ([Gargron](https://github.com/mastodon/mastodon/pull/13683)) -- Fix own following/followers not showing muted users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13614)) -- Fix list of followed people ignoring sorting on Follows & Followers page ([taras2358](https://github.com/mastodon/mastodon/pull/13676)) -- Fix wrong pgHero Content-Security-Policy when `CDN_HOST` is set ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13595)) -- Fix needlessly deduplicating usernames on collisions with remote accounts when signing-up through SAML/CAS ([kaiyou](https://github.com/mastodon/mastodon/pull/13581)) -- Fix page incorrectly scrolling when bringing up dropdown menus in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13574)) -- Fix messed up z-index when NoScript blocks media/previews in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13449)) -- Fix "See what's happening" page showing public instead of local timeline for logged-in users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13499)) -- Fix not being able to resolve public resources in development environment ([Gargron](https://github.com/mastodon/mastodon/pull/13505)) -- Fix uninformative error message when uploading unsupported image files ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13540)) -- Fix expanded video player issues in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13541), [eai04191](https://github.com/mastodon/mastodon/pull/13533)) -- Fix and refactor keyboard navigation in dropdown menus in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13528)) -- Fix uploaded image orientation being messed up in some browsers in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13493)) -- Fix actions log crash when displaying updates of deleted announcements in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13489)) -- Fix search not working due to proxy settings when using hidden services ([Gargron](https://github.com/mastodon/mastodon/pull/13488)) -- Fix poll refresh button not being debounced in web UI ([rasjonell](https://github.com/mastodon/mastodon/pull/13485), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13490)) -- Fix confusing error when failing to add an alias to an unknown account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13480)) -- Fix "Email changed" notification sometimes having wrong e-mail ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13475)) -- Fix various issues on the account aliases page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13452)) -- Fix API footer link in web UI ([bubblineyuri](https://github.com/mastodon/mastodon/pull/13441)) -- Fix pagination of following, followers, follow requests, blocks and mutes lists in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13445)) -- Fix styling of polls in JS-less fallback on public pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13436)) -- Fix trying to delete already deleted file when post-processing ([Gargron](https://github.com/mastodon/mastodon/pull/13406)) - -### Security - -- Fix Doorkeeper vulnerability that exposed app secret to users who authorized the app and reset secret of the web UI that could have been exposed ([dependabot-preview[bot]](https://github.com/mastodon/mastodon/pull/13613), [Gargron](https://github.com/mastodon/mastodon/pull/13688)) - - For apps that self-register on behalf of every individual user (such as most mobile apps), this is a non-issue - - The issue only affects developers of apps who are shared between multiple users, such as server-side apps like cross-posters - -## [3.1.3] - 2020-04-05 - -### Added - -- Add ability to filter audit log in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/13381)) -- Add titles to warning presets in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/13252)) -- Add option to include resolved DNS records when blacklisting e-mail domains in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/13254)) -- Add ability to delete files uploaded for settings in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13192)) -- Add sorting by username, creation and last activity in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13076)) -- Add explanation as to why unlocked accounts may have follow requests in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13385)) -- Add link to bookmarks to dropdown in web UI ([mayaeh](https://github.com/mastodon/mastodon/pull/13273)) -- Add support for links to statuses in announcements to be opened in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13212), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13250)) -- Add tooltips to audio/video player buttons in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13203)) -- Add submit button to the top of preferences pages ([guigeekz](https://github.com/mastodon/mastodon/pull/13068)) -- Add specific rate limits for posting, following and reporting ([Gargron](https://github.com/mastodon/mastodon/pull/13172), [Gargron](https://github.com/mastodon/mastodon/pull/13390)) - - 300 posts every 3 hours - - 400 follows or follow requests every 24 hours - - 400 reports every 24 hours -- Add federation support for the "hide network" preference ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11673)) -- Add `--skip-media-remove` option to `tootctl statuses remove` ([tateisu](https://github.com/mastodon/mastodon/pull/13080)) - -### Changed - -- **Change design of polls in web UI** ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/13257), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13313)) -- Change status click areas in web UI to be bigger ([ariasuni](https://github.com/mastodon/mastodon/pull/13327)) -- **Change `tootctl media remove-orphans` to work for all classes** ([Gargron](https://github.com/mastodon/mastodon/pull/13316)) -- **Change local media attachments to perform heavy processing asynchronously** ([Gargron](https://github.com/mastodon/mastodon/pull/13210)) -- Change video uploads to always be converted to H264/MP4 ([Gargron](https://github.com/mastodon/mastodon/pull/13220), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13239), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13242)) -- Change video uploads to enforce certain limits ([Gargron](https://github.com/mastodon/mastodon/pull/13218)) - - Dimensions smaller than 1920x1200px - - Frame rate at most 60fps -- Change the tooltip "Toggle visibility" to "Hide media" in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13199)) -- Change description of privacy levels to be more intuitive in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13197)) -- Change GIF label to be displayed even when autoplay is enabled in web UI ([koyuawsmbrtn](https://github.com/mastodon/mastodon/pull/13209)) -- Change the string "Hide everything from …" to "Block domain …" in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13178), [mayaeh](https://github.com/mastodon/mastodon/pull/13221)) -- Change wording of media display preferences to be more intuitive ([ariasuni](https://github.com/mastodon/mastodon/pull/13198)) - -### Deprecated - -- `POST /api/v1/media` → `POST /api/v2/media` ([Gargron](https://github.com/mastodon/mastodon/pull/13210)) - -### Fixed - -- Fix `tootctl media remove-orphans` ignoring `PAPERCLIP_ROOT_PATH` ([Gargron](https://github.com/mastodon/mastodon/pull/13375)) -- Fix returning results when searching for URL with non-zero offset ([Gargron](https://github.com/mastodon/mastodon/pull/13377)) -- Fix pinning a column in web UI sometimes redirecting out of web UI ([Gargron](https://github.com/mastodon/mastodon/pull/13376)) -- Fix background jobs not using locks like they are supposed to ([Gargron](https://github.com/mastodon/mastodon/pull/13361)) -- Fix content warning being unnecessarily cleared when hiding content warning input in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13348)) -- Fix "Show more" not switching to "Show less" on public pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13174)) -- Fix import overwrite option not being selectable ([noellabo](https://github.com/mastodon/mastodon/pull/13347)) -- Fix wrong color for ellipsis in boost confirmation dialog in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13355)) -- Fix unnecessary unfollowing when importing follows with overwrite option ([noellabo](https://github.com/mastodon/mastodon/pull/13350)) -- Fix 404 and 410 API errors being silently discarded in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13279)) -- Fix OCR not working on Safari because of unsupported worker-src CSP ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13323)) -- Fix media not being marked sensitive when a content warning is set with no text ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13277)) -- Fix crash after deleting announcements in web UI ([codesections](https://github.com/mastodon/mastodon/pull/13283), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/13312)) -- Fix bookmarks not being searchable ([Kjwon15](https://github.com/mastodon/mastodon/pull/13271), [noellabo](https://github.com/mastodon/mastodon/pull/13293)) -- Fix reported accounts not being whitelisted from further spam checks when resolving a spam check report ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13289)) -- Fix web UI crash in single-column mode on prehistoric browsers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13267)) -- Fix some timeouts when searching for URLs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13253)) -- Fix detailed view of direct messages displaying a 0 boost count in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13244)) -- Fix regression in “Edit media” modal in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13243)) -- Fix public posts from silenced accounts not being changed to unlisted visibility ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13096)) -- Fix error when searching for URLs that contain the mention syntax ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13151)) -- Fix text area above/right of emoji picker being accidentally clickable in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/13148)) -- Fix too large announcements not being scrollable in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13211)) -- Fix `tootctl media remove-orphans` crashing when encountering invalid media ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13170)) -- Fix installation failing when Redis password contains special characters ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13156)) -- Fix announcements with fully-qualified mentions to local users crashing web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13164)) - -### Security - -- Fix re-sending of e-mail confirmation not being rate limited ([Gargron](https://github.com/mastodon/mastodon/pull/13360)) - -## [v3.1.2] - 2020-02-27 - -### Added - -- Add `--reset-password` option to `tootctl accounts modify` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13126)) -- Add source-mapped stacktrace to error message in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13082)) - -### Fixed - -- Fix dismissing an announcement twice raising an obscure error ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13124)) -- Fix misleading error when attempting to re-send a pending follow request ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13133)) -- Fix backups failing when files are missing from media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13146)) -- Fix duplicate accounts being created when fetching an account for its key only ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13147)) -- Fix `/web` redirecting to `/web/web` in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13128)) -- Fix previously OStatus-based accounts not being detected as ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13129)) -- Fix account JSON/RSS not being cacheable due to wrong mime type comparison ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13116)) -- Fix old browsers crashing because of missing `finally` polyfill in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13115)) -- Fix account's bio not being shown if there are no proofs/fields in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13075)) -- Fix sign-ups without checked user agreement being accepted through the web form ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13088)) -- Fix non-x64 architectures not being able to build Docker image because of hardcoded Node.js architecture ([SaraSmiseth](https://github.com/mastodon/mastodon/pull/13081)) -- Fix invite request input not being shown on sign-up error if left empty ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13089)) -- Fix some migration hints mentioning GitLab instead of Mastodon ([saper](https://github.com/mastodon/mastodon/pull/13084)) - -### Security - -- Fix leak of arbitrary statuses through unfavourite action in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/13161)) - -## [3.1.1] - 2020-02-10 - -### Fixed - -- Fix yanked dependency preventing installation ([mayaeh](https://github.com/mastodon/mastodon/pull/13059)) - -## [3.1.0] - 2020-02-09 - -### Added - -- Add bookmarks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/7107), [Gargron](https://github.com/mastodon/mastodon/pull/12494), [Gomasy](https://github.com/mastodon/mastodon/pull/12381)) -- Add announcements ([Gargron](https://github.com/mastodon/mastodon/pull/12662), [Gargron](https://github.com/mastodon/mastodon/pull/12967), [Gargron](https://github.com/mastodon/mastodon/pull/12970), [Gargron](https://github.com/mastodon/mastodon/pull/12963), [Gargron](https://github.com/mastodon/mastodon/pull/12950), [Gargron](https://github.com/mastodon/mastodon/pull/12990), [Gargron](https://github.com/mastodon/mastodon/pull/12949), [Gargron](https://github.com/mastodon/mastodon/pull/12989), [Gargron](https://github.com/mastodon/mastodon/pull/12964), [Gargron](https://github.com/mastodon/mastodon/pull/12965), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12958), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12957), [Gargron](https://github.com/mastodon/mastodon/pull/12955), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12946), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12954)) -- Add number animations in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/12948), [Gargron](https://github.com/mastodon/mastodon/pull/12971)) -- Add `kab`, `is`, `kn`, `mr`, `ur` to available locales ([Gargron](https://github.com/mastodon/mastodon/pull/12882), [BoFFire](https://github.com/mastodon/mastodon/pull/12962), [Gargron](https://github.com/mastodon/mastodon/pull/12379)) -- Add profile filter category ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12918)) -- Add ability to add oneself to lists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12271)) -- Add hint how to contribute translations to preferences page ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12736)) -- Add signatures to statuses in archive takeout ([noellabo](https://github.com/mastodon/mastodon/pull/12649)) -- Add support for `magnet:` and `xmpp` links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12905), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12709)) -- Add `follow_request` notification type ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12198)) -- Add ability to filter reports by account domain in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12154)) -- Add link to search for users connected from the same IP address to admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12157)) -- Add link to reports targeting a specific domain in admin view ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12513)) -- Add support for EventSource streaming in web UI ([BenLubar](https://github.com/mastodon/mastodon/pull/12887)) -- Add hotkey for opening media attachments in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12498), [Kjwon15](https://github.com/mastodon/mastodon/pull/12546)) -- Add relationship-based options to status dropdowns in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/12377), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12535), [Gargron](https://github.com/mastodon/mastodon/pull/12430)) -- Add support for submitting media description with `ctrl`+`enter` in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12272)) -- Add download button to audio and video players in web UI ([NimaBoscarino](https://github.com/mastodon/mastodon/pull/12179)) -- Add setting for whether to crop images in timelines in web UI ([duxovni](https://github.com/mastodon/mastodon/pull/12126)) -- Add support for `Event` activities ([tcitworld](https://github.com/mastodon/mastodon/pull/12637)) -- Add basic support for `Group` actors ([noellabo](https://github.com/mastodon/mastodon/pull/12071)) -- Add `S3_OVERRIDE_PATH_STYLE` environment variable ([Gargron](https://github.com/mastodon/mastodon/pull/12594)) -- Add `S3_OPEN_TIMEOUT` environment variable ([tateisu](https://github.com/mastodon/mastodon/pull/12459)) -- Add `LDAP_MAIL` environment variable ([madmath03](https://github.com/mastodon/mastodon/pull/12053)) -- Add `LDAP_UID_CONVERSION_ENABLED` environment variable ([madmath03](https://github.com/mastodon/mastodon/pull/12461)) -- Add `--remote-only` option to `tootctl emoji purge` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12810)) -- Add `tootctl media remove-orphans` ([Gargron](https://github.com/mastodon/mastodon/pull/12568), [Gargron](https://github.com/mastodon/mastodon/pull/12571)) -- Add `tootctl media lookup` command ([irlcatgirl](https://github.com/mastodon/mastodon/pull/12283)) -- Add cache for OEmbed endpoints to avoid extra HTTP requests ([Gargron](https://github.com/mastodon/mastodon/pull/12403)) -- Add support for KaiOS arrow navigation to public pages ([nolanlawson](https://github.com/mastodon/mastodon/pull/12251)) -- Add `discoverable` to accounts in REST API ([trwnh](https://github.com/mastodon/mastodon/pull/12508)) -- Add admin setting to disable default follows ([ArisuOngaku](https://github.com/mastodon/mastodon/pull/12566)) -- Add support for LDAP and PAM in the OAuth password grant strategy ([ntl-purism](https://github.com/mastodon/mastodon/pull/12390), [Gargron](https://github.com/mastodon/mastodon/pull/12743)) -- Allow support for `Accept`/`Reject` activities with a non-embedded object ([puckipedia](https://github.com/mastodon/mastodon/pull/12199)) -- Add "Show thread" button to public profiles ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/13000)) - -### Changed - -- Change `last_status_at` to be a date, not datetime in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12966)) -- Change followers page to relationships page in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/12927), [Gargron](https://github.com/mastodon/mastodon/pull/12934)) -- Change reported media attachments to always be hidden in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/12879), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12907)) -- Change string from "Disable" to "Disable login" in admin UI ([nileshkumar](https://github.com/mastodon/mastodon/pull/12201)) -- Change report page structure in admin UI ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12615)) -- Change swipe sensitivity to be lower on small screens in web UI ([umonaca](https://github.com/mastodon/mastodon/pull/12168)) -- Change audio/video playback to stop playback when out of view in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/12486)) -- Change media description label based on upload type in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12270)) -- Change large numbers to render without decimal units in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/12706)) -- Change "Add a choice" button to be disabled rather than hidden when poll limit reached in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12319), [hinaloe](https://github.com/mastodon/mastodon/pull/12544)) -- Change `tootctl statuses remove` to keep statuses favourited or bookmarked by local users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11267), [Gomasy](https://github.com/mastodon/mastodon/pull/12818)) -- Change domain block behavior to update user records (fast) before deleting data (slower) ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12247)) -- Change behaviour to strip audio metadata on uploads ([hugogameiro](https://github.com/mastodon/mastodon/pull/12171)) -- Change accepted length of remote media descriptions from 420 to 1,500 characters ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12262)) -- Change preferences pages structure ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12497), [mayaeh](https://github.com/mastodon/mastodon/pull/12517), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12801), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12797), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12799), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12793)) -- Change format of titles in RSS ([devkral](https://github.com/mastodon/mastodon/pull/8596)) -- Change favourite icon animation from spring-based motion to CSS animation in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12175)) -- Change minimum required Node.js version to 10, and default to 12 ([Shleeble](https://github.com/mastodon/mastodon/pull/12791), [mkody](https://github.com/mastodon/mastodon/pull/12906), [Shleeble](https://github.com/mastodon/mastodon/pull/12703)) -- Change spam check to exempt server staff ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12874)) -- Change to fallback to to `Create` audience when `object` has no defined audience ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12249)) -- Change Twemoji library to 12.1.3 in web UI ([koyuawsmbrtn](https://github.com/mastodon/mastodon/pull/12342)) -- Change blocked users to be hidden from following/followers lists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12733)) -- Change signature verification to ignore signatures with invalid host ([Gargron](https://github.com/mastodon/mastodon/pull/13033)) - -### Removed - -- Remove unused dependencies ([ykzts](https://github.com/mastodon/mastodon/pull/12861), [mayaeh](https://github.com/mastodon/mastodon/pull/12826), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12822), [ykzts](https://github.com/mastodon/mastodon/pull/12533)) - -### Fixed - -- Fix some translatable strings being used wrongly ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12569), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12589), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12502), [mayaeh](https://github.com/mastodon/mastodon/pull/12231)) -- Fix headline of public timeline page when set to local-only ([ykzts](https://github.com/mastodon/mastodon/pull/12224)) -- Fix space between tabs not being spread evenly in web UI ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12944), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12961), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12446)) -- Fix interactive delays in database migrations with no TTY ([Gargron](https://github.com/mastodon/mastodon/pull/12969)) -- Fix status overflowing in report dialog in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12959)) -- Fix unlocalized dropdown button title in web UI ([Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/12947)) -- Fix media attachments without file being uploadable ([Gargron](https://github.com/mastodon/mastodon/pull/12562)) -- Fix unfollow confirmations in profile directory in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12922)) -- Fix duplicate `description` meta tag on accounts public pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12923)) -- Fix slow query of federated timeline ([notozeki](https://github.com/mastodon/mastodon/pull/12886)) -- Fix not all of account's active IPs showing up in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/12909), [Gargron](https://github.com/mastodon/mastodon/pull/12943)) -- Fix search by IP not using alternative browser sessions in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/12904)) -- Fix “X new items” not showing up for slow mode on empty timelines in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12875)) -- Fix OEmbed endpoint being inaccessible in secure mode ([Gargron](https://github.com/mastodon/mastodon/pull/12864)) -- Fix proofs API being inaccessible in secure mode ([Gargron](https://github.com/mastodon/mastodon/pull/12495)) -- Fix Ruby 2.7 incompatibilities ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12831), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12824), [Shleeble](https://github.com/mastodon/mastodon/pull/12759), [zunda](https://github.com/mastodon/mastodon/pull/12769)) -- Fix invalid poll votes being accepted in REST API ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12601)) -- Fix old migrations failing because of strong migrations update ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12787), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12692)) -- Fix reuse of detailed status components in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12792)) -- Fix base64-encoded file uploads not being possible in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/12748), [Gargron](https://github.com/mastodon/mastodon/pull/12857)) -- Fix error due to missing authentication call in filters controller ([Gargron](https://github.com/mastodon/mastodon/pull/12746)) -- Fix uncaught unknown format error in host meta controller ([Gargron](https://github.com/mastodon/mastodon/pull/12747)) -- Fix URL search not returning private toots user has access to ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12742), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/12336)) -- Fix cache digesting log noise on status embeds ([Gargron](https://github.com/mastodon/mastodon/pull/12750)) -- Fix slowness due to layout thrashing when reloading a large set of statuses in web UI ([panarom](https://github.com/mastodon/mastodon/pull/12661), [panarom](https://github.com/mastodon/mastodon/pull/12744), [Gargron](https://github.com/mastodon/mastodon/pull/12712)) -- Fix error when fetching followers/following from REST API when user has network hidden ([Gargron](https://github.com/mastodon/mastodon/pull/12716)) -- Fix IDN mentions not being processed, IDN domains not being rendered ([Gargron](https://github.com/mastodon/mastodon/pull/12715), [Gargron](https://github.com/mastodon/mastodon/pull/13035), [Gargron](https://github.com/mastodon/mastodon/pull/13030)) -- Fix error when searching for empty phrase ([Gargron](https://github.com/mastodon/mastodon/pull/12711)) -- Fix backups stopping due to read timeouts ([chr-1x](https://github.com/mastodon/mastodon/pull/12281)) -- Fix batch actions on non-pending tags in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12537)) -- Fix sample `SAML_ACS_URL`, `SAML_ISSUER` ([orlea](https://github.com/mastodon/mastodon/pull/12669)) -- Fix manual scrolling issue on Firefox/Windows in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12648)) -- Fix archive takeout failing if total dump size exceeds 2GB ([scd31](https://github.com/mastodon/mastodon/pull/12602), [Gargron](https://github.com/mastodon/mastodon/pull/12653)) -- Fix custom emoji category creation silently erroring out on duplicate category ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12647)) -- Fix link crawler not specifying preferred content type ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12646)) -- Fix featured hashtag setting page erroring out instead of rejecting invalid tags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12436)) -- Fix tooltip messages of single/multiple-choice polls switcher being reversed in web UI ([acid-chicken](https://github.com/mastodon/mastodon/pull/12616)) -- Fix typo in help text of `tootctl statuses remove` ([trwnh](https://github.com/mastodon/mastodon/pull/12603)) -- Fix generic HTTP 500 error on duplicate records ([Gargron](https://github.com/mastodon/mastodon/pull/12563)) -- Fix old migration failing with new status default scope ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12493)) -- Fix errors when using search API with no query ([Gargron](https://github.com/mastodon/mastodon/pull/12541), [trwnh](https://github.com/mastodon/mastodon/pull/12549)) -- Fix poll options not being selectable via keyboard in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12538)) -- Fix conversations not having an unread indicator in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/12506)) -- Fix lost focus when modals open/close in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12437)) -- Fix pending upload count not being decremented on error in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12499)) -- Fix empty poll options not being removed on remote poll update ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12484)) -- Fix OCR with delete & redraft in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12465)) -- Fix blur behind closed registration message ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12442)) -- Fix OEmbed discovery not handling different URL variants in query ([Gargron](https://github.com/mastodon/mastodon/pull/12439)) -- Fix link crawler crashing on `` tags without `href` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12159)) -- Fix whitelisted subdomains being ignored in whitelist mode ([noiob](https://github.com/mastodon/mastodon/pull/12435)) -- Fix broken audit log in whitelist mode in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12303)) -- Fix unread indicator not honoring "Only media" option in local and federated timelines in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12330)) -- Fix error when rebuilding home feeds ([dariusk](https://github.com/mastodon/mastodon/pull/12324)) -- Fix relationship caches being broken as result of a follow request ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12299)) -- Fix more items than the limit being uploadable in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12300)) -- Fix various issues with account migration ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12301)) -- Fix filtered out items being counted as pending items in slow mode in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12266)) -- Fix notification filters not applying to poll options ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12269)) -- Fix notification message for user's own poll saying it's a poll they voted on in web UI ([ykzts](https://github.com/mastodon/mastodon/pull/12219)) -- Fix polls with an expiration not showing up as expired in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/12222)) -- Fix volume slider having an offset between cursor and slider in Chromium in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12158)) -- Fix Vagrant image not accepting connections ([shrft](https://github.com/mastodon/mastodon/pull/12180)) -- Fix batch actions being hidden on small screens in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12183)) -- Fix incoming federation not working in whitelist mode ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12185)) -- Fix error when passing empty `source` param to `PUT /api/v1/accounts/update_credentials` ([jglauche](https://github.com/mastodon/mastodon/pull/12259)) -- Fix HTTP-based streaming API being cacheable by proxies ([BenLubar](https://github.com/mastodon/mastodon/pull/12945)) -- Fix users being able to register while `tootctl self-destruct` is in progress ([Kjwon15](https://github.com/mastodon/mastodon/pull/12877)) -- Fix microformats detection in link crawler not ignoring `h-card` links ([nightpool](https://github.com/mastodon/mastodon/pull/12189)) -- Fix outline on full-screen video in web UI ([hinaloe](https://github.com/mastodon/mastodon/pull/12176)) -- Fix TLD domain blocks not being editable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12805)) -- Fix Nanobox deploy hooks ([danhunsaker](https://github.com/mastodon/mastodon/pull/12663)) -- Fix needlessly complicated SQL query when performing account search amongst followings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12302)) -- Fix favourites count not updating when unfavouriting in web UI ([NimaBoscarino](https://github.com/mastodon/mastodon/pull/12140)) -- Fix occasional crash on scroll in Chromium in web UI ([hinaloe](https://github.com/mastodon/mastodon/pull/12274)) -- Fix intersection observer not working in single-column mode web UI ([panarom](https://github.com/mastodon/mastodon/pull/12735)) -- Fix voting issue with remote polls that contain trailing spaces ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12515)) -- Fix dynamic elements not working in pgHero due to CSP rules ([ykzts](https://github.com/mastodon/mastodon/pull/12489)) -- Fix overly verbose backtraces when delivering ActivityPub payloads ([zunda](https://github.com/mastodon/mastodon/pull/12798)) -- Fix rendering `` without `href` when scheme unsupported ([Gargron](https://github.com/mastodon/mastodon/pull/13040)) -- Fix unfiltered params error when generating ActivityPub tag pagination ([Gargron](https://github.com/mastodon/mastodon/pull/13049)) -- Fix malformed HTML causing uncaught error ([Gargron](https://github.com/mastodon/mastodon/pull/13042)) -- Fix native share button not being displayed for unlisted toots ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/13045)) -- Fix remote convertible media attachments (e.g. GIFs) not being saved ([Gargron](https://github.com/mastodon/mastodon/pull/13032)) -- Fix account query not using faster index ([abcang](https://github.com/mastodon/mastodon/pull/13016)) -- Fix error when sending moderation notification ([renatolond](https://github.com/mastodon/mastodon/pull/13014)) - -### Security - -- Fix OEmbed leaking information about existence of non-public statuses ([Gargron](https://github.com/mastodon/mastodon/pull/12930)) -- Fix password change/reset not immediately invalidating other sessions ([Gargron](https://github.com/mastodon/mastodon/pull/12928)) -- Fix settings pages being cacheable by the browser ([Gargron](https://github.com/mastodon/mastodon/pull/12714)) - -## [3.0.1] - 2019-10-10 - -### Added - -- Add `tootctl media usage` command ([Gargron](https://github.com/mastodon/mastodon/pull/12115)) -- Add admin setting to auto-approve trending hashtags ([Gargron](https://github.com/mastodon/mastodon/pull/12122), [Gargron](https://github.com/mastodon/mastodon/pull/12130)) - -### Changed - -- Change `tootctl media refresh` to skip already downloaded attachments ([Gargron](https://github.com/mastodon/mastodon/pull/12118)) - -### Removed - -- Remove auto-silence behaviour from spam check ([Gargron](https://github.com/mastodon/mastodon/pull/12117)) -- Remove HTML `lang` attribute from individual statuses in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/12124)) -- Remove fallback to long description on sidebar and meta description ([Gargron](https://github.com/mastodon/mastodon/pull/12119)) - -### Fixed - -- Fix preloaded JSON-LD context for identity not being used ([Gargron](https://github.com/mastodon/mastodon/pull/12138)) -- Fix media editing modal changing dimensions once the image loads ([Gargron](https://github.com/mastodon/mastodon/pull/12131)) -- Fix not showing whether a custom emoji has a local counterpart in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/12135)) -- Fix attachment not being re-downloaded even if file is not stored ([Gargron](https://github.com/mastodon/mastodon/pull/12125)) -- Fix old migration trying to use new column due to default status scope ([Gargron](https://github.com/mastodon/mastodon/pull/12095)) -- Fix column back button missing for not found accounts ([trwnh](https://github.com/mastodon/mastodon/pull/12094)) -- Fix issues with tootctl's parallelization and progress reporting ([Gargron](https://github.com/mastodon/mastodon/pull/12093), [Gargron](https://github.com/mastodon/mastodon/pull/12097)) -- Fix existing user records with now-renamed `pt` locale ([Gargron](https://github.com/mastodon/mastodon/pull/12092)) -- Fix hashtag timeline REST API accepting too many hashtags ([Gargron](https://github.com/mastodon/mastodon/pull/12091)) -- Fix `GET /api/v1/instance` REST APIs being unavailable in secure mode ([Gargron](https://github.com/mastodon/mastodon/pull/12089)) -- Fix performance of home feed regeneration and merging ([Gargron](https://github.com/mastodon/mastodon/pull/12084)) -- Fix ffmpeg performance issues due to stdout buffer overflow ([hugogameiro](https://github.com/mastodon/mastodon/pull/12088)) -- Fix S3 adapter retrying failing uploads with exponential backoff ([Gargron](https://github.com/mastodon/mastodon/pull/12085)) -- Fix `tootctl accounts cull` advertising unused option flag ([Kjwon15](https://github.com/mastodon/mastodon/pull/12074)) - -## [3.0.0] - 2019-10-03 - -### Added - -- Add "not available" label to unloaded media attachments in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11715), [Gargron](https://github.com/mastodon/mastodon/pull/11745)) -- **Add profile directory to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11688), [mayaeh](https://github.com/mastodon/mastodon/pull/11872)) - - Add profile directory opt-in federation - - Add profile directory REST API -- Add special alert for throttled requests in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11677)) -- Add confirmation modal when logging out from the web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11671)) -- **Add audio player in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11644), [Gargron](https://github.com/mastodon/mastodon/pull/11652), [Gargron](https://github.com/mastodon/mastodon/pull/11654), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11629), [Gargron](https://github.com/mastodon/mastodon/pull/12056)) -- **Add autosuggestions for hashtags in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11422), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11632), [Gargron](https://github.com/mastodon/mastodon/pull/11764), [Gargron](https://github.com/mastodon/mastodon/pull/11588), [Gargron](https://github.com/mastodon/mastodon/pull/11442)) -- **Add media editing modal with OCR tool in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11563), [Gargron](https://github.com/mastodon/mastodon/pull/11566), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11575), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11576), [Gargron](https://github.com/mastodon/mastodon/pull/11577), [Gargron](https://github.com/mastodon/mastodon/pull/11573), [Gargron](https://github.com/mastodon/mastodon/pull/11571)) -- Add indicator of unread notifications to window title when web UI is out of focus ([Gargron](https://github.com/mastodon/mastodon/pull/11560), [Gargron](https://github.com/mastodon/mastodon/pull/11572)) -- Add indicator for which options you voted for in a poll in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11195)) -- **Add search results pagination to web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11409), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11447)) -- **Add option to disable real-time updates in web UI ("slow mode")** ([Gargron](https://github.com/mastodon/mastodon/pull/9984), [ykzts](https://github.com/mastodon/mastodon/pull/11880), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11883), [Gargron](https://github.com/mastodon/mastodon/pull/11898), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11859)) -- Add option to disable blurhash previews in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11188)) -- Add native smooth scrolling when supported in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11207)) -- Add scrolling to the search bar on focus in web UI ([Kjwon15](https://github.com/mastodon/mastodon/pull/12032)) -- Add refresh button to list of rebloggers/favouriters in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/12031)) -- Add error description and button to copy stack trace to web UI ([Gargron](https://github.com/mastodon/mastodon/pull/12033)) -- Add search and sort functions to hashtag admin UI ([mayaeh](https://github.com/mastodon/mastodon/pull/11829), [Gargron](https://github.com/mastodon/mastodon/pull/11897), [mayaeh](https://github.com/mastodon/mastodon/pull/11875)) -- Add setting for default search engine indexing in admin UI ([brortao](https://github.com/mastodon/mastodon/pull/11804)) -- Add account bio to account view in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11473)) -- **Add option to include reported statuses in warning e-mail from admin UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11639), [Gargron](https://github.com/mastodon/mastodon/pull/11812), [Gargron](https://github.com/mastodon/mastodon/pull/11741), [Gargron](https://github.com/mastodon/mastodon/pull/11698), [mayaeh](https://github.com/mastodon/mastodon/pull/11765)) -- Add number of pending accounts and pending hashtags to dashboard in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/11514)) -- **Add account migration UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11846), [noellabo](https://github.com/mastodon/mastodon/pull/11905), [noellabo](https://github.com/mastodon/mastodon/pull/11907), [noellabo](https://github.com/mastodon/mastodon/pull/11906), [noellabo](https://github.com/mastodon/mastodon/pull/11902)) -- **Add table of contents to about page** ([Gargron](https://github.com/mastodon/mastodon/pull/11885), [ykzts](https://github.com/mastodon/mastodon/pull/11941), [ykzts](https://github.com/mastodon/mastodon/pull/11895), [Kjwon15](https://github.com/mastodon/mastodon/pull/11916)) -- **Add password challenge to 2FA settings, e-mail notifications** ([Gargron](https://github.com/mastodon/mastodon/pull/11878)) -- **Add optional public list of domain blocks with comments** ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11298), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11515), [Gargron](https://github.com/mastodon/mastodon/pull/11908)) -- Add an RSS feed for featured hashtags ([noellabo](https://github.com/mastodon/mastodon/pull/10502)) -- Add explanations to featured hashtags UI and profile ([Gargron](https://github.com/mastodon/mastodon/pull/11586)) -- **Add hashtag trends with admin and user settings** ([Gargron](https://github.com/mastodon/mastodon/pull/11490), [Gargron](https://github.com/mastodon/mastodon/pull/11502), [Gargron](https://github.com/mastodon/mastodon/pull/11641), [Gargron](https://github.com/mastodon/mastodon/pull/11594), [Gargron](https://github.com/mastodon/mastodon/pull/11517), [mayaeh](https://github.com/mastodon/mastodon/pull/11845), [Gargron](https://github.com/mastodon/mastodon/pull/11774), [Gargron](https://github.com/mastodon/mastodon/pull/11712), [Gargron](https://github.com/mastodon/mastodon/pull/11791), [Gargron](https://github.com/mastodon/mastodon/pull/11743), [Gargron](https://github.com/mastodon/mastodon/pull/11740), [Gargron](https://github.com/mastodon/mastodon/pull/11714), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11631), [Sasha-Sorokin](https://github.com/mastodon/mastodon/pull/11569), [Gargron](https://github.com/mastodon/mastodon/pull/11524), [Gargron](https://github.com/mastodon/mastodon/pull/11513)) - - Add hashtag usage breakdown to admin UI - - Add batch actions for hashtags to admin UI - - Add trends to web UI - - Add trends to public pages - - Add user preference to hide trends - - Add admin setting to disable trends -- **Add categories for custom emojis** ([Gargron](https://github.com/mastodon/mastodon/pull/11196), [Gargron](https://github.com/mastodon/mastodon/pull/11793), [Gargron](https://github.com/mastodon/mastodon/pull/11920), [highemerly](https://github.com/mastodon/mastodon/pull/11876)) - - Add custom emoji categories to emoji picker in web UI - - Add `category` to custom emojis in REST API - - Add batch actions for custom emojis in admin UI -- Add max image dimensions to error message ([raboof](https://github.com/mastodon/mastodon/pull/11552)) -- Add aac, m4a, 3gp, amr, wma to allowed audio formats ([Gargron](https://github.com/mastodon/mastodon/pull/11342), [umonaca](https://github.com/mastodon/mastodon/pull/11687)) -- **Add search syntax for operators and phrases** ([Gargron](https://github.com/mastodon/mastodon/pull/11411)) -- **Add REST API for managing featured hashtags** ([noellabo](https://github.com/mastodon/mastodon/pull/11778)) -- **Add REST API for managing timeline read markers** ([Gargron](https://github.com/mastodon/mastodon/pull/11762)) -- Add `exclude_unreviewed` param to `GET /api/v2/search` REST API ([Gargron](https://github.com/mastodon/mastodon/pull/11977)) -- Add `reason` param to `POST /api/v1/accounts` REST API ([Gargron](https://github.com/mastodon/mastodon/pull/12064)) -- **Add ActivityPub secure mode** ([Gargron](https://github.com/mastodon/mastodon/pull/11269), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11332), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11295)) -- Add HTTP signatures to all outgoing ActivityPub GET requests ([Gargron](https://github.com/mastodon/mastodon/pull/11284), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11300)) -- Add support for ActivityPub Audio activities ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11189)) -- Add ActivityPub actor representing the entire server ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11321), [rtucker](https://github.com/mastodon/mastodon/pull/11400), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11561), [Gargron](https://github.com/mastodon/mastodon/pull/11798)) -- **Add whitelist mode** ([Gargron](https://github.com/mastodon/mastodon/pull/11291), [mayaeh](https://github.com/mastodon/mastodon/pull/11634)) -- Add config of multipart threshold for S3 ([ykzts](https://github.com/mastodon/mastodon/pull/11924), [ykzts](https://github.com/mastodon/mastodon/pull/11944)) -- Add health check endpoint for web ([ykzts](https://github.com/mastodon/mastodon/pull/11770), [ykzts](https://github.com/mastodon/mastodon/pull/11947)) -- Add HTTP signature keyId to request log ([Gargron](https://github.com/mastodon/mastodon/pull/11591)) -- Add `SMTP_REPLY_TO` environment variable ([hugogameiro](https://github.com/mastodon/mastodon/pull/11718)) -- Add `tootctl preview_cards remove` command ([mayaeh](https://github.com/mastodon/mastodon/pull/11320)) -- Add `tootctl media refresh` command ([Gargron](https://github.com/mastodon/mastodon/pull/11775)) -- Add `tootctl cache recount` command ([Gargron](https://github.com/mastodon/mastodon/pull/11597)) -- Add option to exclude suspended domains from `tootctl domains crawl` ([dariusk](https://github.com/mastodon/mastodon/pull/11454)) -- Add parallelization to `tootctl search deploy` ([noellabo](https://github.com/mastodon/mastodon/pull/12051)) -- Add soft delete for statuses for instant deletes through API ([Gargron](https://github.com/mastodon/mastodon/pull/11623), [Gargron](https://github.com/mastodon/mastodon/pull/11648)) -- Add rails-level JSON caching ([Gargron](https://github.com/mastodon/mastodon/pull/11333), [Gargron](https://github.com/mastodon/mastodon/pull/11271)) -- **Add request pool to improve delivery performance** ([Gargron](https://github.com/mastodon/mastodon/pull/10353), [ykzts](https://github.com/mastodon/mastodon/pull/11756)) -- Add concurrent connection attempts to resolved IP addresses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11757)) -- Add index for remember_token to improve login performance ([abcang](https://github.com/mastodon/mastodon/pull/11881)) -- **Add more accurate hashtag search** ([Gargron](https://github.com/mastodon/mastodon/pull/11579), [Gargron](https://github.com/mastodon/mastodon/pull/11427), [Gargron](https://github.com/mastodon/mastodon/pull/11448)) -- **Add more accurate account search** ([Gargron](https://github.com/mastodon/mastodon/pull/11537), [Gargron](https://github.com/mastodon/mastodon/pull/11580)) -- **Add a spam check** ([Gargron](https://github.com/mastodon/mastodon/pull/11217), [Gargron](https://github.com/mastodon/mastodon/pull/11806), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11296)) -- Add new languages ([Gargron](https://github.com/mastodon/mastodon/pull/12062)) - - Breton - - Spanish (Argentina) - - Estonian - - Macedonian - - New Norwegian -- Add NodeInfo endpoint ([Gargron](https://github.com/mastodon/mastodon/pull/12002), [Gargron](https://github.com/mastodon/mastodon/pull/12058)) - -### Changed - -- **Change conversations UI** ([Gargron](https://github.com/mastodon/mastodon/pull/11896)) -- Change dashboard to short number notation ([noellabo](https://github.com/mastodon/mastodon/pull/11847), [noellabo](https://github.com/mastodon/mastodon/pull/11911)) -- Change REST API `GET /api/v1/timelines/public` to require authentication when public preview is off ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11802)) -- Change REST API `POST /api/v1/follow_requests/:id/(approve|reject)` to return relationship ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11800)) -- Change rate limit for media proxy ([ykzts](https://github.com/mastodon/mastodon/pull/11814)) -- Change unlisted custom emoji to not appear in autosuggestions ([Gargron](https://github.com/mastodon/mastodon/pull/11818)) -- Change max length of media descriptions from 420 to 1500 characters ([Gargron](https://github.com/mastodon/mastodon/pull/11819), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11836)) -- **Change deletes to preserve soft-deleted statuses in unresolved reports** ([Gargron](https://github.com/mastodon/mastodon/pull/11805)) -- **Change tootctl to use inline parallelization instead of Sidekiq** ([Gargron](https://github.com/mastodon/mastodon/pull/11776)) -- **Change account deletion page to have better explanations** ([Gargron](https://github.com/mastodon/mastodon/pull/11753), [Gargron](https://github.com/mastodon/mastodon/pull/11763)) -- Change hashtag component in web UI to show numbers for 2 last days ([Gargron](https://github.com/mastodon/mastodon/pull/11742), [Gargron](https://github.com/mastodon/mastodon/pull/11755), [Gargron](https://github.com/mastodon/mastodon/pull/11754)) -- Change OpenGraph description on sign-up page to reflect invite ([Gargron](https://github.com/mastodon/mastodon/pull/11744)) -- Change layout of public profile directory to be the same as in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11705)) -- Change detailed status child ordering to sort self-replies on top ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11686)) -- Change window resize handler to switch to/from mobile layout as soon as needed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11656)) -- Change icon button styles to make hover/focus states more obvious ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11474)) -- Change contrast of status links that are not mentions or hashtags ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11406)) -- **Change hashtags to preserve first-used casing** ([Gargron](https://github.com/mastodon/mastodon/pull/11416), [Gargron](https://github.com/mastodon/mastodon/pull/11508), [Gargron](https://github.com/mastodon/mastodon/pull/11504), [Gargron](https://github.com/mastodon/mastodon/pull/11507), [Gargron](https://github.com/mastodon/mastodon/pull/11441)) -- **Change unconfirmed user login behaviour** ([Gargron](https://github.com/mastodon/mastodon/pull/11375), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11394), [Gargron](https://github.com/mastodon/mastodon/pull/11860)) -- **Change single-column mode to scroll the whole page** ([Gargron](https://github.com/mastodon/mastodon/pull/11359), [Gargron](https://github.com/mastodon/mastodon/pull/11894), [Gargron](https://github.com/mastodon/mastodon/pull/11891), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11655), [Gargron](https://github.com/mastodon/mastodon/pull/11463), [Gargron](https://github.com/mastodon/mastodon/pull/11458), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11395), [Gargron](https://github.com/mastodon/mastodon/pull/11418)) -- Change `tootctl accounts follow` to only work with local accounts ([angristan](https://github.com/mastodon/mastodon/pull/11592)) -- Change Dockerfile ([Shleeble](https://github.com/mastodon/mastodon/pull/11710), [ykzts](https://github.com/mastodon/mastodon/pull/11768), [Shleeble](https://github.com/mastodon/mastodon/pull/11707)) -- Change supported Node versions to include v12 ([abcang](https://github.com/mastodon/mastodon/pull/11706)) -- Change Portuguese language from `pt` to `pt-PT` ([Gargron](https://github.com/mastodon/mastodon/pull/11820)) -- Change domain block silence to always require approval on follow ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11975)) -- Change link preview fetcher to not perform a HEAD request first ([Gargron](https://github.com/mastodon/mastodon/pull/12028)) -- Change `tootctl domains purge` to accept multiple domains at once ([Gargron](https://github.com/mastodon/mastodon/pull/12046)) - -### Removed - -- **Remove OStatus support** ([Gargron](https://github.com/mastodon/mastodon/pull/11205), [Gargron](https://github.com/mastodon/mastodon/pull/11303), [Gargron](https://github.com/mastodon/mastodon/pull/11460), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11280), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11278)) -- Remove Atom feeds and old URLs in the form of `GET /:username/updates/:id` ([Gargron](https://github.com/mastodon/mastodon/pull/11247)) -- Remove WebP support ([angristan](https://github.com/mastodon/mastodon/pull/11589)) -- Remove deprecated config options from Heroku and Scalingo ([ykzts](https://github.com/mastodon/mastodon/pull/11925)) -- Remove deprecated REST API `GET /api/v1/search` API ([Gargron](https://github.com/mastodon/mastodon/pull/11823)) -- Remove deprecated REST API `GET /api/v1/statuses/:id/card` ([Gargron](https://github.com/mastodon/mastodon/pull/11213)) -- Remove deprecated REST API `POST /api/v1/notifications/dismiss?id=:id` ([Gargron](https://github.com/mastodon/mastodon/pull/11214)) -- Remove deprecated REST API `GET /api/v1/timelines/direct` ([Gargron](https://github.com/mastodon/mastodon/pull/11212)) - -### Fixed - -- Fix manifest warning ([ykzts](https://github.com/mastodon/mastodon/pull/11767)) -- Fix admin UI for custom emoji not respecting GIF autoplay preference ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11801)) -- Fix page body not being scrollable in admin/settings layout ([Gargron](https://github.com/mastodon/mastodon/pull/11893)) -- Fix placeholder colors for inputs not being explicitly defined ([Gargron](https://github.com/mastodon/mastodon/pull/11890)) -- Fix incorrect enclosure length in RSS ([tsia](https://github.com/mastodon/mastodon/pull/11889)) -- Fix TOTP codes not being filtered from logs during enabling/disabling ([Gargron](https://github.com/mastodon/mastodon/pull/11877)) -- Fix webfinger response not returning 410 when account is suspended ([Gargron](https://github.com/mastodon/mastodon/pull/11869)) -- Fix ActivityPub Move handler queuing jobs that will fail if account is suspended ([Gargron](https://github.com/mastodon/mastodon/pull/11864)) -- Fix SSO login not using existing account when e-mail is verified ([Gargron](https://github.com/mastodon/mastodon/pull/11862)) -- Fix web UI allowing uploads past status limit via drag & drop ([Gargron](https://github.com/mastodon/mastodon/pull/11863)) -- Fix expiring polls not being displayed as such in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11835)) -- Fix 2FA challenge and password challenge for non-database users ([Gargron](https://github.com/mastodon/mastodon/pull/11831), [Gargron](https://github.com/mastodon/mastodon/pull/11943)) -- Fix profile fields overflowing page width in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11828)) -- Fix web push subscriptions being deleted on rate limit or timeout ([Gargron](https://github.com/mastodon/mastodon/pull/11826)) -- Fix display of long poll options in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11717), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11833)) -- Fix search API not resolving URL when `type` is given ([Gargron](https://github.com/mastodon/mastodon/pull/11822)) -- Fix hashtags being split by ZWNJ character ([Gargron](https://github.com/mastodon/mastodon/pull/11821)) -- Fix scroll position resetting when opening media modals in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11815)) -- Fix duplicate HTML IDs on about page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11803)) -- Fix admin UI showing superfluous reject media/reports on suspended domain blocks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11749)) -- Fix ActivityPub context not being dynamically computed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11746)) -- Fix Mastodon logo style on hover on public pages' footer ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11735)) -- Fix height of dashboard counters ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11736)) -- Fix custom emoji animation on hover in web UI directory bios ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11716)) -- Fix non-numbers being passed to Redis and causing an error ([Gargron](https://github.com/mastodon/mastodon/pull/11697)) -- Fix error in REST API for an account's statuses ([Gargron](https://github.com/mastodon/mastodon/pull/11700)) -- Fix uncaught error when resource param is missing in Webfinger request ([Gargron](https://github.com/mastodon/mastodon/pull/11701)) -- Fix uncaught domain normalization error in remote follow ([Gargron](https://github.com/mastodon/mastodon/pull/11703)) -- Fix uncaught 422 and 500 errors ([Gargron](https://github.com/mastodon/mastodon/pull/11590), [Gargron](https://github.com/mastodon/mastodon/pull/11811)) -- Fix uncaught parameter missing exceptions and missing error templates ([Gargron](https://github.com/mastodon/mastodon/pull/11702)) -- Fix encoding error when checking e-mail MX records ([Gargron](https://github.com/mastodon/mastodon/pull/11696)) -- Fix items in StatusContent render list not all having a key ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11645)) -- Fix remote and staff-removed statuses leaving media behind for a day ([Gargron](https://github.com/mastodon/mastodon/pull/11638)) -- Fix CSP needlessly allowing blob URLs in script-src ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11620)) -- Fix ignoring whole status because of one invalid hashtag ([Gargron](https://github.com/mastodon/mastodon/pull/11621)) -- Fix hidden statuses losing focus ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11208)) -- Fix loading bar being obscured by other elements in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11598)) -- Fix multiple issues with replies collection for pages further than self-replies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11582)) -- Fix blurhash and autoplay not working on public pages ([Gargron](https://github.com/mastodon/mastodon/pull/11585)) -- Fix 422 being returned instead of 404 when POSTing to unmatched routes ([Gargron](https://github.com/mastodon/mastodon/pull/11574), [Gargron](https://github.com/mastodon/mastodon/pull/11704)) -- Fix client-side resizing of image uploads ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11570)) -- Fix short number formatting for numbers above million in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11559)) -- Fix ActivityPub and REST API queries setting cookies and preventing caching ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11539), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11557), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11336), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11331)) -- Fix some emojis in profile metadata labels are not emojified. ([kedamaDQ](https://github.com/mastodon/mastodon/pull/11534)) -- Fix account search always returning exact match on paginated results ([Gargron](https://github.com/mastodon/mastodon/pull/11525)) -- Fix acct URIs with IDN domains not being resolved ([Gargron](https://github.com/mastodon/mastodon/pull/11520)) -- Fix admin dashboard missing latest features ([Gargron](https://github.com/mastodon/mastodon/pull/11505)) -- Fix jumping of toot date when clicking spoiler button ([ariasuni](https://github.com/mastodon/mastodon/pull/11449)) -- Fix boost to original audience not working on mobile in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11371)) -- Fix handling of webfinger redirects in ResolveAccountService ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11279)) -- Fix URLs appearing twice in errors of ActivityPub::DeliveryWorker ([Gargron](https://github.com/mastodon/mastodon/pull/11231)) -- Fix support for HTTP proxies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11245)) -- Fix HTTP requests to IPv6 hosts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11240)) -- Fix error in Elasticsearch index import ([mayaeh](https://github.com/mastodon/mastodon/pull/11192)) -- Fix duplicate account error when seeding development database ([ysksn](https://github.com/mastodon/mastodon/pull/11366)) -- Fix performance of session clean-up scheduler ([abcang](https://github.com/mastodon/mastodon/pull/11871)) -- Fix older migrations not running ([zunda](https://github.com/mastodon/mastodon/pull/11377)) -- Fix URLs counting towards RTL detection ([ahangarha](https://github.com/mastodon/mastodon/pull/11759)) -- Fix unnecessary status re-rendering in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11211)) -- Fix http_parser.rb gem not being compiled when no network available ([petabyteboy](https://github.com/mastodon/mastodon/pull/11444)) -- Fix muted text color not applying to all text ([trwnh](https://github.com/mastodon/mastodon/pull/11996)) -- Fix follower/following lists resetting on back-navigation in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11986)) -- Fix n+1 query when approving multiple follow requests ([abcang](https://github.com/mastodon/mastodon/pull/12004)) -- Fix records not being indexed into Elasticsearch sometimes ([Gargron](https://github.com/mastodon/mastodon/pull/12024)) -- Fix needlessly indexing unsearchable statuses into Elasticsearch ([Gargron](https://github.com/mastodon/mastodon/pull/12041)) -- Fix new user bootstrapping crashing when to-be-followed accounts are invalid ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/12037)) -- Fix featured hashtag URL being interpreted as media or replies tab ([Gargron](https://github.com/mastodon/mastodon/pull/12048)) -- Fix account counters being overwritten by parallel writes ([Gargron](https://github.com/mastodon/mastodon/pull/12045)) - -### Security - -- Fix performance of GIF re-encoding and always strip EXIF data from videos ([Gargron](https://github.com/mastodon/mastodon/pull/12057)) - -## [2.9.3] - 2019-08-10 - -### Added - -- Add GIF and WebP support for custom emojis ([Gargron](https://github.com/mastodon/mastodon/pull/11519)) -- Add logout link to dropdown menu in web UI ([koyuawsmbrtn](https://github.com/mastodon/mastodon/pull/11353)) -- Add indication that text search is unavailable in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11112), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11202)) -- Add `suffix` to `Mastodon::Version` to help forks ([clarfon](https://github.com/mastodon/mastodon/pull/11407)) -- Add on-hover animation to animated custom emoji in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11348), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11404), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11522)) -- Add custom emoji support in profile metadata labels ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11350)) - -### Changed - -- Change default interface of web and streaming from 0.0.0.0 to 127.0.0.1 ([Gargron](https://github.com/mastodon/mastodon/pull/11302), [zunda](https://github.com/mastodon/mastodon/pull/11378), [Gargron](https://github.com/mastodon/mastodon/pull/11351), [zunda](https://github.com/mastodon/mastodon/pull/11326)) -- Change the retry limit of web push notifications ([highemerly](https://github.com/mastodon/mastodon/pull/11292)) -- Change ActivityPub deliveries to not retry HTTP 501 errors ([Gargron](https://github.com/mastodon/mastodon/pull/11233)) -- Change language detection to include hashtags as words ([Gargron](https://github.com/mastodon/mastodon/pull/11341)) -- Change terms and privacy policy pages to always be accessible ([Gargron](https://github.com/mastodon/mastodon/pull/11334)) -- Change robots tag to include `noarchive` when user opts out of indexing ([Kjwon15](https://github.com/mastodon/mastodon/pull/11421)) - -### Fixed - -- Fix account domain block not clearing out notifications ([Gargron](https://github.com/mastodon/mastodon/pull/11393)) -- Fix incorrect locale sometimes being detected for browser ([Gargron](https://github.com/mastodon/mastodon/pull/8657)) -- Fix crash when saving invalid domain name ([Gargron](https://github.com/mastodon/mastodon/pull/11528)) -- Fix pinned statuses REST API returning pagination headers ([Gargron](https://github.com/mastodon/mastodon/pull/11526)) -- Fix "cancel follow request" button having unreadable text in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11521)) -- Fix image uploads being blank when canvas read access is blocked ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11499)) -- Fix avatars not being animated on hover when not logged in ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11349)) -- Fix overzealous sanitization of HTML lists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11354)) -- Fix block crashing when a follow request exists ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11288)) -- Fix backup service crashing when an attachment is missing ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11241)) -- Fix account moderation action always sending e-mail notification ([Gargron](https://github.com/mastodon/mastodon/pull/11242)) -- Fix swiping columns on mobile sometimes failing in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11200)) -- Fix wrong actor URI being serialized into poll updates ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11194)) -- Fix statsd UDP sockets not being cleaned up in Sidekiq ([Gargron](https://github.com/mastodon/mastodon/pull/11230)) -- Fix expiration date of filters being set to "never" when editing them ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11204)) -- Fix support for MP4 files that are actually M4V files ([Gargron](https://github.com/mastodon/mastodon/pull/11210)) -- Fix `alerts` not being typecast correctly in push subscription in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/11343)) -- Fix some notices staying on unrelated pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11364)) -- Fix unboosting sometimes preventing a boost from reappearing on feed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11405), [Gargron](https://github.com/mastodon/mastodon/pull/11450)) -- Fix only one middle dot being recognized in hashtags ([Gargron](https://github.com/mastodon/mastodon/pull/11345), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11363)) -- Fix unnecessary SQL query performed on unauthenticated requests ([Gargron](https://github.com/mastodon/mastodon/pull/11179)) -- Fix incorrect timestamp displayed on featured tags ([Kjwon15](https://github.com/mastodon/mastodon/pull/11477)) -- Fix privacy dropdown active state when dropdown is placed on top of it ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11495)) -- Fix filters not being applied to poll options ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11174)) -- Fix keyboard navigation on various dropdowns ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11511), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11492), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11491)) -- Fix keyboard navigation in modals ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11493)) -- Fix image conversation being non-deterministic due to timestamps ([Gargron](https://github.com/mastodon/mastodon/pull/11408)) -- Fix web UI performance ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11211), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11234)) -- Fix scrolling to compose form when not necessary in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11246), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/11182)) -- Fix save button being enabled when list title is empty in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11475)) -- Fix poll expiration not being pre-filled on delete & redraft in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11203)) -- Fix content warning sometimes being set when not requested in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11206)) - -### Security - -- Fix invites not being disabled upon account suspension ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11412)) -- Fix blocked domains still being able to fill database with account records ([Gargron](https://github.com/mastodon/mastodon/pull/11219)) - -## [2.9.2] - 2019-06-22 - -### Added - -- Add `short_description` and `approval_required` to `GET /api/v1/instance` ([Gargron](https://github.com/mastodon/mastodon/pull/11146)) - -### Changed - -- Change camera icon to paperclip icon in upload form ([koyuawsmbrtn](https://github.com/mastodon/mastodon/pull/11149)) - -### Fixed - -- Fix audio-only OGG and WebM files not being processed as such ([Gargron](https://github.com/mastodon/mastodon/pull/11151)) -- Fix audio not being downloaded from remote servers ([Gargron](https://github.com/mastodon/mastodon/pull/11145)) - -## [2.9.1] - 2019-06-22 - -### Added - -- Add moderation API ([Gargron](https://github.com/mastodon/mastodon/pull/9387)) -- Add audio uploads ([Gargron](https://github.com/mastodon/mastodon/pull/11123), [Gargron](https://github.com/mastodon/mastodon/pull/11141)) - -### Changed - -- Change domain blocks to automatically support subdomains ([Gargron](https://github.com/mastodon/mastodon/pull/11138)) -- Change Nanobox configuration to bring it up to date ([danhunsaker](https://github.com/mastodon/mastodon/pull/11083)) - -### Removed - -- Remove expensive counters from federation page in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/11139)) - -### Fixed - -- Fix converted media being saved with original extension and mime type ([Gargron](https://github.com/mastodon/mastodon/pull/11130)) -- Fix layout of identity proofs settings ([acid-chicken](https://github.com/mastodon/mastodon/pull/11126)) -- Fix active scope only returning suspended users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11111)) -- Fix sanitizer making block level elements unreadable ([Gargron](https://github.com/mastodon/mastodon/pull/10836)) -- Fix label for site theme not being translated in admin UI ([palindromordnilap](https://github.com/mastodon/mastodon/pull/11121)) -- Fix statuses not being filtered irreversibly in web UI under some circumstances ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11113)) -- Fix scrolling behaviour in compose form ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11093)) - -## [2.9.0] - 2019-06-13 - -### Added - -- **Add single-column mode in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/10807), [Gargron](https://github.com/mastodon/mastodon/pull/10848), [Gargron](https://github.com/mastodon/mastodon/pull/11003), [Gargron](https://github.com/mastodon/mastodon/pull/10961), [Hanage999](https://github.com/mastodon/mastodon/pull/10915), [noellabo](https://github.com/mastodon/mastodon/pull/10917), [abcang](https://github.com/mastodon/mastodon/pull/10859), [Gargron](https://github.com/mastodon/mastodon/pull/10820), [Gargron](https://github.com/mastodon/mastodon/pull/10835), [Gargron](https://github.com/mastodon/mastodon/pull/10809), [Gargron](https://github.com/mastodon/mastodon/pull/10963), [noellabo](https://github.com/mastodon/mastodon/pull/10883), [Hanage999](https://github.com/mastodon/mastodon/pull/10839)) -- Add waiting time to the list of pending accounts in admin UI ([Gargron](https://github.com/mastodon/mastodon/pull/10985)) -- Add a keyboard shortcut to hide/show media in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10647), [Gargron](https://github.com/mastodon/mastodon/pull/10838), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10872)) -- Add `account_id` param to `GET /api/v1/notifications` ([pwoolcoc](https://github.com/mastodon/mastodon/pull/10796)) -- Add confirmation modal for unboosting toots in web UI ([aurelien-reeves](https://github.com/mastodon/mastodon/pull/10287)) -- Add emoji suggestions to content warning and poll option fields in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10555)) -- Add `source` attribute to response of `DELETE /api/v1/statuses/:id` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10669)) -- Add some caching for HTML versions of public status pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10701)) -- Add button to conveniently copy OAuth code ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/11065)) - -### Changed - -- **Change default layout to single column in web UI** ([Gargron](https://github.com/mastodon/mastodon/pull/10847)) -- **Change light theme** ([Gargron](https://github.com/mastodon/mastodon/pull/10992), [Gargron](https://github.com/mastodon/mastodon/pull/10996), [yuzulabo](https://github.com/mastodon/mastodon/pull/10754), [Gargron](https://github.com/mastodon/mastodon/pull/10845)) -- **Change preferences page into appearance, notifications, and other** ([Gargron](https://github.com/mastodon/mastodon/pull/10977), [Gargron](https://github.com/mastodon/mastodon/pull/10988)) -- Change priority of delete activity forwards for replies and reblogs ([Gargron](https://github.com/mastodon/mastodon/pull/11002)) -- Change Mastodon logo to use primary text color of the given theme ([Gargron](https://github.com/mastodon/mastodon/pull/10994)) -- Change reblogs counter to be updated when boosted privately ([Gargron](https://github.com/mastodon/mastodon/pull/10964)) -- Change bio limit from 160 to 500 characters ([trwnh](https://github.com/mastodon/mastodon/pull/10790)) -- Change API rate limiting to reduce allowed unauthenticated requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10860), [hinaloe](https://github.com/mastodon/mastodon/pull/10868), [mayaeh](https://github.com/mastodon/mastodon/pull/10867)) -- Change help text of `tootctl emoji import` command to specify a gzipped TAR archive is required ([dariusk](https://github.com/mastodon/mastodon/pull/11000)) -- Change web UI to hide poll options behind content warnings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10983)) -- Change silencing to ensure local effects and remote effects are the same for silenced local users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10575)) -- Change `tootctl domains purge` to remove custom emoji as well ([Kjwon15](https://github.com/mastodon/mastodon/pull/10721)) -- Change Docker image to keep `apt` working ([SuperSandro2000](https://github.com/mastodon/mastodon/pull/10830)) - -### Removed - -- Remove `dist-upgrade` from Docker image ([SuperSandro2000](https://github.com/mastodon/mastodon/pull/10822)) - -### Fixed - -- Fix RTL layout not being RTL within the columns area in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10990)) -- Fix display of alternative text when a media attachment is not available in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10981)) -- Fix not being able to directly switch between list timelines in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10973)) -- Fix media sensitivity not being maintained in delete & redraft in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10980)) -- Fix emoji picker being always displayed in web UI ([noellabo](https://github.com/mastodon/mastodon/pull/10979), [yuzulabo](https://github.com/mastodon/mastodon/pull/10801), [wcpaez](https://github.com/mastodon/mastodon/pull/10978)) -- Fix potential private status leak through caching ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10969)) -- Fix refreshing featured toots when the new collection is empty in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10971)) -- Fix undoing domain block also undoing individual moderation on users from before the domain block ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10660)) -- Fix time not being local in the audit log ([yuzulabo](https://github.com/mastodon/mastodon/pull/10751)) -- Fix statuses removed by moderation re-appearing on subsequent fetches ([Kjwon15](https://github.com/mastodon/mastodon/pull/10732)) -- Fix misattribution of inlined announces if `attributedTo` isn't present in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10967)) -- Fix `GET /api/v1/polls/:id` not requiring authentication for non-public polls ([Gargron](https://github.com/mastodon/mastodon/pull/10960)) -- Fix handling of blank poll options in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10946)) -- Fix avatar preview aspect ratio on edit profile page ([Kjwon15](https://github.com/mastodon/mastodon/pull/10931)) -- Fix web push notifications not being sent for polls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10864)) -- Fix cut off letters in last paragraph of statuses in web UI ([ariasuni](https://github.com/mastodon/mastodon/pull/10821)) -- Fix list not being automatically unpinned when it returns 404 in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/11045)) -- Fix login sometimes redirecting to paths that are not pages ([Gargron](https://github.com/mastodon/mastodon/pull/11019)) - -## [2.8.4] - 2019-05-24 - -### Fixed - -- Fix delivery not retrying on some inbox errors that should be retriable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10812)) -- Fix unnecessary 5 minute cooldowns on signature verifications in some cases ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10813)) -- Fix possible race condition when processing statuses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10815)) - -### Security - -- Require specific OAuth scopes for specific endpoints of the streaming API, instead of merely requiring a token for all endpoints, and allow using WebSockets protocol negotiation to specify the access token instead of using a query string ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10818)) - -## [2.8.3] - 2019-05-19 - -### Added - -- Add `og:image:alt` OpenGraph tag ([BenLubar](https://github.com/mastodon/mastodon/pull/10779)) -- Add clickable area below avatar in statuses in web UI ([Dar13](https://github.com/mastodon/mastodon/pull/10766)) -- Add crossed-out eye icon on account gallery in web UI ([Kjwon15](https://github.com/mastodon/mastodon/pull/10715)) -- Add media description tooltip to thumbnails in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10713)) - -### Changed - -- Change "mark as sensitive" button into a checkbox for clarity ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10748)) - -### Fixed - -- Fix bug allowing users to publicly boost their private statuses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10775), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10783)) -- Fix performance in formatter by a little ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10765)) -- Fix some colors in the light theme ([yuzulabo](https://github.com/mastodon/mastodon/pull/10754)) -- Fix some colors of the high contrast theme ([yuzulabo](https://github.com/mastodon/mastodon/pull/10711)) -- Fix ambivalent active state of poll refresh button in web UI ([MaciekBaron](https://github.com/mastodon/mastodon/pull/10720)) -- Fix duplicate posting being possible from web UI ([hinaloe](https://github.com/mastodon/mastodon/pull/10785)) -- Fix "invited by" not showing up in admin UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10791)) - -## [2.8.2] - 2019-05-05 - -### Added - -- Add `SOURCE_TAG` environment variable ([ushitora-anqou](https://github.com/mastodon/mastodon/pull/10698)) - -### Fixed - -- Fix cropped hero image on frontpage ([BaptisteGelez](https://github.com/mastodon/mastodon/pull/10702)) -- Fix blurhash gem not compiling on some operating systems ([Gargron](https://github.com/mastodon/mastodon/pull/10700)) -- Fix unexpected CSS animations in some browsers ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10699)) -- Fix closing video modal scrolling timelines to top ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10695)) - -## [2.8.1] - 2019-05-04 - -### Added - -- Add link to existing domain block when trying to block an already-blocked domain ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10663)) -- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10676)) -- Add ability to create multiple-choice polls in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10603)) -- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/mastodon/mastodon/pull/10600)) -- Add `/interact/` paths to `robots.txt` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10666)) -- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/mastodon/mastodon/pull/10630)) - -### Changed - -- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10630)) -- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10630)) -- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/mastodon/mastodon/pull/10683)) -- Change cache header of REST API results to no-cache ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10655)) -- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10673), [Gargron](https://github.com/mastodon/mastodon/pull/10682)) -- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/mastodon/mastodon/pull/10667), [Gargron](https://github.com/mastodon/mastodon/pull/10674)) - -### Fixed - -- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/mastodon/mastodon/pull/10621)) -- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/mastodon/mastodon/pull/10684)) -- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10614)) -- Fix toots not being scrolled into view sometimes through keyboard selection ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10593)) -- Fix expired invite links being usable to bypass approval mode ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10657)) -- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/mastodon/mastodon/pull/10622)) -- Fix upload progressbar when image resizing is involved ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10632)) -- Fix block action not automatically cancelling pending follow request ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10633)) -- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/mastodon/mastodon/pull/10624)) -- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/mastodon/mastodon/pull/10623)) -- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/mastodon/mastodon/pull/10553)) -- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/mastodon/mastodon/pull/10605)) -- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/mastodon/mastodon/pull/10565)) -- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/mastodon/mastodon/pull/10549)) -- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/mastodon/mastodon/pull/10604)) -- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10594)) -- Fix confirmation modals being too narrow for a secondary action button ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10586)) - -## [2.8.0] - 2019-04-10 - -### Added - -- Add polls ([Gargron](https://github.com/mastodon/mastodon/pull/10111), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10155), [Gargron](https://github.com/mastodon/mastodon/pull/10184), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10196), [Gargron](https://github.com/mastodon/mastodon/pull/10248), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10255), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10322), [Gargron](https://github.com/mastodon/mastodon/pull/10138), [Gargron](https://github.com/mastodon/mastodon/pull/10139), [Gargron](https://github.com/mastodon/mastodon/pull/10144), [Gargron](https://github.com/mastodon/mastodon/pull/10145),[Gargron](https://github.com/mastodon/mastodon/pull/10146), [Gargron](https://github.com/mastodon/mastodon/pull/10148), [Gargron](https://github.com/mastodon/mastodon/pull/10151), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10150), [Gargron](https://github.com/mastodon/mastodon/pull/10168), [Gargron](https://github.com/mastodon/mastodon/pull/10165), [Gargron](https://github.com/mastodon/mastodon/pull/10172), [Gargron](https://github.com/mastodon/mastodon/pull/10170), [Gargron](https://github.com/mastodon/mastodon/pull/10171), [Gargron](https://github.com/mastodon/mastodon/pull/10186), [Gargron](https://github.com/mastodon/mastodon/pull/10189), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10200), [rinsuki](https://github.com/mastodon/mastodon/pull/10203), [Gargron](https://github.com/mastodon/mastodon/pull/10213), [Gargron](https://github.com/mastodon/mastodon/pull/10246), [Gargron](https://github.com/mastodon/mastodon/pull/10265), [Gargron](https://github.com/mastodon/mastodon/pull/10261), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10333), [Gargron](https://github.com/mastodon/mastodon/pull/10352), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10140), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10142), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10141), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10162), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10161), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10158), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10156), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10160), [Gargron](https://github.com/mastodon/mastodon/pull/10185), [Gargron](https://github.com/mastodon/mastodon/pull/10188), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10195), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10208), [Gargron](https://github.com/mastodon/mastodon/pull/10187), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10214), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10209)) -- Add follows & followers managing UI ([Gargron](https://github.com/mastodon/mastodon/pull/10268), [Gargron](https://github.com/mastodon/mastodon/pull/10308), [Gargron](https://github.com/mastodon/mastodon/pull/10404), [Gargron](https://github.com/mastodon/mastodon/pull/10293)) -- Add identity proof integration with Keybase ([Gargron](https://github.com/mastodon/mastodon/pull/10297), [xgess](https://github.com/mastodon/mastodon/pull/10375), [Gargron](https://github.com/mastodon/mastodon/pull/10338), [Gargron](https://github.com/mastodon/mastodon/pull/10350), [Gargron](https://github.com/mastodon/mastodon/pull/10414)) -- Add option to overwrite imported data instead of merging ([Gargron](https://github.com/mastodon/mastodon/pull/9962)) -- Add featured hashtags to profiles ([Gargron](https://github.com/mastodon/mastodon/pull/9755), [Gargron](https://github.com/mastodon/mastodon/pull/10167), [Gargron](https://github.com/mastodon/mastodon/pull/10249), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10034)) -- Add admission-based registrations mode ([Gargron](https://github.com/mastodon/mastodon/pull/10250), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10269), [Gargron](https://github.com/mastodon/mastodon/pull/10264), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10321), [Gargron](https://github.com/mastodon/mastodon/pull/10349), [Gargron](https://github.com/mastodon/mastodon/pull/10469)) -- Add support for WebP uploads ([acid-chicken](https://github.com/mastodon/mastodon/pull/9879)) -- Add "copy link" item to status action bars in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/9983)) -- Add list title editing in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9748)) -- Add a "Block & Report" button to the block confirmation dialog in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10360)) -- Add disappointed elephant when the page crashes in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10275)) -- Add ability to upload multiple files at once in web UI ([tmm576](https://github.com/mastodon/mastodon/pull/9856)) -- Add indication when you are not allowed to follow an account in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10420), [Gargron](https://github.com/mastodon/mastodon/pull/10491)) -- Add validations to admin settings to catch common mistakes ([Gargron](https://github.com/mastodon/mastodon/pull/10348), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10354)) -- Add `type`, `limit`, `offset`, `min_id`, `max_id`, `account_id` to search API ([Gargron](https://github.com/mastodon/mastodon/pull/10091)) -- Add a preferences API so apps can share basic behaviours ([Gargron](https://github.com/mastodon/mastodon/pull/10109)) -- Add `visibility` param to reblog REST API ([Gargron](https://github.com/mastodon/mastodon/pull/9851), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10302)) -- Add `allowfullscreen` attribute to OEmbed iframe ([rinsuki](https://github.com/mastodon/mastodon/pull/10370)) -- Add `blocked_by` relationship to the REST API ([Gargron](https://github.com/mastodon/mastodon/pull/10373)) -- Add `tootctl statuses remove` to sweep unreferenced statuses ([Gargron](https://github.com/mastodon/mastodon/pull/10063)) -- Add `tootctl search deploy` to avoid ugly rake task syntax ([Gargron](https://github.com/mastodon/mastodon/pull/10403)) -- Add `tootctl self-destruct` to shut down server gracefully ([Gargron](https://github.com/mastodon/mastodon/pull/10367)) -- Add option to hide application used to toot ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9897), [rinsuki](https://github.com/mastodon/mastodon/pull/9994), [hinaloe](https://github.com/mastodon/mastodon/pull/10086)) -- Add `DB_SSLMODE` configuration variable ([sascha-sl](https://github.com/mastodon/mastodon/pull/10210)) -- Add click-to-copy UI to invites page ([Gargron](https://github.com/mastodon/mastodon/pull/10259)) -- Add self-replies fetching ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10106), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10128), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10175), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10201)) -- Add rate limit for media proxy requests ([Gargron](https://github.com/mastodon/mastodon/pull/10490)) -- Add `tootctl emoji purge` ([Gargron](https://github.com/mastodon/mastodon/pull/10481)) -- Add `tootctl accounts approve` ([Gargron](https://github.com/mastodon/mastodon/pull/10480)) -- Add `tootctl accounts reset-relationships` ([noellabo](https://github.com/mastodon/mastodon/pull/10483)) - -### Changed - -- Change design of landing page ([Gargron](https://github.com/mastodon/mastodon/pull/10232), [Gargron](https://github.com/mastodon/mastodon/pull/10260), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10284), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10291), [koyuawsmbrtn](https://github.com/mastodon/mastodon/pull/10356), [Gargron](https://github.com/mastodon/mastodon/pull/10245)) -- Change design of profile column in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10337), [Aditoo17](https://github.com/mastodon/mastodon/pull/10387), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10390), [mayaeh](https://github.com/mastodon/mastodon/pull/10379), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10411)) -- Change language detector threshold from 140 characters to 4 words ([Gargron](https://github.com/mastodon/mastodon/pull/10376)) -- Change language detector to always kick in for non-latin alphabets ([Gargron](https://github.com/mastodon/mastodon/pull/10276)) -- Change icons of features on admin dashboard ([Gargron](https://github.com/mastodon/mastodon/pull/10366)) -- Change DNS timeouts from 1s to 5s ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10238)) -- Change Docker image to use Ubuntu with jemalloc ([Sir-Boops](https://github.com/mastodon/mastodon/pull/10100), [BenLubar](https://github.com/mastodon/mastodon/pull/10212)) -- Change public pages to be cacheable by proxies ([BenLubar](https://github.com/mastodon/mastodon/pull/9059)) -- Change the 410 gone response for suspended accounts to be cacheable by proxies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10339)) -- Change web UI to not empty timeline of blocked users on block ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10359)) -- Change JSON serializer to remove unused `@context` values ([Gargron](https://github.com/mastodon/mastodon/pull/10378)) -- Change GIFV file size limit to be the same as for other videos ([rinsuki](https://github.com/mastodon/mastodon/pull/9924)) -- Change Webpack to not use @babel/preset-env to compile node_modules ([ykzts](https://github.com/mastodon/mastodon/pull/10289)) -- Change web UI to use new Web Share Target API ([gol-cha](https://github.com/mastodon/mastodon/pull/9963)) -- Change ActivityPub reports to have persistent URIs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10303)) -- Change `tootctl accounts cull --dry-run` to list accounts that would be deleted ([BenLubar](https://github.com/mastodon/mastodon/pull/10460)) -- Change format of CSV exports of follows and mutes to include extra settings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10495), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10335)) -- Change ActivityPub collections to be cacheable by proxies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10467)) -- Change REST API and public profiles to not return follows/followers for users that have blocked you ([Gargron](https://github.com/mastodon/mastodon/pull/10491)) -- Change the groupings of menu items in settings navigation ([Gargron](https://github.com/mastodon/mastodon/pull/10533)) - -### Removed - -- Remove zopfli compression to speed up Webpack from 6min to 1min ([nolanlawson](https://github.com/mastodon/mastodon/pull/10288)) -- Remove stats.json generation to speed up Webpack ([nolanlawson](https://github.com/mastodon/mastodon/pull/10290)) - -### Fixed - -- Fix public timelines being broken by new toots when they are not mounted in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10131)) -- Fix quick filter settings not being saved when selecting a different filter in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10296)) -- Fix remote interaction dialogs being indexed by search engines ([Gargron](https://github.com/mastodon/mastodon/pull/10240)) -- Fix maxed-out invites not showing up as expired in UI ([Gargron](https://github.com/mastodon/mastodon/pull/10274)) -- Fix scrollbar styles on compose textarea ([Gargron](https://github.com/mastodon/mastodon/pull/10292)) -- Fix timeline merge workers being queued for remote users ([Gargron](https://github.com/mastodon/mastodon/pull/10355)) -- Fix alternative relay support regression ([Gargron](https://github.com/mastodon/mastodon/pull/10398)) -- Fix trying to fetch keys of unknown accounts on a self-delete from them ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10326)) -- Fix CAS `:service_validate_url` option ([enewhuis](https://github.com/mastodon/mastodon/pull/10328)) -- Fix race conditions when creating backups ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10234)) -- Fix whitespace not being stripped out of username before validation ([aurelien-reeves](https://github.com/mastodon/mastodon/pull/10239)) -- Fix n+1 query when deleting status ([Gargron](https://github.com/mastodon/mastodon/pull/10247)) -- Fix exiting follows not being rejected when suspending a remote account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10230)) -- Fix the underlying button element in a disabled icon button not being disabled ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10194)) -- Fix race condition when streaming out deleted statuses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10280)) -- Fix performance of admin federation UI by caching account counts ([Gargron](https://github.com/mastodon/mastodon/pull/10374)) -- Fix JS error on pages that don't define a CSRF token ([hinaloe](https://github.com/mastodon/mastodon/pull/10383)) -- Fix `tootctl accounts cull` sometimes removing accounts that are temporarily unreachable ([BenLubar](https://github.com/mastodon/mastodon/pull/10460)) - -## [2.7.4] - 2019-03-05 - -### Fixed - -- Fix web UI not cleaning up notifications after block ([Gargron](https://github.com/mastodon/mastodon/pull/10108)) -- Fix redundant HTTP requests when resolving private statuses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10115)) -- Fix performance of account media query ([abcang](https://github.com/mastodon/mastodon/pull/10121)) -- Fix mention processing for unknown accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10125)) -- Fix getting started column not scrolling on short screens ([trwnh](https://github.com/mastodon/mastodon/pull/10075)) -- Fix direct messages pagination in the web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10126)) -- Fix serialization of Announce activities ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10129)) -- Fix home timeline perpetually reloading when empty in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/10130)) -- Fix lists export ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10136)) -- Fix edit profile page crash for suspended-then-unsuspended users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10178)) - -## [2.7.3] - 2019-02-23 - -### Added - -- Add domain filter to the admin federation page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10071)) -- Add quick link from admin account view to block/unblock instance ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10073)) - -### Fixed - -- Fix video player width not being updated to fit container width ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10069)) -- Fix domain filter being shown in admin page when local filter is active ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10074)) -- Fix crash when conversations have no valid participants ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10078)) -- Fix error when performing admin actions on no statuses ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10094)) - -### Changed - -- Change custom emojis to randomize stored file name ([hinaloe](https://github.com/mastodon/mastodon/pull/10090)) - -## [2.7.2] - 2019-02-17 - -### Added - -- Add support for IPv6 in e-mail validation ([zoc](https://github.com/mastodon/mastodon/pull/10009)) -- Add record of IP address used for signing up ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10026)) -- Add tight rate-limit for API deletions (30 per 30 minutes) ([Gargron](https://github.com/mastodon/mastodon/pull/10042)) -- Add support for embedded `Announce` objects attributed to the same actor ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9998), [Gargron](https://github.com/mastodon/mastodon/pull/10065)) -- Add spam filter for `Create` and `Announce` activities ([Gargron](https://github.com/mastodon/mastodon/pull/10005), [Gargron](https://github.com/mastodon/mastodon/pull/10041), [Gargron](https://github.com/mastodon/mastodon/pull/10062)) -- Add `registrations` attribute to `GET /api/v1/instance` ([Gargron](https://github.com/mastodon/mastodon/pull/10060)) -- Add `vapid_key` to `POST /api/v1/apps` and `GET /api/v1/apps/verify_credentials` ([Gargron](https://github.com/mastodon/mastodon/pull/10058)) - -### Fixed - -- Fix link color and add link underlines in high-contrast theme ([Gargron](https://github.com/mastodon/mastodon/pull/9949), [Gargron](https://github.com/mastodon/mastodon/pull/10028)) -- Fix unicode characters in URLs not being linkified ([JMendyk](https://github.com/mastodon/mastodon/pull/8447), [hinaloe](https://github.com/mastodon/mastodon/pull/9991)) -- Fix URLs linkifier grabbing ending quotation as part of the link ([Gargron](https://github.com/mastodon/mastodon/pull/9997)) -- Fix authorized applications page design ([rinsuki](https://github.com/mastodon/mastodon/pull/9969)) -- Fix custom emojis not showing up in share page emoji picker ([rinsuki](https://github.com/mastodon/mastodon/pull/9970)) -- Fix too liberal application of whitespace in toots ([trwnh](https://github.com/mastodon/mastodon/pull/9968)) -- Fix misleading e-mail hint being displayed in admin view ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9973)) -- Fix tombstones not being cleared out ([abcang](https://github.com/mastodon/mastodon/pull/9978)) -- Fix some timeline jumps ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9982), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/10001), [rinsuki](https://github.com/mastodon/mastodon/pull/10046)) -- Fix content warning input taking keyboard focus even when hidden ([hinaloe](https://github.com/mastodon/mastodon/pull/10017)) -- Fix hashtags select styling in default and high-contrast themes ([Gargron](https://github.com/mastodon/mastodon/pull/10029)) -- Fix style regressions on landing page ([Gargron](https://github.com/mastodon/mastodon/pull/10030)) -- Fix hashtag column not subscribing to stream on mount ([Gargron](https://github.com/mastodon/mastodon/pull/10040)) -- Fix relay enabling/disabling not resetting inbox availability status ([Gargron](https://github.com/mastodon/mastodon/pull/10048)) -- Fix mutes, blocks, domain blocks and follow requests not paginating ([Gargron](https://github.com/mastodon/mastodon/pull/10057)) -- Fix crash on public hashtag pages when streaming fails ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10061)) - -### Changed - -- Change icon for unlisted visibility level ([clarcharr](https://github.com/mastodon/mastodon/pull/9952)) -- Change queue of actor deletes from push to pull for non-follower recipients ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/10016)) -- Change robots.txt to exclude media proxy URLs ([nightpool](https://github.com/mastodon/mastodon/pull/10038)) -- Change upload description input to allow line breaks ([BenLubar](https://github.com/mastodon/mastodon/pull/10036)) -- Change `dist/mastodon-streaming.service` to recommend running node without intermediary npm command ([nolanlawson](https://github.com/mastodon/mastodon/pull/10032)) -- Change conversations to always show names of other participants ([Gargron](https://github.com/mastodon/mastodon/pull/10047)) -- Change buttons on timeline preview to open the interaction dialog ([Gargron](https://github.com/mastodon/mastodon/pull/10054)) -- Change error graphic to hover-to-play ([Gargron](https://github.com/mastodon/mastodon/pull/10055)) - -## [2.7.1] - 2019-01-28 - -### Fixed - -- Fix SSO authentication not working due to missing agreement boolean ([Gargron](https://github.com/mastodon/mastodon/pull/9915)) -- Fix slow fallback of CopyAccountStats migration setting stats to 0 ([Gargron](https://github.com/mastodon/mastodon/pull/9930)) -- Fix wrong command in migration error message ([angristan](https://github.com/mastodon/mastodon/pull/9877)) -- Fix initial value of volume slider in video player and handle volume changes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9929)) -- Fix missing hotkeys for notifications ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9927)) -- Fix being able to attach unattached media created by other users ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9921)) -- Fix unrescued SSL error during link verification ([renatolond](https://github.com/mastodon/mastodon/pull/9914)) -- Fix Firefox scrollbar color regression ([trwnh](https://github.com/mastodon/mastodon/pull/9908)) -- Fix scheduled status with media immediately creating a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9894)) -- Fix missing strong style for landing page description ([Kjwon15](https://github.com/mastodon/mastodon/pull/9892)) - -## [2.7.0] - 2019-01-20 - -### Added - -- Add link for adding a user to a list from their profile ([namelessGonbai](https://github.com/mastodon/mastodon/pull/9062)) -- Add joining several hashtags in a single column ([gdpelican](https://github.com/mastodon/mastodon/pull/8904)) -- Add volume sliders for videos ([sumdog](https://github.com/mastodon/mastodon/pull/9366)) -- Add a tooltip explaining what a locked account is ([pawelngei](https://github.com/mastodon/mastodon/pull/9403)) -- Add preloaded cache for common JSON-LD contexts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9412)) -- Add profile directory ([Gargron](https://github.com/mastodon/mastodon/pull/9427)) -- Add setting to not group reblogs in home feed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9248)) -- Add admin ability to remove a user's header image ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9495)) -- Add account hashtags to ActivityPub actor JSON ([Gargron](https://github.com/mastodon/mastodon/pull/9450)) -- Add error message for avatar image that's too large ([sumdog](https://github.com/mastodon/mastodon/pull/9518)) -- Add notification quick-filter bar ([pawelngei](https://github.com/mastodon/mastodon/pull/9399)) -- Add new first-time tutorial ([Gargron](https://github.com/mastodon/mastodon/pull/9531)) -- Add moderation warnings ([Gargron](https://github.com/mastodon/mastodon/pull/9519)) -- Add emoji codepoint mappings for v11.0 ([Gargron](https://github.com/mastodon/mastodon/pull/9618)) -- Add REST API for creating an account ([Gargron](https://github.com/mastodon/mastodon/pull/9572)) -- Add support for Malayalam in language filter ([tachyons](https://github.com/mastodon/mastodon/pull/9624)) -- Add exclude_reblogs option to account statuses API ([Gargron](https://github.com/mastodon/mastodon/pull/9640)) -- Add local followers page to admin account UI ([chr-1x](https://github.com/mastodon/mastodon/pull/9610)) -- Add healthcheck commands to docker-compose.yml ([BenLubar](https://github.com/mastodon/mastodon/pull/9143)) -- Add handler for Move activity to migrate followers ([Gargron](https://github.com/mastodon/mastodon/pull/9629)) -- Add CSV export for lists and domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/9677)) -- Add `tootctl accounts follow ACCT` ([Gargron](https://github.com/mastodon/mastodon/pull/9414)) -- Add scheduled statuses ([Gargron](https://github.com/mastodon/mastodon/pull/9706)) -- Add immutable caching for S3 objects ([nolanlawson](https://github.com/mastodon/mastodon/pull/9722)) -- Add cache to custom emojis API ([Gargron](https://github.com/mastodon/mastodon/pull/9732)) -- Add preview cards to non-detailed statuses on public pages ([Gargron](https://github.com/mastodon/mastodon/pull/9714)) -- Add `mod` and `moderator` to list of default reserved usernames ([Gargron](https://github.com/mastodon/mastodon/pull/9713)) -- Add quick links to the admin interface in the web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8545)) -- Add `tootctl domains crawl` ([Gargron](https://github.com/mastodon/mastodon/pull/9809)) -- Add attachment list fallback to public pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9780)) -- Add `tootctl --version` ([Gargron](https://github.com/mastodon/mastodon/pull/9835)) -- Add information about how to opt-in to the directory on the directory ([Gargron](https://github.com/mastodon/mastodon/pull/9834)) -- Add timeouts for S3 ([Gargron](https://github.com/mastodon/mastodon/pull/9842)) -- Add support for non-public reblogs from ActivityPub ([Gargron](https://github.com/mastodon/mastodon/pull/9841)) -- Add sending of `Reject` activity when sending a `Block` activity ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9811)) - -### Changed - -- Temporarily pause timeline if mouse moved recently ([lmorchard](https://github.com/mastodon/mastodon/pull/9200)) -- Change the password form order ([mayaeh](https://github.com/mastodon/mastodon/pull/9267)) -- Redesign admin UI for accounts ([Gargron](https://github.com/mastodon/mastodon/pull/9340), [Gargron](https://github.com/mastodon/mastodon/pull/9643)) -- Redesign admin UI for instances/domain blocks ([Gargron](https://github.com/mastodon/mastodon/pull/9645)) -- Swap avatar and header input fields in profile page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9271)) -- When posting in mobile mode, go back to previous history location ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9502)) -- Split out is_changing_upload from is_submitting ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9536)) -- Back to the getting-started when pins the timeline. ([kedamaDQ](https://github.com/mastodon/mastodon/pull/9561)) -- Allow unauthenticated REST API access to GET /api/v1/accounts/:id/statuses ([Gargron](https://github.com/mastodon/mastodon/pull/9573)) -- Limit maximum visibility of local silenced users to unlisted ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9583)) -- Change API error message for unconfirmed accounts ([noellabo](https://github.com/mastodon/mastodon/pull/9625)) -- Change the icon to "reply-all" when it's a reply to other accounts ([mayaeh](https://github.com/mastodon/mastodon/pull/9378)) -- Do not ignore federated reports targeting already-reported accounts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9534)) -- Upgrade default Ruby version to 2.6.0 ([Gargron](https://github.com/mastodon/mastodon/pull/9688)) -- Change e-mail digest frequency ([Gargron](https://github.com/mastodon/mastodon/pull/9689)) -- Change Docker images for Tor support in docker-compose.yml ([Sir-Boops](https://github.com/mastodon/mastodon/pull/9438)) -- Display fallback link card thumbnail when none is given ([Gargron](https://github.com/mastodon/mastodon/pull/9715)) -- Change account bio length validation to ignore mention domains and URLs ([Gargron](https://github.com/mastodon/mastodon/pull/9717)) -- Use configured contact user for "anonymous" federation activities ([yukimochi](https://github.com/mastodon/mastodon/pull/9661)) -- Change remote interaction dialog to use specific actions instead of generic "interact" ([Gargron](https://github.com/mastodon/mastodon/pull/9743)) -- Always re-fetch public key when signature verification fails to support blind key rotation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9667)) -- Make replies to boosts impossible, connect reply to original status instead ([valerauko](https://github.com/mastodon/mastodon/pull/9129)) -- Change e-mail MX validation to check both A and MX records against blacklist ([Gargron](https://github.com/mastodon/mastodon/pull/9489)) -- Hide floating action button on search and getting started pages ([tmm576](https://github.com/mastodon/mastodon/pull/9826)) -- Redesign public hashtag page to use a masonry layout ([Gargron](https://github.com/mastodon/mastodon/pull/9822)) -- Use `summary` as summary instead of content warning for converted ActivityPub objects ([Gargron](https://github.com/mastodon/mastodon/pull/9823)) -- Display a double reply arrow on public pages for toots that are replies ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9808)) -- Change admin UI right panel size to be wider ([Kjwon15](https://github.com/mastodon/mastodon/pull/9768)) - -### Removed - -- Remove links to bridge.joinmastodon.org (non-functional) ([Gargron](https://github.com/mastodon/mastodon/pull/9608)) -- Remove LD-Signatures from activities that do not need them ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9659)) - -### Fixed - -- Remove unused computation of reblog references from updateTimeline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9244)) -- Fix loaded embeds resetting if a status arrives from API again ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9270)) -- Fix race condition causing shallow status with only a "favourited" attribute ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9272)) -- Remove intermediary arrays when creating hash maps from results ([Gargron](https://github.com/mastodon/mastodon/pull/9291)) -- Extract counters from accounts table to account_stats table to improve performance ([Gargron](https://github.com/mastodon/mastodon/pull/9295)) -- Change identities id column to a bigint ([Gargron](https://github.com/mastodon/mastodon/pull/9371)) -- Fix conversations API pagination ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9407)) -- Improve account suspension speed and completeness ([Gargron](https://github.com/mastodon/mastodon/pull/9290)) -- Fix thread depth computation in statuses_controller ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9426)) -- Fix database deadlocks by moving account stats update outside transaction ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9437)) -- Escape HTML in profile name preview in profile settings ([pawelngei](https://github.com/mastodon/mastodon/pull/9446)) -- Use same CORS policy for /@:username and /users/:username ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9485)) -- Make custom emoji domains case insensitive ([Esteth](https://github.com/mastodon/mastodon/pull/9474)) -- Various fixes to scrollable lists and media gallery ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9501)) -- Fix bootsnap cache directory being declared relatively ([Gargron](https://github.com/mastodon/mastodon/pull/9511)) -- Fix timeline pagination in the web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9516)) -- Fix padding on dropdown elements in preferences ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9517)) -- Make avatar and headers respect GIF autoplay settings ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9515)) -- Do no retry Web Push workers if the server returns a 4xx response ([Gargron](https://github.com/mastodon/mastodon/pull/9434)) -- Minor scrollable list fixes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9551)) -- Ignore low-confidence CharlockHolmes guesses when parsing link cards ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9510)) -- Fix `tootctl accounts rotate` not updating public keys ([Gargron](https://github.com/mastodon/mastodon/pull/9556)) -- Fix CSP / X-Frame-Options for media players ([jomo](https://github.com/mastodon/mastodon/pull/9558)) -- Fix unnecessary loadMore calls when the end of a timeline has been reached ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9581)) -- Skip mailer job retries when a record no longer exists ([Gargron](https://github.com/mastodon/mastodon/pull/9590)) -- Fix composer not getting focus after reply confirmation dialog ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9602)) -- Fix signature verification stoplight triggering on non-timeout errors ([Gargron](https://github.com/mastodon/mastodon/pull/9617)) -- Fix ThreadResolveWorker getting queued with invalid URLs ([Gargron](https://github.com/mastodon/mastodon/pull/9628)) -- Fix crash when clearing uninitialized timeline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9662)) -- Avoid duplicate work by merging ReplyDistributionWorker into DistributionWorker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9660)) -- Skip full text search if it fails, instead of erroring out completely ([Kjwon15](https://github.com/mastodon/mastodon/pull/9654)) -- Fix profile metadata links not verifying correctly sometimes ([shrft](https://github.com/mastodon/mastodon/pull/9673)) -- Ensure blocked user unfollows blocker if Block/Undo-Block activities are processed out of order ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9687)) -- Fix unreadable text color in report modal for some statuses ([Gargron](https://github.com/mastodon/mastodon/pull/9716)) -- Stop GIFV timeline preview explicitly when it's opened in modal ([kedamaDQ](https://github.com/mastodon/mastodon/pull/9749)) -- Fix scrollbar width compensation ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9824)) -- Fix race conditions when processing deleted toots ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9815)) -- Fix SSO issues on WebKit browsers by disabling Same-Site cookie again ([moritzheiber](https://github.com/mastodon/mastodon/pull/9819)) -- Fix empty OEmbed error ([renatolond](https://github.com/mastodon/mastodon/pull/9807)) -- Fix drag & drop modal not disappearing sometimes ([hinaloe](https://github.com/mastodon/mastodon/pull/9797)) -- Fix statuses with content warnings being displayed in web push notifications sometimes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9778)) -- Fix scroll-to-detailed status not working on public pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9773)) -- Fix media modal loading indicator ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9771)) -- Fix hashtag search results not having a permalink fallback in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9810)) -- Fix slightly cropped font on settings page dropdowns when using system font ([ariasuni](https://github.com/mastodon/mastodon/pull/9839)) -- Fix not being able to drag & drop text into forms ([tmm576](https://github.com/mastodon/mastodon/pull/9840)) - -### Security - -- Sanitize and sandbox toot embeds in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9552)) -- Add tombstones for remote statuses to prevent replay attacks ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9830)) - -## [2.6.5] - 2018-12-01 - -### Changed - -- Change lists to display replies to others on the list and list owner ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9324)) - -### Fixed - -- Fix failures caused by commonly-used JSON-LD contexts being unavailable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9412)) - -## [2.6.4] - 2018-11-30 - -### Fixed - -- Fix yarn dependencies not installing due to yanked event-stream package ([Gargron](https://github.com/mastodon/mastodon/pull/9401)) - -## [2.6.3] - 2018-11-30 - -### Added - -- Add hyphen to characters allowed in remote usernames ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9345)) - -### Changed - -- Change server user count to exclude suspended accounts ([Gargron](https://github.com/mastodon/mastodon/pull/9380)) - -### Fixed - -- Fix ffmpeg processing sometimes stalling due to overfilled stdout buffer ([hugogameiro](https://github.com/mastodon/mastodon/pull/9368)) -- Fix missing DNS records raising the wrong kind of exception ([Gargron](https://github.com/mastodon/mastodon/pull/9379)) -- Fix already queued deliveries still trying to reach inboxes marked as unavailable ([Gargron](https://github.com/mastodon/mastodon/pull/9358)) - -### Security - -- Fix TLS handshake timeout not being enforced ([Gargron](https://github.com/mastodon/mastodon/pull/9381)) - -## [2.6.2] - 2018-11-23 - -### Added - -- Add Page to whitelisted ActivityPub types ([mbajur](https://github.com/mastodon/mastodon/pull/9188)) -- Add 20px to column width in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/9227)) -- Add amount of freed disk space in `tootctl media remove` ([Gargron](https://github.com/mastodon/mastodon/pull/9229), [Gargron](https://github.com/mastodon/mastodon/pull/9239), [mayaeh](https://github.com/mastodon/mastodon/pull/9288)) -- Add "Show thread" link to self-replies ([Gargron](https://github.com/mastodon/mastodon/pull/9228)) - -### Changed - -- Change order of Atom and RSS links so Atom is first ([Alkarex](https://github.com/mastodon/mastodon/pull/9302)) -- Change Nginx configuration for Nanobox apps ([danhunsaker](https://github.com/mastodon/mastodon/pull/9310)) -- Change the follow action to appear instant in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/9220)) -- Change how the ActiveRecord connection is instantiated in on_worker_boot ([Gargron](https://github.com/mastodon/mastodon/pull/9238)) -- Change `tootctl accounts cull` to always touch accounts so they can be skipped ([renatolond](https://github.com/mastodon/mastodon/pull/9293)) -- Change mime type comparison to ignore JSON-LD profile ([valerauko](https://github.com/mastodon/mastodon/pull/9179)) - -### Fixed - -- Fix web UI crash when conversation has no last status ([sammy8806](https://github.com/mastodon/mastodon/pull/9207)) -- Fix follow limit validator reporting lower number past threshold ([Gargron](https://github.com/mastodon/mastodon/pull/9230)) -- Fix form validation flash message color and input borders ([Gargron](https://github.com/mastodon/mastodon/pull/9235)) -- Fix invalid twitter:player cards being displayed ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9254)) -- Fix emoji update date being processed incorrectly ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9255)) -- Fix playing embed resetting if status is reloaded in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9270), [Gargron](https://github.com/mastodon/mastodon/pull/9275)) -- Fix web UI crash when favouriting a deleted status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9272)) -- Fix intermediary arrays being created for hash maps ([Gargron](https://github.com/mastodon/mastodon/pull/9291)) -- Fix filter ID not being a string in REST API ([Gargron](https://github.com/mastodon/mastodon/pull/9303)) - -### Security - -- Fix multiple remote account deletions being able to deadlock the database ([Gargron](https://github.com/mastodon/mastodon/pull/9292)) -- Fix HTTP connection timeout of 10s not being enforced ([Gargron](https://github.com/mastodon/mastodon/pull/9329)) - -## [2.6.1] - 2018-10-30 - -### Fixed - -- Fix resolving resources by URL not working due to a regression in [valerauko](https://github.com/mastodon/mastodon/pull/9132) ([Gargron](https://github.com/mastodon/mastodon/pull/9171)) -- Fix reducer error in web UI when a conversation has no last status ([Gargron](https://github.com/mastodon/mastodon/pull/9173)) - -## [2.6.0] - 2018-10-30 - -### Added - -- Add link ownership verification ([Gargron](https://github.com/mastodon/mastodon/pull/8703)) -- Add conversations API ([Gargron](https://github.com/mastodon/mastodon/pull/8832)) -- Add limit for the number of people that can be followed from one account ([Gargron](https://github.com/mastodon/mastodon/pull/8807)) -- Add admin setting to customize mascot ([ashleyhull-versent](https://github.com/mastodon/mastodon/pull/8766)) -- Add support for more granular ActivityPub audiences from other software, i.e. circles ([Gargron](https://github.com/mastodon/mastodon/pull/8950), [Gargron](https://github.com/mastodon/mastodon/pull/9093), [Gargron](https://github.com/mastodon/mastodon/pull/9150)) -- Add option to block all reports from a domain ([Gargron](https://github.com/mastodon/mastodon/pull/8830)) -- Add user preference to always expand toots marked with content warnings ([webroo](https://github.com/mastodon/mastodon/pull/8762)) -- Add user preference to always hide all media ([fvh-P](https://github.com/mastodon/mastodon/pull/8569)) -- Add `force_login` param to OAuth authorize page ([Gargron](https://github.com/mastodon/mastodon/pull/8655)) -- Add `tootctl accounts backup` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl accounts create` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl accounts cull` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl accounts delete` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl accounts modify` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl accounts refresh` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl feeds build` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl feeds clear` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl settings registrations open` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `tootctl settings registrations close` ([Gargron](https://github.com/mastodon/mastodon/pull/8642), [Gargron](https://github.com/mastodon/mastodon/pull/8811)) -- Add `min_id` param to REST API to support backwards pagination ([Gargron](https://github.com/mastodon/mastodon/pull/8736)) -- Add a confirmation dialog when hitting reply and the compose box isn't empty ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8893)) -- Add PostgreSQL disk space growth tracking in PGHero ([Gargron](https://github.com/mastodon/mastodon/pull/8906)) -- Add button for disabling local account to report quick actions bar ([Gargron](https://github.com/mastodon/mastodon/pull/9024)) -- Add Czech language ([Aditoo17](https://github.com/mastodon/mastodon/pull/8594)) -- Add `same-site` (`lax`) attribute to cookies ([sorin-davidoi](https://github.com/mastodon/mastodon/pull/8626)) -- Add support for styled scrollbars in Firefox Nightly ([sorin-davidoi](https://github.com/mastodon/mastodon/pull/8653)) -- Add highlight to the active tab in web UI profiles ([rhoio](https://github.com/mastodon/mastodon/pull/8673)) -- Add auto-focus for comment textarea in report modal ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8689)) -- Add auto-focus for emoji picker's search field ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8688)) -- Add nginx and systemd templates to `dist/` directory ([Gargron](https://github.com/mastodon/mastodon/pull/8770)) -- Add support for `/.well-known/change-password` ([Gargron](https://github.com/mastodon/mastodon/pull/8828)) -- Add option to override FFMPEG binary path ([sascha-sl](https://github.com/mastodon/mastodon/pull/8855)) -- Add `dns-prefetch` tag when using different host for assets or uploads ([Gargron](https://github.com/mastodon/mastodon/pull/8942)) -- Add `description` meta tag ([Gargron](https://github.com/mastodon/mastodon/pull/8941)) -- Add `Content-Security-Policy` header ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8957)) -- Add cache for the instance info API ([ykzts](https://github.com/mastodon/mastodon/pull/8765)) -- Add suggested follows to search screen in mobile layout ([Gargron](https://github.com/mastodon/mastodon/pull/9010)) -- Add CORS header to `/.well-known/*` routes ([BenLubar](https://github.com/mastodon/mastodon/pull/9083)) -- Add `card` attribute to statuses returned from REST API ([Gargron](https://github.com/mastodon/mastodon/pull/9120)) -- Add in-stream link preview ([Gargron](https://github.com/mastodon/mastodon/pull/9120)) -- Add support for ActivityPub `Page` objects ([mbajur](https://github.com/mastodon/mastodon/pull/9121)) - -### Changed - -- Change forms design ([Gargron](https://github.com/mastodon/mastodon/pull/8703)) -- Change reports overview to group by target account ([Gargron](https://github.com/mastodon/mastodon/pull/8674)) -- Change web UI to show "read more" link on overly long in-stream statuses ([lanodan](https://github.com/mastodon/mastodon/pull/8205)) -- Change design of direct messages column ([Gargron](https://github.com/mastodon/mastodon/pull/8832), [Gargron](https://github.com/mastodon/mastodon/pull/9022)) -- Change home timelines to exclude DMs ([Gargron](https://github.com/mastodon/mastodon/pull/8940)) -- Change list timelines to exclude all replies ([cbayerlein](https://github.com/mastodon/mastodon/pull/8683)) -- Change admin accounts UI default sort to most recent ([Gargron](https://github.com/mastodon/mastodon/pull/8813)) -- Change documentation URL in the UI ([Gargron](https://github.com/mastodon/mastodon/pull/8898)) -- Change style of success and failure messages ([Gargron](https://github.com/mastodon/mastodon/pull/8973)) -- Change DM filtering to always allow DMs from staff ([qguv](https://github.com/mastodon/mastodon/pull/8993)) -- Change recommended Ruby version to 2.5.3 ([zunda](https://github.com/mastodon/mastodon/pull/9003)) -- Change docker-compose default to persist volumes in current directory ([Gargron](https://github.com/mastodon/mastodon/pull/9055)) -- Change character counters on edit profile page to input length limit ([Gargron](https://github.com/mastodon/mastodon/pull/9100)) -- Change notification filtering to always let through messages from staff ([Gargron](https://github.com/mastodon/mastodon/pull/9152)) -- Change "hide boosts from user" function also hiding notifications about boosts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9147)) -- Change CSS `detailed-status__wrapper` class actually wrap the detailed status ([trwnh](https://github.com/mastodon/mastodon/pull/8547)) - -### Deprecated - -- `GET /api/v1/timelines/direct` → `GET /api/v1/conversations` ([Gargron](https://github.com/mastodon/mastodon/pull/8832)) -- `POST /api/v1/notifications/dismiss` → `POST /api/v1/notifications/:id/dismiss` ([Gargron](https://github.com/mastodon/mastodon/pull/8905)) -- `GET /api/v1/statuses/:id/card` → `card` attributed included in status ([Gargron](https://github.com/mastodon/mastodon/pull/9120)) - -### Removed - -- Remove "on this device" label in column push settings ([rhoio](https://github.com/mastodon/mastodon/pull/8704)) -- Remove rake tasks in favour of tootctl commands ([Gargron](https://github.com/mastodon/mastodon/pull/8675)) - -### Fixed - -- Fix remote statuses using instance's default locale if no language given ([Kjwon15](https://github.com/mastodon/mastodon/pull/8861)) -- Fix streaming API not exiting when port or socket is unavailable ([Gargron](https://github.com/mastodon/mastodon/pull/9023)) -- Fix network calls being performed in database transaction in ActivityPub handler ([Gargron](https://github.com/mastodon/mastodon/pull/8951)) -- Fix dropdown arrow position ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8637)) -- Fix first element of dropdowns being focused even if not using keyboard ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8679)) -- Fix tootctl requiring `bundle exec` invocation ([abcang](https://github.com/mastodon/mastodon/pull/8619)) -- Fix public pages not using animation preference for avatars ([renatolond](https://github.com/mastodon/mastodon/pull/8614)) -- Fix OEmbed/OpenGraph cards not understanding relative URLs ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8669)) -- Fix some dark emojis not having a white outline ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8597)) -- Fix media description not being displayed in various media modals ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8678)) -- Fix generated URLs of desktop notifications missing base URL ([GenbuHase](https://github.com/mastodon/mastodon/pull/8758)) -- Fix RTL styles ([mabkenar](https://github.com/mastodon/mastodon/pull/8764), [mabkenar](https://github.com/mastodon/mastodon/pull/8767), [mabkenar](https://github.com/mastodon/mastodon/pull/8823), [mabkenar](https://github.com/mastodon/mastodon/pull/8897), [mabkenar](https://github.com/mastodon/mastodon/pull/9005), [mabkenar](https://github.com/mastodon/mastodon/pull/9007), [mabkenar](https://github.com/mastodon/mastodon/pull/9018), [mabkenar](https://github.com/mastodon/mastodon/pull/9021), [mabkenar](https://github.com/mastodon/mastodon/pull/9145), [mabkenar](https://github.com/mastodon/mastodon/pull/9146)) -- Fix crash in streaming API when tag param missing ([Gargron](https://github.com/mastodon/mastodon/pull/8955)) -- Fix hotkeys not working when no element is focused ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8998)) -- Fix some hotkeys not working on detailed status view ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9006)) -- Fix og:url on status pages ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9047)) -- Fix upload option buttons only being visible on hover ([Gargron](https://github.com/mastodon/mastodon/pull/9074)) -- Fix tootctl not returning exit code 1 on wrong arguments ([sascha-sl](https://github.com/mastodon/mastodon/pull/9094)) -- Fix preview cards for appearing for profiles mentioned in toot ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/6934), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/9158)) -- Fix local accounts sometimes being duplicated as faux-remote ([Gargron](https://github.com/mastodon/mastodon/pull/9109)) -- Fix emoji search when the shortcode has multiple separators ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/9124)) -- Fix dropdowns sometimes being partially obscured by other elements ([kedamaDQ](https://github.com/mastodon/mastodon/pull/9126)) -- Fix cache not updating when reply/boost/favourite counters or media sensitivity update ([Gargron](https://github.com/mastodon/mastodon/pull/9119)) -- Fix empty display name precedence over username in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/9163)) -- Fix td instead of th in sessions table header ([Gargron](https://github.com/mastodon/mastodon/pull/9162)) -- Fix handling of content types with profile ([valerauko](https://github.com/mastodon/mastodon/pull/9132)) - -## [2.5.2] - 2018-10-12 - -### Security - -- Fix XSS vulnerability ([Gargron](https://github.com/mastodon/mastodon/pull/8959)) - -## [2.5.1] - 2018-10-07 - -### Fixed - -- Fix database migrations for PostgreSQL below 9.5 ([Gargron](https://github.com/mastodon/mastodon/pull/8903)) -- Fix class autoloading issue in ActivityPub Create handler ([Gargron](https://github.com/mastodon/mastodon/pull/8820)) -- Fix cache statistics not being sent via statsd when statsd enabled ([ykzts](https://github.com/mastodon/mastodon/pull/8831)) -- Bump puma from 3.11.4 to 3.12.0 ([dependabot[bot]](https://github.com/mastodon/mastodon/pull/8883)) - -### Security - -- Fix some local images not having their EXIF metadata stripped on upload ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8714)) -- Fix being able to enable a disabled relay via ActivityPub Accept handler ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8864)) -- Bump nokogiri from 1.8.4 to 1.8.5 ([dependabot[bot]](https://github.com/mastodon/mastodon/pull/8881)) -- Fix being able to report statuses not belonging to the reported account ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/8916)) +_For previous changes, review the [stable-3.5 branch](https://github.com/mastodon/mastodon/blob/stable-3.5/CHANGELOG.md)_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8563165d8..c176eeb0a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to Catstodon # +# Contributing to Catstodon Thank you for your interest in contributing to a fork of the `glitch-soc` project! Before you do anything here, please check if you can contribute to either the [vanilla Mastodon project](https://github.com/mastodon/mastodon) or [glitch-soc](https://github.com/glitch-soc/mastodon) first. @@ -49,6 +49,10 @@ You can contribute in the following ways: If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon). +## API Changes and Additions + +Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation). + ## Bug reports Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/mastodon/mastodon/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected. diff --git a/Capfile b/Capfile deleted file mode 100644 index 86efa5bacf..0000000000 --- a/Capfile +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'capistrano/setup' -require 'capistrano/deploy' -require 'capistrano/scm/git' - -install_plugin Capistrano::SCM::Git - -require 'capistrano/rbenv' -require 'capistrano/bundler' -require 'capistrano/yarn' -require 'capistrano/rails/assets' -require 'capistrano/rails/migrations' - -Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r } diff --git a/Dockerfile b/Dockerfile index cb5096581c..4d5bd57f11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,104 +1,259 @@ # syntax=docker/dockerfile:1.4 -# This needs to be bullseye-slim because the Ruby image is built on bullseye-slim -ARG NODE_VERSION="16.20-bullseye-slim" -FROM ghcr.io/moritzheiber/ruby-jemalloc:3.2.2-slim as ruby -FROM node:${NODE_VERSION} as build +# Please see https://docs.docker.com/engine/reference/builder for information about +# the extended buildx capabilities used in this file. +# Make sure multiarch TARGETPLATFORM is available for interpolation +# See: https://docs.docker.com/build/building/multi-platform/ +ARG TARGETPLATFORM=${TARGETPLATFORM} +ARG BUILDPLATFORM=${BUILDPLATFORM} -COPY --link --from=ruby /opt/ruby /opt/ruby +# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.2.2"] +ARG RUBY_VERSION="3.2.2" +# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] +ARG NODE_MAJOR_VERSION="20" +# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] +ARG DEBIAN_VERSION="bookworm" +# Node image to use for base image based on combined variables (ex: 20-bookworm-slim) +FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node +# Ruby image to use for base image based on combined variables (ex: 3.2.2-slim-bookworm) +FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby -ENV DEBIAN_FRONTEND="noninteractive" \ - PATH="${PATH}:/opt/ruby/bin" - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -WORKDIR /opt/mastodon -COPY Gemfile* package.json yarn.lock /opt/mastodon/ - -# hadolint ignore=DL3008 -RUN apt-get update && \ - apt-get install -y --no-install-recommends build-essential \ - git \ - libicu-dev \ - libidn11-dev \ - libpq-dev \ - libjemalloc-dev \ - zlib1g-dev \ - libgdbm-dev \ - libgmp-dev \ - libssl-dev \ - libyaml-0-2 \ - ca-certificates \ - libreadline8 \ - python3 \ - shared-mime-info && \ - bundle config set --local deployment 'true' && \ - bundle config set --local without 'development test' && \ - bundle config set silence_root_warning true && \ - bundle install -j"$(nproc)" && \ - yarn install --pure-lockfile --production --network-timeout 600000 && \ - yarn cache clean - -FROM node:${NODE_VERSION} - -# Use those args to specify your own version flags & suffixes -ARG MASTODON_VERSION_FLAGS="" -ARG MASTODON_VERSION_SUFFIX="" +# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA +# Example: v4.2.0-nightly.2023.11.09+something +# Overwrite existance of 'alpha.0' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] +ARG MASTODON_VERSION_PRERELEASE="" +# Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="something"] +ARG MASTODON_VERSION_METADATA="" +# Allow Ruby on Rails to serve static files +# See: https://docs.joinmastodon.org/admin/config/#rails_serve_static_files +ARG RAILS_SERVE_STATIC_FILES="true" +# Allow to use YJIT compiler +# See: https://github.com/ruby/ruby/blob/master/doc/yjit/yjit.md +ARG RUBY_YJIT_ENABLE="1" +# Timezone used by the Docker container and runtime, change with [--build-arg TZ=Europe/Berlin] +ARG TZ="Etc/UTC" +# Linux UID (user id) for the mastodon user, change with [--build-arg UID=1234] ARG UID="991" +# Linux GID (group id) for the mastodon user, change with [--build-arg GID=1234] ARG GID="991" -COPY --link --from=ruby /opt/ruby /opt/ruby +# Apply Mastodon build options based on options above +ENV \ +# Apply Mastodon version information + MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ + MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \ +# Apply Mastodon static files and YJIT options + RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \ + RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \ +# Apply timezone + TZ=${TZ} -SHELL ["/bin/bash", "-o", "pipefail", "-c"] +ENV \ +# Configure the IP to bind Mastodon to when serving traffic + BIND="0.0.0.0" \ +# Use production settings for Yarn, Node and related nodejs based tools + NODE_ENV="production" \ +# Use production settings for Ruby on Rails + RAILS_ENV="production" \ +# Add Ruby and Mastodon installation to the PATH + DEBIAN_FRONTEND="noninteractive" \ + PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ +# Optimize jemalloc 5.x performance + MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" -ENV DEBIAN_FRONTEND="noninteractive" \ - PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" +# Set default shell used for running commands +SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"] -# Ignoring these here since we don't want to pin any versions and the Debian image removes apt-get content after use -# hadolint ignore=DL3008,DL3009 -RUN apt-get update && \ - echo "Etc/UTC" > /etc/localtime && \ - groupadd -g "${GID}" mastodon && \ - useradd -l -u "$UID" -g "${GID}" -m -d /opt/mastodon mastodon && \ - apt-get -y --no-install-recommends install whois \ - wget \ - procps \ - libssl1.1 \ - libpq5 \ - imagemagick \ - ffmpeg \ - libjemalloc2 \ - libicu67 \ - libidn11 \ - libyaml-0-2 \ - file \ - ca-certificates \ - tzdata \ - libreadline8 \ - tini && \ - ln -s /opt/mastodon /mastodon +ARG TARGETPLATFORM -# Note: no, cleaning here since Debian does this automatically -# See the file /etc/apt/apt.conf.d/docker-clean within the Docker image's filesystem +RUN echo "Target platform is $TARGETPLATFORM" -COPY --chown=mastodon:mastodon . /opt/mastodon -COPY --chown=mastodon:mastodon --from=build /opt/mastodon /opt/mastodon +RUN \ +# Remove automatic apt cache Docker cleanup scripts + rm -f /etc/apt/apt.conf.d/docker-clean; \ +# Sets timezone + echo "${TZ}" > /etc/localtime; \ +# Creates mastodon user/group and sets home directory + groupadd -g "${GID}" mastodon; \ + useradd -l -u "${UID}" -g "${GID}" -m -d /opt/mastodon mastodon; \ +# Creates /mastodon symlink to /opt/mastodon + ln -s /opt/mastodon /mastodon; -ENV RAILS_ENV="production" \ - NODE_ENV="production" \ - RAILS_SERVE_STATIC_FILES="true" \ - BIND="0.0.0.0" \ - MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \ - MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}" - -# Set the run user -USER mastodon +# Set /opt/mastodon as working directory WORKDIR /opt/mastodon -# Precompile assets -RUN OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder rails assets:precompile +# hadolint ignore=DL3008,DL3005 +RUN \ +# Mount Apt cache and lib directories from Docker buildx caches +--mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ +--mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ +# Apt update & upgrade to check for security updates to Debian image + apt-get update; \ + apt-get dist-upgrade -yq; \ +# Install jemalloc, curl and other necessary components + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + ffmpeg \ + file \ + imagemagick \ + libjemalloc2 \ + patchelf \ + procps \ + tini \ + tzdata \ + ; \ +# Patch Ruby to use jemalloc + patchelf --add-needed libjemalloc.so.2 /usr/local/bin/ruby; \ +# Discard patchelf after use + apt-get purge -y \ + patchelf \ + ; -# Set the work dir and the container entry point -ENTRYPOINT ["/usr/bin/tini", "--"] -EXPOSE 3000 4000 +# Create temporary build layer from base image +FROM ruby as build + +# Copy Node package configuration files into working directory +COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ +COPY .yarn /opt/mastodon/.yarn + +COPY --from=node /usr/local/bin /usr/local/bin +COPY --from=node /usr/local/lib /usr/local/lib + +ARG TARGETPLATFORM + +# hadolint ignore=DL3008 +RUN \ +# Mount Apt cache and lib directories from Docker buildx caches +--mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ +--mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ +# Install build tools and bundler dependencies from APT + apt-get install -y --no-install-recommends \ + g++ \ + gcc \ + git \ + libgdbm-dev \ + libgmp-dev \ + libicu-dev \ + libidn-dev \ + libpq-dev \ + libssl-dev \ + make \ + shared-mime-info \ + zlib1g-dev \ + ; + +RUN \ +# Configure Corepack + rm /usr/local/bin/yarn*; \ + corepack enable; \ + corepack prepare --activate; + +# Create temporary bundler specific build layer from build layer +FROM build as bundler + +ARG TARGETPLATFORM + +# Copy Gemfile config into working directory +COPY Gemfile* /opt/mastodon/ + +RUN \ +# Mount Ruby Gem caches +--mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ +# Configure bundle to prevent changes to Gemfile and Gemfile.lock + bundle config set --global frozen "true"; \ +# Configure bundle to not cache downloaded Gems + bundle config set --global cache_all "false"; \ +# Configure bundle to only process production Gems + bundle config set --local without "development test"; \ +# Configure bundle to not warn about root user + bundle config set silence_root_warning "true"; \ +# Download and install required Gems + bundle install -j"$(nproc)"; + +# Create temporary node specific build layer from build layer +FROM build as yarn + +ARG TARGETPLATFORM + +# Copy Node package configuration files into working directory +COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ +COPY streaming/package.json /opt/mastodon/streaming/ +COPY .yarn /opt/mastodon/.yarn + +# hadolint ignore=DL3008 +RUN \ +--mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ +--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ +# Install Node packages + yarn workspaces focus --production @mastodon/mastodon; + +# Create temporary assets build layer from build layer +FROM build as precompiler + +# Copy Mastodon sources into precompiler layer +COPY . /opt/mastodon/ + +# Copy bundler and node packages from build layer to container +COPY --from=yarn /opt/mastodon /opt/mastodon/ +COPY --from=bundler /opt/mastodon /opt/mastodon/ +COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/ + +ARG TARGETPLATFORM + +RUN \ +# Use Ruby on Rails to create Mastodon assets + OTP_SECRET=precompile_placeholder SECRET_KEY_BASE=precompile_placeholder bundle exec rails assets:precompile; \ +# Cleanup temporary files + rm -fr /opt/mastodon/tmp; + +# Prep final Mastodon Ruby layer +FROM ruby as mastodon + +ARG TARGETPLATFORM + +# hadolint ignore=DL3008 +RUN \ +# Mount Apt cache and lib directories from Docker buildx caches +--mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ +--mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ +# Mount Corepack and Yarn caches from Docker buildx caches +--mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ +--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ +# Apt update install non-dev versions of necessary components + apt-get install -y --no-install-recommends \ + libssl3 \ + libpq5 \ + libicu72 \ + libidn12 \ + libreadline8 \ + libyaml-0-2 \ + ; + +# Copy Mastodon sources into final layer +COPY . /opt/mastodon/ + +# Copy compiled assets to layer +COPY --from=precompiler /opt/mastodon/public/packs /opt/mastodon/public/packs +COPY --from=precompiler /opt/mastodon/public/assets /opt/mastodon/public/assets +# Copy bundler components to layer +COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/ + +RUN \ +# Precompile bootsnap code for faster Rails startup + bundle exec bootsnap precompile --gemfile app/ lib/; + +RUN \ +# Pre-create and chown system volume to Mastodon user + mkdir -p /opt/mastodon/public/system; \ + chown mastodon:mastodon /opt/mastodon/public/system; \ +# Set Mastodon user as owner of tmp folder + chown -R mastodon:mastodon /opt/mastodon/tmp; + +# Set the running user for resulting container +USER mastodon +# Expose default Puma ports +EXPOSE 3000 +# Set container tini as default entry point +ENTRYPOINT ["/usr/bin/tini", "--"] \ No newline at end of file diff --git a/FEDERATION.md b/FEDERATION.md index cd1957cbd1..e3721d7241 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -27,4 +27,5 @@ More information on HTTP Signatures, as well as examples, can be found here: htt - Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld - Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/ -- Followers collection synchronization: https://git.activitypub.dev/ActivityPubDev/Fediverse-Enhancement-Proposals/src/branch/main/feps/fep-8fcf.md +- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md +- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md diff --git a/Gemfile b/Gemfile index 7a0fbdc82d..300036804b 100644 --- a/Gemfile +++ b/Gemfile @@ -4,26 +4,29 @@ source 'https://rubygems.org' ruby '>= 3.0.0' gem 'puma', '~> 6.3' -gem 'rails', '~> 6.1.7' -gem 'sprockets', '~> 3.7.2' +gem 'rails', '~> 7.1.1' +gem 'propshaft' gem 'thor', '~> 1.2' gem 'rack', '~> 2.2.7' +# For why irb is in the Gemfile, see: https://ruby.social/@st0012/111444685161478182 +gem 'irb', '~> 1.8' + gem 'haml-rails', '~>2.0' gem 'pg', '~> 1.5' -gem 'makara', '~> 0.5' gem 'pghero' gem 'dotenv-rails', '~> 2.8' gem 'aws-sdk-s3', '~> 1.123', require: false gem 'fog-core', '<= 2.4.0' -gem 'fog-openstack', '~> 0.3', require: false +gem 'fog-openstack', '~> 1.0', require: false gem 'kt-paperclip', '~> 7.2' +gem 'md-paperclip-azure', '~> 2.2', require: false gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.16.0', require: false +gem 'bootsnap', '~> 1.17.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' @@ -35,11 +38,14 @@ group :pam_authentication, optional: true do end gem 'net-ldap', '~> 0.18' -gem 'omniauth-cas', '~> 2.0' -gem 'omniauth-saml', '~> 1.10' + +# TODO: Point back at released omniauth-cas gem when PR merged +# https://github.com/dlindahl/omniauth-cas/pull/68 +gem 'omniauth-cas', github: 'stanhu/omniauth-cas', ref: '4211e6d05941b4a981f9a36b49ec166cecd0e271' +gem 'omniauth-saml', '~> 2.0' gem 'omniauth_openid_connect', '~> 0.6.1' -gem 'omniauth', '~> 1.9' -gem 'omniauth-rails_csrf_protection', '~> 0.1' +gem 'omniauth', '~> 2.0' +gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'color_diff', '~> 0.1' gem 'discard', '~> 1.2' @@ -56,8 +62,9 @@ gem 'httplog', '~> 1.6.2' gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' -gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar' +gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' gem 'nokogiri', '~> 1.15' +gem 'nsa', github: 'jhawthorn/nsa', ref: 'e020fcc3a54d993ab45b7194d89ab720296c111b' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' @@ -67,7 +74,7 @@ gem 'pundit', '~> 2.3' gem 'premailer-rails' gem 'rack-attack', '~> 6.6' gem 'rack-cors', '~> 2.0', require: 'rack/cors' -gem 'rails-i18n', '~> 6.0' +gem 'rails-i18n', '~> 7.0' gem 'rails-settings-cached', '~> 0.6', git: 'https://github.com/mastodon/rails-settings-cached.git', branch: 'v0.6.6-aliases-true' gem 'redcarpet', '~> 3.6' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] @@ -82,9 +89,8 @@ gem 'sidekiq-unique-jobs', '~> 7.1' gem 'sidekiq-bulk', '~> 0.2.0' gem 'simple-navigation', '~> 4.4' gem 'simple_form', '~> 5.2' -gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie' gem 'stoplight', '~> 3.0.1' -gem 'strong_migrations', '~> 0.8' +gem 'strong_migrations', '1.6.4' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' @@ -99,20 +105,24 @@ gem 'rdf-normalize', '~> 0.5' gem 'private_address_check', '~> 0.5' group :test do - # RSpec runner for rails - gem 'rspec-rails', '~> 6.0' - - # Used to split testing into chunks in CI - gem 'rspec_chunked', '~> 0.6' + # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab + gem 'rspec-github', '~> 2.4', require: false # RSpec progress bar formatter gem 'fuubar', '~> 2.5' + # RSpec helpers for email specs + gem 'email_spec' + # Extra RSpec extenion methods and helpers for sidekiq - gem 'rspec-sidekiq', '~> 3.1' + gem 'rspec-sidekiq', '~> 4.0' # Browser integration testing gem 'capybara', '~> 3.39' + gem 'selenium-webdriver' + + # Used to reset the database between system tests + gem 'database_cleaner-active_record' # Used to mock environment variables gem 'climate_control', '~> 0.2' @@ -134,6 +144,7 @@ group :test do # Coverage formatter for RSpec test if DISABLE_SIMPLECOV is false gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-lcov', '~> 0.8', require: false # Stub web requests for specs gem 'webmock', '~> 3.18' @@ -159,24 +170,28 @@ group :development do gem 'letter_opener_web', '~> 2.0' # Security analysis CLI tools - gem 'brakeman', '~> 5.4', require: false + gem 'brakeman', '~> 6.0', require: false gem 'bundler-audit', '~> 0.9', require: false # Linter CLI for HAML files gem 'haml_lint', require: false - # Deployment automation - gem 'capistrano', '~> 3.17' - gem 'capistrano-rails', '~> 1.6' - gem 'capistrano-rbenv', '~> 2.2' - gem 'capistrano-yarn', '~> 2.0' - # Validate missing i18n keys gem 'i18n-tasks', '~> 1.0', require: false +end + +group :development, :test do + # Interactive Debugging tools + gem 'debug', '~> 1.8' # Profiling tools gem 'memory_profiler', require: false + gem 'ruby-prof', require: false gem 'stackprof', require: false + gem 'test-prof' + + # RSpec runner for rails + gem 'rspec-rails', '~> 6.0' end group :production do @@ -189,7 +204,7 @@ gem 'xorcist', '~> 1.1' gem 'cocoon', '~> 1.2' -gem 'net-http', '~> 0.3.2' +gem 'net-http', '~> 0.4.0' gem 'rubyzip', '~> 2.3' gem 'hcaptcha', '~> 7.1' diff --git a/Gemfile.lock b/Gemfile.lock index 2fd8e1fbeb..9b78ca733e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,6 +7,17 @@ GIT hkdf (~> 0.2) jwt (~> 2.0) +GIT + remote: https://github.com/jhawthorn/nsa.git + revision: e020fcc3a54d993ab45b7194d89ab720296c111b + ref: e020fcc3a54d993ab45b7194d89ab720296c111b + specs: + nsa (0.2.8) + activesupport (>= 4.2, < 7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + sidekiq (>= 3.5) + statsd-ruby (~> 1.4, >= 1.4.0) + GIT remote: https://github.com/mastodon/rails-settings-cached.git revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab @@ -15,78 +26,101 @@ GIT rails-settings-cached (0.6.6) rails (>= 4.2.0) +GIT + remote: https://github.com/stanhu/omniauth-cas.git + revision: 4211e6d05941b4a981f9a36b49ec166cecd0e271 + ref: 4211e6d05941b4a981f9a36b49ec166cecd0e271 + specs: + omniauth-cas (2.0.0) + addressable (~> 2.3) + nokogiri (~> 1.5) + omniauth (>= 1.2, < 3) + GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.4) - actionpack (= 6.1.7.4) - activesupport (= 6.1.7.4) + actioncable (7.1.2) + actionpack (= 7.1.2) + activesupport (= 7.1.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.4) - actionpack (= 6.1.7.4) - activejob (= 6.1.7.4) - activerecord (= 6.1.7.4) - activestorage (= 6.1.7.4) - activesupport (= 6.1.7.4) + zeitwerk (~> 2.6) + actionmailbox (7.1.2) + actionpack (= 7.1.2) + activejob (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) mail (>= 2.7.1) - actionmailer (6.1.7.4) - actionpack (= 6.1.7.4) - actionview (= 6.1.7.4) - activejob (= 6.1.7.4) - activesupport (= 6.1.7.4) + net-imap + net-pop + net-smtp + actionmailer (7.1.2) + actionpack (= 7.1.2) + actionview (= 7.1.2) + activejob (= 7.1.2) + activesupport (= 7.1.2) mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.7.4) - actionview (= 6.1.7.4) - activesupport (= 6.1.7.4) - rack (~> 2.0, >= 2.0.9) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.4) - actionpack (= 6.1.7.4) - activerecord (= 6.1.7.4) - activestorage (= 6.1.7.4) - activesupport (= 6.1.7.4) + net-imap + net-pop + net-smtp + rails-dom-testing (~> 2.2) + actionpack (7.1.2) + actionview (= 7.1.2) + activesupport (= 7.1.2) nokogiri (>= 1.8.5) - actionview (6.1.7.4) - activesupport (= 6.1.7.4) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.2) + actionpack (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.1.2) + activesupport (= 7.1.2) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - active_model_serializers (0.10.13) - actionpack (>= 4.1, < 7.1) - activemodel (>= 4.1, < 7.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + active_model_serializers (0.10.14) + actionpack (>= 4.1) + activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (6.1.7.4) - activesupport (= 6.1.7.4) + activejob (7.1.2) + activesupport (= 7.1.2) globalid (>= 0.3.6) - activemodel (6.1.7.4) - activesupport (= 6.1.7.4) - activerecord (6.1.7.4) - activemodel (= 6.1.7.4) - activesupport (= 6.1.7.4) - activestorage (6.1.7.4) - actionpack (= 6.1.7.4) - activejob (= 6.1.7.4) - activerecord (= 6.1.7.4) - activesupport (= 6.1.7.4) + activemodel (7.1.2) + activesupport (= 7.1.2) + activerecord (7.1.2) + activemodel (= 7.1.2) + activesupport (= 7.1.2) + timeout (>= 0.4.0) + activestorage (7.1.2) + actionpack (= 7.1.2) + activejob (= 7.1.2) + activerecord (= 7.1.2) + activesupport (= 7.1.2) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (6.1.7.4) + activesupport (7.1.2) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.4) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) aes_key_wrap (1.1.0) - airbrussh (1.4.1) - sshkit (>= 1.6.1, != 1.7.0) android_key_attestation (0.3.0) annotate (3.2.0) activerecord (>= 3.2, < 8.0) @@ -96,42 +130,52 @@ GEM encryptor (~> 3.0.0) attr_required (1.0.1) awrence (1.2.1) - aws-eventstream (1.2.0) - aws-partitions (1.780.0) - aws-sdk-core (3.175.0) - aws-eventstream (~> 1, >= 1.0.2) + aws-eventstream (1.3.0) + aws-partitions (1.860.0) + aws-sdk-core (3.189.0) + aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.67.0) - aws-sdk-core (~> 3, >= 3.174.0) + aws-sdk-kms (1.74.0) + aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.126.0) - aws-sdk-core (~> 3, >= 3.174.0) + aws-sdk-s3 (1.141.0) + aws-sdk-core (~> 3, >= 3.189.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.5.2) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) + azure-storage-blob (2.0.3) + azure-storage-common (~> 2.0) + nokogiri (~> 1, >= 1.10.8) + azure-storage-common (2.0.4) + faraday (~> 1.0) + faraday_middleware (~> 1.0, >= 1.0.0.rc1) + net-http-persistent (~> 4.0) + nokogiri (~> 1, >= 1.10.8) + base64 (0.2.0) bcp47_spec (0.2.1) - bcrypt (3.1.18) + bcrypt (3.1.20) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - better_html (2.0.1) + better_html (2.0.2) actionview (>= 6.0) activesupport (>= 6.0) ast (~> 2.0) erubi (~> 1.4) parser (>= 2.4) smart_properties + bigdecimal (3.1.5) bindata (2.4.15) binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) blurhash (0.1.7) - bootsnap (1.16.0) + bootsnap (1.17.0) msgpack (~> 1.2) - brakeman (5.4.1) + brakeman (6.1.0) browser (5.3.1) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) @@ -140,21 +184,6 @@ GEM bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) - capistrano (3.17.3) - airbrussh (>= 1.0.0) - i18n - rake (>= 10.0.0) - sshkit (>= 1.9.0) - capistrano-bundler (2.1.0) - capistrano (~> 3.1) - capistrano-rails (1.6.2) - capistrano (~> 3.1) - capistrano-bundler (>= 1.1, < 3) - capistrano-rbenv (2.2.0) - capistrano (~> 3.1) - sshkit (~> 1.3) - capistrano-yarn (2.0.2) - capistrano (~> 3.0) capybara (3.39.2) addressable matrix @@ -168,7 +197,7 @@ GEM activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (7.3.2) + chewy (7.4.0) activesupport (>= 5.2) elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl @@ -186,35 +215,44 @@ GEM crass (1.0.6) css_parser (1.14.0) addressable - date (3.3.3) + database_cleaner-active_record (2.1.0) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) + date (3.3.4) + debug (1.9.1) + irb (~> 1.10) + reline (>= 0.3.8) debug_inspector (1.1.0) - devise (4.9.2) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (4.1.0) - activesupport (< 7.1) + devise-two-factor (4.1.1) + activesupport (~> 7.0) attr_encrypted (>= 1.3, < 5, != 2) devise (~> 4.0) - railties (< 7.1) + railties (~> 7.0) rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.5.0) - discard (1.2.1) + discard (1.3.0) activerecord (>= 4.2, < 8) docile (1.4.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.6.6) + doorkeeper (5.6.8) railties (>= 5) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) + drb (2.2.0) + ruby2_keywords ed25519 (1.3.0) elasticsearch (7.13.3) elasticsearch-api (= 7.13.3) @@ -225,13 +263,17 @@ GEM elasticsearch-transport (7.13.3) faraday (~> 1) multi_json + email_spec (2.2.2) + htmlentities (~> 4.3.3) + launchy (~> 2.1) + mail (~> 2.7) encryptor (3.0.0) erubi (1.12.0) et-orbi (1.2.7) tzinfo - excon (0.100.0) - fabrication (2.30.0) - faker (3.2.0) + excon (0.104.0) + fabrication (2.31.0) + faker (3.2.2) i18n (>= 1.8.11, < 2) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -256,34 +298,35 @@ GEM faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) fast_blank (1.0.1) fastimage (2.2.7) ffi (1.15.5) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake - fog-core (2.1.0) + fog-core (2.3.0) builder - excon (~> 0.58) - formatador (~> 0.2) + excon (~> 0.71) + formatador (>= 0.2, < 2.0) mime-types fog-json (1.2.0) fog-core multi_json (~> 1.10) - fog-openstack (0.3.10) - fog-core (>= 1.45, <= 2.1.0) + fog-openstack (1.1.0) + fog-core (~> 2.1) fog-json (>= 1.0) - ipaddress (>= 0.8) - formatador (0.3.0) + formatador (1.1.0) fugit (1.8.1) et-orbi (~> 1, >= 1.2.7) raabro (~> 1.4) fuubar (2.5.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - globalid (1.1.0) - activesupport (>= 5.0) - haml (6.1.1) + globalid (1.2.1) + activesupport (>= 6.1) + haml (6.3.0) temple (>= 0.8.2) thor tilt @@ -292,11 +335,11 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.45.0) - haml (>= 4.0, < 6.2) + haml_lint (0.52.0) + haml (>= 4.0) parallel (~> 1.10) rainbow - rubocop (>= 0.50.0) + rubocop (>= 1.0) sysexits (~> 1.1) hashdiff (1.0.1) hashie (5.0.0) @@ -321,21 +364,24 @@ GEM rainbow (>= 2.0.0) i18n (1.14.1) concurrent-ruby (~> 1.0) - i18n-tasks (1.0.12) + i18n-tasks (1.0.13) activesupport (>= 4.0.2) ast (>= 2.1.0) better_html (>= 1.0, < 3.0) erubi highline (>= 2.0.0) i18n - parser (>= 2.2.3.0) + parser (>= 3.2.2.1) rails-i18n rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) idn-ruby (0.1.5) - ipaddress (0.8.3) + io-console (0.7.1) + irb (1.11.0) + rdoc + reline (>= 0.3.8) jmespath (1.6.2) - json (2.6.3) + json (2.7.1) json-canonicalization (1.0.0) json-jwt (1.15.3) activesupport (>= 4.2) @@ -368,12 +414,13 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - kt-paperclip (7.2.0) + kt-paperclip (7.2.1) activemodel (>= 4.2.0) activesupport (>= 4.2.0) marcel (~> 1.0.1) mime-types terrapin (~> 0.6.0) + language_server-protocol (3.17.0.3) launchy (2.5.2) addressable (~> 2.8) letter_opener (1.8.1) @@ -387,12 +434,12 @@ GEM llhttp-ffi (0.4.0) ffi-compiler (~> 1.0) rake (~> 13.0) - lograge (0.12.0) + lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.21.3) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -400,56 +447,55 @@ GEM net-imap net-pop net-smtp - makara (0.5.1) - activerecord (>= 5.2.0) marcel (1.0.2) mario-redis-lock (1.2.1) redis (>= 3.0.5) matrix (0.4.2) + md-paperclip-azure (2.2.0) + addressable (~> 2.5) + azure-storage-blob (~> 2.0.1) + hashie (~> 5.0) memory_profiler (1.0.1) - method_source (1.0.0) - mime-types (3.4.1) + mime-types (3.5.1) mime-types-data (~> 3.2015) - mime-types-data (3.2023.0218.1) - mini_mime (1.1.2) - mini_portile2 (2.8.2) - minitest (5.18.1) - msgpack (1.7.1) + mime-types-data (3.2023.1003) + mini_mime (1.1.5) + mini_portile2 (2.8.5) + minitest (5.20.0) + msgpack (1.7.2) multi_json (1.15.0) multipart-post (2.3.0) - net-http (0.3.2) + mutex_m (0.2.0) + net-http (0.4.0) uri - net-imap (0.3.6) + net-http-persistent (4.0.2) + connection_pool (~> 2.2) + net-imap (0.4.4) date net-protocol net-ldap (0.18.0) net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout - net-scp (4.0.0) - net-ssh (>= 2.6.5, < 8.0.0) - net-smtp (0.3.3) + net-smtp (0.4.0) net-protocol - net-ssh (7.1.0) nio4r (2.5.9) - nokogiri (1.15.3) + nokogiri (1.15.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.15.0) - omniauth (1.9.2) + oj (3.16.3) + bigdecimal (>= 3.0) + omniauth (2.1.1) hashie (>= 3.4.6) - rack (>= 1.6.2, < 3) - omniauth-cas (2.0.0) - addressable (~> 2.3) - nokogiri (~> 1.5) - omniauth (~> 1.2) - omniauth-rails_csrf_protection (0.1.2) + rack (>= 2.2.3) + rack-protection + omniauth-rails_csrf_protection (1.0.1) actionpack (>= 4.2) - omniauth (>= 1.3.1) - omniauth-saml (1.10.3) - omniauth (~> 1.3, >= 1.3.2) - ruby-saml (~> 1.9) + omniauth (~> 2.0) + omniauth-saml (2.1.0) + omniauth (~> 2.0) + ruby-saml (~> 1.12) omniauth_openid_connect (0.6.1) omniauth (>= 1.9, < 3) openid_connect (~> 1.1) @@ -468,16 +514,16 @@ GEM openssl-signature_algorithm (1.3.0) openssl (> 2.0) orm_adapter (0.5.0) - ox (2.14.16) - parallel (1.23.0) - parser (3.2.2.3) + ox (2.14.17) + parallel (1.24.0) + parser (3.2.2.4) ast (~> 2.4.1) racc parslet (2.0.0) pastel (0.8.0) tty-color (~> 0.5) - pg (1.5.3) - pghero (3.3.3) + pg (1.5.4) + pghero (3.4.0) activerecord (>= 6) posix-spawn (0.3.15) premailer (1.21.0) @@ -489,16 +535,23 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) private_address_check (0.5.0) - public_suffix (5.0.1) - puma (6.3.0) + propshaft (0.8.0) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + railties (>= 7.0.0) + psych (5.1.2) + stringio + public_suffix (5.0.4) + puma (6.4.0) nio4r (~> 2.0) - pundit (2.3.0) + pundit (2.3.1) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.1) - rack (2.2.7) - rack-attack (6.6.1) - rack (>= 1.0, < 3) + racc (1.7.3) + rack (2.2.8) + rack-attack (6.7.0) + rack (>= 1.0, < 4) rack-cors (2.0.1) rack (>= 2.0.0) rack-oauth2 (1.21.3) @@ -507,65 +560,78 @@ GEM httpclient json-jwt (>= 1.11.0) rack (>= 2.1.0) + rack-protection (3.0.5) + rack rack-proxy (0.7.6) rack + rack-session (1.0.1) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.4) - actioncable (= 6.1.7.4) - actionmailbox (= 6.1.7.4) - actionmailer (= 6.1.7.4) - actionpack (= 6.1.7.4) - actiontext (= 6.1.7.4) - actionview (= 6.1.7.4) - activejob (= 6.1.7.4) - activemodel (= 6.1.7.4) - activerecord (= 6.1.7.4) - activestorage (= 6.1.7.4) - activesupport (= 6.1.7.4) + rackup (1.0.0) + rack (< 3) + webrick + rails (7.1.2) + actioncable (= 7.1.2) + actionmailbox (= 7.1.2) + actionmailer (= 7.1.2) + actionpack (= 7.1.2) + actiontext (= 7.1.2) + actionview (= 7.1.2) + activejob (= 7.1.2) + activemodel (= 7.1.2) + activerecord (= 7.1.2) + activestorage (= 7.1.2) + activesupport (= 7.1.2) bundler (>= 1.15.0) - railties (= 6.1.7.4) - sprockets-rails (>= 2.0.0) + railties (= 7.1.2) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (6.0.0) + rails-i18n (7.0.8) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 7) - railties (6.1.7.4) - actionpack (= 6.1.7.4) - activesupport (= 6.1.7.4) - method_source + railties (>= 6.0.0, < 8) + railties (7.1.2) + actionpack (= 7.1.2) + activesupport (= 7.1.2) + irb + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.0.6) + rake (13.1.0) rdf (3.3.1) bcp47_spec (~> 0.2) link_header (~> 0.0, >= 0.0.8) - rdf-normalize (0.6.0) + rdf-normalize (0.6.1) rdf (~> 3.2) + rdoc (6.6.2) + psych (>= 4.0.0) redcarpet (3.6.0) redis (4.8.1) redis-namespace (1.11.0) redis (>= 4) redlock (1.3.2) redis (>= 3.0.0, < 6.0) - regexp_parser (2.8.1) + regexp_parser (2.8.3) + reline (0.4.1) + io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) - responders (3.1.0) + responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.5) - rotp (6.2.2) + rexml (3.2.6) + rotp (6.3.0) rouge (4.1.2) rpam2 (4.0.2) rqrcode (2.2.0) @@ -577,10 +643,12 @@ GEM rspec-expectations (3.12.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-mocks (3.12.5) + rspec-github (2.4.0) + rspec-core (~> 3.0) + rspec-mocks (3.12.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) - rspec-rails (6.0.3) + rspec-rails (6.1.0) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) @@ -588,38 +656,42 @@ GEM rspec-expectations (~> 3.12) rspec-mocks (~> 3.12) rspec-support (~> 3.12) - rspec-sidekiq (3.1.0) - rspec-core (~> 3.0, >= 3.0.0) - sidekiq (>= 2.4.0) - rspec-support (3.12.0) - rspec_chunked (0.6) - rubocop (1.52.1) + rspec-sidekiq (4.1.0) + rspec-core (~> 3.0) + rspec-expectations (~> 3.0) + rspec-mocks (~> 3.0) + sidekiq (>= 5, < 8) + rspec-support (3.12.1) + rubocop (1.59.0) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.2.2.4) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) + rubocop-ast (1.30.0) parser (>= 3.2.1.0) - rubocop-capybara (2.18.0) + rubocop-capybara (2.19.0) rubocop (~> 1.41) - rubocop-factory_bot (2.23.1) + rubocop-factory_bot (2.24.0) rubocop (~> 1.33) - rubocop-performance (1.18.0) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-rails (2.19.1) + rubocop-performance (1.20.0) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rails (2.23.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - rubocop-rspec (2.22.0) - rubocop (~> 1.33) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rspec (2.25.0) + rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) + ruby-prof (1.6.3) ruby-progressbar (1.13.0) ruby-saml (1.15.0) nokogiri (>= 1.13.10) @@ -630,14 +702,18 @@ GEM fugit (~> 1.1, >= 1.1.6) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.0.2) + sanitize (6.1.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) scenic (1.7.0) activerecord (>= 4.0.0) railties (>= 4.0.0) + selenium-webdriver (4.16.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) semantic_range (3.0.0) - sidekiq (6.5.9) + sidekiq (6.5.12) connection_pool (>= 2.2.5, < 3) rack (~> 2.0) redis (>= 4.5.0, < 5) @@ -647,7 +723,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) tilt (>= 1.4.0) - sidekiq-unique-jobs (7.1.29) + sidekiq-unique-jobs (7.1.30) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) redis (< 5.0) @@ -655,7 +731,7 @@ GEM thor (>= 0.20, < 3.0) simple-navigation (4.4.0) activesupport (>= 2.3.2) - simple_form (5.2.0) + simple_form (5.3.0) actionpack (>= 5.2) activemodel (>= 5.2) simplecov (0.22.0) @@ -663,36 +739,30 @@ GEM simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) + simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sprockets (3.7.2) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - sprockets (>= 3.0.0) - sshkit (1.21.4) - net-scp (>= 1.1.2) - net-ssh (>= 2.8.0) stackprof (0.2.25) - stoplight (3.0.1) + statsd-ruby (1.5.0) + stoplight (3.0.2) redlock (~> 1.0) - strong_migrations (0.8.0) + stringio (3.1.0) + strong_migrations (1.6.4) activerecord (>= 5.2) swd (1.3.0) activesupport (>= 3) attr_required (>= 0.0.5) httpclient (>= 2.4) sysexits (1.2.0) - temple (0.10.2) + temple (0.10.3) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) - thor (1.2.2) - tilt (2.2.0) - timeout (0.3.2) + test-prof (1.3.1) + thor (1.3.0) + tilt (2.3.0) + timeout (0.4.1) tpm-key_attestation (0.12.0) bindata (~> 2.4) openssl (> 2.0) @@ -717,7 +787,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) uri (0.12.2) validate_email (0.1.6) activemodel (>= 3.0) @@ -739,7 +809,7 @@ GEM webfinger (1.2.0) activesupport httpclient (>= 2.4) - webmock (3.18.1) + webmock (3.19.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -748,14 +818,16 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - websocket-driver (0.7.5) + webrick (1.8.1) + websocket (1.2.10) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.1) xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.8) + zeitwerk (2.6.12) PLATFORMS ruby @@ -768,14 +840,10 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.16.0) - brakeman (~> 5.4) + bootsnap (~> 1.17.0) + brakeman (~> 6.0) browser bundler-audit (~> 0.9) - capistrano (~> 3.17) - capistrano-rails (~> 1.6) - capistrano-rbenv (~> 2.2) - capistrano-yarn (~> 2.0) capybara (~> 3.39) charlock_holmes (~> 0.7.7) chewy (~> 7.3) @@ -784,6 +852,8 @@ DEPENDENCIES color_diff (~> 0.1) concurrent-ruby connection_pool + database_cleaner-active_record + debug (~> 1.8) devise (~> 4.9) devise-two-factor (~> 4.1) devise_pam_authenticatable2 (~> 9.2) @@ -791,12 +861,13 @@ DEPENDENCIES doorkeeper (~> 5.6) dotenv-rails (~> 2.8) ed25519 (~> 1.3) + email_spec fabrication (~> 2.30) faker (~> 3.2) fast_blank (~> 1.0) fastimage fog-core (<= 2.4.0) - fog-openstack (~> 0.3) + fog-openstack (~> 1.0) fuubar (~> 2.5) haml-rails (~> 2.0) haml_lint @@ -808,6 +879,7 @@ DEPENDENCIES httplog (~> 1.6.2) i18n-tasks (~> 1.0) idn-ruby + irb (~> 1.8) json-ld json-ld-preloaded (~> 3.2) json-schema (~> 4.0) @@ -817,18 +889,19 @@ DEPENDENCIES letter_opener_web (~> 2.0) link_header (~> 0.0) lograge (~> 0.12) - makara (~> 0.5) mario-redis-lock (~> 1.2) + md-paperclip-azure (~> 2.2) memory_profiler - mime-types (~> 3.4.1) - net-http (~> 0.3.2) + mime-types (~> 3.5.0) + net-http (~> 0.4.0) net-ldap (~> 0.18) nokogiri (~> 1.15) + nsa! oj (~> 3.14) - omniauth (~> 1.9) - omniauth-cas (~> 2.0) - omniauth-rails_csrf_protection (~> 0.1) - omniauth-saml (~> 1.10) + omniauth (~> 2.0) + omniauth-cas! + omniauth-rails_csrf_protection (~> 1.0) + omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.6.1) ox (~> 2.14) parslet @@ -837,6 +910,7 @@ DEPENDENCIES posix-spawn premailer-rails private_address_check (~> 0.5) + propshaft public_suffix (~> 5.0) puma (~> 6.3) pundit (~> 2.3) @@ -844,27 +918,29 @@ DEPENDENCIES rack-attack (~> 6.6) rack-cors (~> 2.0) rack-test (~> 2.1) - rails (~> 6.1.7) + rails (~> 7.1.1) rails-controller-testing (~> 1.0) - rails-i18n (~> 6.0) + rails-i18n (~> 7.0) rails-settings-cached (~> 0.6)! rdf-normalize (~> 0.5) redcarpet (~> 3.6) redis (~> 4.5) redis-namespace (~> 1.10) rqrcode (~> 2.2) + rspec-github (~> 2.4) rspec-rails (~> 6.0) - rspec-sidekiq (~> 3.1) - rspec_chunked (~> 0.6) + rspec-sidekiq (~> 4.0) rubocop rubocop-capybara rubocop-performance rubocop-rails rubocop-rspec + ruby-prof ruby-progressbar (~> 1.13) rubyzip (~> 2.3) sanitize (~> 6.0) scenic (~> 1.7) + selenium-webdriver sidekiq (~> 6.5) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 5.0) @@ -872,11 +948,11 @@ DEPENDENCIES simple-navigation (~> 4.4) simple_form (~> 5.2) simplecov (~> 0.22) - sprockets (~> 3.7.2) - sprockets-rails (~> 3.4) + simplecov-lcov (~> 0.8) stackprof stoplight (~> 3.0.1) - strong_migrations (~> 0.8) + strong_migrations (= 1.6.4) + test-prof thor (~> 1.2) tty-prompt (~> 0.23) twitter-text (~> 3.1.0) @@ -891,4 +967,4 @@ RUBY VERSION ruby 3.2.2p53 BUNDLED WITH - 2.4.13 + 2.4.20 diff --git a/Procfile.dev b/Procfile.dev index ba04fb661b..f81333b04c 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq -stream: env PORT=4000 yarn run start -webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 +stream: env PORT=4000 yarn workspace @mastodon/streaming start +webpack: bin/webpack-dev-server diff --git a/Rakefile b/Rakefile index ba6b733dd2..e51cf0e17e 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,8 @@ +# frozen_string_literal: true + # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. -require File.expand_path('../config/application', __FILE__) +require File.expand_path('config/application', __dir__) Rails.application.load_tasks diff --git a/SECURITY.md b/SECURITY.md index 6a51c126ab..954ff73a24 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,11 @@ # Security Policy -If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can reach us at . +If you believe you've identified a security vulnerability in Mastodon (a bug that allows something to happen that shouldn't be possible), you can either: -You should _not_ report such issues on GitHub or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk. +- open a [Github security issue on the Mastodon project](https://github.com/mastodon/mastodon/security/advisories/new) +- reach us at + +You should _not_ report such issues on public GitHub issues or in other public spaces to give us time to publish a fix for the issue without exposing Mastodon's users to increased risk. ## Scope @@ -10,9 +13,10 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through ## Supported Versions -| Version | Supported | -| ------- | --------- | -| 4.1.x | Yes | -| 4.0.x | Yes | -| 3.5.x | Yes | -| < 3.5 | No | +| Version | Supported | +| ------- | ---------------- | +| 4.2.x | Yes | +| 4.1.x | Yes | +| 4.0.x | No | +| 3.5.x | Until 2023-12-31 | +| < 3.5 | No | diff --git a/Vagrantfile b/Vagrantfile index 043bab3e91..6f0f511095 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -10,7 +10,11 @@ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main' # Add repo for NodeJS -curl -sL https://deb.nodesource.com/setup_16.x | sudo bash - +sudo mkdir -p /etc/apt/keyrings +curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +NODE_MAJOR=20 +echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list +sudo apt-get update # Add firewall rule to redirect 80 to PORT and save sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]} @@ -60,6 +64,38 @@ sudo usermod -a -G rvm $USER SCRIPT +$provisionElasticsearch = < - - - - - - - 中国域名网站 - - - -

- -
- -
- - -
-
-
-

网址大全

- -
-
-
-
-
-

中文域名简介

-

- “中国域名”是中文域名的一种,特指以“中国”为后缀的中文域名,是我国域名体系和全球互联网域名体系的重要组成部分。“中国”是在全球互联网上代表中国的中文顶级域名,于2010年7月正式纳入全球互联网域名体系,全球互联网域名体系,全球网民可通过联网计算机在世界任何国家和地区实现无障碍访问。“中国”域名在使用上和 .CN,相似属于互联网上的基础服务,基于域名可以提供WWW.EMAIL FTP等应用服务。 -

-
- - - - diff --git a/spec/fixtures/requests/json-ld.activitystreams.txt b/spec/fixtures/requests/json-ld.activitystreams.txt deleted file mode 100644 index 395797b272..0000000000 --- a/spec/fixtures/requests/json-ld.activitystreams.txt +++ /dev/null @@ -1,391 +0,0 @@ -HTTP/1.1 200 OK -Date: Tue, 01 May 2018 23:25:57 GMT -Content-Location: activitystreams.jsonld -Vary: negotiate,accept -TCN: choice -Last-Modified: Mon, 16 Apr 2018 00:28:23 GMT -ETag: "1eb0-569ec4caa97c0;d3-540ee27e0eec0" -Accept-Ranges: bytes -Content-Length: 7856 -Cache-Control: max-age=21600 -Expires: Wed, 02 May 2018 05:25:57 GMT -P3P: policyref="http://www.w3.org/2014/08/p3p.xml" -Access-Control-Allow-Origin: * -Content-Type: application/ld+json -Strict-Transport-Security: max-age=15552000; includeSubdomains; preload -Content-Security-Policy: upgrade-insecure-requests - -{ - "@context": { - "@vocab": "_:", - "xsd": "http://www.w3.org/2001/XMLSchema#", - "as": "https://www.w3.org/ns/activitystreams#", - "ldp": "http://www.w3.org/ns/ldp#", - "id": "@id", - "type": "@type", - "Accept": "as:Accept", - "Activity": "as:Activity", - "IntransitiveActivity": "as:IntransitiveActivity", - "Add": "as:Add", - "Announce": "as:Announce", - "Application": "as:Application", - "Arrive": "as:Arrive", - "Article": "as:Article", - "Audio": "as:Audio", - "Block": "as:Block", - "Collection": "as:Collection", - "CollectionPage": "as:CollectionPage", - "Relationship": "as:Relationship", - "Create": "as:Create", - "Delete": "as:Delete", - "Dislike": "as:Dislike", - "Document": "as:Document", - "Event": "as:Event", - "Follow": "as:Follow", - "Flag": "as:Flag", - "Group": "as:Group", - "Ignore": "as:Ignore", - "Image": "as:Image", - "Invite": "as:Invite", - "Join": "as:Join", - "Leave": "as:Leave", - "Like": "as:Like", - "Link": "as:Link", - "Mention": "as:Mention", - "Note": "as:Note", - "Object": "as:Object", - "Offer": "as:Offer", - "OrderedCollection": "as:OrderedCollection", - "OrderedCollectionPage": "as:OrderedCollectionPage", - "Organization": "as:Organization", - "Page": "as:Page", - "Person": "as:Person", - "Place": "as:Place", - "Profile": "as:Profile", - "Question": "as:Question", - "Reject": "as:Reject", - "Remove": "as:Remove", - "Service": "as:Service", - "TentativeAccept": "as:TentativeAccept", - "TentativeReject": "as:TentativeReject", - "Tombstone": "as:Tombstone", - "Undo": "as:Undo", - "Update": "as:Update", - "Video": "as:Video", - "View": "as:View", - "Listen": "as:Listen", - "Read": "as:Read", - "Move": "as:Move", - "Travel": "as:Travel", - "IsFollowing": "as:IsFollowing", - "IsFollowedBy": "as:IsFollowedBy", - "IsContact": "as:IsContact", - "IsMember": "as:IsMember", - "subject": { - "@id": "as:subject", - "@type": "@id" - }, - "relationship": { - "@id": "as:relationship", - "@type": "@id" - }, - "actor": { - "@id": "as:actor", - "@type": "@id" - }, - "attributedTo": { - "@id": "as:attributedTo", - "@type": "@id" - }, - "attachment": { - "@id": "as:attachment", - "@type": "@id" - }, - "bcc": { - "@id": "as:bcc", - "@type": "@id" - }, - "bto": { - "@id": "as:bto", - "@type": "@id" - }, - "cc": { - "@id": "as:cc", - "@type": "@id" - }, - "context": { - "@id": "as:context", - "@type": "@id" - }, - "current": { - "@id": "as:current", - "@type": "@id" - }, - "first": { - "@id": "as:first", - "@type": "@id" - }, - "generator": { - "@id": "as:generator", - "@type": "@id" - }, - "icon": { - "@id": "as:icon", - "@type": "@id" - }, - "image": { - "@id": "as:image", - "@type": "@id" - }, - "inReplyTo": { - "@id": "as:inReplyTo", - "@type": "@id" - }, - "items": { - "@id": "as:items", - "@type": "@id" - }, - "instrument": { - "@id": "as:instrument", - "@type": "@id" - }, - "orderedItems": { - "@id": "as:items", - "@type": "@id", - "@container": "@list" - }, - "last": { - "@id": "as:last", - "@type": "@id" - }, - "location": { - "@id": "as:location", - "@type": "@id" - }, - "next": { - "@id": "as:next", - "@type": "@id" - }, - "object": { - "@id": "as:object", - "@type": "@id" - }, - "oneOf": { - "@id": "as:oneOf", - "@type": "@id" - }, - "anyOf": { - "@id": "as:anyOf", - "@type": "@id" - }, - "closed": { - "@id": "as:closed", - "@type": "xsd:dateTime" - }, - "origin": { - "@id": "as:origin", - "@type": "@id" - }, - "accuracy": { - "@id": "as:accuracy", - "@type": "xsd:float" - }, - "prev": { - "@id": "as:prev", - "@type": "@id" - }, - "preview": { - "@id": "as:preview", - "@type": "@id" - }, - "replies": { - "@id": "as:replies", - "@type": "@id" - }, - "result": { - "@id": "as:result", - "@type": "@id" - }, - "audience": { - "@id": "as:audience", - "@type": "@id" - }, - "partOf": { - "@id": "as:partOf", - "@type": "@id" - }, - "tag": { - "@id": "as:tag", - "@type": "@id" - }, - "target": { - "@id": "as:target", - "@type": "@id" - }, - "to": { - "@id": "as:to", - "@type": "@id" - }, - "url": { - "@id": "as:url", - "@type": "@id" - }, - "altitude": { - "@id": "as:altitude", - "@type": "xsd:float" - }, - "content": "as:content", - "contentMap": { - "@id": "as:content", - "@container": "@language" - }, - "name": "as:name", - "nameMap": { - "@id": "as:name", - "@container": "@language" - }, - "duration": { - "@id": "as:duration", - "@type": "xsd:duration" - }, - "endTime": { - "@id": "as:endTime", - "@type": "xsd:dateTime" - }, - "height": { - "@id": "as:height", - "@type": "xsd:nonNegativeInteger" - }, - "href": { - "@id": "as:href", - "@type": "@id" - }, - "hreflang": "as:hreflang", - "latitude": { - "@id": "as:latitude", - "@type": "xsd:float" - }, - "longitude": { - "@id": "as:longitude", - "@type": "xsd:float" - }, - "mediaType": "as:mediaType", - "published": { - "@id": "as:published", - "@type": "xsd:dateTime" - }, - "radius": { - "@id": "as:radius", - "@type": "xsd:float" - }, - "rel": "as:rel", - "startIndex": { - "@id": "as:startIndex", - "@type": "xsd:nonNegativeInteger" - }, - "startTime": { - "@id": "as:startTime", - "@type": "xsd:dateTime" - }, - "summary": "as:summary", - "summaryMap": { - "@id": "as:summary", - "@container": "@language" - }, - "totalItems": { - "@id": "as:totalItems", - "@type": "xsd:nonNegativeInteger" - }, - "units": "as:units", - "updated": { - "@id": "as:updated", - "@type": "xsd:dateTime" - }, - "width": { - "@id": "as:width", - "@type": "xsd:nonNegativeInteger" - }, - "describes": { - "@id": "as:describes", - "@type": "@id" - }, - "formerType": { - "@id": "as:formerType", - "@type": "@id" - }, - "deleted": { - "@id": "as:deleted", - "@type": "xsd:dateTime" - }, - "inbox": { - "@id": "ldp:inbox", - "@type": "@id" - }, - "outbox": { - "@id": "as:outbox", - "@type": "@id" - }, - "following": { - "@id": "as:following", - "@type": "@id" - }, - "followers": { - "@id": "as:followers", - "@type": "@id" - }, - "streams": { - "@id": "as:streams", - "@type": "@id" - }, - "preferredUsername": "as:preferredUsername", - "endpoints": { - "@id": "as:endpoints", - "@type": "@id" - }, - "uploadMedia": { - "@id": "as:uploadMedia", - "@type": "@id" - }, - "proxyUrl": { - "@id": "as:proxyUrl", - "@type": "@id" - }, - "liked": { - "@id": "as:liked", - "@type": "@id" - }, - "oauthAuthorizationEndpoint": { - "@id": "as:oauthAuthorizationEndpoint", - "@type": "@id" - }, - "oauthTokenEndpoint": { - "@id": "as:oauthTokenEndpoint", - "@type": "@id" - }, - "provideClientKey": { - "@id": "as:provideClientKey", - "@type": "@id" - }, - "signClientKey": { - "@id": "as:signClientKey", - "@type": "@id" - }, - "sharedInbox": { - "@id": "as:sharedInbox", - "@type": "@id" - }, - "Public": { - "@id": "as:Public", - "@type": "@id" - }, - "source": "as:source", - "likes": { - "@id": "as:likes", - "@type": "@id" - }, - "shares": { - "@id": "as:shares", - "@type": "@id" - } - } -} diff --git a/spec/fixtures/requests/json-ld.identity.txt b/spec/fixtures/requests/json-ld.identity.txt deleted file mode 100644 index 8810526cb1..0000000000 --- a/spec/fixtures/requests/json-ld.identity.txt +++ /dev/null @@ -1,100 +0,0 @@ -HTTP/1.1 200 OK -Accept-Ranges: bytes -Access-Control-Allow-Headers: DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Accept-Encoding -Access-Control-Allow-Origin: * -Content-Type: application/ld+json -Date: Tue, 01 May 2018 23:28:21 GMT -Etag: "e26-547a6fc75b04a-gzip" -Last-Modified: Fri, 03 Feb 2017 21:30:09 GMT -Server: Apache/2.4.7 (Ubuntu) -Vary: Accept-Encoding -Transfer-Encoding: chunked - -{ - "@context": { - "id": "@id", - "type": "@type", - - "cred": "https://w3id.org/credentials#", - "dc": "http://purl.org/dc/terms/", - "identity": "https://w3id.org/identity#", - "perm": "https://w3id.org/permissions#", - "ps": "https://w3id.org/payswarm#", - "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#", - "sec": "https://w3id.org/security#", - "schema": "http://schema.org/", - "xsd": "http://www.w3.org/2001/XMLSchema#", - - "Group": "https://www.w3.org/ns/activitystreams#Group", - - "claim": {"@id": "cred:claim", "@type": "@id"}, - "credential": {"@id": "cred:credential", "@type": "@id"}, - "issued": {"@id": "cred:issued", "@type": "xsd:dateTime"}, - "issuer": {"@id": "cred:issuer", "@type": "@id"}, - "recipient": {"@id": "cred:recipient", "@type": "@id"}, - "Credential": "cred:Credential", - "CryptographicKeyCredential": "cred:CryptographicKeyCredential", - - "about": {"@id": "schema:about", "@type": "@id"}, - "address": {"@id": "schema:address", "@type": "@id"}, - "addressCountry": "schema:addressCountry", - "addressLocality": "schema:addressLocality", - "addressRegion": "schema:addressRegion", - "comment": "rdfs:comment", - "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, - "creator": {"@id": "dc:creator", "@type": "@id"}, - "description": "schema:description", - "email": "schema:email", - "familyName": "schema:familyName", - "givenName": "schema:givenName", - "image": {"@id": "schema:image", "@type": "@id"}, - "label": "rdfs:label", - "name": "schema:name", - "postalCode": "schema:postalCode", - "streetAddress": "schema:streetAddress", - "title": "dc:title", - "url": {"@id": "schema:url", "@type": "@id"}, - "Person": "schema:Person", - "PostalAddress": "schema:PostalAddress", - "Organization": "schema:Organization", - - "identityService": {"@id": "identity:identityService", "@type": "@id"}, - "idp": {"@id": "identity:idp", "@type": "@id"}, - "Identity": "identity:Identity", - - "paymentProcessor": "ps:processor", - "preferences": {"@id": "ps:preferences", "@type": "@vocab"}, - - "cipherAlgorithm": "sec:cipherAlgorithm", - "cipherData": "sec:cipherData", - "cipherKey": "sec:cipherKey", - "digestAlgorithm": "sec:digestAlgorithm", - "digestValue": "sec:digestValue", - "domain": "sec:domain", - "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, - "initializationVector": "sec:initializationVector", - "member": {"@id": "schema:member", "@type": "@id"}, - "memberOf": {"@id": "schema:memberOf", "@type": "@id"}, - "nonce": "sec:nonce", - "normalizationAlgorithm": "sec:normalizationAlgorithm", - "owner": {"@id": "sec:owner", "@type": "@id"}, - "password": "sec:password", - "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, - "privateKeyPem": "sec:privateKeyPem", - "publicKey": {"@id": "sec:publicKey", "@type": "@id"}, - "publicKeyPem": "sec:publicKeyPem", - "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"}, - "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, - "signature": "sec:signature", - "signatureAlgorithm": "sec:signatureAlgorithm", - "signatureValue": "sec:signatureValue", - "CryptographicKey": "sec:Key", - "EncryptedMessage": "sec:EncryptedMessage", - "GraphSignature2012": "sec:GraphSignature2012", - "LinkedDataSignature2015": "sec:LinkedDataSignature2015", - - "accessControl": {"@id": "perm:accessControl", "@type": "@id"}, - "writePermission": {"@id": "perm:writePermission", "@type": "@id"} - } -} diff --git a/spec/fixtures/requests/json-ld.security.txt b/spec/fixtures/requests/json-ld.security.txt deleted file mode 100644 index 0d29903e60..0000000000 --- a/spec/fixtures/requests/json-ld.security.txt +++ /dev/null @@ -1,61 +0,0 @@ -HTTP/1.1 200 OK -Accept-Ranges: bytes -Access-Control-Allow-Headers: DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Accept-Encoding -Access-Control-Allow-Origin: * -Content-Type: application/ld+json -Date: Wed, 02 May 2018 16:25:32 GMT -Etag: "7e3-5651ec0f7c5ed-gzip" -Last-Modified: Tue, 13 Feb 2018 21:34:04 GMT -Server: Apache/2.4.7 (Ubuntu) -Vary: Accept-Encoding -Content-Length: 2019 - -{ - "@context": { - "id": "@id", - "type": "@type", - - "dc": "http://purl.org/dc/terms/", - "sec": "https://w3id.org/security#", - "xsd": "http://www.w3.org/2001/XMLSchema#", - - "EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016", - "Ed25519Signature2018": "sec:Ed25519Signature2018", - "EncryptedMessage": "sec:EncryptedMessage", - "GraphSignature2012": "sec:GraphSignature2012", - "LinkedDataSignature2015": "sec:LinkedDataSignature2015", - "LinkedDataSignature2016": "sec:LinkedDataSignature2016", - "CryptographicKey": "sec:Key", - - "authenticationTag": "sec:authenticationTag", - "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", - "cipherAlgorithm": "sec:cipherAlgorithm", - "cipherData": "sec:cipherData", - "cipherKey": "sec:cipherKey", - "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, - "creator": {"@id": "dc:creator", "@type": "@id"}, - "digestAlgorithm": "sec:digestAlgorithm", - "digestValue": "sec:digestValue", - "domain": "sec:domain", - "encryptionKey": "sec:encryptionKey", - "expiration": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, - "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, - "initializationVector": "sec:initializationVector", - "iterationCount": "sec:iterationCount", - "nonce": "sec:nonce", - "normalizationAlgorithm": "sec:normalizationAlgorithm", - "owner": {"@id": "sec:owner", "@type": "@id"}, - "password": "sec:password", - "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, - "privateKeyPem": "sec:privateKeyPem", - "publicKey": {"@id": "sec:publicKey", "@type": "@id"}, - "publicKeyBase58": "sec:publicKeyBase58", - "publicKeyPem": "sec:publicKeyPem", - "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"}, - "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, - "salt": "sec:salt", - "signature": "sec:signature", - "signatureAlgorithm": "sec:signingAlgorithm", - "signatureValue": "sec:signatureValue" - } -} diff --git a/spec/generators/post_deployment_migration_generator_spec.rb b/spec/generators/post_deployment_migration_generator_spec.rb index d552880e3b..55e70a7917 100644 --- a/spec/generators/post_deployment_migration_generator_spec.rb +++ b/spec/generators/post_deployment_migration_generator_spec.rb @@ -1,18 +1,18 @@ # frozen_string_literal: true require 'rails_helper' -require 'rails/generators/testing/behaviour' +require 'rails/generators/testing/behavior' require 'rails/generators/testing/assertions' require 'generators/post_deployment_migration/post_deployment_migration_generator' describe PostDeploymentMigrationGenerator, type: :generator do - include Rails::Generators::Testing::Behaviour + include Rails::Generators::Testing::Behavior include Rails::Generators::Testing::Assertions include FileUtils tests described_class - destination File.expand_path('../../tmp', __dir__) + destination Rails.root.join('tmp', 'generator-test') before { prepare_destination } after { rm_rf(destination_root) } diff --git a/spec/helpers/admin/account_moderation_notes_helper_spec.rb b/spec/helpers/admin/account_moderation_notes_helper_spec.rb index 6386f07ac9..91bf4ab6fe 100644 --- a/spec/helpers/admin/account_moderation_notes_helper_spec.rb +++ b/spec/helpers/admin/account_moderation_notes_helper_spec.rb @@ -18,13 +18,15 @@ RSpec.describe Admin::AccountModerationNotesHelper do let(:account) { Fabricate(:account) } it 'calls #link_to' do - expect(helper).to receive(:link_to).with( + allow(helper).to receive(:link_to) + + helper.admin_account_link_to(account) + + expect(helper).to have_received(:link_to).with( admin_account_path(account.id), class: name_tag_classes(account), title: account.acct ) - - helper.admin_account_link_to(account) end end end diff --git a/spec/helpers/admin/action_logs_helper_spec.rb b/spec/helpers/admin/action_logs_helper_spec.rb deleted file mode 100644 index 4e9d08f09d..0000000000 --- a/spec/helpers/admin/action_logs_helper_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::ActionLogsHelper do -end diff --git a/spec/helpers/admin/disputes_helper_spec.rb b/spec/helpers/admin/disputes_helper_spec.rb new file mode 100644 index 0000000000..5f9a85df86 --- /dev/null +++ b/spec/helpers/admin/disputes_helper_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::DisputesHelper do + describe 'strike_action_label' do + it 'returns html describing the appeal' do + adam = Account.new(username: 'Adam') + becky = Account.new(username: 'Becky') + strike = AccountWarning.new(account: adam, action: :suspend) + appeal = Appeal.new(strike: strike, account: becky) + + expected = <<~OUTPUT.strip + Adam suspended Becky's account + OUTPUT + result = helper.strike_action_label(appeal) + + expect(result).to eq(expected) + end + end +end diff --git a/spec/helpers/admin/filter_helper_spec.rb b/spec/helpers/admin/filter_helper_spec.rb index bbf90a9969..40ed63239f 100644 --- a/spec/helpers/admin/filter_helper_spec.rb +++ b/spec/helpers/admin/filter_helper_spec.rb @@ -7,8 +7,7 @@ describe Admin::FilterHelper do params = ActionController::Parameters.new( { test: 'test' } ) - allow(helper).to receive(:params).and_return(params) - allow(helper).to receive(:url_for).and_return('/test') + allow(helper).to receive_messages(params: params, url_for: '/test') result = helper.filter_link_to('text', { resolved: true }) expect(result).to match(/text/) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 5cdc45a39a..ec9d908ee9 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -29,18 +29,27 @@ describe ApplicationHelper do describe 'body_classes' do context 'with a body class string from a controller' do - before do - without_partial_double_verification do - allow(helper).to receive(:body_class_string).and_return('modal-layout compose-standalone') - allow(helper).to receive(:current_flavour).and_return('glitch') - allow(helper).to receive(:current_skin).and_return('default') - allow(helper).to receive(:current_account).and_return(Fabricate(:account)) - end - end + before { helper.extend controller_helpers } it 'uses the controller body classes in the result' do expect(helper.body_classes).to match(/modal-layout compose-standalone/) end + + private + + def controller_helpers + Module.new do + def body_class_string = 'modal-layout compose-standalone' + def body_class_string = 'modal-layout compose-standalone' + + def current_account + @current_account ||= Fabricate(:account) + end + + def current_flavour = 'glitch' + def current_skin = 'default' + end + end end end @@ -78,23 +87,21 @@ describe ApplicationHelper do describe 'open_registrations?' do it 'returns true when open for registrations' do - without_partial_double_verification do - expect(Setting).to receive(:registrations_mode).and_return('open') - end + allow(Setting).to receive(:[]).with('registrations_mode').and_return('open') expect(helper.open_registrations?).to be true + expect(Setting).to have_received(:[]).with('registrations_mode') end it 'returns false when closed for registrations' do - without_partial_double_verification do - expect(Setting).to receive(:registrations_mode).and_return('none') - end + allow(Setting).to receive(:[]).with('registrations_mode').and_return('none') expect(helper.open_registrations?).to be false + expect(Setting).to have_received(:[]).with('registrations_mode') end end - describe 'show_landing_strip?', without_verify_partial_doubles: true do + describe 'show_landing_strip?', :without_verify_partial_doubles do describe 'when signed in' do before do allow(helper).to receive(:user_signed_in?).and_return(true) @@ -127,9 +134,7 @@ describe ApplicationHelper do describe 'available_sign_up_path' do context 'when registrations are closed' do before do - without_partial_double_verification do - allow(Setting).to receive(:registrations_mode).and_return('none') - end + allow(Setting).to receive(:[]).with('registrations_mode').and_return 'none' end it 'redirects to joinmastodon site' do @@ -289,16 +294,51 @@ describe ApplicationHelper do end describe 'title' do - around do |example| - site_title = Setting.site_title - example.run - Setting.site_title = site_title - end - it 'returns site title on production environment' do Setting.site_title = 'site title' - expect(Rails.env).to receive(:production?).and_return(true) + allow(Rails.env).to receive(:production?).and_return(true) expect(helper.title).to eq 'site title' + expect(Rails.env).to have_received(:production?) + end + + it 'returns site title with note on non-production environment' do + Setting.site_title = 'site title' + allow(Rails.env).to receive(:production?).and_return(false) + expect(helper.title).to eq 'site title (Dev)' + expect(Rails.env).to have_received(:production?) + end + end + + describe 'html_title' do + before do + allow(Rails.env).to receive(:production?).and_return(true) + end + + context 'with a page_title content_for value' do + it 'uses the value in the html title' do + Setting.site_title = 'Site Title' + helper.content_for(:page_title, 'Test Value') + + expect(helper.html_title).to eq 'Test Value - Site Title' + expect(helper.html_title).to be_html_safe + end + + it 'removes extra new lines' do + Setting.site_title = 'Site Title' + helper.content_for(:page_title, "Test Value\n") + + expect(helper.html_title).to eq 'Test Value - Site Title' + expect(helper.html_title).to be_html_safe + end + end + + context 'without any page_title content_for value' do + it 'returns the site title' do + Setting.site_title = 'Site Title' + + expect(helper.html_title).to eq 'Site Title' + expect(helper.html_title).to be_html_safe + end end end end diff --git a/spec/helpers/flashes_helper_spec.rb b/spec/helpers/flashes_helper_spec.rb index 035e8a1de0..a6a3b062d7 100644 --- a/spec/helpers/flashes_helper_spec.rb +++ b/spec/helpers/flashes_helper_spec.rb @@ -4,16 +4,23 @@ require 'rails_helper' describe FlashesHelper do describe 'user_facing_flashes' do - it 'returns user facing flashes' do + before do + # rubocop:disable Rails/I18nLocaleTexts flash[:alert] = 'an alert' flash[:error] = 'an error' flash[:notice] = 'a notice' flash[:success] = 'a success' flash[:not_user_facing] = 'a not user facing flash' - expect(helper.user_facing_flashes).to eq 'alert' => 'an alert', - 'error' => 'an error', - 'notice' => 'a notice', - 'success' => 'a success' + # rubocop:enable Rails/I18nLocaleTexts + end + + it 'returns user facing flashes' do + expect(helper.user_facing_flashes).to eq( + 'alert' => 'an alert', + 'error' => 'an error', + 'notice' => 'a notice', + 'success' => 'a success' + ) end end end diff --git a/spec/helpers/home_helper_spec.rb b/spec/helpers/home_helper_spec.rb index 15067471ed..befc8a5c80 100644 --- a/spec/helpers/home_helper_spec.rb +++ b/spec/helpers/home_helper_spec.rb @@ -23,13 +23,19 @@ RSpec.describe HomeHelper do context 'with a valid account' do let(:account) { Fabricate(:account) } - it 'returns a link to the account' do - without_partial_double_verification do - allow(helper).to receive(:current_account).and_return(account) - allow(helper).to receive(:prefers_autoplay?).and_return(false) - result = helper.account_link_to(account) + before { helper.extend controller_helpers } - expect(result).to match "@#{account.acct}" + it 'returns a link to the account' do + result = helper.account_link_to(account) + + expect(result).to match "@#{account.acct}" + end + + private + + def controller_helpers + Module.new do + def current_account = Account.last end end end @@ -101,8 +107,7 @@ RSpec.describe HomeHelper do context 'with open registrations' do it 'returns correct sign up message' do - allow(helper).to receive(:closed_registrations?).and_return(false) - allow(helper).to receive(:open_registrations?).and_return(true) + allow(helper).to receive_messages(closed_registrations?: false, open_registrations?: true) result = helper.sign_up_message expect(result).to eq t('auth.register') @@ -111,9 +116,7 @@ RSpec.describe HomeHelper do context 'with approved registrations' do it 'returns correct sign up message' do - allow(helper).to receive(:closed_registrations?).and_return(false) - allow(helper).to receive(:open_registrations?).and_return(false) - allow(helper).to receive(:approved_registrations?).and_return(true) + allow(helper).to receive_messages(closed_registrations?: false, open_registrations?: false, approved_registrations?: true) result = helper.sign_up_message expect(result).to eq t('auth.apply_for_account') diff --git a/spec/helpers/instance_helper_spec.rb b/spec/helpers/instance_helper_spec.rb index c2e26dbed3..7e39b7cb3d 100644 --- a/spec/helpers/instance_helper_spec.rb +++ b/spec/helpers/instance_helper_spec.rb @@ -4,12 +4,6 @@ require 'rails_helper' describe InstanceHelper do describe 'site_title' do - around do |example| - site_title = Setting.site_title - example.run - Setting.site_title = site_title - end - it 'Uses the Setting.site_title value when it exists' do Setting.site_title = 'New site title' @@ -18,7 +12,7 @@ describe InstanceHelper do end describe 'site_hostname' do - around(:each) do |example| + around do |example| before = Rails.configuration.x.local_domain example.run Rails.configuration.x.local_domain = before diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/json_ld_helper_spec.rb similarity index 96% rename from spec/helpers/jsonld_helper_spec.rb rename to spec/helpers/json_ld_helper_spec.rb index 3575bba859..99857278a1 100644 --- a/spec/helpers/jsonld_helper_spec.rb +++ b/spec/helpers/json_ld_helper_spec.rb @@ -134,7 +134,7 @@ describe JsonLdHelper do expect(compact(json).dig('object', 'tag', 'href')).to eq 'foo' end - it 'compacts the activistreams Public collection' do + it 'compacts the activitystreams Public collection' do expect(compact(json)['to']).to eq 'as:Public' end @@ -158,14 +158,14 @@ describe JsonLdHelper do it 'deems a safe compacting as such' do json['object'].delete('convo') compacted = compact(json) - deemed_compatible = patch_for_forwarding!(json, compacted) + patch_for_forwarding!(json, compacted) expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public'] expect(safe_for_forwarding?(json, compacted)).to be true end it 'deems an unsafe compacting as such' do compacted = compact(json) - deemed_compatible = patch_for_forwarding!(json, compacted) + patch_for_forwarding!(json, compacted) expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public'] expect(safe_for_forwarding?(json, compacted)).to be false end diff --git a/spec/helpers/languages_helper_spec.rb b/spec/helpers/languages_helper_spec.rb index 98c8064a33..99461b293b 100644 --- a/spec/helpers/languages_helper_spec.rb +++ b/spec/helpers/languages_helper_spec.rb @@ -60,4 +60,30 @@ describe LanguagesHelper do end end end + + describe 'sorted_locales' do + context 'when sorting with native name' do + it 'returns Suomi after Gàidhlig' do + expect(described_class.sorted_locale_keys(%w(fi gd))).to eq(%w(gd fi)) + end + end + + context 'when sorting with diacritics' do + it 'returns Íslensk before Suomi' do + expect(described_class.sorted_locale_keys(%w(fi is))).to eq(%w(is fi)) + end + end + + context 'when sorting with non-Latin' do + it 'returns Suomi before Amharic' do + expect(described_class.sorted_locale_keys(%w(am fi))).to eq(%w(fi am)) + end + end + + context 'when sorting with local variants' do + it 'returns variant in-line' do + expect(described_class.sorted_locale_keys(%w(en eo en-GB))).to eq(%w(en en-GB eo)) + end + end + end end diff --git a/spec/helpers/media_component_helper_spec.rb b/spec/helpers/media_component_helper_spec.rb index 71a9af6f3b..af5d92769c 100644 --- a/spec/helpers/media_component_helper_spec.rb +++ b/spec/helpers/media_component_helper_spec.rb @@ -3,16 +3,12 @@ require 'rails_helper' describe MediaComponentHelper do + before { helper.extend controller_helpers } + describe 'render_video_component' do let(:media) { Fabricate(:media_attachment, type: :video, status: Fabricate(:status)) } let(:result) { helper.render_video_component(media.status) } - before do - without_partial_double_verification do - allow(helper).to receive(:current_account).and_return(media.account) - end - end - it 'renders a react component for the video' do expect(parsed_html.div['data-component']).to eq('Video') end @@ -22,12 +18,6 @@ describe MediaComponentHelper do let(:media) { Fabricate(:media_attachment, type: :audio, status: Fabricate(:status)) } let(:result) { helper.render_audio_component(media.status) } - before do - without_partial_double_verification do - allow(helper).to receive(:current_account).and_return(media.account) - end - end - it 'renders a react component for the audio' do expect(parsed_html.div['data-component']).to eq('Audio') end @@ -37,25 +27,17 @@ describe MediaComponentHelper do let(:media) { Fabricate(:media_attachment, type: :audio, status: Fabricate(:status)) } let(:result) { helper.render_media_gallery_component(media.status) } - before do - without_partial_double_verification do - allow(helper).to receive(:current_account).and_return(media.account) - end - end - it 'renders a react component for the media gallery' do expect(parsed_html.div['data-component']).to eq('MediaGallery') end end describe 'render_card_component' do - let(:status) { Fabricate(:status, preview_cards: [Fabricate(:preview_card)]) } + let(:status) { Fabricate(:status) } let(:result) { helper.render_card_component(status) } before do - without_partial_double_verification do - allow(helper).to receive(:current_account).and_return(status.account) - end + PreviewCardsStatus.create(status: status, preview_card: Fabricate(:preview_card)) end it 'returns the correct react component markup' do @@ -67,12 +49,6 @@ describe MediaComponentHelper do let(:status) { Fabricate(:status, poll: Fabricate(:poll)) } let(:result) { helper.render_poll_component(status) } - before do - without_partial_double_verification do - allow(helper).to receive(:current_account).and_return(status.account) - end - end - it 'returns the correct react component markup' do expect(parsed_html.div['data-component']).to eq('Poll') end @@ -83,4 +59,10 @@ describe MediaComponentHelper do def parsed_html Nokogiri::Slop(result) end + + def controller_helpers + Module.new do + def current_account = Account.last + end + end end diff --git a/spec/helpers/statuses_helper_spec.rb b/spec/helpers/statuses_helper_spec.rb index b7824ca604..c67e1f3f2b 100644 --- a/spec/helpers/statuses_helper_spec.rb +++ b/spec/helpers/statuses_helper_spec.rb @@ -114,129 +114,4 @@ describe StatusesHelper do params[:controller] = StatusesHelper::EMBEDDED_CONTROLLER params[:action] = StatusesHelper::EMBEDDED_ACTION end - - describe '#style_classes' do - it do - status = instance_double(Status, reblog?: false) - classes = helper.style_classes(status, false, false, false) - - expect(classes).to eq 'entry' - end - - it do - status = instance_double(Status, reblog?: true) - classes = helper.style_classes(status, false, false, false) - - expect(classes).to eq 'entry entry-reblog' - end - - it do - status = instance_double(Status, reblog?: false) - classes = helper.style_classes(status, true, false, false) - - expect(classes).to eq 'entry entry-predecessor' - end - - it do - status = instance_double(Status, reblog?: false) - classes = helper.style_classes(status, false, true, false) - - expect(classes).to eq 'entry entry-successor' - end - - it do - status = instance_double(Status, reblog?: false) - classes = helper.style_classes(status, false, false, true) - - expect(classes).to eq 'entry entry-center' - end - - it do - status = instance_double(Status, reblog?: true) - classes = helper.style_classes(status, true, true, true) - - expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center' - end - end - - describe '#microformats_classes' do - it do - status = instance_double(Status, reblog?: false) - classes = helper.microformats_classes(status, false, false) - - expect(classes).to eq '' - end - - it do - status = instance_double(Status, reblog?: false) - classes = helper.microformats_classes(status, true, false) - - expect(classes).to eq 'p-in-reply-to' - end - - it do - status = instance_double(Status, reblog?: false) - classes = helper.microformats_classes(status, false, true) - - expect(classes).to eq 'p-comment' - end - - it do - status = instance_double(Status, reblog?: true) - classes = helper.microformats_classes(status, true, false) - - expect(classes).to eq 'p-in-reply-to p-repost-of' - end - - it do - status = instance_double(Status, reblog?: true) - classes = helper.microformats_classes(status, true, true) - - expect(classes).to eq 'p-in-reply-to p-repost-of p-comment' - end - end - - describe '#microformats_h_class' do - it do - status = instance_double(Status, reblog?: false) - css_class = helper.microformats_h_class(status, false, false, false) - - expect(css_class).to eq 'h-entry' - end - - it do - status = instance_double(Status, reblog?: true) - css_class = helper.microformats_h_class(status, false, false, false) - - expect(css_class).to eq 'h-cite' - end - - it do - status = instance_double(Status, reblog?: false) - css_class = helper.microformats_h_class(status, true, false, false) - - expect(css_class).to eq 'h-cite' - end - - it do - status = instance_double(Status, reblog?: false) - css_class = helper.microformats_h_class(status, false, true, false) - - expect(css_class).to eq 'h-cite' - end - - it do - status = instance_double(Status, reblog?: false) - css_class = helper.microformats_h_class(status, false, false, true) - - expect(css_class).to eq '' - end - - it do - status = instance_double(Status, reblog?: true) - css_class = helper.microformats_h_class(status, true, true, true) - - expect(css_class).to eq 'h-cite' - end - end end diff --git a/spec/lib/account_reach_finder_spec.rb b/spec/lib/account_reach_finder_spec.rb index 7c6c20968f..e5d85656a2 100644 --- a/spec/lib/account_reach_finder_spec.rb +++ b/spec/lib/account_reach_finder_spec.rb @@ -5,15 +5,15 @@ require 'rails_helper' RSpec.describe AccountReachFinder do let(:account) { Fabricate(:account) } - let(:ap_follower_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-1') } - let(:ap_follower_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-2') } - let(:ap_follower_with_shared) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/users/a/inbox', shared_inbox_url: 'https://foo.bar/inbox') } + let(:ap_follower_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-1', domain: 'example.com') } + let(:ap_follower_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-2', domain: 'example.org') } + let(:ap_follower_with_shared) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/users/a/inbox', domain: 'foo.bar', shared_inbox_url: 'https://foo.bar/inbox') } - let(:ap_mentioned_with_shared) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/users/b/inbox', shared_inbox_url: 'https://foo.bar/inbox') } - let(:ap_mentioned_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-3') } - let(:ap_mentioned_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-4') } + let(:ap_mentioned_with_shared) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/users/b/inbox', domain: 'foo.bar', shared_inbox_url: 'https://foo.bar/inbox') } + let(:ap_mentioned_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-3', domain: 'example.com') } + let(:ap_mentioned_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-4', domain: 'example.org') } - let(:unrelated_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/unrelated-inbox') } + let(:unrelated_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/unrelated-inbox', domain: 'example.com') } before do ap_follower_example_com.follow!(account) diff --git a/spec/models/account_statuses_filter_spec.rb b/spec/lib/account_statuses_filter_spec.rb similarity index 59% rename from spec/models/account_statuses_filter_spec.rb rename to spec/lib/account_statuses_filter_spec.rb index fa7664d921..7774585124 100644 --- a/spec/models/account_statuses_filter_spec.rb +++ b/spec/lib/account_statuses_filter_spec.rb @@ -3,8 +3,6 @@ require 'rails_helper' RSpec.describe AccountStatusesFilter do - subject { described_class.new(account, current_account, params) } - let(:account) { Fabricate(:account) } let(:current_account) { nil } let(:params) { {} } @@ -38,6 +36,8 @@ RSpec.describe AccountStatusesFilter do end describe '#results' do + subject { described_class.new(account, current_account, params).results } + let(:tag) { Fabricate(:tag) } before do @@ -56,7 +56,7 @@ RSpec.describe AccountStatusesFilter do let(:params) { { only_media: true } } it 'returns only statuses with media' do - expect(subject.results.all?(&:with_media?)).to be true + expect(subject.all?(&:with_media?)).to be true end end @@ -64,7 +64,7 @@ RSpec.describe AccountStatusesFilter do let(:params) { { tagged: tag.name } } it 'returns only statuses with tag' do - expect(subject.results.all? { |s| s.tags.include?(tag) }).to be true + expect(subject.all? { |s| s.tags.include?(tag) }).to be true end end @@ -72,7 +72,7 @@ RSpec.describe AccountStatusesFilter do let(:params) { { exclude_replies: true } } it 'returns only statuses that are not replies' do - expect(subject.results.none?(&:reply?)).to be true + expect(subject.none?(&:reply?)).to be true end end @@ -80,7 +80,7 @@ RSpec.describe AccountStatusesFilter do let(:params) { { exclude_reblogs: true } } it 'returns only statuses that are not reblogs' do - expect(subject.results.none?(&:reblog?)).to be true + expect(subject.none?(&:reblog?)).to be true end end end @@ -89,16 +89,12 @@ RSpec.describe AccountStatusesFilter do let(:current_account) { nil } let(:direct_status) { nil } - it 'returns only public statuses' do - expect(subject.results.pluck(:visibility).uniq).to match_array %w(unlisted public) - end + it 'returns only public statuses, public replies, and public reblogs' do + expect(results_unique_visibilities).to match_array %w(unlisted public) - it 'returns public replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end + expect(results_in_reply_to_ids).to_not be_empty - it 'returns public reblogs' do - expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + expect(results_reblog_of_ids).to_not be_empty end it_behaves_like 'filter params' @@ -112,23 +108,19 @@ RSpec.describe AccountStatusesFilter do end it 'returns nothing' do - expect(subject.results.to_a).to be_empty + expect(subject.to_a).to be_empty end end context 'when accessed by self' do let(:current_account) { account } - it 'returns everything' do - expect(subject.results.pluck(:visibility).uniq).to match_array %w(direct private unlisted public) - end + it 'returns all statuses, replies, and reblogs' do + expect(results_unique_visibilities).to match_array %w(direct private unlisted public) - it 'returns replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end + expect(results_in_reply_to_ids).to_not be_empty - it 'returns reblogs' do - expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + expect(results_reblog_of_ids).to_not be_empty end it_behaves_like 'filter params' @@ -141,23 +133,19 @@ RSpec.describe AccountStatusesFilter do current_account.follow!(account) end - it 'returns private statuses' do - expect(subject.results.pluck(:visibility).uniq).to match_array %w(private unlisted public) - end + it 'returns private statuses, replies, and reblogs' do + expect(results_unique_visibilities).to match_array %w(private unlisted public) - it 'returns replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end + expect(results_in_reply_to_ids).to_not be_empty - it 'returns reblogs' do - expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + expect(results_reblog_of_ids).to_not be_empty end context 'when there is a direct status mentioning the non-follower' do let!(:direct_status) { status_with_mention!(:direct, current_account) } it 'returns the direct status' do - expect(subject.results.pluck(:id)).to include(direct_status.id) + expect(results_ids).to include(direct_status.id) end end @@ -167,23 +155,19 @@ RSpec.describe AccountStatusesFilter do context 'when accessed by a non-follower' do let(:current_account) { Fabricate(:account) } - it 'returns only public statuses' do - expect(subject.results.pluck(:visibility).uniq).to match_array %w(unlisted public) - end + it 'returns only public statuses, replies, and reblogs' do + expect(results_unique_visibilities).to match_array %w(unlisted public) - it 'returns public replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end + expect(results_in_reply_to_ids).to_not be_empty - it 'returns public reblogs' do - expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + expect(results_reblog_of_ids).to_not be_empty end context 'when there is a private status mentioning the non-follower' do let!(:private_status) { status_with_mention!(:private, current_account) } it 'returns the private status' do - expect(subject.results.pluck(:id)).to include(private_status.id) + expect(results_ids).to include(private_status.id) end end @@ -195,7 +179,35 @@ RSpec.describe AccountStatusesFilter do end it 'does not return reblog of blocked account' do - expect(subject.results.pluck(:id)).to_not include(reblog.id) + expect(results_ids).to_not include(reblog.id) + end + end + + context 'when blocking a reblogged domain' do + let(:other_account) { Fabricate(:account, domain: 'example.com') } + let(:reblogging_status) { Fabricate(:status, account: other_account) } + let!(:reblog) { Fabricate(:status, account: account, visibility: 'public', reblog: reblogging_status) } + + before do + current_account.block_domain!(other_account.domain) + end + + it 'does not return reblog of blocked domain' do + expect(results_ids).to_not include(reblog.id) + end + end + + context 'when blocking an unrelated domain' do + let(:other_account) { Fabricate(:account, domain: nil) } + let(:reblogging_status) { Fabricate(:status, account: other_account, visibility: 'public') } + let!(:reblog) { Fabricate(:status, account: account, visibility: 'public', reblog: reblogging_status) } + + before do + current_account.block_domain!('example.com') + end + + it 'returns the reblog from the non-blocked domain' do + expect(results_ids).to include(reblog.id) end end @@ -207,7 +219,7 @@ RSpec.describe AccountStatusesFilter do end it 'does not return reblog of muted account' do - expect(subject.results.pluck(:id)).to_not include(reblog.id) + expect(results_ids).to_not include(reblog.id) end end @@ -219,11 +231,29 @@ RSpec.describe AccountStatusesFilter do end it 'does not return reblog of blocked-by account' do - expect(subject.results.pluck(:id)).to_not include(reblog.id) + expect(results_ids).to_not include(reblog.id) end end it_behaves_like 'filter params' end + + private + + def results_unique_visibilities + subject.pluck(:visibility).uniq + end + + def results_in_reply_to_ids + subject.pluck(:in_reply_to_id) + end + + def results_reblog_of_ids + subject.pluck(:reblog_of_id) + end + + def results_ids + subject.pluck(:id) + end end end diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb index 365861bcd8..8ad892975d 100644 --- a/spec/lib/activitypub/activity/announce_spec.rb +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Announce do subject { described_class.new(json, sender) } - let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', uri: 'https://example.com/actor') } + let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', uri: 'https://example.com/actor', domain: 'example.com') } let(:recipient) { Fabricate(:account) } let(:status) { Fabricate(:status, account: recipient) } @@ -114,7 +114,7 @@ RSpec.describe ActivityPub::Activity::Announce do context 'when the sender is relayed' do subject { described_class.new(json, sender, relayed_through_actor: relay_account) } - let!(:relay_account) { Fabricate(:account, inbox_url: 'https://relay.example.com/inbox') } + let!(:relay_account) { Fabricate(:account, inbox_url: 'https://relay.example.com/inbox', domain: 'relay.example.com') } let!(:relay) { Fabricate(:relay, inbox_url: 'https://relay.example.com/inbox') } let(:object_json) { 'https://example.com/actor/hello-world' } diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index d7c4c131a2..a0340216ad 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -23,6 +23,102 @@ RSpec.describe ActivityPub::Activity::Create do stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' }) end + describe 'processing posts received out of order', :sidekiq_fake do + let(:follower) { Fabricate(:account, username: 'bob') } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'), + type: 'Note', + to: [ + 'https://www.w3.org/ns/activitystreams#Public', + ActivityPub::TagManager.instance.uri_for(follower), + ], + content: '@bob lorem ipsum', + published: 1.hour.ago.utc.iso8601, + updated: 1.hour.ago.utc.iso8601, + tag: { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(follower), + }, + } + end + + let(:reply_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), 'reply'].join('/'), + type: 'Note', + inReplyTo: object_json[:id], + to: [ + 'https://www.w3.org/ns/activitystreams#Public', + ActivityPub::TagManager.instance.uri_for(follower), + ], + content: '@bob lorem ipsum', + published: Time.now.utc.iso8601, + updated: Time.now.utc.iso8601, + tag: { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(follower), + }, + } + end + + def activity_for_object(json) + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: [json[:id], 'activity'].join('/'), + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: json, + }.with_indifferent_access + end + + before do + follower.follow!(sender) + end + + it 'correctly processes posts and inserts them in timelines', :aggregate_failures do + # Simulate a temporary failure preventing from fetching the parent post + stub_request(:get, object_json[:id]).to_return(status: 500) + + # When receiving the reply… + described_class.new(activity_for_object(reply_json), sender, delivery: true).perform + + # NOTE: Refering explicitly to the workers is a bit awkward + DistributionWorker.drain + FeedInsertWorker.drain + + # …it creates a status with an unknown parent + reply = Status.find_by(uri: reply_json[:id]) + expect(reply.reply?).to be true + expect(reply.in_reply_to_id).to be_nil + + # …and creates a notification + expect(LocalNotificationWorker.jobs.size).to eq 1 + + # …but does not insert it into timelines + expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_nil + + # When receiving the parent… + described_class.new(activity_for_object(object_json), sender, delivery: true).perform + + Sidekiq::Worker.drain_all + + # …it creates a status and insert it into timelines + parent = Status.find_by(uri: object_json[:id]) + expect(parent.reply?).to be false + expect(parent.in_reply_to_id).to be_nil + expect(reply.reload.in_reply_to_id).to eq parent.id + + # Check that the both statuses have been inserted into the home feed + expect(redis.zscore(FeedManager.instance.key(:home, follower.id), parent.id)).to be_within(0.1).of(parent.id.to_f) + expect(redis.zscore(FeedManager.instance.key(:home, follower.id), reply.id)).to be_within(0.1).of(reply.id.to_f) + + # Creates two notifications + expect(Notification.count).to eq 2 + end + end + describe '#perform' do context 'when fetching' do subject { described_class.new(json, sender) } @@ -31,6 +127,46 @@ RSpec.describe ActivityPub::Activity::Create do subject.perform end + context 'when object publication date is below ISO8601 range' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + published: '-0977-11-03T08:31:22Z', + } + end + + it 'creates status with a valid creation date', :aggregate_failures do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + + expect(status.created_at).to be_within(30).of(Time.now.utc) + end + end + + context 'when object publication date is above ISO8601 range' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + published: '10000-11-03T08:31:22Z', + } + end + + it 'creates status with a valid creation date', :aggregate_failures do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + + expect(status.created_at).to be_within(30).of(Time.now.utc) + end + end + context 'when object has been edited' do let(:object_json) do { @@ -42,18 +178,16 @@ RSpec.describe ActivityPub::Activity::Create do } end - it 'creates status' do + it 'creates status with appropriate creation and edition dates', :aggregate_failures do status = sender.statuses.first expect(status).to_not be_nil expect(status.text).to eq 'Lorem ipsum' - end - it 'marks status as edited' do - status = sender.statuses.first + expect(status.created_at).to eq '2022-01-22T15:00:00Z'.to_datetime - expect(status).to_not be_nil expect(status.edited?).to be true + expect(status.edited_at).to eq '2022-01-22T16:00:00Z'.to_datetime end end @@ -444,15 +578,21 @@ RSpec.describe ActivityPub::Activity::Create do mediaType: 'image/png', url: 'http://example.com/attachment.png', }, + { + type: 'Document', + mediaType: 'image/png', + url: 'http://example.com/emoji.png', + }, ], } end - it 'creates status' do + it 'creates status with correctly-ordered media attachments' do status = sender.statuses.first expect(status).to_not be_nil - expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png') + expect(status.ordered_media_attachments.map(&:remote_url)).to eq ['http://example.com/attachment.png', 'http://example.com/emoji.png'] + expect(status.ordered_media_attachment_ids).to be_present end end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 3a73b3726c..aec2c71e58 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -50,6 +50,10 @@ RSpec.describe ActivityPub::Activity::Delete do it 'sends delete activity to followers of rebloggers' do expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once end + + it 'deletes the reblog' do + expect { reblog.reload }.to raise_error(ActiveRecord::RecordNotFound) + end end end diff --git a/spec/lib/activitypub/activity/flag_spec.rb b/spec/lib/activitypub/activity/flag_spec.rb index 601473069b..8593d567f4 100644 --- a/spec/lib/activitypub/activity/flag_spec.rb +++ b/spec/lib/activitypub/activity/flag_spec.rb @@ -139,6 +139,35 @@ RSpec.describe ActivityPub::Activity::Flag do expect(report.status_ids).to eq [] end end + + context 'when an account is passed but no status' do + let(:mentioned) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: flag_id, + type: 'Flag', + content: 'Boo!!', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: [ + ActivityPub::TagManager.instance.uri_for(flagged), + ], + }.with_indifferent_access + end + + before do + subject.perform + end + + it 'creates a report with no attached status' do + report = Report.find_by(account: sender, target_account: flagged) + + expect(report).to_not be_nil + expect(report.comment).to eq 'Boo!!' + expect(report.status_ids).to eq [] + end + end end describe '#perform with a defined uri' do diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb index f77279c028..87e96d2d1b 100644 --- a/spec/lib/activitypub/activity/update_spec.rb +++ b/spec/lib/activitypub/activity/update_spec.rb @@ -5,22 +5,38 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Update do subject { described_class.new(json, sender) } - let!(:sender) { Fabricate(:account) } - - before do - sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender)) - end + let!(:sender) { Fabricate(:account, domain: 'example.com', inbox_url: 'https://example.com/foo/inbox', outbox_url: 'https://example.com/foo/outbox') } describe '#perform' do context 'with an Actor object' do - let(:modified_sender) do - sender.tap do |modified_sender| - modified_sender.display_name = 'Totally modified now' - end - end - let(:actor_json) do - ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter).as_json + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + toot: 'http://joinmastodon.org/ns#', + featured: { '@id': 'toot:featured', '@type': '@id' }, + featuredTags: { '@id': 'toot:featuredTags', '@type': '@id' }, + }, + ], + id: sender.uri, + type: 'Person', + following: 'https://example.com/users/dfsdf/following', + followers: 'https://example.com/users/dfsdf/followers', + inbox: sender.inbox_url, + outbox: sender.outbox_url, + featured: 'https://example.com/users/dfsdf/featured', + featuredTags: 'https://example.com/users/dfsdf/tags', + preferredUsername: sender.username, + name: 'Totally modified now', + publicKey: { + id: "#{sender.uri}#main-key", + owner: sender.uri, + publicKeyPem: sender.public_key, + }, + } end let(:json) do @@ -28,7 +44,7 @@ RSpec.describe ActivityPub::Activity::Update do '@context': 'https://www.w3.org/ns/activitystreams', id: 'foo', type: 'Update', - actor: ActivityPub::TagManager.instance.uri_for(sender), + actor: sender.uri, object: actor_json, }.with_indifferent_access end @@ -38,6 +54,7 @@ RSpec.describe ActivityPub::Activity::Update do stub_request(:get, actor_json[:followers]).to_return(status: 404) stub_request(:get, actor_json[:following]).to_return(status: 404) stub_request(:get, actor_json[:featured]).to_return(status: 404) + stub_request(:get, actor_json[:featuredTags]).to_return(status: 404) subject.perform end @@ -49,17 +66,17 @@ RSpec.describe ActivityPub::Activity::Update do context 'with a Question object' do let!(:at_time) { Time.now.utc } - let!(:status) { Fabricate(:status, account: sender, poll: Poll.new(account: sender, options: %w(Bar Baz), cached_tallies: [0, 0], expires_at: at_time + 5.days)) } + let!(:status) { Fabricate(:status, uri: 'https://example.com/statuses/poll', account: sender, poll: Poll.new(account: sender, options: %w(Bar Baz), cached_tallies: [0, 0], expires_at: at_time + 5.days)) } let(:json) do { '@context': 'https://www.w3.org/ns/activitystreams', id: 'foo', type: 'Update', - actor: ActivityPub::TagManager.instance.uri_for(sender), + actor: sender.uri, object: { type: 'Question', - id: ActivityPub::TagManager.instance.uri_for(status), + id: status.uri, content: 'Foo', endTime: (at_time + 5.days).iso8601, oneOf: [ diff --git a/spec/lib/activitypub/adapter_spec.rb b/spec/lib/activitypub/adapter_spec.rb index f9f8b8dce0..7d8d703ec2 100644 --- a/spec/lib/activitypub/adapter_spec.rb +++ b/spec/lib/activitypub/adapter_spec.rb @@ -53,7 +53,7 @@ RSpec.describe ActivityPub::Adapter do describe '#serializable_hash' do subject { ActiveModelSerializers::SerializableResource.new(TestObject.new(foo: 'bar'), serializer: serializer_class, adapter: described_class).as_json } - let(:serializer_class) {} + let(:serializer_class) { nil } context 'when serializer defines no context' do let(:serializer_class) { TestWithBasicContextSerializer } diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index 619d6df124..97268eea6d 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -7,7 +7,7 @@ RSpec.describe ActivityPub::LinkedDataSignature do subject { described_class.new(json) } - let!(:sender) { Fabricate(:account, uri: 'http://example.com/alice') } + let!(:sender) { Fabricate(:account, uri: 'http://example.com/alice', domain: 'example.com') } let(:raw_json) do { @@ -18,10 +18,6 @@ RSpec.describe ActivityPub::LinkedDataSignature do let(:json) { raw_json.merge('signature' => signature) } - before do - stub_jsonld_contexts! - end - describe '#verify_actor!' do context 'when signature matches' do let(:raw_signature) do @@ -38,6 +34,40 @@ RSpec.describe ActivityPub::LinkedDataSignature do end end + context 'when local account record is missing a public key' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + let(:service_stub) { instance_double(ActivityPub::FetchRemoteKeyService) } + + before do + # Ensure signature is computed with the old key + signature + + # Unset key + old_key = sender.public_key + sender.update!(private_key: '', public_key: '') + + allow(ActivityPub::FetchRemoteKeyService).to receive(:new).and_return(service_stub) + + allow(service_stub).to receive(:call).with('http://example.com/alice', id: false) do + sender.update!(public_key: old_key) + sender + end + end + + it 'fetches key and returns creator' do + expect(subject.verify_actor!).to eq sender + expect(service_stub).to have_received(:call).with('http://example.com/alice', id: false).once + end + end + context 'when signature is missing' do let(:signature) { nil } diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index 596e91e954..2bff125a6a 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -139,7 +139,7 @@ RSpec.describe ActivityPub::TagManager do end it 'returns the remote account by matching URI without fragment part' do - account = Fabricate(:account, uri: 'https://example.com/123') + account = Fabricate(:account, uri: 'https://example.com/123', domain: 'example.com') expect(subject.uri_to_resource('https://example.com/123#456', Account)).to eq account end diff --git a/spec/lib/admin/system_check/elasticsearch_check_spec.rb b/spec/lib/admin/system_check/elasticsearch_check_spec.rb index 1ffac89ee4..a885640ce0 100644 --- a/spec/lib/admin/system_check/elasticsearch_check_spec.rb +++ b/spec/lib/admin/system_check/elasticsearch_check_spec.rb @@ -11,7 +11,25 @@ describe Admin::SystemCheck::ElasticsearchCheck do describe 'pass?' do context 'when chewy is enabled' do - before { allow(Chewy).to receive(:enabled?).and_return(true) } + before do + allow(Chewy).to receive(:enabled?).and_return(true) + allow(Chewy.client.cluster).to receive(:health).and_return({ 'status' => 'green', 'number_of_nodes' => 1 }) + allow(Chewy.client.indices).to receive_messages(get_mapping: { + AccountsIndex.index_name => AccountsIndex.mappings_hash.deep_stringify_keys, + StatusesIndex.index_name => StatusesIndex.mappings_hash.deep_stringify_keys, + PublicStatusesIndex.index_name => PublicStatusesIndex.mappings_hash.deep_stringify_keys, + InstancesIndex.index_name => InstancesIndex.mappings_hash.deep_stringify_keys, + TagsIndex.index_name => TagsIndex.mappings_hash.deep_stringify_keys, + }, get_settings: { + 'chewy_specifications' => { + 'settings' => { + 'index' => { + 'number_of_replicas' => 0, + }, + }, + }, + }) + end context 'when running version is present and high enough' do before do @@ -49,11 +67,7 @@ describe Admin::SystemCheck::ElasticsearchCheck do end context 'when running version is missing' do - before do - client = instance_double(Elasticsearch::Transport::Client) - allow(client).to receive(:info).and_raise(Elasticsearch::Transport::Transport::Error) - allow(Chewy).to receive(:client).and_return(client) - end + before { stub_elasticsearch_error } it 'returns false' do expect(check.pass?).to be false @@ -71,8 +85,20 @@ describe Admin::SystemCheck::ElasticsearchCheck do end describe 'message' do + before do + allow(Chewy).to receive(:enabled?).and_return(true) + allow(Chewy.client.cluster).to receive(:health).and_return({ 'status' => 'green', 'number_of_nodes' => 1 }) + allow(Chewy.client.indices).to receive(:get_mapping).and_return({ + AccountsIndex.index_name => AccountsIndex.mappings_hash.deep_stringify_keys, + StatusesIndex.index_name => StatusesIndex.mappings_hash.deep_stringify_keys, + PublicStatusesIndex.index_name => PublicStatusesIndex.mappings_hash.deep_stringify_keys, + InstancesIndex.index_name => InstancesIndex.mappings_hash.deep_stringify_keys, + TagsIndex.index_name => TagsIndex.mappings_hash.deep_stringify_keys, + }) + end + context 'when running version is present' do - before { allow(Chewy.client).to receive(:info).and_return({ 'version' => { 'number' => '999.99.9' } }) } + before { allow(Chewy.client).to receive(:info).and_return({ 'version' => { 'number' => '1.2.3' } }) } it 'sends class name symbol to message instance' do allow(Admin::SystemCheck::Message).to receive(:new) @@ -81,11 +107,13 @@ describe Admin::SystemCheck::ElasticsearchCheck do check.message expect(Admin::SystemCheck::Message).to have_received(:new) - .with(:elasticsearch_version_check, 'Elasticsearch 999.99.9 is running while 7.x is required') + .with(:elasticsearch_version_check, 'Elasticsearch 1.2.3 is running while 7.x is required') end end context 'when running version is missing' do + before { stub_elasticsearch_error } + it 'sends class name symbol to message instance' do allow(Admin::SystemCheck::Message).to receive(:new) .with(:elasticsearch_running_check) @@ -97,4 +125,10 @@ describe Admin::SystemCheck::ElasticsearchCheck do end end end + + def stub_elasticsearch_error + client = instance_double(Elasticsearch::Transport::Client) + allow(client).to receive(:info).and_raise(Elasticsearch::Transport::Transport::Error) + allow(Chewy).to receive(:client).and_return(client) + end end diff --git a/spec/lib/admin/system_check/software_version_check_spec.rb b/spec/lib/admin/system_check/software_version_check_spec.rb new file mode 100644 index 0000000000..de4335fc51 --- /dev/null +++ b/spec/lib/admin/system_check/software_version_check_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::SoftwareVersionCheck do + include RoutingHelper + + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + describe 'skip?' do + context 'when user cannot view devops' do + before { allow(user).to receive(:can?).with(:view_devops).and_return(false) } + + it 'returns true' do + expect(check.skip?).to be true + end + end + + context 'when user can view devops' do + before { allow(user).to receive(:can?).with(:view_devops).and_return(true) } + + it 'returns false' do + expect(check.skip?).to be false + end + + context 'when checks are disabled' do + around do |example| + ClimateControl.modify UPDATE_CHECK_URL: '' do + example.run + end + end + + it 'returns true' do + expect(check.skip?).to be true + end + end + end + end + + describe 'pass?' do + context 'when there is no known update' do + it 'returns true' do + expect(check.pass?).to be true + end + end + + context 'when there is a non-urgent major release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: false) + end + + it 'returns true' do + expect(check.pass?).to be true + end + end + + context 'when there is an urgent major release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: true) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when there is an urgent minor release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'minor', urgent: true) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when there is an urgent patch release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when there is a non-urgent patch release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + end + + describe 'message' do + context 'when there is a non-urgent patch release pending' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false) + end + + it 'sends class name symbol to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new) + .with(:software_version_patch_check, anything, anything) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new) + .with(:software_version_patch_check, nil, admin_software_updates_path) + end + end + + context 'when there is an urgent patch release pending' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true) + end + + it 'sends class name symbol to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new) + .with(:software_version_critical_check, anything, anything, anything) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new) + .with(:software_version_critical_check, nil, admin_software_updates_path, true) + end + end + end +end diff --git a/spec/lib/cache_buster_spec.rb b/spec/lib/cache_buster_spec.rb new file mode 100644 index 0000000000..78ca183490 --- /dev/null +++ b/spec/lib/cache_buster_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe CacheBuster do + subject { described_class.new(secret_header: secret_header, secret: secret, http_method: http_method) } + + let(:secret_header) { nil } + let(:secret) { nil } + let(:http_method) { nil } + + let(:purge_url) { 'https://example.com/test_purge' } + + describe '#bust' do + shared_examples 'makes_request' do + it 'makes an HTTP purging request' do + method = http_method&.to_sym || :get + stub_request(method, purge_url).to_return(status: 200) + + subject.bust(purge_url) + + test_request = a_request(method, purge_url) + + test_request = test_request.with(headers: { secret_header => secret }) if secret && secret_header + + expect(test_request).to have_been_made.once + end + end + + context 'when using default options' do + around do |example| + # Disables the CacheBuster.new deprecation warning about default arguments. + # Remove this `silence` block when default arg support is removed from CacheBuster + Rails.application.deprecators[:mastodon].silence do + example.run + end + end + + include_examples 'makes_request' + end + + context 'when specifying a secret header' do + let(:secret_header) { 'X-Purge-Secret' } + let(:secret) { SecureRandom.hex(20) } + + include_examples 'makes_request' + end + + context 'when specifying a PURGE method' do + let(:http_method) { 'purge' } + + context 'when not using headers' do + include_examples 'makes_request' + end + + context 'when specifying a secret header' do + let(:secret_header) { 'X-Purge-Secret' } + let(:secret) { SecureRandom.hex(20) } + + include_examples 'makes_request' + end + end + end +end diff --git a/spec/lib/content_security_policy_spec.rb b/spec/lib/content_security_policy_spec.rb new file mode 100644 index 0000000000..4286f14980 --- /dev/null +++ b/spec/lib/content_security_policy_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ContentSecurityPolicy do + subject { described_class.new } + + around do |example| + original_asset_host = Rails.configuration.action_controller.asset_host + original_web_domain = Rails.configuration.x.web_domain + original_use_https = Rails.configuration.x.use_https + example.run + Rails.configuration.action_controller.asset_host = original_asset_host + Rails.configuration.x.web_domain = original_web_domain + Rails.configuration.x.use_https = original_use_https + end + + describe '#base_host' do + before { Rails.configuration.x.web_domain = 'host.example' } + + it 'returns the configured value for the web domain' do + expect(subject.base_host).to eq 'host.example' + end + end + + describe '#assets_host' do + context 'when asset_host is not configured' do + before { Rails.configuration.action_controller.asset_host = nil } + + context 'with a configured web domain' do + before { Rails.configuration.x.web_domain = 'host.example' } + + context 'when use_https is enabled' do + before { Rails.configuration.x.use_https = true } + + it 'returns value from base host with https protocol' do + expect(subject.assets_host).to eq 'https://host.example' + end + end + + context 'when use_https is disabled' do + before { Rails.configuration.x.use_https = false } + + it 'returns value from base host with http protocol' do + expect(subject.assets_host).to eq 'http://host.example' + end + end + end + end + + context 'when asset_host is configured' do + before do + Rails.configuration.action_controller.asset_host = 'https://assets.host.example' + end + + it 'returns full value from configured host' do + expect(subject.assets_host).to eq 'https://assets.host.example' + end + end + end + + describe '#media_hosts' do + context 'when there is no configured CDN' do + it 'defaults to using the assets_host value' do + expect(subject.media_hosts).to contain_exactly(subject.assets_host) + end + end + + context 'when an S3 alias host is configured' do + around do |example| + ClimateControl.modify S3_ALIAS_HOST: 'asset-host.s3-alias.example' do + example.run + end + end + + it 'uses the s3 alias host value' do + expect(subject.media_hosts).to contain_exactly(subject.assets_host, 'https://asset-host.s3-alias.example') + end + end + + context 'when an S3 alias host with a trailing path is configured' do + around do |example| + ClimateControl.modify S3_ALIAS_HOST: 'asset-host.s3-alias.example/pathname' do + example.run + end + end + + it 'uses the s3 alias host value and preserves the path' do + expect(subject.media_hosts).to contain_exactly(subject.assets_host, 'https://asset-host.s3-alias.example/pathname/') + end + end + + context 'when an S3 cloudfront host is configured' do + around do |example| + ClimateControl.modify S3_CLOUDFRONT_HOST: 'asset-host.s3-cloudfront.example' do + example.run + end + end + + it 'uses the s3 cloudfront host value' do + expect(subject.media_hosts).to contain_exactly(subject.assets_host, 'https://asset-host.s3-cloudfront.example') + end + end + + context 'when an azure alias host is configured' do + around do |example| + ClimateControl.modify AZURE_ALIAS_HOST: 'asset-host.azure-alias.example' do + example.run + end + end + + it 'uses the azure alias host value' do + expect(subject.media_hosts).to contain_exactly(subject.assets_host, 'https://asset-host.azure-alias.example') + end + end + + context 'when s3_enabled is configured' do + around do |example| + ClimateControl.modify S3_ENABLED: 'true', S3_HOSTNAME: 'asset-host.s3.example' do + example.run + end + end + + it 'uses the s3 hostname host value' do + expect(subject.media_hosts).to contain_exactly(subject.assets_host, 'https://asset-host.s3.example') + end + end + end +end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 64cf8ebf79..6b87eda151 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -10,7 +10,7 @@ RSpec.describe FeedManager do end end - it 'tracks at least as many statuses as reblogs', skip_stub: true do + it 'tracks at least as many statuses as reblogs', :skip_stub do expect(FeedManager::REBLOG_FALLOFF).to be <= FeedManager::MAX_ITEMS end @@ -532,6 +532,44 @@ RSpec.describe FeedManager do end end + describe '#unmerge_tag_from_home' do + let(:receiver) { Fabricate(:account) } + let(:tag) { Fabricate(:tag) } + + it 'leaves a tagged status' do + status = Fabricate(:status) + status.tags << tag + described_class.instance.push_to_home(receiver, status) + + described_class.instance.unmerge_tag_from_home(tag, receiver) + + expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to_not include(status.id.to_s) + end + + it 'remains a tagged status written by receiver\'s followee' do + followee = Fabricate(:account) + receiver.follow!(followee) + + status = Fabricate(:status, account: followee) + status.tags << tag + described_class.instance.push_to_home(receiver, status) + + described_class.instance.unmerge_tag_from_home(tag, receiver) + + expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to include(status.id.to_s) + end + + it 'remains a tagged status written by receiver' do + status = Fabricate(:status, account: receiver) + status.tags << tag + described_class.instance.push_to_home(receiver, status) + + described_class.instance.unmerge_tag_from_home(tag, receiver) + + expect(redis.zrange("feed:home:#{receiver.id}", 0, -1)).to include(status.id.to_s) + end + end + describe '#clear_from_home' do let(:account) { Fabricate(:account) } let(:followed_account) { Fabricate(:account) } diff --git a/spec/lib/importer/public_statuses_index_importer_spec.rb b/spec/lib/importer/public_statuses_index_importer_spec.rb new file mode 100644 index 0000000000..bc7c038a97 --- /dev/null +++ b/spec/lib/importer/public_statuses_index_importer_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Importer::PublicStatusesIndexImporter do + describe 'import!' do + let(:pool) { Concurrent::FixedThreadPool.new(5) } + let(:importer) { described_class.new(batch_size: 123, executor: pool) } + + before { Fabricate(:status, account: Fabricate(:account, indexable: true)) } + + it 'indexes relevant statuses' do + expect { importer.import! }.to update_index(PublicStatusesIndex) + end + end +end diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb index ef501efbff..8c485cef2a 100644 --- a/spec/lib/link_details_extractor_spec.rb +++ b/spec/lib/link_details_extractor_spec.rb @@ -3,33 +3,31 @@ require 'rails_helper' RSpec.describe LinkDetailsExtractor do - subject { described_class.new(original_url, html, html_charset) } + subject { described_class.new(original_url, html, nil) } - let(:original_url) { '' } - let(:html) { '' } - let(:html_charset) { nil } + let(:original_url) { 'https://example.com/dog.html?tracking=123' } describe '#canonical_url' do - let(:original_url) { 'https://foo.com/article?bar=baz123' } + let(:html) { "" } + + context 'when canonical URL points to the same host' do + let(:url) { 'https://example.com/dog.html' } + + it 'ignores the canonical URLs' do + expect(subject.canonical_url).to eq 'https://example.com/dog.html' + end + end context 'when canonical URL points to another host' do - let(:html) { '' } + let(:url) { 'https://different.example.net/dog.html' } it 'ignores the canonical URLs' do expect(subject.canonical_url).to eq original_url end end - context 'when canonical URL points to the same host' do - let(:html) { '' } - - it 'ignores the canonical URLs' do - expect(subject.canonical_url).to eq 'https://foo.com/article' - end - end - context 'when canonical URL is set to "null"' do - let(:html) { '' } + let(:url) { 'null' } it 'ignores the canonical URLs' do expect(subject.canonical_url).to eq original_url @@ -37,46 +35,113 @@ RSpec.describe LinkDetailsExtractor do end end + context 'when only basic metadata is present' do + let(:html) { <<~HTML } + + + + Man bites dog + + + + HTML + + describe '#title' do + it 'returns the title from title tag' do + expect(subject.title).to eq 'Man bites dog' + end + end + + describe '#description' do + it 'returns the description from meta tag' do + expect(subject.description).to eq "A dog's tale" + end + end + + describe '#language' do + it 'returns the language from lang attribute' do + expect(subject.language).to eq 'en' + end + end + end + context 'when structured data is present' do - let(:original_url) { 'https://example.com/page.html' } - - context 'when is wrapped in CDATA tags' do - let(:html) { <<~HTML } - - - - - - - HTML + let(:ld_json) do + { + '@context' => 'https://schema.org', + '@type' => 'NewsArticle', + 'headline' => 'Man bites dog', + 'description' => "A dog's tale", + 'datePublished' => '2022-01-31T19:53:00+00:00', + 'author' => { + '@type' => 'Organization', + 'name' => 'Charlie Brown', + }, + 'publisher' => { + '@type' => 'NewsMediaOrganization', + 'name' => 'Pet News', + 'url' => 'https://example.com', + }, + 'inLanguage' => { + name: 'English', + alternateName: 'en', + }, + }.to_json + end + shared_examples 'structured data' do describe '#title' do it 'returns the title from structured data' do - expect(subject.title).to eq 'Foo' + expect(subject.title).to eq 'Man bites dog' end end describe '#description' do it 'returns the description from structured data' do - expect(subject.description).to eq 'Bar' + expect(subject.description).to eq "A dog's tale" end end - describe '#provider_name' do - it 'returns the provider name from structured data' do - expect(subject.provider_name).to eq 'Baz' + describe '#published_at' do + it 'returns the publicaton time from structured data' do + expect(subject.published_at).to eq '2022-01-31T19:53:00+00:00' end end describe '#author_name' do it 'returns the author name from structured data' do - expect(subject.author_name).to eq 'Hoge' + expect(subject.author_name).to eq 'Charlie Brown' end end + + describe '#provider_name' do + it 'returns the provider name from structured data' do + expect(subject.provider_name).to eq 'Pet News' + end + end + + describe '#language' do + it 'returns the language from structured data' do + expect(subject.language).to eq 'en' + end + end + end + + context 'when is wrapped in CDATA tags' do + let(:html) { <<~HTML } + + + + + + + HTML + + include_examples 'structured data' end context 'with the first tag is invalid JSON' do @@ -85,76 +150,152 @@ RSpec.describe LinkDetailsExtractor do HTML - describe '#title' do - it 'returns the title from structured data' do - expect(subject.title).to eq 'Foo' - end - end + include_examples 'structured data' + end - describe '#description' do - it 'returns the description from structured data' do - expect(subject.description).to eq 'Bar' - end - end + context 'with preceding block of unsupported LD+JSON' do + let(:html) { <<~HTML } + + + + + + + + HTML - describe '#provider_name' do - it 'returns the provider name from structured data' do - expect(subject.provider_name).to eq 'Baz' - end - end + include_examples 'structured data' + end - describe '#author_name' do - it 'returns the author name from structured data' do - expect(subject.author_name).to eq 'Hoge' - end + context 'with unsupported in same block LD+JSON' do + let(:html) { <<~HTML } + + + + + + + HTML + + include_examples 'structured data' + end + end + + context 'when Open Graph protocol data is present' do + let(:html) { <<~HTML } + + + + + + + + + + + + + + + HTML + + describe '#canonical_url' do + it 'returns the URL from Open Graph protocol data' do + expect(subject.canonical_url).to eq 'https://example.com/dog.html' + end + end + + describe '#title' do + it 'returns the title from Open Graph protocol data' do + expect(subject.title).to eq 'Man bites dog' + end + end + + describe '#description' do + it 'returns the description from Open Graph protocol data' do + expect(subject.description).to eq "A dog's tale" + end + end + + describe '#published_at' do + it 'returns the publicaton time from Open Graph protocol data' do + expect(subject.published_at).to eq '2022-01-31T19:53:00+00:00' + end + end + + describe '#author_name' do + it 'returns the author name from Open Graph protocol data' do + expect(subject.author_name).to eq 'Charlie Brown' + end + end + + describe '#language' do + it 'returns the language from Open Graph protocol data' do + expect(subject.language).to eq 'en' + end + end + + describe '#image' do + it 'returns the image from Open Graph protocol data' do + expect(subject.image).to eq 'https://example.com/snoopy.jpg' + end + end + + describe '#image:alt' do + it 'returns the image description from Open Graph protocol data' do + expect(subject.image_alt).to eq 'A good boy' + end + end + + describe '#provider_name' do + it 'returns the provider name from Open Graph protocol data' do + expect(subject.provider_name).to eq 'Pet News' end end end diff --git a/spec/lib/mastodon/cli/accounts_spec.rb b/spec/lib/mastodon/cli/accounts_spec.rb index a263d673de..64df29ebb3 100644 --- a/spec/lib/mastodon/cli/accounts_spec.rb +++ b/spec/lib/mastodon/cli/accounts_spec.rb @@ -4,34 +4,51 @@ require 'rails_helper' require 'mastodon/cli/accounts' describe Mastodon::CLI::Accounts do - let(:cli) { described_class.new } + subject { cli.invoke(action, arguments, options) } - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + # `parallelize_with_progress` cannot run in transactions, so instead, + # stub it with an alternative implementation that runs sequentially + # and can run in transactions. + def stub_parallelize_with_progress! + allow(cli).to receive(:parallelize_with_progress) do |scope, &block| + aggregate = 0 + total = 0 + + scope.reorder(nil).find_each do |record| + value = block.call(record) + aggregate += value if value.is_a?(Integer) + total += 1 + end + + [total, aggregate] end end describe '#create' do + let(:action) { :create } + shared_examples 'a new user with given email address and username' do - it 'creates a new user with the specified email address' do - cli.invoke(:create, arguments, options) - - expect(User.find_by(email: options[:email])).to be_present - end - - it 'creates a new local account with the specified username' do - cli.invoke(:create, arguments, options) - - expect(Account.find_local('tootctl_username')).to be_present - end - - it 'returns "OK" and newly generated password' do + it 'creates user and accounts from options and displays success message' do allow(SecureRandom).to receive(:hex).and_return('test_password') - expect { cli.invoke(:create, arguments, options) }.to output( - a_string_including("OK\nNew password: test_password") - ).to_stdout + expect { subject } + .to output_results('OK', 'New password: test_password') + expect(user_from_options).to be_present + expect(account_from_options).to be_present + end + + def user_from_options + User.find_by(email: options[:email]) + end + + def account_from_options + Account.find_local('tootctl_username') end end @@ -47,9 +64,8 @@ describe Mastodon::CLI::Accounts do let(:options) { { email: 'invalid' } } it 'exits with an error message' do - expect { cli.invoke(:create, arguments, options) }.to output( - a_string_including('Failure/Error: email') - ).to_stdout + expect { subject } + .to output_results('Failure/Error: email') .and raise_error(SystemExit) end end @@ -61,7 +77,8 @@ describe Mastodon::CLI::Accounts do it_behaves_like 'a new user with given email address and username' it 'creates a new user with confirmed status' do - cli.invoke(:create, arguments, options) + expect { subject } + .to output_results('New password') user = User.find_by(email: options[:email]) @@ -79,7 +96,8 @@ describe Mastodon::CLI::Accounts do it_behaves_like 'a new user with given email address and username' it 'creates a new user with approved status' do - cli.invoke(:create, arguments, options) + expect { subject } + .to output_results('New password') user = User.find_by(email: options[:email]) @@ -95,7 +113,8 @@ describe Mastodon::CLI::Accounts do it_behaves_like 'a new user with given email address and username' it 'creates a new user and assigns the specified role' do - cli.invoke(:create, arguments, options) + expect { subject } + .to output_results('New password') role = User.find_by(email: options[:email])&.role @@ -107,9 +126,8 @@ describe Mastodon::CLI::Accounts do let(:options) { { email: 'tootctl@example.com', role: '404' } } it 'exits with an error message indicating the role name was not found' do - expect { cli.invoke(:create, arguments, options) }.to output( - a_string_including('Cannot find user role with that name') - ).to_stdout + expect { subject } + .to output_results('Cannot find user role with that name') .and raise_error(SystemExit) end end @@ -125,16 +143,16 @@ describe Mastodon::CLI::Accounts do end it 'returns an error message indicating the username is already taken' do - expect { cli.invoke(:create, arguments, options) }.to output( - a_string_including("The chosen username is currently in use\nUse --force to reattach it anyway and delete the other user") - ).to_stdout + expect { subject } + .to output_results("The chosen username is currently in use\nUse --force to reattach it anyway and delete the other user") end context 'with --force option' do let(:options) { { email: 'tootctl_new@example.com', reattach: true, force: true } } it 'reattaches the account to the new user and deletes the previous user' do - cli.invoke(:create, arguments, options) + expect { subject } + .to output_results('New password') user = Account.find_local('tootctl_username')&.user @@ -159,20 +177,21 @@ describe Mastodon::CLI::Accounts do let(:arguments) { ['tootctl_username'] } it 'raises a required argument missing error (Thor::RequiredArgumentMissingError)' do - expect { cli.invoke(:create, arguments) } + expect { subject } .to raise_error(Thor::RequiredArgumentMissingError) end end end describe '#modify' do + let(:action) { :modify } + context 'when the given username is not found' do let(:arguments) { ['non_existent_username'] } it 'exits with an error message indicating the user was not found' do - expect { cli.invoke(:modify, arguments) }.to output( - a_string_including('No user with such username') - ).to_stdout + expect { subject } + .to output_results('No user with such username') .and raise_error(SystemExit) end end @@ -182,15 +201,9 @@ describe Mastodon::CLI::Accounts do let(:arguments) { [user.account.username] } context 'when no option is provided' do - it 'returns a successful message' do - expect { cli.invoke(:modify, arguments) }.to output( - a_string_including('OK') - ).to_stdout - end - - it 'does not modify the user' do - cli.invoke(:modify, arguments) - + it 'returns a successful message and preserves user' do + expect { subject } + .to output_results('OK') expect(user).to eq(user.reload) end end @@ -200,9 +213,8 @@ describe Mastodon::CLI::Accounts do let(:options) { { role: '404' } } it 'exits with an error message indicating the role was not found' do - expect { cli.invoke(:modify, arguments, options) }.to output( - a_string_including('Cannot find user role with that name') - ).to_stdout + expect { subject } + .to output_results('Cannot find user role with that name') .and raise_error(SystemExit) end end @@ -212,7 +224,8 @@ describe Mastodon::CLI::Accounts do let(:options) { { role: default_role.name } } it "updates the user's role to the specified role" do - cli.invoke(:modify, arguments, options) + expect { subject } + .to output_results('OK') role = user.reload.role @@ -227,7 +240,8 @@ describe Mastodon::CLI::Accounts do let(:user) { Fabricate(:user, role: role) } it "removes the user's role successfully" do - cli.invoke(:modify, arguments, options) + expect { subject } + .to output_results('OK') role = user.reload.role @@ -240,13 +254,15 @@ describe Mastodon::CLI::Accounts do let(:options) { { email: 'new_email@email.com' } } it "sets the user's unconfirmed email to the provided email address" do - cli.invoke(:modify, arguments, options) + expect { subject } + .to output_results('OK') expect(user.reload.unconfirmed_email).to eq(options[:email]) end it "does not update the user's original email address" do - cli.invoke(:modify, arguments, options) + expect { subject } + .to output_results('OK') expect(user.reload.email).to eq('old_email@email.com') end @@ -256,13 +272,15 @@ describe Mastodon::CLI::Accounts do let(:options) { { email: 'new_email@email.com', confirm: true } } it "updates the user's email address to the provided email" do - cli.invoke(:modify, arguments, options) + expect { subject } + .to output_results('OK') expect(user.reload.email).to eq(options[:email]) end it "sets the user's email address as confirmed" do - cli.invoke(:modify, arguments, options) + expect { subject } + .to output_results('OK') expect(user.reload.confirmed?).to be(true) end @@ -274,7 +292,8 @@ describe Mastodon::CLI::Accounts do let(:options) { { confirm: true } } it "confirms the user's email address" do - cli.invoke(:modify, arguments, options) + expect { subject } + .to output_results('OK') expect(user.reload.confirmed?).to be(true) end @@ -289,7 +308,9 @@ describe Mastodon::CLI::Accounts do end it 'approves the user' do - expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.approved }.from(false).to(true) + expect { subject } + .to output_results('OK') + .and change { user.reload.approved }.from(false).to(true) end end @@ -298,7 +319,9 @@ describe Mastodon::CLI::Accounts do let(:options) { { disable: true } } it 'disables the user' do - expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(false).to(true) + expect { subject } + .to output_results('OK') + .and change { user.reload.disabled }.from(false).to(true) end end @@ -307,7 +330,9 @@ describe Mastodon::CLI::Accounts do let(:options) { { enable: true } } it 'enables the user' do - expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(true).to(false) + expect { subject } + .to output_results('OK') + .and change { user.reload.disabled }.from(true).to(false) end end @@ -317,9 +342,8 @@ describe Mastodon::CLI::Accounts do it 'returns a new password for the user' do allow(SecureRandom).to receive(:hex).and_return('new_password') - expect { cli.invoke(:modify, arguments, options) }.to output( - a_string_including('new_password') - ).to_stdout + expect { subject } + .to output_results('new_password') end end @@ -328,7 +352,9 @@ describe Mastodon::CLI::Accounts do let(:options) { { disable_2fa: true } } it 'disables the two-factor authentication for the user' do - expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.otp_required_for_login }.from(true).to(false) + expect { subject } + .to output_results('OK') + .and change { user.reload.otp_required_for_login }.from(true).to(false) end end @@ -337,9 +363,8 @@ describe Mastodon::CLI::Accounts do let(:options) { { email: 'invalid' } } it 'exits with an error message' do - expect { cli.invoke(:modify, arguments, options) }.to output( - a_string_including('Failure/Error: email') - ).to_stdout + expect { subject } + .to output_results('Failure/Error: email') .and raise_error(SystemExit) end end @@ -347,9 +372,8 @@ describe Mastodon::CLI::Accounts do end describe '#delete' do + let(:action) { :delete } let(:account) { Fabricate(:account) } - let(:arguments) { [account.username] } - let(:options) { { email: account.user.email } } let(:delete_account_service) { instance_double(DeleteAccountService) } before do @@ -358,26 +382,30 @@ describe Mastodon::CLI::Accounts do end context 'when both username and --email are provided' do + let(:arguments) { [account.username] } + let(:options) { { email: account.user.email } } + it 'exits with an error message indicating that only one should be used' do - expect { cli.invoke(:delete, arguments, options) }.to output( - a_string_including('Use username or --email, not both') - ).to_stdout + expect { subject } + .to output_results('Use username or --email, not both') .and raise_error(SystemExit) end end context 'when neither username nor --email are provided' do it 'exits with an error message indicating that no username was provided' do - expect { cli.invoke(:delete) }.to output( - a_string_including('No username provided') - ).to_stdout + expect { subject } + .to output_results('No username provided') .and raise_error(SystemExit) end end context 'when username is provided' do + let(:arguments) { [account.username] } + it 'deletes the specified user successfully' do - cli.invoke(:delete, arguments) + expect { subject } + .to output_results('Deleting') expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once end @@ -385,34 +413,30 @@ describe Mastodon::CLI::Accounts do context 'with --dry-run option' do let(:options) { { dry_run: true } } - it 'does not delete the specified user' do - cli.invoke(:delete, arguments, options) - + it 'outputs a successful message in dry run mode and does not delete the user' do + expect { subject } + .to output_results('OK (DRY RUN)') expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false) end - - it 'outputs a successful message in dry run mode' do - expect { cli.invoke(:delete, arguments, options) }.to output( - a_string_including('OK (DRY RUN)') - ).to_stdout - end end context 'when the given username is not found' do let(:arguments) { ['non_existent_username'] } it 'exits with an error message indicating that no user was found' do - expect { cli.invoke(:delete, arguments) }.to output( - a_string_including('No user with such username') - ).to_stdout + expect { subject } + .to output_results('No user with such username') .and raise_error(SystemExit) end end end context 'when --email is provided' do + let(:options) { { email: account.user.email } } + it 'deletes the specified user successfully' do - cli.invoke(:delete, nil, options) + expect { subject } + .to output_results('Deleting') expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once end @@ -420,16 +444,12 @@ describe Mastodon::CLI::Accounts do context 'with --dry-run option' do let(:options) { { email: account.user.email, dry_run: true } } - it 'does not delete the user' do - cli.invoke(:delete, nil, options) - - expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false) - end - - it 'outputs a successful message in dry run mode' do - expect { cli.invoke(:delete, nil, options) }.to output( - a_string_including('OK (DRY RUN)') - ).to_stdout + it 'outputs a successful message in dry run mode and does not delete the user' do + expect { subject } + .to output_results('OK (DRY RUN)') + expect(delete_account_service) + .to_not have_received(:call) + .with(account, reserve_email: false) end end @@ -437,9 +457,8 @@ describe Mastodon::CLI::Accounts do let(:options) { { email: '404@example.com' } } it 'exits with an error message indicating that no user was found' do - expect { cli.invoke(:delete, nil, options) }.to output( - a_string_including('No user with such email') - ).to_stdout + expect { subject } + .to output_results('No user with such email') .and raise_error(SystemExit) end end @@ -447,7 +466,8 @@ describe Mastodon::CLI::Accounts do end describe '#approve' do - let(:total_users) { 10 } + let(:action) { :approve } + let(:total_users) { 4 } before do Form::AdminSettings.new(registrations_mode: 'approved').save @@ -455,8 +475,11 @@ describe Mastodon::CLI::Accounts do end context 'with --all option' do + let(:options) { { all: true } } + it 'approves all pending registrations' do - cli.invoke(:approve, nil, all: true) + expect { subject } + .to output_results('OK') expect(User.pluck(:approved).all?(true)).to be(true) end @@ -464,30 +487,31 @@ describe Mastodon::CLI::Accounts do context 'with --number option' do context 'when the number is positive' do - let(:options) { { number: 3 } } + let(:options) { { number: 2 } } - it 'approves the earliest n pending registrations' do - cli.invoke(:approve, nil, options) - - n_earliest_pending_registrations = User.order(created_at: :asc).first(options[:number]) + it 'approves the earliest n pending registrations but not the remaining ones' do + expect { subject } + .to output_results('OK') expect(n_earliest_pending_registrations.all?(&:approved?)).to be(true) + expect(pending_registrations.all?(&:approved?)).to be(false) end - it 'does not approve the remaining pending registrations' do - cli.invoke(:approve, nil, options) + def n_earliest_pending_registrations + User.order(created_at: :asc).first(options[:number]) + end - pending_registrations = User.order(created_at: :asc).last(total_users - options[:number]) - - expect(pending_registrations.all?(&:approved?)).to be(false) + def pending_registrations + User.order(created_at: :asc).last(total_users - options[:number]) end end context 'when the number is negative' do + let(:options) { { number: -1 } } + it 'exits with an error message indicating that the number must be positive' do - expect { cli.invoke(:approve, nil, number: -1) }.to output( - a_string_including('Number must be positive') - ).to_stdout + expect { subject } + .to output_results('Number must be positive') .and raise_error(SystemExit) end end @@ -495,16 +519,11 @@ describe Mastodon::CLI::Accounts do context 'when the given number is greater than the number of users' do let(:options) { { number: total_users * 2 } } - it 'approves all users' do - cli.invoke(:approve, nil, options) - + it 'approves all users and does not raise any error' do + expect { subject } + .to output_results('OK') expect(User.pluck(:approved).all?(true)).to be(true) end - - it 'does not raise any error' do - expect { cli.invoke(:approve, nil, options) } - .to_not raise_error - end end end @@ -514,7 +533,8 @@ describe Mastodon::CLI::Accounts do let(:arguments) { [user.account.username] } it 'approves the specified user successfully' do - cli.invoke(:approve, arguments) + expect { subject } + .to output_results('OK') expect(user.reload.approved?).to be(true) end @@ -524,9 +544,8 @@ describe Mastodon::CLI::Accounts do let(:arguments) { ['non_existent_username'] } it 'exits with an error message indicating that no such account was found' do - expect { cli.invoke(:approve, arguments) }.to output( - a_string_including('No such account') - ).to_stdout + expect { subject } + .to output_results('No such account') .and raise_error(SystemExit) end end @@ -534,13 +553,14 @@ describe Mastodon::CLI::Accounts do end describe '#follow' do + let(:action) { :follow } + context 'when the given username is not found' do let(:arguments) { ['non_existent_username'] } it 'exits with an error message indicating that no account with the given username was found' do - expect { cli.invoke(:follow, arguments) }.to output( - a_string_including('No such account') - ).to_stdout + expect { subject } + .to output_results('No such account') .and raise_error(SystemExit) end end @@ -551,88 +571,70 @@ describe Mastodon::CLI::Accounts do let!(:follower_rony) { Fabricate(:account, username: 'rony') } let!(:follower_charles) { Fabricate(:account, username: 'charles') } let(:follow_service) { instance_double(FollowService, call: nil) } - let(:scope) { Account.local.without_suspended } + let(:arguments) { [target_account.username] } before do - allow(cli).to receive(:parallelize_with_progress).and_yield(follower_bob) - .and_yield(follower_rony) - .and_yield(follower_charles) - .and_return([3, nil]) allow(FollowService).to receive(:new).and_return(follow_service) + stub_parallelize_with_progress! end - it 'makes all local accounts follow the target account' do - cli.follow(target_account.username) - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once + it 'displays a successful message and makes all local accounts follow the target account' do + expect { subject } + .to output_results("OK, followed target from #{Account.local.count} accounts") expect(follow_service).to have_received(:call).with(follower_bob, target_account, any_args).once expect(follow_service).to have_received(:call).with(follower_rony, target_account, any_args).once expect(follow_service).to have_received(:call).with(follower_charles, target_account, any_args).once end - - it 'displays a successful message' do - expect { cli.follow(target_account.username) }.to output( - a_string_including('OK, followed target from 3 accounts') - ).to_stdout - end end end describe '#unfollow' do + let(:action) { :unfollow } + context 'when the given username is not found' do let(:arguments) { ['non_existent_username'] } it 'exits with an error message indicating that no account with the given username was found' do - expect { cli.invoke(:unfollow, arguments) }.to output( - a_string_including('No such account') - ).to_stdout + expect { subject } + .to output_results('No such account') .and raise_error(SystemExit) end end context 'when the given username is found' do let!(:target_account) { Fabricate(:account) } - let!(:follower_chris) { Fabricate(:account, username: 'chris') } - let!(:follower_rambo) { Fabricate(:account, username: 'rambo') } - let!(:follower_ana) { Fabricate(:account, username: 'ana') } + let!(:follower_chris) { Fabricate(:account, username: 'chris', domain: nil) } + let!(:follower_rambo) { Fabricate(:account, username: 'rambo', domain: nil) } + let!(:follower_ana) { Fabricate(:account, username: 'ana', domain: nil) } let(:unfollow_service) { instance_double(UnfollowService, call: nil) } - let(:scope) { target_account.followers.local } + let(:arguments) { [target_account.username] } before do accounts = [follower_chris, follower_rambo, follower_ana] - accounts.each { |account| target_account.follow!(account) } - allow(cli).to receive(:parallelize_with_progress).and_yield(follower_chris) - .and_yield(follower_rambo) - .and_yield(follower_ana) - .and_return([3, nil]) + accounts.each { |account| account.follow!(target_account) } allow(UnfollowService).to receive(:new).and_return(unfollow_service) + stub_parallelize_with_progress! end - it 'makes all local accounts unfollow the target account' do - cli.unfollow(target_account.username) - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once + it 'displays a successful message and makes all local accounts unfollow the target account' do + expect { subject } + .to output_results('OK, unfollowed target from 3 accounts') expect(unfollow_service).to have_received(:call).with(follower_chris, target_account).once expect(unfollow_service).to have_received(:call).with(follower_rambo, target_account).once expect(unfollow_service).to have_received(:call).with(follower_ana, target_account).once end - - it 'displays a successful message' do - expect { cli.unfollow(target_account.username) }.to output( - a_string_including('OK, unfollowed target from 3 accounts') - ).to_stdout - end end end describe '#backup' do + let(:action) { :backup } + context 'when the given username is not found' do let(:arguments) { ['non_existent_username'] } it 'exits with an error message indicating that there is no such account' do - expect { cli.invoke(:backup, arguments) }.to output( - a_string_including('No user with such username') - ).to_stdout + expect { subject } + .to output_results('No user with such username') .and raise_error(SystemExit) end end @@ -642,23 +644,17 @@ describe Mastodon::CLI::Accounts do let(:user) { account.user } let(:arguments) { [account.username] } - it 'creates a new backup for the specified user' do - expect { cli.invoke(:backup, arguments) }.to change { user.backups.count }.by(1) - end - - it 'creates a backup job' do - allow(BackupWorker).to receive(:perform_async) - - cli.invoke(:backup, arguments) - latest_backup = user.backups.last + before { allow(BackupWorker).to receive(:perform_async) } + it 'creates a new backup and backup job for the specified user and outputs success message' do + expect { subject } + .to change { user.backups.count }.by(1) + .and output_results('OK') expect(BackupWorker).to have_received(:perform_async).with(latest_backup.id).once end - it 'displays a successful message' do - expect { cli.invoke(:backup, arguments) }.to output( - a_string_including('OK') - ).to_stdout + def latest_backup + user.backups.last end end end @@ -671,6 +667,8 @@ describe Mastodon::CLI::Accounts do let(:scope) { Account.remote } before do + # TODO: we should be using `stub_parallelize_with_progress!` but + # this makes the assertions harder to write allow(cli).to receive(:parallelize_with_progress).and_yield(remote_account_example_com) .and_yield(account_example_net) .and_return([2, nil]) @@ -681,7 +679,8 @@ describe Mastodon::CLI::Accounts do allow(remote_account_example_com).to receive(:reset_avatar!) allow(account_example_net).to receive(:reset_avatar!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(remote_account_example_com).to have_received(:reset_avatar!).once @@ -691,7 +690,8 @@ describe Mastodon::CLI::Accounts do it 'does not refresh avatar for local accounts' do allow(local_account).to receive(:reset_avatar!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(local_account).to_not have_received(:reset_avatar!) @@ -701,7 +701,8 @@ describe Mastodon::CLI::Accounts do allow(remote_account_example_com).to receive(:reset_header!) allow(account_example_net).to receive(:reset_header!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(remote_account_example_com).to have_received(:reset_header!).once @@ -711,16 +712,16 @@ describe Mastodon::CLI::Accounts do it 'does not refresh the header for local accounts' do allow(local_account).to receive(:reset_header!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(local_account).to_not have_received(:reset_header!) end it 'displays a successful message' do - expect { cli.refresh }.to output( - a_string_including('Refreshed 2 accounts') - ).to_stdout + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') end context 'with --dry-run option' do @@ -733,7 +734,8 @@ describe Mastodon::CLI::Accounts do allow(remote_account_example_com).to receive(:reset_avatar!) allow(account_example_net).to receive(:reset_avatar!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(local_account).to_not have_received(:reset_avatar!) @@ -746,7 +748,8 @@ describe Mastodon::CLI::Accounts do allow(remote_account_example_com).to receive(:reset_header!) allow(account_example_net).to receive(:reset_header!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(local_account).to_not have_received(:reset_header!) @@ -755,9 +758,8 @@ describe Mastodon::CLI::Accounts do end it 'displays a successful message with (DRY RUN)' do - expect { cli.refresh }.to output( - a_string_including('Refreshed 2 accounts (DRY RUN)') - ).to_stdout + expect { cli.refresh } + .to output_results('Refreshed 2 accounts (DRY RUN)') end end end @@ -769,6 +771,8 @@ describe Mastodon::CLI::Accounts do let(:arguments) { [account_example_com_a.acct, account_example_com_b.acct] } before do + # NOTE: `Account.find_remote` is stubbed so that `Account#reset_avatar!` + # can be stubbed on the individual accounts. allow(Account).to receive(:find_remote).with(account_example_com_a.username, account_example_com_a.domain).and_return(account_example_com_a) allow(Account).to receive(:find_remote).with(account_example_com_b.username, account_example_com_b.domain).and_return(account_example_com_b) allow(Account).to receive(:find_remote).with(account_example_net.username, account_example_net.domain).and_return(account_example_net) @@ -778,7 +782,8 @@ describe Mastodon::CLI::Accounts do allow(account_example_com_a).to receive(:reset_avatar!) allow(account_example_com_b).to receive(:reset_avatar!) - cli.refresh(*arguments) + expect { cli.refresh(*arguments) } + .to output_results('OK') expect(account_example_com_a).to have_received(:reset_avatar!).once expect(account_example_com_b).to have_received(:reset_avatar!).once @@ -787,7 +792,8 @@ describe Mastodon::CLI::Accounts do it 'does not reset the avatar for unspecified accounts' do allow(account_example_net).to receive(:reset_avatar!) - cli.refresh(*arguments) + expect { cli.refresh(*arguments) } + .to output_results('OK') expect(account_example_net).to_not have_received(:reset_avatar!) end @@ -796,7 +802,8 @@ describe Mastodon::CLI::Accounts do allow(account_example_com_a).to receive(:reset_header!) allow(account_example_com_b).to receive(:reset_header!) - cli.refresh(*arguments) + expect { cli.refresh(*arguments) } + .to output_results('OK') expect(account_example_com_a).to have_received(:reset_header!).once expect(account_example_com_b).to have_received(:reset_header!).once @@ -805,7 +812,8 @@ describe Mastodon::CLI::Accounts do it 'does not reset the header for unspecified accounts' do allow(account_example_net).to receive(:reset_header!) - cli.refresh(*arguments) + expect { cli.refresh(*arguments) } + .to output_results('OK') expect(account_example_net).to_not have_received(:reset_header!) end @@ -815,9 +823,7 @@ describe Mastodon::CLI::Accounts do allow(account_example_com_a).to receive(:reset_avatar!).and_raise(Mastodon::UnexpectedResponseError) expect { cli.refresh(*arguments) } - .to output( - a_string_including("Account failed: #{account_example_com_a.username}@#{account_example_com_a.domain}") - ).to_stdout + .to output_results("Account failed: #{account_example_com_a.username}@#{account_example_com_a.domain}") end end @@ -825,9 +831,8 @@ describe Mastodon::CLI::Accounts do it 'exits with an error message' do allow(Account).to receive(:find_remote).with(account_example_com_b.username, account_example_com_b.domain).and_return(nil) - expect { cli.refresh(*arguments) }.to output( - a_string_including('No such account') - ).to_stdout + expect { cli.refresh(*arguments) } + .to output_results('No such account') .and raise_error(SystemExit) end end @@ -841,7 +846,8 @@ describe Mastodon::CLI::Accounts do allow(account_example_com_a).to receive(:reset_avatar!) allow(account_example_com_b).to receive(:reset_avatar!) - cli.refresh(*arguments) + expect { cli.refresh(*arguments) } + .to output_results('OK (DRY RUN)') expect(account_example_com_a).to_not have_received(:reset_avatar!) expect(account_example_com_b).to_not have_received(:reset_avatar!) @@ -851,7 +857,8 @@ describe Mastodon::CLI::Accounts do allow(account_example_com_a).to receive(:reset_header!) allow(account_example_com_b).to receive(:reset_header!) - cli.refresh(*arguments) + expect { cli.refresh(*arguments) } + .to output_results('OK (DRY RUN)') expect(account_example_com_a).to_not have_received(:reset_header!) expect(account_example_com_b).to_not have_received(:reset_header!) @@ -870,7 +877,6 @@ describe Mastodon::CLI::Accounts do allow(cli).to receive(:parallelize_with_progress).and_yield(account_example_com_a) .and_yield(account_example_com_b) .and_return([2, nil]) - cli.options = { domain: domain } end @@ -878,7 +884,8 @@ describe Mastodon::CLI::Accounts do allow(account_example_com_a).to receive(:reset_avatar!) allow(account_example_com_b).to receive(:reset_avatar!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(account_example_com_a).to have_received(:reset_avatar!).once @@ -888,7 +895,8 @@ describe Mastodon::CLI::Accounts do it 'does not refresh the avatar for accounts outside specified domain' do allow(account_example_net).to receive(:reset_avatar!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(account_example_net).to_not have_received(:reset_avatar!) @@ -898,7 +906,8 @@ describe Mastodon::CLI::Accounts do allow(account_example_com_a).to receive(:reset_header!) allow(account_example_com_b).to receive(:reset_header!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope) expect(account_example_com_a).to have_received(:reset_header!).once @@ -908,7 +917,8 @@ describe Mastodon::CLI::Accounts do it 'does not refresh the header for accounts outside specified domain' do allow(account_example_net).to receive(:reset_header!) - cli.refresh + expect { cli.refresh } + .to output_results('Refreshed 2 accounts') expect(cli).to have_received(:parallelize_with_progress).with(scope).once expect(account_example_net).to_not have_received(:reset_header!) @@ -917,32 +927,34 @@ describe Mastodon::CLI::Accounts do context 'when neither a list of accts nor options are provided' do it 'exits with an error message' do - expect { cli.refresh }.to output( - a_string_including('No account(s) given') - ).to_stdout + expect { cli.refresh } + .to output_results('No account(s) given') .and raise_error(SystemExit) end end end describe '#rotate' do + let(:action) { :rotate } + context 'when neither username nor --all option are given' do it 'exits with an error message' do - expect { cli.rotate }.to output( - a_string_including('No account(s) given') - ).to_stdout + expect { subject } + .to output_results('No account(s) given') .and raise_error(SystemExit) end end context 'when a username is given' do let(:account) { Fabricate(:account) } + let(:arguments) { [account.username] } it 'correctly rotates keys for the specified account' do old_private_key = account.private_key old_public_key = account.public_key - cli.rotate(account.username) + expect { subject } + .to output_results('OK') account.reload expect(account.private_key).to_not eq(old_private_key) @@ -952,35 +964,33 @@ describe Mastodon::CLI::Accounts do it 'broadcasts the new keys for the specified account' do allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) - cli.rotate(account.username) + expect { subject } + .to output_results('OK') expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once end + end - context 'when the given username is not found' do - it 'exits with an error message when the specified username is not found' do - expect { cli.rotate('non_existent_username') }.to output( - a_string_including('No such account') - ).to_stdout - .and raise_error(SystemExit) - end + context 'when the given username is not found' do + let(:arguments) { ['non_existent_username'] } + + it 'exits with an error message when the specified username is not found' do + expect { subject } + .to output_results('No such account') + .and raise_error(SystemExit) end end context 'when --all option is provided' do - let(:accounts) { Fabricate.times(3, :account) } - let(:options) { { all: true } } - - before do - allow(Account).to receive(:local).and_return(Account.where(id: accounts.map(&:id))) - cli.options = { all: true } - end + let!(:accounts) { Fabricate.times(2, :account) } + let(:options) { { all: true } } it 'correctly rotates keys for all local accounts' do old_private_keys = accounts.map(&:private_key) old_public_keys = accounts.map(&:public_key) - cli.rotate + expect { subject } + .to output_results('rotated') accounts.each(&:reload) expect(accounts.map(&:private_key)).to_not eq(old_private_keys) @@ -990,7 +1000,8 @@ describe Mastodon::CLI::Accounts do it 'broadcasts the new keys for each account' do allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) - cli.rotate + expect { subject } + .to output_results('rotated') accounts.each do |account| expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once @@ -1000,11 +1011,12 @@ describe Mastodon::CLI::Accounts do end describe '#merge' do + let(:action) { :merge } + shared_examples 'an account not found' do |acct| it 'exits with an error message indicating that there is no such account' do - expect { cli.invoke(:merge, arguments) }.to output( - a_string_including("No such account (#{acct})") - ).to_stdout + expect { subject } + .to output_results("No such account (#{acct})") .and raise_error(SystemExit) end end @@ -1054,9 +1066,8 @@ describe Mastodon::CLI::Accounts do end it 'exits with an error message indicating that the accounts do not have the same pub key' do - expect { cli.invoke(:merge, arguments) }.to output( - a_string_including("Accounts don't have the same public key, might not be duplicates!\nOverride with --force") - ).to_stdout + expect { subject } + .to output_results("Accounts don't have the same public key, might not be duplicates!\nOverride with --force") .and raise_error(SystemExit) end @@ -1068,15 +1079,11 @@ describe Mastodon::CLI::Accounts do allow(from_account).to receive(:destroy) end - it 'merges "from_account" into "to_account"' do - cli.invoke(:merge, arguments, options) + it 'merges `from_account` into `to_account` and deletes `from_account`' do + expect { subject } + .to output_results('OK') expect(to_account).to have_received(:merge_with!).with(from_account).once - end - - it 'deletes "from_account"' do - cli.invoke(:merge, arguments, options) - expect(from_account).to have_received(:destroy).once end end @@ -1096,122 +1103,94 @@ describe Mastodon::CLI::Accounts do allow(from_account).to receive(:destroy) end - it 'merges "from_account" into "to_account"' do - cli.invoke(:merge, arguments) + it 'merges "from_account" into "to_account" and deletes from_account' do + expect { subject } + .to output_results('OK') expect(to_account).to have_received(:merge_with!).with(from_account).once - end - - it 'deletes "from_account"' do - cli.invoke(:merge, arguments) - expect(from_account).to have_received(:destroy) end end end describe '#cull' do + let(:action) { :cull } let(:delete_account_service) { instance_double(DeleteAccountService, call: nil) } - let!(:tom) { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com') } - let!(:bob) { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org') } - let!(:gon) { Fabricate(:account, updated_at: 15.days.ago, last_webfingered_at: 15.days.ago, username: 'gon', uri: 'https://example.net/users/gon', domain: 'example.net') } - let!(:ana) { Fabricate(:account, username: 'ana', uri: 'https://example.com/users/ana', domain: 'example.com') } - let!(:tales) { Fabricate(:account, updated_at: 10.days.ago, last_webfingered_at: nil, username: 'tales', uri: 'https://example.net/users/tales', domain: 'example.net') } + let!(:tom) { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com', protocol: :activitypub) } + let!(:bob) { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org', protocol: :activitypub) } + let!(:gon) { Fabricate(:account, updated_at: 15.days.ago, last_webfingered_at: 15.days.ago, username: 'gon', uri: 'https://example.net/users/gon', domain: 'example.net', protocol: :activitypub) } + let!(:ana) { Fabricate(:account, username: 'ana', uri: 'https://example.com/users/ana', domain: 'example.com', protocol: :activitypub) } + let!(:tales) { Fabricate(:account, updated_at: 10.days.ago, last_webfingered_at: nil, username: 'tales', uri: 'https://example.net/users/tales', domain: 'example.net', protocol: :activitypub) } before do allow(DeleteAccountService).to receive(:new).and_return(delete_account_service) end context 'when no domain is specified' do - let(:scope) { Account.remote.where(protocol: :activitypub).partitioned } - before do - allow(cli).to receive(:parallelize_with_progress).and_yield(tom) - .and_yield(bob) - .and_yield(gon) - .and_yield(ana) - .and_yield(tales) - .and_return([5, 3]) + stub_parallelize_with_progress! stub_request(:head, 'https://example.org/users/bob').to_return(status: 404) stub_request(:head, 'https://example.net/users/gon').to_return(status: 410) stub_request(:head, 'https://example.net/users/tales').to_return(status: 200) end - it 'deletes all inactive remote accounts that longer exist in the origin server' do - cli.cull - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once + def expect_delete_inactive_remote_accounts expect(delete_account_service).to have_received(:call).with(bob, reserve_username: false).once expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once end - it 'does not delete any active remote account that still exists in the origin server' do - cli.cull - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once + def expect_not_delete_active_accounts expect(delete_account_service).to_not have_received(:call).with(tom, reserve_username: false) expect(delete_account_service).to_not have_received(:call).with(ana, reserve_username: false) expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false) end - it 'touches inactive remote accounts that have not been deleted' do - allow(tales).to receive(:touch) - - cli.cull - - expect(tales).to have_received(:touch).once - end - - it 'displays the summary correctly' do - expect { cli.cull }.to output( - a_string_including('Visited 5 accounts, removed 3') - ).to_stdout + it 'touches inactive remote accounts that have not been deleted and summarizes activity' do + expect { subject } + .to change { tales.reload.updated_at } + .and output_results('Visited 5 accounts, removed 2') + expect_delete_inactive_remote_accounts + expect_not_delete_active_accounts end end context 'when a domain is specified' do - let(:domain) { 'example.net' } - let(:scope) { Account.remote.where(protocol: :activitypub, domain: domain).partitioned } + let(:arguments) { ['example.net'] } before do - allow(cli).to receive(:parallelize_with_progress).and_yield(gon) - .and_yield(tales) - .and_return([2, 2]) + stub_parallelize_with_progress! stub_request(:head, 'https://example.net/users/gon').to_return(status: 410) stub_request(:head, 'https://example.net/users/tales').to_return(status: 404) end - it 'deletes inactive remote accounts that longer exist in the specified domain' do - cli.cull(domain) - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once + def expect_delete_inactive_remote_accounts expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once expect(delete_account_service).to have_received(:call).with(tales, reserve_username: false).once end - it 'displays the summary correctly' do - expect { cli.cull }.to output( - a_string_including('Visited 2 accounts, removed 2') - ).to_stdout + it 'displays the summary correctly and deletes inactive remote accounts' do + expect { subject } + .to output_results('Visited 2 accounts, removed 2') + expect_delete_inactive_remote_accounts end end context 'when a domain is unavailable' do shared_examples 'an unavailable domain' do before do - allow(cli).to receive(:parallelize_with_progress).and_yield(tales).and_return([1, 0]) + stub_parallelize_with_progress! + stub_request(:head, 'https://example.org/users/bob').to_return(status: 200) + stub_request(:head, 'https://example.net/users/gon').to_return(status: 200) end - it 'skips accounts from the unavailable domain' do - cli.cull - + def expect_skip_accounts_from_unavailable_domain expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false) end - it 'displays the summary correctly' do - expect { cli.cull }.to output( - a_string_including("Visited 1 accounts, removed 0\nThe following domains were not available during the check:\n example.net") - ).to_stdout + it 'displays the summary correctly and skip accounts from unavailable domains' do + expect { subject } + .to output_results("Visited 5 accounts, removed 0\nThe following domains were not available during the check:\n example.net") + expect_skip_accounts_from_unavailable_domain end end @@ -1250,31 +1229,31 @@ describe Mastodon::CLI::Accounts do end describe '#reset_relationships' do + let(:action) { :reset_relationships } let(:target_account) { Fabricate(:account) } let(:arguments) { [target_account.username] } context 'when no option is given' do it 'exits with an error message indicating that at least one option is required' do - expect { cli.invoke(:reset_relationships, arguments) }.to output( - a_string_including('Please specify either --follows or --followers, or both') - ).to_stdout + expect { subject } + .to output_results('Please specify either --follows or --followers, or both') .and raise_error(SystemExit) end end context 'when the given username is not found' do let(:arguments) { ['non_existent_username'] } + let(:options) { { follows: true } } it 'exits with an error message indicating that there is no such account' do - expect { cli.invoke(:reset_relationships, arguments, follows: true) }.to output( - a_string_including('No such account') - ).to_stdout + expect { subject } + .to output_results('No such account') .and raise_error(SystemExit) end end context 'when the given username is found' do - let(:total_relationships) { 10 } + let(:total_relationships) { 3 } let!(:accounts) { Fabricate.times(total_relationships, :account) } context 'with --follows option' do @@ -1282,26 +1261,14 @@ describe Mastodon::CLI::Accounts do before do accounts.each { |account| target_account.follow!(account) } - end - - it 'resets all "following" relationships from the target account' do - cli.invoke(:reset_relationships, arguments, options) - - expect(target_account.reload.following).to be_empty - end - - it 'calls BootstrapTimelineWorker once to rebuild the timeline' do allow(BootstrapTimelineWorker).to receive(:perform_async) - - cli.invoke(:reset_relationships, arguments, options) - - expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once end - it 'displays a successful message' do - expect { cli.invoke(:reset_relationships, arguments, options) }.to output( - a_string_including("Processed #{total_relationships} relationships") - ).to_stdout + it 'resets following relationships and displays a successful message and rebuilds timeline' do + expect { subject } + .to output_results("Processed #{total_relationships} relationships") + expect(target_account.reload.following).to be_empty + expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once end end @@ -1312,53 +1279,264 @@ describe Mastodon::CLI::Accounts do accounts.each { |account| account.follow!(target_account) } end - it 'resets all "followers" relationships from the target account' do - cli.invoke(:reset_relationships, arguments, options) - + it 'resets followers relationships and displays a successful message' do + expect { subject } + .to output_results("Processed #{total_relationships} relationships") expect(target_account.reload.followers).to be_empty end - - it 'displays a successful message' do - expect { cli.invoke(:reset_relationships, arguments, options) }.to output( - a_string_including("Processed #{total_relationships} relationships") - ).to_stdout - end end context 'with --follows and --followers options' do let(:options) { { followers: true, follows: true } } before do - accounts.first(6).each { |account| account.follow!(target_account) } - accounts.last(4).each { |account| target_account.follow!(account) } - end - - it 'resets all "followers" relationships from the target account' do - cli.invoke(:reset_relationships, arguments, options) - - expect(target_account.reload.followers).to be_empty - end - - it 'resets all "following" relationships from the target account' do - cli.invoke(:reset_relationships, arguments, options) - - expect(target_account.reload.following).to be_empty - end - - it 'calls BootstrapTimelineWorker once to rebuild the timeline' do + accounts.first(2).each { |account| account.follow!(target_account) } + accounts.last(1).each { |account| target_account.follow!(account) } allow(BootstrapTimelineWorker).to receive(:perform_async) - - cli.invoke(:reset_relationships, arguments, options) - - expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once end - it 'displays a successful message' do - expect { cli.invoke(:reset_relationships, arguments, options) }.to output( - a_string_including("Processed #{total_relationships} relationships") - ).to_stdout + it 'resets followers and following and displays a successful message and rebuilds timeline' do + expect { subject } + .to output_results("Processed #{total_relationships} relationships") + expect(target_account.reload.followers).to be_empty + expect(target_account.reload.following).to be_empty + expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once end end end end + + describe '#prune' do + let(:action) { :prune } + let!(:local_account) { Fabricate(:account) } + let!(:bot_account) { Fabricate(:account, bot: true, domain: 'example.com') } + let!(:group_account) { Fabricate(:account, actor_type: 'Group', domain: 'example.com') } + let!(:mentioned_account) { Fabricate(:account, domain: 'example.com') } + let!(:prunable_accounts) do + Fabricate.times(2, :account, domain: 'example.com', bot: false, suspended_at: nil, silenced_at: nil) + end + + before do + Fabricate(:mention, account: mentioned_account, status: Fabricate(:status, account: Fabricate(:account))) + stub_parallelize_with_progress! + end + + def expect_prune_remote_accounts_without_interaction + prunable_account_ids = prunable_accounts.pluck(:id) + + expect(Account.where(id: prunable_account_ids).count).to eq(0) + end + + it 'displays a successful message and handles accounts correctly' do + expect { subject } + .to output_results("OK, pruned #{prunable_accounts.size} accounts") + expect_prune_remote_accounts_without_interaction + expect_not_prune_local_accounts + expect_not_prune_bot_accounts + expect_not_prune_group_accounts + expect_not_prune_mentioned_accounts + end + + def expect_not_prune_local_accounts + expect(Account.exists?(id: local_account.id)).to be(true) + end + + def expect_not_prune_bot_accounts + expect(Account.exists?(id: bot_account.id)).to be(true) + end + + def expect_not_prune_group_accounts + expect(Account.exists?(id: group_account.id)).to be(true) + end + + def expect_not_prune_mentioned_accounts + expect(Account.exists?(id: mentioned_account.id)).to be true + end + + context 'with --dry-run option' do + let(:options) { { dry_run: true } } + + def expect_no_account_prunes + prunable_account_ids = prunable_accounts.pluck(:id) + + expect(Account.where(id: prunable_account_ids).count).to eq(prunable_accounts.size) + end + + it 'displays a successful message with (DRY RUN) and doesnt prune anything' do + expect { subject } + .to output_results("OK, pruned #{prunable_accounts.size} accounts (DRY RUN)") + expect_no_account_prunes + end + end + end + + describe '#migrate' do + let(:action) { :migrate } + let!(:source_account) { Fabricate(:account) } + let!(:target_account) { Fabricate(:account, domain: 'example.com') } + let(:arguments) { [source_account.username] } + let(:resolve_account_service) { instance_double(ResolveAccountService, call: nil) } + let(:move_service) { instance_double(MoveService, call: nil) } + + before do + allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service) + allow(MoveService).to receive(:new).and_return(move_service) + end + + shared_examples 'a successful migration' do + it 'calls the MoveService for the last migration' do + expect { subject } + .to output_results('OK') + + last_migration = source_account.migrations.last + + expect(move_service).to have_received(:call).with(last_migration).once + end + + it 'displays a successful message' do + expect { subject } + .to output_results("OK, migrated #{source_account.acct} to #{target_account.acct}") + end + end + + context 'when both --replay and --target options are given' do + let(:options) { { replay: true, target: "#{target_account.username}@example.com" } } + + it 'exits with an error message indicating that using both options is not possible' do + expect { subject } + .to output_results('Use --replay or --target, not both') + .and raise_error(SystemExit) + end + end + + context 'when no option is given' do + it 'exits with an error message indicating that at least one option must be used' do + expect { subject } + .to output_results('Use either --replay or --target') + .and raise_error(SystemExit) + end + end + + context 'when the given username is not found' do + let(:arguments) { ['non_existent_username'] } + let(:options) { { replay: true } } + + it 'exits with an error message indicating that there is no such account' do + expect { subject } + .to output_results("No such account: #{arguments.first}") + .and raise_error(SystemExit) + end + end + + context 'with --replay option' do + let(:options) { { replay: true } } + + context 'when the specified account has no previous migrations' do + it 'exits with an error message indicating that the given account has no previous migrations' do + expect { subject } + .to output_results('The specified account has not performed any migration') + .and raise_error(SystemExit) + end + end + + context 'when the specified account has a previous migration' do + before do + allow(resolve_account_service).to receive(:call).with(source_account.acct, any_args).and_return(source_account) + allow(resolve_account_service).to receive(:call).with(target_account.acct, any_args).and_return(target_account) + target_account.aliases.create!(acct: source_account.acct) + source_account.migrations.create!(acct: target_account.acct) + source_account.update!(moved_to_account: target_account) + end + + it_behaves_like 'a successful migration' + + context 'when the specified account is redirecting to a different target account' do + before do + source_account.update!(moved_to_account: nil) + end + + it 'exits with an error message' do + expect { subject } + .to output_results('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway') + .and raise_error(SystemExit) + end + end + + context 'with --force option' do + let(:options) { { replay: true, force: true } } + + it_behaves_like 'a successful migration' + end + end + end + + context 'with --target option' do + let(:options) { { target: target_account.acct } } + + before do + allow(resolve_account_service).to receive(:call).with(source_account.acct, any_args).and_return(source_account) + allow(resolve_account_service).to receive(:call).with(target_account.acct, any_args).and_return(target_account) + end + + context 'when the specified target account is not found' do + before do + allow(resolve_account_service).to receive(:call).with(target_account.acct).and_return(nil) + end + + it 'exits with an error message indicating that there is no such account' do + expect { subject } + .to output_results("The specified target account could not be found: #{options[:target]}") + .and raise_error(SystemExit) + end + end + + context 'when the specified target account exists' do + before do + target_account.aliases.create!(acct: source_account.acct) + end + + it 'creates a migration for the specified account with the target account' do + expect { subject } + .to output_results('migrated') + + last_migration = source_account.migrations.last + + expect(last_migration.acct).to eq(target_account.acct) + end + + it_behaves_like 'a successful migration' + end + + context 'when the migration record is invalid' do + it 'exits with an error indicating that the validation failed' do + expect { subject } + .to output_results('Error: Validation failed') + .and raise_error(SystemExit) + end + end + + context 'when the specified account is redirecting to a different target account' do + before do + source_account.update(moved_to_account: Fabricate(:account)) + end + + it 'exits with an error message' do + expect { subject } + .to output_results('The specified account is redirecting to a different target account. Use --force if you want to change the migration target') + .and raise_error(SystemExit) + end + end + + context 'with --target and --force options' do + let(:options) { { target: target_account.acct, force: true } } + + before do + source_account.update(moved_to_account: Fabricate(:account)) + target_account.aliases.create!(acct: source_account.acct) + end + + it_behaves_like 'a successful migration' + end + end + end end diff --git a/spec/lib/mastodon/cli/cache_spec.rb b/spec/lib/mastodon/cli/cache_spec.rb index 3ab42dc8ce..b1515801eb 100644 --- a/spec/lib/mastodon/cli/cache_spec.rb +++ b/spec/lib/mastodon/cli/cache_spec.rb @@ -4,26 +4,29 @@ require 'rails_helper' require 'mastodon/cli/cache' describe Mastodon::CLI::Cache do - let(:cli) { described_class.new } + subject { cli.invoke(action, arguments, options) } - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true - end - end + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' describe '#clear' do + let(:action) { :clear } + before { allow(Rails.cache).to receive(:clear) } it 'clears the Rails cache' do - expect { cli.invoke(:clear) }.to output( - a_string_including('OK') - ).to_stdout + expect { subject } + .to output_results('OK') expect(Rails.cache).to have_received(:clear) end end describe '#recount' do + let(:action) { :recount } + context 'with the `accounts` argument' do let(:arguments) { ['accounts'] } let(:account_stat) { Fabricate(:account_stat) } @@ -33,9 +36,8 @@ describe Mastodon::CLI::Cache do end it 're-calculates account records in the cache' do - expect { cli.invoke(:recount, arguments) }.to output( - a_string_including('OK') - ).to_stdout + expect { subject } + .to output_results('OK') expect(account_stat.reload.statuses_count).to be_zero end @@ -50,9 +52,8 @@ describe Mastodon::CLI::Cache do end it 're-calculates account records in the cache' do - expect { cli.invoke(:recount, arguments) }.to output( - a_string_including('OK') - ).to_stdout + expect { subject } + .to output_results('OK') expect(status_stat.reload.replies_count).to be_zero end @@ -62,9 +63,9 @@ describe Mastodon::CLI::Cache do let(:arguments) { ['other-type'] } it 'Exits with an error message' do - expect { cli.invoke(:recount, arguments) }.to output( - a_string_including('Unknown') - ).to_stdout.and raise_error(SystemExit) + expect { subject } + .to output_results('Unknown') + .and raise_error(SystemExit) end end end diff --git a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb index eb57a3cd15..1745ea01bf 100644 --- a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb +++ b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb @@ -4,46 +4,45 @@ require 'rails_helper' require 'mastodon/cli/canonical_email_blocks' describe Mastodon::CLI::CanonicalEmailBlocks do - let(:cli) { described_class.new } + subject { cli.invoke(action, arguments, options) } - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true - end - end + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' describe '#find' do + let(:action) { :find } let(:arguments) { ['user@example.com'] } context 'when a block is present' do before { Fabricate(:canonical_email_block, email: 'user@example.com') } it 'announces the presence of the block' do - expect { cli.invoke(:find, arguments) }.to output( - a_string_including('user@example.com is blocked') - ).to_stdout + expect { subject } + .to output_results('user@example.com is blocked') end end context 'when a block is not present' do it 'announces the absence of the block' do - expect { cli.invoke(:find, arguments) }.to output( - a_string_including('user@example.com is not blocked') - ).to_stdout + expect { subject } + .to output_results('user@example.com is not blocked') end end end describe '#remove' do + let(:action) { :remove } let(:arguments) { ['user@example.com'] } context 'when a block is present' do before { Fabricate(:canonical_email_block, email: 'user@example.com') } it 'removes the block' do - expect { cli.invoke(:remove, arguments) }.to output( - a_string_including('Unblocked user@example.com') - ).to_stdout + expect { subject } + .to output_results('Unblocked user@example.com') expect(CanonicalEmailBlock.matching_email('user@example.com')).to be_empty end @@ -51,9 +50,8 @@ describe Mastodon::CLI::CanonicalEmailBlocks do context 'when a block is not present' do it 'announces the absence of the block' do - expect { cli.invoke(:remove, arguments) }.to output( - a_string_including('user@example.com is not blocked') - ).to_stdout + expect { subject } + .to output_results('user@example.com is not blocked') end end end diff --git a/spec/lib/mastodon/cli/domains_spec.rb b/spec/lib/mastodon/cli/domains_spec.rb index ea58845c00..24f341c124 100644 --- a/spec/lib/mastodon/cli/domains_spec.rb +++ b/spec/lib/mastodon/cli/domains_spec.rb @@ -4,9 +4,75 @@ require 'rails_helper' require 'mastodon/cli/domains' describe Mastodon::CLI::Domains do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#purge' do + let(:action) { :purge } + + context 'with accounts from the domain' do + let(:domain) { 'host.example' } + let!(:account) { Fabricate(:account, domain: domain) } + let(:arguments) { [domain] } + + it 'removes the account' do + expect { subject } + .to output_results('Removed 1 accounts') + + expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe '#crawl' do + let(:action) { :crawl } + + context 'with accounts from the domain' do + let(:domain) { 'host.example' } + + before do + Fabricate(:account, domain: domain) + stub_request(:get, 'https://host.example/api/v1/instance').to_return(status: 200, body: {}.to_json) + stub_request(:get, 'https://host.example/api/v1/instance/peers').to_return(status: 200, body: {}.to_json) + stub_request(:get, 'https://host.example/api/v1/instance/activity').to_return(status: 200, body: {}.to_json) + stub_const('Mastodon::CLI::Domains::CRAWL_SLEEP_TIME', 0) + end + + context 'with --format of summary' do + let(:options) { { format: 'summary' } } + + it 'crawls the domains and summarizes results' do + expect { subject } + .to output_results('Visited 1 domains, 0 failed') + end + end + + context 'with --format of domains' do + let(:options) { { format: 'domains' } } + + it 'crawls the domains and summarizes results' do + expect { subject } + .to output_results(domain) + end + end + + context 'with --format of json' do + let(:options) { { format: 'json' } } + + it 'crawls the domains and summarizes results' do + expect { subject } + .to output_results(json_summary) + end + + def json_summary + Oj.dump('host.example': { activity: {} }) + end + end end end end diff --git a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb index 333ae3f2b7..13deb05b6c 100644 --- a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb +++ b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb @@ -4,9 +4,101 @@ require 'rails_helper' require 'mastodon/cli/email_domain_blocks' describe Mastodon::CLI::EmailDomainBlocks do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#list' do + let(:action) { :list } + + context 'with email domain block records' do + let!(:parent_block) { Fabricate(:email_domain_block) } + let!(:child_block) { Fabricate(:email_domain_block, parent: parent_block) } + + it 'lists the blocks' do + expect { subject } + .to output_results( + parent_block.domain, + child_block.domain + ) + end + end + end + + describe '#add' do + let(:action) { :add } + + context 'without any options' do + it 'warns about usage and exits' do + expect { subject } + .to output_results('No domain(s) given') + .and raise_error(SystemExit) + end + end + + context 'when blocks exist' do + let(:options) { {} } + let(:domain) { 'host.example' } + let(:arguments) { [domain] } + + before { Fabricate(:email_domain_block, domain: domain) } + + it 'does not add a new block' do + expect { subject } + .to output_results('is already blocked') + .and(not_change(EmailDomainBlock, :count)) + end + end + + context 'when no blocks exist' do + let(:domain) { 'host.example' } + let(:arguments) { [domain] } + + it 'adds a new block' do + expect { subject } + .to output_results('Added 1') + .and(change(EmailDomainBlock, :count).by(1)) + end + end + end + + describe '#remove' do + let(:action) { :remove } + + context 'without any options' do + it 'warns about usage and exits' do + expect { subject } + .to output_results('No domain(s) given') + .and raise_error(SystemExit) + end + end + + context 'when blocks exist' do + let(:domain) { 'host.example' } + let(:arguments) { [domain] } + + before { Fabricate(:email_domain_block, domain: domain) } + + it 'removes the block' do + expect { subject } + .to output_results('Removed 1') + .and(change(EmailDomainBlock, :count).by(-1)) + end + end + + context 'when no blocks exist' do + let(:domain) { 'host.example' } + let(:arguments) { [domain] } + + it 'does not remove a block' do + expect { subject } + .to output_results('is not yet blocked') + .and(not_change(EmailDomainBlock, :count)) + end end end end diff --git a/spec/lib/mastodon/cli/emoji_spec.rb b/spec/lib/mastodon/cli/emoji_spec.rb index 9b58653729..d05e972e77 100644 --- a/spec/lib/mastodon/cli/emoji_spec.rb +++ b/spec/lib/mastodon/cli/emoji_spec.rb @@ -4,9 +4,61 @@ require 'rails_helper' require 'mastodon/cli/emoji' describe Mastodon::CLI::Emoji do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#purge' do + let(:action) { :purge } + + context 'with existing custom emoji' do + before { Fabricate(:custom_emoji) } + + it 'reports a successful purge' do + expect { subject } + .to output_results('OK') + end + end + end + + describe '#import' do + context 'with existing custom emoji' do + let(:import_path) { Rails.root.join('spec', 'fixtures', 'files', 'elite-assets.tar.gz') } + let(:action) { :import } + let(:arguments) { [import_path] } + + it 'reports about imported emoji' do + expect { subject } + .to output_results('Imported 1') + .and change(CustomEmoji, :count).by(1) + end + end + end + + describe '#export' do + context 'with existing custom emoji' do + before do + FileUtils.rm_rf(export_path.dirname) + FileUtils.mkdir_p(export_path.dirname) + + Fabricate(:custom_emoji) + end + + after { FileUtils.rm_rf(export_path.dirname) } + + let(:export_path) { Rails.root.join('tmp', 'cli-tests', 'export.tar.gz') } + let(:arguments) { [export_path.dirname.to_s] } + let(:action) { :export } + + it 'reports about exported emoji' do + expect { subject } + .to output_results('Exported 1') + .and change { File.exist?(export_path) }.from(false).to(true) + end end end end diff --git a/spec/lib/mastodon/cli/feeds_spec.rb b/spec/lib/mastodon/cli/feeds_spec.rb index 030f087212..1997980527 100644 --- a/spec/lib/mastodon/cli/feeds_spec.rb +++ b/spec/lib/mastodon/cli/feeds_spec.rb @@ -4,24 +4,25 @@ require 'rails_helper' require 'mastodon/cli/feeds' describe Mastodon::CLI::Feeds do - let(:cli) { described_class.new } + subject { cli.invoke(action, arguments, options) } - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true - end - end + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' describe '#build' do + let(:action) { :build } + before { Fabricate(:account) } context 'with --all option' do let(:options) { { all: true } } it 'regenerates feeds for all accounts' do - expect { cli.invoke(:build, [], options) }.to output( - a_string_including('Regenerated feeds') - ).to_stdout + expect { subject } + .to output_results('Regenerated feeds') end end @@ -31,9 +32,8 @@ describe Mastodon::CLI::Feeds do let(:arguments) { ['alice'] } it 'regenerates feeds for the account' do - expect { cli.invoke(:build, arguments) }.to output( - a_string_including('OK') - ).to_stdout + expect { subject } + .to output_results('OK') end end @@ -41,22 +41,23 @@ describe Mastodon::CLI::Feeds do let(:arguments) { ['invalid-username'] } it 'displays an error and exits' do - expect { cli.invoke(:build, arguments) }.to output( - a_string_including('No such account') - ).to_stdout.and raise_error(SystemExit) + expect { subject } + .to output_results('No such account') + .and raise_error(SystemExit) end end end describe '#clear' do + let(:action) { :clear } + before do allow(redis).to receive(:del).with(key_namespace) end it 'clears the redis `feed:*` namespace' do - expect { cli.invoke(:clear) }.to output( - a_string_including('OK') - ).to_stdout + expect { subject } + .to output_results('OK') expect(redis).to have_received(:del).with(key_namespace).once end diff --git a/spec/lib/mastodon/cli/ip_blocks_spec.rb b/spec/lib/mastodon/cli/ip_blocks_spec.rb index 030d9fcb19..82be10813e 100644 --- a/spec/lib/mastodon/cli/ip_blocks_spec.rb +++ b/spec/lib/mastodon/cli/ip_blocks_spec.rb @@ -4,15 +4,16 @@ require 'rails_helper' require 'mastodon/cli/ip_blocks' describe Mastodon::CLI::IpBlocks do - let(:cli) { described_class.new } + subject { cli.invoke(action, arguments, options) } - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true - end - end + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' describe '#add' do + let(:action) { :add } let(:ip_list) do [ '192.0.2.1', @@ -29,29 +30,28 @@ describe Mastodon::CLI::IpBlocks do ] end let(:options) { { severity: 'no_access' } } + let(:arguments) { ip_list } shared_examples 'ip address blocking' do - it 'blocks all specified IP addresses' do - cli.invoke(:add, ip_list, options) - - blocked_ip_addresses = IpBlock.where(ip: ip_list).pluck(:ip) - expected_ip_addresses = ip_list.map { |ip| IPAddr.new(ip) } - - expect(blocked_ip_addresses).to match_array(expected_ip_addresses) + def blocked_ip_addresses + IpBlock.where(ip: ip_list).pluck(:ip) end - it 'sets the severity for all blocked IP addresses' do - cli.invoke(:add, ip_list, options) - - blocked_ips_severity = IpBlock.where(ip: ip_list).pluck(:severity).all?(options[:severity]) - - expect(blocked_ips_severity).to be(true) + def expected_ip_addresses + ip_list.map { |ip| IPAddr.new(ip) } end - it 'displays a success message with a summary' do - expect { cli.invoke(:add, ip_list, options) }.to output( - a_string_including("Added #{ip_list.size}, skipped 0, failed 0") - ).to_stdout + def blocked_ips_severity + IpBlock.where(ip: ip_list).pluck(:severity).all?(options[:severity]) + end + + it 'blocks and sets severity for ip address and displays summary' do + expect { subject } + .to output_results("Added #{ip_list.size}, skipped 0, failed 0") + expect(blocked_ip_addresses) + .to match_array(expected_ip_addresses) + expect(blocked_ips_severity) + .to be(true) end end @@ -61,28 +61,25 @@ describe Mastodon::CLI::IpBlocks do context 'when a specified IP address is already blocked' do let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: options[:severity]) } + let(:arguments) { ip_list } - it 'skips the already blocked IP address' do - allow(IpBlock).to receive(:new).and_call_original + before { allow(IpBlock).to receive(:new).and_call_original } - cli.invoke(:add, ip_list, options) + it 'skips already block ip and displays the correct summary' do + expect { subject } + .to output_results("#{ip_list.last} is already blocked\nAdded #{ip_list.size - 1}, skipped 1, failed 0") expect(IpBlock).to_not have_received(:new).with(ip: ip_list.last) end - it 'displays the correct summary' do - expect { cli.invoke(:add, ip_list, options) }.to output( - a_string_including("#{ip_list.last} is already blocked\nAdded #{ip_list.size - 1}, skipped 1, failed 0") - ).to_stdout - end - context 'with --force option' do let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: 'no_access') } let(:options) { { severity: 'sign_up_requires_approval', force: true } } it 'overwrites the existing IP block record' do - expect { cli.invoke(:add, ip_list, options) } - .to change { blocked_ip.reload.severity } + expect { subject } + .to output_results('Added 11') + .and change { blocked_ip.reload.severity } .from('no_access') .to('sign_up_requires_approval') end @@ -93,11 +90,11 @@ describe Mastodon::CLI::IpBlocks do context 'when a specified IP address is invalid' do let(:ip_list) { ['320.15.175.0', '9.5.105.255', '0.0.0.0'] } + let(:arguments) { ip_list } it 'displays the correct summary' do - expect { cli.invoke(:add, ip_list, options) }.to output( - a_string_including("#{ip_list.first} is invalid\nAdded #{ip_list.size - 1}, skipped 0, failed 1") - ).to_stdout + expect { subject } + .to output_results("#{ip_list.first} is invalid\nAdded #{ip_list.size - 1}, skipped 0, failed 1") end end @@ -128,6 +125,7 @@ describe Mastodon::CLI::IpBlocks do context 'when a specified IP address fails to be blocked' do let(:ip_address) { '127.0.0.1' } let(:ip_block) { instance_double(IpBlock, ip: ip_address, save: false) } + let(:arguments) { [ip_address] } before do allow(IpBlock).to receive(:new).and_return(ip_block) @@ -136,24 +134,25 @@ describe Mastodon::CLI::IpBlocks do end it 'displays an error message' do - expect { cli.invoke(:add, [ip_address], options) } - .to output( - a_string_including("#{ip_address} could not be saved") - ).to_stdout + expect { subject } + .to output_results("#{ip_address} could not be saved") end end context 'when no IP address is provided' do + let(:arguments) { [] } + it 'exits with an error message' do - expect { cli.add }.to output( - a_string_including('No IP(s) given') - ).to_stdout + expect { subject } + .to output_results('No IP(s) given') .and raise_error(SystemExit) end end end describe '#remove' do + let(:action) { :remove } + context 'when removing exact matches' do let(:ip_list) do [ @@ -170,22 +169,17 @@ describe Mastodon::CLI::IpBlocks do '::/128', ] end + let(:arguments) { ip_list } before do ip_list.each { |ip| IpBlock.create(ip: ip, severity: :no_access) } end - it 'removes exact IP blocks' do - cli.invoke(:remove, ip_list) - + it 'removes exact ip blocks and displays success message with a summary' do + expect { subject } + .to output_results("Removed #{ip_list.size}, skipped 0") expect(IpBlock.where(ip: ip_list)).to_not exist end - - it 'displays success message with a summary' do - expect { cli.invoke(:remove, ip_list) }.to output( - a_string_including("Removed #{ip_list.size}, skipped 0") - ).to_stdout - end end context 'with --force option' do @@ -195,62 +189,61 @@ describe Mastodon::CLI::IpBlocks do let(:arguments) { ['192.168.0.5', '10.0.1.50'] } let(:options) { { force: true } } - it 'removes blocks for IP ranges that cover given IP(s)' do - cli.invoke(:remove, arguments, options) + it 'removes blocks for IP ranges that cover given IP(s) and keeps other ranges' do + expect { subject } + .to output_results('Removed 2') - expect(IpBlock.where(id: [first_ip_range_block.id, second_ip_range_block.id])).to_not exist + expect(covered_ranges).to_not exist + expect(other_ranges).to exist end - it 'does not remove other IP ranges' do - cli.invoke(:remove, arguments, options) + def covered_ranges + IpBlock.where(id: [first_ip_range_block.id, second_ip_range_block.id]) + end - expect(IpBlock.where(id: third_ip_range_block.id)).to exist + def other_ranges + IpBlock.where(id: third_ip_range_block.id) end end context 'when a specified IP address is not blocked' do let(:unblocked_ip) { '192.0.2.1' } + let(:arguments) { [unblocked_ip] } - it 'skips the IP address' do - expect { cli.invoke(:remove, [unblocked_ip]) }.to output( - a_string_including("#{unblocked_ip} is not yet blocked") - ).to_stdout - end - - it 'displays the summary correctly' do - expect { cli.invoke(:remove, [unblocked_ip]) }.to output( - a_string_including('Removed 0, skipped 1') - ).to_stdout + it 'skips the IP address and displays summary' do + expect { subject } + .to output_results( + "#{unblocked_ip} is not yet blocked", + 'Removed 0, skipped 1' + ) end end context 'when a specified IP address is invalid' do let(:invalid_ip) { '320.15.175.0' } + let(:arguments) { [invalid_ip] } - it 'skips the invalid IP address' do - expect { cli.invoke(:remove, [invalid_ip]) }.to output( - a_string_including("#{invalid_ip} is invalid") - ).to_stdout - end - - it 'displays the summary correctly' do - expect { cli.invoke(:remove, [invalid_ip]) }.to output( - a_string_including('Removed 0, skipped 1') - ).to_stdout + it 'skips the invalid IP address and displays summary' do + expect { subject } + .to output_results( + "#{invalid_ip} is invalid", + 'Removed 0, skipped 1' + ) end end context 'when no IP address is provided' do it 'exits with an error message' do - expect { cli.remove }.to output( - a_string_including('No IP(s) given') - ).to_stdout + expect { subject } + .to output_results('No IP(s) given') .and raise_error(SystemExit) end end end describe '#export' do + let(:action) { :export } + let(:first_ip_range_block) { IpBlock.create(ip: '192.168.0.0/24', severity: :no_access) } let(:second_ip_range_block) { IpBlock.create(ip: '10.0.0.0/16', severity: :no_access) } let(:third_ip_range_block) { IpBlock.create(ip: '127.0.0.1', severity: :sign_up_block) } @@ -259,15 +252,13 @@ describe Mastodon::CLI::IpBlocks do let(:options) { { format: 'plain' } } it 'exports blocked IPs with "no_access" severity in plain format' do - expect { cli.invoke(:export, nil, options) }.to output( - a_string_including("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}") - ).to_stdout + expect { subject } + .to output_results("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}") end - it 'does not export bloked IPs with different severities' do - expect { cli.invoke(:export, nil, options) }.to_not output( - a_string_including("#{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}") - ).to_stdout + it 'does not export blocked IPs with different severities' do + expect { subject } + .to_not output_results("#{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}") end end @@ -275,23 +266,20 @@ describe Mastodon::CLI::IpBlocks do let(:options) { { format: 'nginx' } } it 'exports blocked IPs with "no_access" severity in plain format' do - expect { cli.invoke(:export, nil, options) }.to output( - a_string_including("deny #{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};\ndeny #{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix};") - ).to_stdout + expect { subject } + .to output_results("deny #{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};\ndeny #{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix};") end - it 'does not export bloked IPs with different severities' do - expect { cli.invoke(:export, nil, options) }.to_not output( - a_string_including("deny #{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};") - ).to_stdout + it 'does not export blocked IPs with different severities' do + expect { subject } + .to_not output_results("deny #{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};") end end context 'when --format option is not provided' do it 'exports blocked IPs in plain format by default' do - expect { cli.export }.to output( - a_string_including("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}") - ).to_stdout + expect { subject } + .to output_results("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}") end end end diff --git a/spec/lib/mastodon/cli/main_spec.rb b/spec/lib/mastodon/cli/main_spec.rb index e3709afe37..59f1fc4784 100644 --- a/spec/lib/mastodon/cli/main_spec.rb +++ b/spec/lib/mastodon/cli/main_spec.rb @@ -4,17 +4,20 @@ require 'rails_helper' require 'mastodon/cli/main' describe Mastodon::CLI::Main do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true - end - end + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#version' do + let(:action) { :version } - describe 'version' do it 'returns the Mastodon version' do - expect { described_class.new.invoke(:version) }.to output( - a_string_including(Mastodon::Version.to_s) - ).to_stdout + expect { subject } + .to output_results(Mastodon::Version.to_s) end end end diff --git a/spec/lib/mastodon/cli/maintenance_spec.rb b/spec/lib/mastodon/cli/maintenance_spec.rb index 12cd9ca8a6..ca492bbf69 100644 --- a/spec/lib/mastodon/cli/maintenance_spec.rb +++ b/spec/lib/mastodon/cli/maintenance_spec.rb @@ -4,9 +4,602 @@ require 'rails_helper' require 'mastodon/cli/maintenance' describe Mastodon::CLI::Maintenance do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#fix_duplicates' do + let(:action) { :fix_duplicates } + + context 'when the database version is too old' do + before do + allow(ActiveRecord::Migrator).to receive(:current_version).and_return(2000_01_01_000000) # Earlier than minimum + end + + it 'Exits with error message' do + expect { subject } + .to output_results('is too old') + .and raise_error(SystemExit) + end + end + + context 'when the database version is too new and the user does not continue' do + before do + allow(ActiveRecord::Migrator).to receive(:current_version).and_return(2100_01_01_000000) # Later than maximum + allow(cli.shell).to receive(:yes?).with('Continue anyway? (Yes/No)').and_return(false).once + end + + it 'Exits with error message' do + expect { subject } + .to output_results('more recent') + .and raise_error(SystemExit) + end + end + + context 'when Sidekiq is running' do + before do + allow(ActiveRecord::Migrator).to receive(:current_version).and_return(2022_01_01_000000) # Higher than minimum, lower than maximum + allow(Sidekiq::ProcessSet).to receive(:new).and_return [:process] + end + + it 'Exits with error message' do + expect { subject } + .to output_results('Sidekiq is running') + .and raise_error(SystemExit) + end + end + + context 'when requirements are met' do + before do + allow(ActiveRecord::Migrator).to receive(:current_version).and_return(2023_08_22_081029) # The latest migration before the cutoff + agree_to_backup_warning + end + + context 'with duplicate accounts' do + before do + prepare_duplicate_data + choose_local_account_to_keep + end + + let(:duplicate_account_username) { 'username' } + let(:duplicate_account_domain) { 'host.example' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating accounts', + 'Multiple local accounts were found for', + 'Restoring index_accounts_on_username_and_domain_lower', + 'Reindexing textual indexes on accounts…', + 'Finished!' + ) + .and change(duplicate_remote_accounts, :count).from(2).to(1) + .and change(duplicate_local_accounts, :count).from(2).to(1) + end + + def duplicate_remote_accounts + Account.where(username: duplicate_account_username, domain: duplicate_account_domain) + end + + def duplicate_local_accounts + Account.where(username: duplicate_account_username, domain: nil) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :accounts, name: :index_accounts_on_username_and_domain_lower + _remote_account = Fabricate(:account, username: duplicate_account_username, domain: duplicate_account_domain) + _remote_account_dupe = Fabricate.build(:account, username: duplicate_account_username, domain: duplicate_account_domain).save(validate: false) + _local_account = Fabricate(:account, username: duplicate_account_username, domain: nil) + _local_account_dupe = Fabricate.build(:account, username: duplicate_account_username, domain: nil).save(validate: false) + end + + def choose_local_account_to_keep + allow(cli.shell) + .to receive(:ask) + .with(/Account to keep unchanged/, anything) + .and_return('0') + .once + end + end + + context 'with duplicate users on email' do + before do + prepare_duplicate_data + end + + let(:duplicate_email) { 'duplicate@example.host' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating user records', + 'Restoring users indexes', + 'Finished!' + ) + .and change(duplicate_users, :count).from(2).to(1) + end + + def duplicate_users + User.where(email: duplicate_email) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :users, :email + Fabricate(:user, email: duplicate_email) + Fabricate.build(:user, email: duplicate_email).save(validate: false) + end + end + + context 'with duplicate users on confirmation_token' do + before do + prepare_duplicate_data + end + + let(:duplicate_confirmation_token) { '123ABC' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating user records', + 'Unsetting confirmation token', + 'Restoring users indexes', + 'Finished!' + ) + .and change(duplicate_users, :count).from(2).to(1) + end + + def duplicate_users + User.where(confirmation_token: duplicate_confirmation_token) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :users, :confirmation_token + Fabricate(:user, confirmation_token: duplicate_confirmation_token) + Fabricate.build(:user, confirmation_token: duplicate_confirmation_token).save(validate: false) + end + end + + context 'with duplicate users on reset_password_token' do + before do + prepare_duplicate_data + end + + let(:duplicate_reset_password_token) { '123ABC' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating user records', + 'Unsetting password reset token', + 'Restoring users indexes', + 'Finished!' + ) + .and change(duplicate_users, :count).from(2).to(1) + end + + def duplicate_users + User.where(reset_password_token: duplicate_reset_password_token) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :users, :reset_password_token + Fabricate(:user, reset_password_token: duplicate_reset_password_token) + Fabricate.build(:user, reset_password_token: duplicate_reset_password_token).save(validate: false) + end + end + + context 'with duplicate account_domain_blocks' do + before do + prepare_duplicate_data + end + + let(:duplicate_domain) { 'example.host' } + let(:account) { Fabricate(:account) } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Removing duplicate account domain blocks', + 'Restoring account domain blocks indexes', + 'Finished!' + ) + .and change(duplicate_account_domain_blocks, :count).from(2).to(1) + end + + def duplicate_account_domain_blocks + AccountDomainBlock.where(account: account, domain: duplicate_domain) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :account_domain_blocks, [:account_id, :domain] + Fabricate(:account_domain_block, account: account, domain: duplicate_domain) + Fabricate.build(:account_domain_block, account: account, domain: duplicate_domain).save(validate: false) + end + end + + context 'with duplicate announcement_reactions' do + before do + prepare_duplicate_data + end + + let(:account) { Fabricate(:account) } + let(:announcement) { Fabricate(:announcement) } + let(:name) { Fabricate(:custom_emoji).shortcode } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Removing duplicate announcement reactions', + 'Restoring announcement_reactions indexes', + 'Finished!' + ) + .and change(duplicate_announcement_reactions, :count).from(2).to(1) + end + + def duplicate_announcement_reactions + AnnouncementReaction.where(account: account, announcement: announcement, name: name) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :announcement_reactions, [:account_id, :announcement_id, :name] + Fabricate(:announcement_reaction, account: account, announcement: announcement, name: name) + Fabricate.build(:announcement_reaction, account: account, announcement: announcement, name: name).save(validate: false) + end + end + + context 'with duplicate conversations' do + before do + prepare_duplicate_data + end + + let(:uri) { 'https://example.host/path' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating conversations', + 'Restoring conversations indexes', + 'Finished!' + ) + .and change(duplicate_conversations, :count).from(2).to(1) + end + + def duplicate_conversations + Conversation.where(uri: uri) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :conversations, :uri + Fabricate(:conversation, uri: uri) + Fabricate.build(:conversation, uri: uri).save(validate: false) + end + end + + context 'with duplicate custom_emojis' do + before do + prepare_duplicate_data + end + + let(:duplicate_shortcode) { 'wowzers' } + let(:duplicate_domain) { 'example.host' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating custom_emojis', + 'Restoring custom_emojis indexes', + 'Finished!' + ) + .and change(duplicate_custom_emojis, :count).from(2).to(1) + end + + def duplicate_custom_emojis + CustomEmoji.where(shortcode: duplicate_shortcode, domain: duplicate_domain) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :custom_emojis, [:shortcode, :domain] + Fabricate(:custom_emoji, shortcode: duplicate_shortcode, domain: duplicate_domain) + Fabricate.build(:custom_emoji, shortcode: duplicate_shortcode, domain: duplicate_domain).save(validate: false) + end + end + + context 'with duplicate custom_emoji_categories' do + before do + prepare_duplicate_data + end + + let(:duplicate_name) { 'name_value' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating custom_emoji_categories', + 'Restoring custom_emoji_categories indexes', + 'Finished!' + ) + .and change(duplicate_custom_emoji_categories, :count).from(2).to(1) + end + + def duplicate_custom_emoji_categories + CustomEmojiCategory.where(name: duplicate_name) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :custom_emoji_categories, :name + Fabricate(:custom_emoji_category, name: duplicate_name) + Fabricate.build(:custom_emoji_category, name: duplicate_name).save(validate: false) + end + end + + context 'with duplicate domain_allows' do + before do + prepare_duplicate_data + end + + let(:domain) { 'example.host' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating domain_allows', + 'Restoring domain_allows indexes', + 'Finished!' + ) + .and change(duplicate_domain_allows, :count).from(2).to(1) + end + + def duplicate_domain_allows + DomainAllow.where(domain: domain) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :domain_allows, :domain + Fabricate(:domain_allow, domain: domain) + Fabricate.build(:domain_allow, domain: domain).save(validate: false) + end + end + + context 'with duplicate domain_blocks' do + before do + prepare_duplicate_data + end + + let(:domain) { 'example.host' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating domain_blocks', + 'Restoring domain_blocks indexes', + 'Finished!' + ) + .and change(duplicate_domain_blocks, :count).from(2).to(1) + end + + def duplicate_domain_blocks + DomainBlock.where(domain: domain) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :domain_blocks, :domain + Fabricate(:domain_block, domain: domain) + Fabricate.build(:domain_block, domain: domain).save(validate: false) + end + end + + context 'with duplicate email_domain_blocks' do + before do + prepare_duplicate_data + end + + let(:domain) { 'example.host' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating email_domain_blocks', + 'Restoring email_domain_blocks indexes', + 'Finished!' + ) + .and change(duplicate_email_domain_blocks, :count).from(2).to(1) + end + + def duplicate_email_domain_blocks + EmailDomainBlock.where(domain: domain) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :email_domain_blocks, :domain + Fabricate(:email_domain_block, domain: domain) + Fabricate.build(:email_domain_block, domain: domain).save(validate: false) + end + end + + context 'with duplicate media_attachments' do + before do + prepare_duplicate_data + end + + let(:shortcode) { 'codenam' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating media_attachments', + 'Restoring media_attachments indexes', + 'Finished!' + ) + .and change(duplicate_media_attachments, :count).from(2).to(1) + end + + def duplicate_media_attachments + MediaAttachment.where(shortcode: shortcode) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :media_attachments, :shortcode + Fabricate(:media_attachment, shortcode: shortcode) + Fabricate.build(:media_attachment, shortcode: shortcode).save(validate: false) + end + end + + context 'with duplicate preview_cards' do + before do + prepare_duplicate_data + end + + let(:url) { 'https://example.host/path' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating preview_cards', + 'Restoring preview_cards indexes', + 'Finished!' + ) + .and change(duplicate_preview_cards, :count).from(2).to(1) + end + + def duplicate_preview_cards + PreviewCard.where(url: url) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :preview_cards, :url + Fabricate(:preview_card, url: url) + Fabricate.build(:preview_card, url: url).save(validate: false) + end + end + + context 'with duplicate statuses' do + before do + prepare_duplicate_data + end + + let(:uri) { 'https://example.host/path' } + let(:account) { Fabricate(:account) } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating statuses', + 'Restoring statuses indexes', + 'Finished!' + ) + .and change(duplicate_statuses, :count).from(2).to(1) + end + + def duplicate_statuses + Status.where(uri: uri) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :statuses, :uri + Fabricate(:status, account: account, uri: uri) + duplicate = Fabricate.build(:status, account: account, uri: uri) + duplicate.save(validate: false) + Fabricate(:status_pin, account: account, status: duplicate) + Fabricate(:status, in_reply_to_id: duplicate.id) + Fabricate(:status, reblog_of_id: duplicate.id) + end + end + + context 'with duplicate tags' do + before do + prepare_duplicate_data + end + + let(:name) { 'tagname' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating tags', + 'Restoring tags indexes', + 'Finished!' + ) + .and change(duplicate_tags, :count).from(2).to(1) + end + + def duplicate_tags + Tag.where(name: name) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :tags, name: 'index_tags_on_name_lower_btree' + Fabricate(:tag, name: name) + Fabricate.build(:tag, name: name).save(validate: false) + end + end + + context 'with duplicate webauthn_credentials' do + before do + prepare_duplicate_data + end + + let(:external_id) { '123_123_123' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating webauthn_credentials', + 'Restoring webauthn_credentials indexes', + 'Finished!' + ) + .and change(duplicate_webauthn_credentials, :count).from(2).to(1) + end + + def duplicate_webauthn_credentials + WebauthnCredential.where(external_id: external_id) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :webauthn_credentials, :external_id + Fabricate(:webauthn_credential, external_id: external_id) + Fabricate.build(:webauthn_credential, external_id: external_id).save(validate: false) + end + end + + context 'with duplicate webhooks' do + before do + prepare_duplicate_data + end + + let(:url) { 'https://example.host/path' } + + it 'runs the deduplication process' do + expect { subject } + .to output_results( + 'Deduplicating webhooks', + 'Restoring webhooks indexes', + 'Finished!' + ) + .and change(duplicate_webhooks, :count).from(2).to(1) + end + + def duplicate_webhooks + Webhook.where(url: url) + end + + def prepare_duplicate_data + ActiveRecord::Base.connection.remove_index :webhooks, :url + Fabricate(:webhook, url: url) + Fabricate.build(:webhook, url: url).save(validate: false) + end + end + + def agree_to_backup_warning + allow(cli.shell) + .to receive(:yes?) + .with('Continue? (Yes/No)') + .and_return(true) + .once + end end end end diff --git a/spec/lib/mastodon/cli/media_spec.rb b/spec/lib/mastodon/cli/media_spec.rb index 29f7d424a9..24e1467a3c 100644 --- a/spec/lib/mastodon/cli/media_spec.rb +++ b/spec/lib/mastodon/cli/media_spec.rb @@ -4,9 +4,184 @@ require 'rails_helper' require 'mastodon/cli/media' describe Mastodon::CLI::Media do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#remove' do + let(:action) { :remove } + + context 'with --prune-profiles and --remove-headers' do + let(:options) { { prune_profiles: true, remove_headers: true } } + + it 'warns about usage and exits' do + expect { subject } + .to output_results('--prune-profiles and --remove-headers should not be specified simultaneously') + .and raise_error(SystemExit) + end + end + + context 'with --include-follows but not including --prune-profiles and --remove-headers' do + let(:options) { { include_follows: true } } + + it 'warns about usage and exits' do + expect { subject } + .to output_results('--include-follows can only be used with --prune-profiles or --remove-headers') + .and raise_error(SystemExit) + end + end + + context 'with a relevant account' do + let!(:account) do + Fabricate(:account, domain: 'example.com', updated_at: 1.month.ago, last_webfingered_at: 1.month.ago, avatar: attachment_fixture('attachment.jpg'), header: attachment_fixture('attachment.jpg')) + end + + context 'with --prune-profiles' do + let(:options) { { prune_profiles: true } } + + it 'removes account avatars' do + expect { subject } + .to output_results('Visited 1') + + expect(account.reload.avatar).to be_blank + end + end + + context 'with --remove-headers' do + let(:options) { { remove_headers: true } } + + it 'removes account header' do + expect { subject } + .to output_results('Visited 1') + + expect(account.reload.header).to be_blank + end + end + end + + context 'with a relevant media attachment' do + let!(:media_attachment) { Fabricate(:media_attachment, remote_url: 'https://example.com/image.jpg', created_at: 1.month.ago) } + + context 'without options' do + it 'removes account avatars' do + expect { subject } + .to output_results('Removed 1') + + expect(media_attachment.reload.file).to be_blank + expect(media_attachment.reload.thumbnail).to be_blank + end + end + end + end + + describe '#usage' do + let(:action) { :usage } + + context 'without options' do + it 'reports about storage size' do + expect { subject } + .to output_results('0 Bytes') + end + end + end + + describe '#lookup' do + let(:action) { :lookup } + let(:arguments) { [url] } + + context 'with valid url not connected to a record' do + let(:url) { 'https://example.host/assets/1' } + + it 'warns about url and exits' do + expect { subject } + .to output_results('Not a media URL') + .and raise_error(SystemExit) + end + end + + context 'with a valid media url' do + let(:status) { Fabricate(:status) } + let(:media_attachment) { Fabricate(:media_attachment, status: status) } + let(:url) { media_attachment.file.url(:original) } + + it 'displays the url of a connected status' do + expect { subject } + .to output_results(status.id.to_s) + end + end + end + + describe '#refresh' do + let(:action) { :refresh } + + context 'without any options' do + it 'warns about usage and exits' do + expect { subject } + .to output_results('Specify the source') + .and raise_error(SystemExit) + end + end + + context 'with --status option' do + before do + media_attachment.update(file_file_name: nil) + end + + let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'https://host.example/asset.jpg') } + let(:options) { { status: status.id } } + let(:status) { Fabricate(:status) } + + it 'redownloads the attachment file' do + expect { subject } + .to output_results('Downloaded 1 media') + end + end + + context 'with --account option' do + context 'when the account does not exist' do + let(:options) { { account: 'not-real-user@example.host' } } + + it 'warns about usage and exits' do + expect { subject } + .to output_results('No such account') + .and raise_error(SystemExit) + end + end + + context 'when the account exists' do + before do + media_attachment.update(file_file_name: nil) + end + + let(:media_attachment) { Fabricate(:media_attachment, account: account) } + let(:options) { { account: account.acct } } + let(:account) { Fabricate(:account) } + + it 'redownloads the attachment file' do + expect { subject } + .to output_results('Downloaded 1 media') + end + end + end + + context 'with --domain option' do + before do + media_attachment.update(file_file_name: nil) + end + + let(:domain) { 'example.host' } + let(:media_attachment) { Fabricate(:media_attachment, account: account) } + let(:options) { { domain: domain } } + let(:account) { Fabricate(:account, domain: domain) } + + it 'redownloads the attachment file' do + expect { subject } + .to output_results('Downloaded 1 media') + end end end end diff --git a/spec/lib/mastodon/cli/preview_cards_spec.rb b/spec/lib/mastodon/cli/preview_cards_spec.rb index b4b018b3be..951ae3758f 100644 --- a/spec/lib/mastodon/cli/preview_cards_spec.rb +++ b/spec/lib/mastodon/cli/preview_cards_spec.rb @@ -4,9 +4,57 @@ require 'rails_helper' require 'mastodon/cli/preview_cards' describe Mastodon::CLI::PreviewCards do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#remove' do + let(:action) { :remove } + + context 'with relevant preview cards' do + before do + Fabricate(:preview_card, updated_at: 10.years.ago, type: :link) + Fabricate(:preview_card, updated_at: 10.months.ago, type: :photo) + Fabricate(:preview_card, updated_at: 10.days.ago, type: :photo) + end + + context 'with no arguments' do + it 'deletes thumbnails for local preview cards' do + expect { subject } + .to output_results( + 'Removed 2 preview cards', + 'approx. 119 KB' + ) + end + end + + context 'with the --link option' do + let(:options) { { link: true } } + + it 'deletes thumbnails for local preview cards' do + expect { subject } + .to output_results( + 'Removed 1 link-type preview cards', + 'approx. 59.6 KB' + ) + end + end + + context 'with the --days option' do + let(:options) { { days: 365 } } + + it 'deletes thumbnails for local preview cards' do + expect { subject } + .to output_results( + 'Removed 1 preview cards', + 'approx. 59.6 KB' + ) + end + end end end end diff --git a/spec/lib/mastodon/cli/search_spec.rb b/spec/lib/mastodon/cli/search_spec.rb index d5cae5bf49..cb0c80c11d 100644 --- a/spec/lib/mastodon/cli/search_spec.rb +++ b/spec/lib/mastodon/cli/search_spec.rb @@ -4,9 +4,79 @@ require 'rails_helper' require 'mastodon/cli/search' describe Mastodon::CLI::Search do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#deploy' do + let(:action) { :deploy } + + context 'with concurrency out of range' do + let(:options) { { concurrency: -100 } } + + it 'Exits with error message' do + expect { subject } + .to output_results('this concurrency setting') + .and raise_error(SystemExit) + end + end + + context 'with batch size out of range' do + let(:options) { { batch_size: -100_000 } } + + it 'Exits with error message' do + expect { subject } + .to output_results('this batch_size setting') + .and raise_error(SystemExit) + end + end + + context 'without options' do + before { stub_search_indexes } + + let(:indexed_count) { 1 } + let(:deleted_count) { 2 } + + it 'reports about storage size' do + expect { subject } + .to output_results( + "Indexed #{described_class::INDICES.size * indexed_count} records", + "de-indexed #{described_class::INDICES.size * deleted_count}" + ) + end + end + + def stub_search_indexes + described_class::INDICES.each do |index| + allow(index) + .to receive_messages( + specification: instance_double(Chewy::Index::Specification, changed?: true, lock!: nil), + purge: nil + ) + + importer_double = importer_double_for(index) + allow(importer_double).to receive(:on_progress).and_yield([indexed_count, deleted_count]) + allow("Importer::#{index}Importer".constantize) + .to receive(:new) + .and_return(importer_double) + end + end + + def importer_double_for(index) + instance_double( + "Importer::#{index}Importer".constantize, + clean_up!: nil, + estimate!: 100, + import!: nil, + on_failure: nil, + # on_progress: nil, + optimize_for_import!: nil, + optimize_for_search!: nil + ) end end end diff --git a/spec/lib/mastodon/cli/settings_spec.rb b/spec/lib/mastodon/cli/settings_spec.rb index ae58e74e56..e1b353eb90 100644 --- a/spec/lib/mastodon/cli/settings_spec.rb +++ b/spec/lib/mastodon/cli/settings_spec.rb @@ -4,66 +4,57 @@ require 'rails_helper' require 'mastodon/cli/settings' describe Mastodon::CLI::Settings do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true - end - end + it_behaves_like 'CLI Command' describe 'subcommand "registrations"' do + subject { cli.invoke(action, arguments, options) } + let(:cli) { Mastodon::CLI::Registrations.new } + let(:arguments) { [] } + let(:options) { {} } before do Setting.registrations_mode = nil end describe '#open' do - it 'changes "registrations_mode" to "open"' do - expect { cli.open }.to change(Setting, :registrations_mode).from(nil).to('open') - end + let(:action) { :open } - it 'displays success message' do - expect { cli.open }.to output( - a_string_including('OK') - ).to_stdout + it 'changes "registrations_mode" to "open" and displays success' do + expect { subject } + .to change(Setting, :registrations_mode).from(nil).to('open') + .and output_results('OK') end end describe '#approved' do - it 'changes "registrations_mode" to "approved"' do - expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved') - end + let(:action) { :approved } - it 'displays success message' do - expect { cli.approved }.to output( - a_string_including('OK') - ).to_stdout + it 'changes "registrations_mode" to "approved" and displays success' do + expect { subject } + .to change(Setting, :registrations_mode).from(nil).to('approved') + .and output_results('OK') end context 'with --require-reason' do - before do - cli.options = { require_reason: true } - end + let(:options) { { require_reason: true } } - it 'changes "registrations_mode" to "approved"' do - expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved') - end - - it 'sets "require_invite_text" to "true"' do - expect { cli.approved }.to change(Setting, :require_invite_text).from(false).to(true) + it 'changes registrations_mode and require_invite_text' do + expect { subject } + .to output_results('OK') + .and change(Setting, :registrations_mode).from(nil).to('approved') + .and change(Setting, :require_invite_text).from(false).to(true) end end end describe '#close' do - it 'changes "registrations_mode" to "none"' do - expect { cli.close }.to change(Setting, :registrations_mode).from(nil).to('none') - end + let(:action) { :close } - it 'displays success message' do - expect { cli.close }.to output( - a_string_including('OK') - ).to_stdout + it 'changes "registrations_mode" to "none" and displays success' do + expect { subject } + .to change(Setting, :registrations_mode).from(nil).to('none') + .and output_results('OK') end end end diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb index 2430a88416..63d494bbb6 100644 --- a/spec/lib/mastodon/cli/statuses_spec.rb +++ b/spec/lib/mastodon/cli/statuses_spec.rb @@ -4,9 +4,32 @@ require 'rails_helper' require 'mastodon/cli/statuses' describe Mastodon::CLI::Statuses do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#remove', use_transactional_tests: false do + let(:action) { :remove } + + context 'with small batch size' do + let(:options) { { batch_size: 0 } } + + it 'exits with error message' do + expect { subject } + .to output_results('Cannot run') + .and raise_error(SystemExit) + end + end + + context 'with default batch size' do + it 'removes unreferenced statuses' do + expect { subject } + .to output_results('Done after') + end end end end diff --git a/spec/lib/mastodon/cli/upgrade_spec.rb b/spec/lib/mastodon/cli/upgrade_spec.rb index 9e0ab9d06e..6861e04887 100644 --- a/spec/lib/mastodon/cli/upgrade_spec.rb +++ b/spec/lib/mastodon/cli/upgrade_spec.rb @@ -4,9 +4,27 @@ require 'rails_helper' require 'mastodon/cli/upgrade' describe Mastodon::CLI::Upgrade do - describe '.exit_on_failure?' do - it 'returns true' do - expect(described_class.exit_on_failure?).to be true + subject { cli.invoke(action, arguments, options) } + + let(:cli) { described_class.new } + let(:arguments) { [] } + let(:options) { {} } + + it_behaves_like 'CLI Command' + + describe '#storage_schema' do + let(:action) { :storage_schema } + + context 'with records that dont need upgrading' do + before do + Fabricate(:account) + Fabricate(:media_attachment) + end + + it 'does not upgrade storage for the attachments' do + expect { subject } + .to output_results('Upgraded storage schema of 0 records') + end end end end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index f0861376b9..c7620cf9b6 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -64,8 +64,11 @@ describe Request do end it 'closes underlying connection' do - expect_any_instance_of(HTTP::Client).to receive(:close) + allow(subject.send(:http_client)).to receive(:close) + expect { |block| subject.perform(&block) }.to yield_control + + expect(subject.send(:http_client)).to have_received(:close) end it 'returns response which implements body_with_limit' do diff --git a/spec/lib/sanitize_config_spec.rb b/spec/lib/sanitize/config_spec.rb similarity index 100% rename from spec/lib/sanitize_config_spec.rb rename to spec/lib/sanitize/config_spec.rb diff --git a/spec/lib/search_query_parser_spec.rb b/spec/lib/search_query_parser_spec.rb new file mode 100644 index 0000000000..66b0e8f9e2 --- /dev/null +++ b/spec/lib/search_query_parser_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'parslet/rig/rspec' + +describe SearchQueryParser do + let(:parser) { described_class.new } + + context 'with term' do + it 'consumes "hello"' do + expect(parser.term).to parse('hello') + end + end + + context 'with prefix' do + it 'consumes "foo:"' do + expect(parser.prefix).to parse('foo:') + end + end + + context 'with operator' do + it 'consumes "+"' do + expect(parser.operator).to parse('+') + end + + it 'consumes "-"' do + expect(parser.operator).to parse('-') + end + end + + context 'with shortcode' do + it 'consumes ":foo:"' do + expect(parser.shortcode).to parse(':foo:') + end + end + + context 'with phrase' do + it 'consumes "hello world"' do + expect(parser.phrase).to parse('"hello world"') + end + end + + context 'with clause' do + it 'consumes "foo"' do + expect(parser.clause).to parse('foo') + end + + it 'consumes "-foo"' do + expect(parser.clause).to parse('-foo') + end + + it 'consumes "foo:bar"' do + expect(parser.clause).to parse('foo:bar') + end + + it 'consumes "-foo:bar"' do + expect(parser.clause).to parse('-foo:bar') + end + + it 'consumes \'foo:"hello world"\'' do + expect(parser.clause).to parse('foo:"hello world"') + end + + it 'consumes \'-foo:"hello world"\'' do + expect(parser.clause).to parse('-foo:"hello world"') + end + + it 'consumes "foo:"' do + expect(parser.clause).to parse('foo:') + end + + it 'consumes \'"\'' do + expect(parser.clause).to parse('"') + end + end + + context 'with query' do + it 'consumes "hello -world"' do + expect(parser.query).to parse('hello -world') + end + + it 'consumes \'foo "hello world"\'' do + expect(parser.query).to parse('foo "hello world"') + end + + it 'consumes "foo:bar hello"' do + expect(parser.query).to parse('foo:bar hello') + end + + it 'consumes \'"hello" world "\'' do + expect(parser.query).to parse('"hello" world "') + end + + it 'consumes "foo:bar bar: hello"' do + expect(parser.query).to parse('foo:bar bar: hello') + end + end +end diff --git a/spec/lib/search_query_transformer_spec.rb b/spec/lib/search_query_transformer_spec.rb index 1095334695..5817e3d1d2 100644 --- a/spec/lib/search_query_transformer_spec.rb +++ b/spec/lib/search_query_transformer_spec.rb @@ -3,16 +3,78 @@ require 'rails_helper' describe SearchQueryTransformer do - describe 'initialization' do - let(:parser) { SearchQueryParser.new.parse('query') } + subject { described_class.new.apply(parser, current_account: account) } - it 'sets attributes' do - transformer = described_class.new.apply(parser) + let(:account) { Fabricate(:account) } + let(:parser) { SearchQueryParser.new.parse(query) } - expect(transformer.should_clauses.first).to be_a(SearchQueryTransformer::TermClause) - expect(transformer.must_clauses.first).to be_nil - expect(transformer.must_not_clauses.first).to be_nil - expect(transformer.filter_clauses.first).to be_nil + context 'with "hello world"' do + let(:query) { 'hello world' } + + it 'transforms clauses' do + expect(subject.send(:must_clauses).map(&:term)).to match_array %w(hello world) + expect(subject.send(:must_not_clauses)).to be_empty + expect(subject.send(:filter_clauses)).to be_empty + end + end + + context 'with "hello -world"' do + let(:query) { 'hello -world' } + + it 'transforms clauses' do + expect(subject.send(:must_clauses).map(&:term)).to match_array %w(hello) + expect(subject.send(:must_not_clauses).map(&:term)).to match_array %w(world) + expect(subject.send(:filter_clauses)).to be_empty + end + end + + context 'with "hello is:reply"' do + let(:query) { 'hello is:reply' } + + it 'transforms clauses' do + expect(subject.send(:must_clauses).map(&:term)).to match_array %w(hello) + expect(subject.send(:must_not_clauses)).to be_empty + expect(subject.send(:filter_clauses).map(&:term)).to match_array %w(reply) + end + end + + context 'with "foo: bar"' do + let(:query) { 'foo: bar' } + + it 'transforms clauses' do + expect(subject.send(:must_clauses).map(&:term)).to match_array %w(foo bar) + expect(subject.send(:must_not_clauses)).to be_empty + expect(subject.send(:filter_clauses)).to be_empty + end + end + + context 'with "foo:bar"' do + let(:query) { 'foo:bar' } + + it 'transforms clauses' do + expect(subject.send(:must_clauses).map(&:term)).to contain_exactly('foo bar') + expect(subject.send(:must_not_clauses)).to be_empty + expect(subject.send(:filter_clauses)).to be_empty + end + end + + context 'with \'"hello world"\'' do + let(:query) { '"hello world"' } + + it 'transforms clauses' do + expect(subject.send(:must_clauses).map(&:phrase)).to contain_exactly('hello world') + expect(subject.send(:must_not_clauses)).to be_empty + expect(subject.send(:filter_clauses)).to be_empty + end + end + + context 'with \'before:"2022-01-01 23:00"\'' do + let(:query) { 'before:"2022-01-01 23:00"' } + + it 'transforms clauses' do + expect(subject.send(:must_clauses)).to be_empty + expect(subject.send(:must_not_clauses)).to be_empty + expect(subject.send(:filter_clauses).map(&:term)).to contain_exactly(lt: '2022-01-01 23:00', time_zone: 'UTC') end end end diff --git a/spec/lib/status_filter_spec.rb b/spec/lib/status_filter_spec.rb index 98e2ef913a..cf6f3c7959 100644 --- a/spec/lib/status_filter_spec.rb +++ b/spec/lib/status_filter_spec.rb @@ -23,7 +23,8 @@ describe StatusFilter do context 'when status policy does not allow show' do it 'filters the status' do - expect_any_instance_of(StatusPolicy).to receive(:show?).and_return(false) + policy = instance_double(StatusPolicy, show?: false) + allow(StatusPolicy).to receive(:new).and_return(policy) expect(filter).to be_filtered end @@ -74,7 +75,8 @@ describe StatusFilter do context 'when status policy does not allow show' do it 'filters the status' do - expect_any_instance_of(StatusPolicy).to receive(:show?).and_return(false) + policy = instance_double(StatusPolicy, show?: false) + allow(StatusPolicy).to receive(:new).and_return(policy) expect(filter).to be_filtered end diff --git a/spec/lib/status_finder_spec.rb b/spec/lib/status_finder_spec.rb index 61483f4bfe..53f5039af9 100644 --- a/spec/lib/status_finder_spec.rb +++ b/spec/lib/status_finder_spec.rb @@ -18,10 +18,13 @@ describe StatusFinder do it 'raises an error if action is not :show' do recognized = Rails.application.routes.recognize_path(url) - expect(recognized).to receive(:[]).with(:action).and_return(:create) - expect(Rails.application.routes).to receive(:recognize_path).with(url).and_return(recognized) + allow(recognized).to receive(:[]).with(:action).and_return(:create) + allow(Rails.application.routes).to receive(:recognize_path).with(url).and_return(recognized) expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) + + expect(Rails.application.routes).to have_received(:recognize_path) + expect(recognized).to have_received(:[]) end end diff --git a/spec/lib/translation_service/deepl_spec.rb b/spec/lib/translation_service/deepl_spec.rb index 5a1d0f094a..4797a3dc63 100644 --- a/spec/lib/translation_service/deepl_spec.rb +++ b/spec/lib/translation_service/deepl_spec.rb @@ -8,12 +8,14 @@ RSpec.describe TranslationService::DeepL do let(:plan) { 'advanced' } before do - stub_request(:get, 'https://api.deepl.com/v2/languages?type=source').to_return( - body: '[{"language":"EN","name":"English"},{"language":"UK","name":"Ukrainian"}]' - ) - stub_request(:get, 'https://api.deepl.com/v2/languages?type=target').to_return( - body: '[{"language":"EN-GB","name":"English (British)"},{"language":"ZH","name":"Chinese"}]' - ) + %w(api-free.deepl.com api.deepl.com).each do |host| + stub_request(:get, "https://#{host}/v2/languages?type=source").to_return( + body: '[{"language":"EN","name":"English"},{"language":"UK","name":"Ukrainian"}]' + ) + stub_request(:get, "https://#{host}/v2/languages?type=target").to_return( + body: '[{"language":"EN-GB","name":"English (British)"},{"language":"ZH","name":"Chinese"}]' + ) + end end describe '#translate' do @@ -73,28 +75,25 @@ RSpec.describe TranslationService::DeepL do end end - describe '#request' do + describe 'the paid and free plan api hostnames' do before do - stub_request(:any, //) - # rubocop:disable Lint/EmptyBlock - service.send(:request, :get, '/v2/languages') { |res| } - # rubocop:enable Lint/EmptyBlock + service.languages end - it 'uses paid plan base URL' do - expect(a_request(:get, 'https://api.deepl.com/v2/languages')).to have_been_made.once - end - - context 'with free plan' do - let(:plan) { 'free' } - - it 'uses free plan base URL' do - expect(a_request(:get, 'https://api-free.deepl.com/v2/languages')).to have_been_made.once + context 'without a plan set' do + it 'uses paid plan base URL and sends an API key' do + expect(a_request(:get, 'https://api.deepl.com/v2/languages?type=source').with(headers: { Authorization: 'DeepL-Auth-Key my-api-key' })).to have_been_made.once + expect(a_request(:get, 'https://api.deepl.com/v2/languages?type=target').with(headers: { Authorization: 'DeepL-Auth-Key my-api-key' })).to have_been_made.once end end - it 'sends API key' do - expect(a_request(:get, 'https://api.deepl.com/v2/languages').with(headers: { Authorization: 'DeepL-Auth-Key my-api-key' })).to have_been_made.once + context 'with the free plan' do + let(:plan) { 'free' } + + it 'uses free plan base URL and sends an API key' do + expect(a_request(:get, 'https://api-free.deepl.com/v2/languages?type=source').with(headers: { Authorization: 'DeepL-Auth-Key my-api-key' })).to have_been_made.once + expect(a_request(:get, 'https://api-free.deepl.com/v2/languages?type=target').with(headers: { Authorization: 'DeepL-Auth-Key my-api-key' })).to have_been_made.once + end end end end diff --git a/spec/lib/vacuum/applications_vacuum_spec.rb b/spec/lib/vacuum/applications_vacuum_spec.rb new file mode 100644 index 0000000000..df5c860602 --- /dev/null +++ b/spec/lib/vacuum/applications_vacuum_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Vacuum::ApplicationsVacuum do + subject { described_class.new } + + describe '#perform' do + let!(:app_with_token) { Fabricate(:application, created_at: 1.month.ago) } + let!(:app_with_grant) { Fabricate(:application, created_at: 1.month.ago) } + let!(:app_with_signup) { Fabricate(:application, created_at: 1.month.ago) } + let!(:app_with_owner) { Fabricate(:application, created_at: 1.month.ago, owner: Fabricate(:user)) } + let!(:unused_app) { Fabricate(:application, created_at: 1.month.ago) } + let!(:recent_app) { Fabricate(:application, created_at: 1.hour.ago) } + + before do + Fabricate(:access_token, application: app_with_token) + Fabricate(:access_grant, application: app_with_grant) + Fabricate(:user, created_by_application: app_with_signup) + + subject.perform + end + + it 'does not delete applications with valid access tokens' do + expect { app_with_token.reload }.to_not raise_error + end + + it 'does not delete applications with valid access grants' do + expect { app_with_grant.reload }.to_not raise_error + end + + it 'does not delete applications that were used to create users' do + expect { app_with_signup.reload }.to_not raise_error + end + + it 'does not delete owned applications' do + expect { app_with_owner.reload }.to_not raise_error + end + + it 'does not delete applications registered less than a day ago' do + expect { recent_app.reload }.to_not raise_error + end + + it 'deletes unused applications' do + expect { unused_app.reload }.to raise_error ActiveRecord::RecordNotFound + end + end +end diff --git a/spec/lib/vacuum/imports_vacuum_spec.rb b/spec/lib/vacuum/imports_vacuum_spec.rb index 1e0abc5e01..c712b7b9b2 100644 --- a/spec/lib/vacuum/imports_vacuum_spec.rb +++ b/spec/lib/vacuum/imports_vacuum_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Vacuum::ImportsVacuum do describe '#perform' do it 'cleans up the expected imports' do - expect { subject.perform }.to change { BulkImport.all.pluck(:id) }.from([old_unconfirmed, new_unconfirmed, recent_ongoing, recent_finished, old_finished].map(&:id)).to([new_unconfirmed, recent_ongoing, recent_finished].map(&:id)) + expect { subject.perform }.to change { BulkImport.pluck(:id) }.from([old_unconfirmed, new_unconfirmed, recent_ongoing, recent_finished, old_finished].map(&:id)).to([new_unconfirmed, recent_ongoing, recent_finished].map(&:id)) end end end diff --git a/spec/lib/vacuum/preview_cards_vacuum_spec.rb b/spec/lib/vacuum/preview_cards_vacuum_spec.rb index c1b7f7e9c5..9dbdf0bc2f 100644 --- a/spec/lib/vacuum/preview_cards_vacuum_spec.rb +++ b/spec/lib/vacuum/preview_cards_vacuum_spec.rb @@ -30,5 +30,9 @@ RSpec.describe Vacuum::PreviewCardsVacuum do it 'does not delete attached preview cards' do expect(new_preview_card.reload).to be_persisted end + + it 'does not delete orphaned preview cards in the retention period' do + expect(orphaned_preview_card.reload).to be_persisted + end end end diff --git a/spec/lib/webfinger_resource_spec.rb b/spec/lib/webfinger_resource_spec.rb index 2cad04fccd..0e2bdcb71a 100644 --- a/spec/lib/webfinger_resource_spec.rb +++ b/spec/lib/webfinger_resource_spec.rb @@ -27,13 +27,18 @@ describe WebfingerResource do recognized = Rails.application.routes.recognize_path(resource) allow(recognized).to receive(:[]).with(:controller).and_return('accounts') allow(recognized).to receive(:[]).with(:username).and_return('alice') - expect(recognized).to receive(:[]).with(:action).and_return('create') + allow(recognized).to receive(:[]).with(:action).and_return('create') - expect(Rails.application.routes).to receive(:recognize_path).with(resource).and_return(recognized).at_least(:once) + allow(Rails.application.routes).to receive(:recognize_path).with(resource).and_return(recognized) expect do described_class.new(resource).username end.to raise_error(ActiveRecord::RecordNotFound) + expect(recognized).to have_received(:[]).exactly(3).times + + expect(Rails.application.routes).to have_received(:recognize_path) + .with(resource) + .at_least(:once) end it 'raises with a string that doesnt start with URL' do diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb index 8e2eec40fd..88ad7aa02b 100644 --- a/spec/mailers/admin_mailer_spec.rb +++ b/spec/mailers/admin_mailer_spec.rb @@ -7,82 +7,122 @@ RSpec.describe AdminMailer do let(:sender) { Fabricate(:account, username: 'John') } let(:recipient) { Fabricate(:account, username: 'Mike') } let(:report) { Fabricate(:report, account: sender, target_account: recipient) } - let(:mail) { described_class.new_report(recipient, report) } + let(:mail) { described_class.with(recipient: recipient).new_report(report) } before do recipient.user.update(locale: :en) end - it 'renders the headers' do - expect(mail.subject).to eq("New report for cb6e6126.ngrok.io (##{report.id})") - expect(mail.to).to eq [recipient.user_email] - expect(mail.from).to eq ['notifications@localhost'] - end - - it 'renders the body' do - expect(mail.body.encoded).to eq("Mike,\r\n\r\nJohn has reported Mike\r\n\r\nView: https://cb6e6126.ngrok.io/admin/reports/#{report.id}\r\n") + it 'renders the email' do + expect(mail) + .to be_present + .and(deliver_to(recipient.user_email)) + .and(deliver_from('notifications@localhost')) + .and(have_subject("New report for cb6e6126.ngrok.io (##{report.id})")) + .and(have_body_text("Mike,\r\n\r\nJohn has reported Mike\r\n\r\nView: https://cb6e6126.ngrok.io/admin/reports/#{report.id}\r\n")) end end describe '.new_appeal' do let(:appeal) { Fabricate(:appeal) } let(:recipient) { Fabricate(:account, username: 'Kurt') } - let(:mail) { described_class.new_appeal(recipient, appeal) } + let(:mail) { described_class.with(recipient: recipient).new_appeal(appeal) } before do recipient.user.update(locale: :en) end - it 'renders the headers' do - expect(mail.subject).to eq("#{appeal.account.username} is appealing a moderation decision on cb6e6126.ngrok.io") - expect(mail.to).to eq [recipient.user_email] - expect(mail.from).to eq ['notifications@localhost'] - end - - it 'renders the body' do - expect(mail.body.encoded).to match "#{appeal.account.username} is appealing a moderation decision by #{appeal.strike.account.username}" + it 'renders the email' do + expect(mail) + .to be_present + .and(deliver_to(recipient.user_email)) + .and(deliver_from('notifications@localhost')) + .and(have_subject("#{appeal.account.username} is appealing a moderation decision on cb6e6126.ngrok.io")) + .and(have_body_text("#{appeal.account.username} is appealing a moderation decision by #{appeal.strike.account.username}")) end end describe '.new_pending_account' do let(:recipient) { Fabricate(:account, username: 'Barklums') } let(:user) { Fabricate(:user) } - let(:mail) { described_class.new_pending_account(recipient, user) } + let(:mail) { described_class.with(recipient: recipient).new_pending_account(user) } before do recipient.user.update(locale: :en) end - it 'renders the headers' do - expect(mail.subject).to eq("New account up for review on cb6e6126.ngrok.io (#{user.account.username})") - expect(mail.to).to eq [recipient.user_email] - expect(mail.from).to eq ['notifications@localhost'] - end - - it 'renders the body' do - expect(mail.body.encoded).to match 'The details of the new account are below. You can approve or reject this application.' + it 'renders the email' do + expect(mail) + .to be_present + .and(deliver_to(recipient.user_email)) + .and(deliver_from('notifications@localhost')) + .and(have_subject("New account up for review on cb6e6126.ngrok.io (#{user.account.username})")) + .and(have_body_text('The details of the new account are below. You can approve or reject this application.')) end end describe '.new_trends' do let(:recipient) { Fabricate(:account, username: 'Snurf') } - let(:links) { [] } - let(:statuses) { [] } - let(:tags) { [] } - let(:mail) { described_class.new_trends(recipient, links, tags, statuses) } + let(:link) { Fabricate(:preview_card, trendable: true, language: 'en') } + let(:status) { Fabricate(:status) } + let(:tag) { Fabricate(:tag) } + let(:mail) { described_class.with(recipient: recipient).new_trends([link], [tag], [status]) } + + before do + PreviewCardTrend.create!(preview_card: link) + StatusTrend.create!(status: status, account: Fabricate(:account)) + recipient.user.update(locale: :en) + end + + it 'renders the email' do + expect(mail) + .to be_present + .and(deliver_to(recipient.user_email)) + .and(deliver_from('notifications@localhost')) + .and(have_subject('New trends up for review on cb6e6126.ngrok.io')) + .and(have_body_text('The following items need a review before they can be displayed publicly')) + .and(have_body_text(ActivityPub::TagManager.instance.url_for(status))) + .and(have_body_text(link.title)) + .and(have_body_text(tag.display_name)) + end + end + + describe '.new_software_updates' do + let(:recipient) { Fabricate(:account, username: 'Bob') } + let(:mail) { described_class.with(recipient: recipient).new_software_updates } before do recipient.user.update(locale: :en) end - it 'renders the headers' do - expect(mail.subject).to eq('New trends up for review on cb6e6126.ngrok.io') - expect(mail.to).to eq [recipient.user_email] - expect(mail.from).to eq ['notifications@localhost'] + it 'renders the email' do + expect(mail) + .to be_present + .and(deliver_to(recipient.user_email)) + .and(deliver_from('notifications@localhost')) + .and(have_subject('New Mastodon versions are available for cb6e6126.ngrok.io!')) + .and(have_body_text('New Mastodon versions have been released, you may want to update!')) + end + end + + describe '.new_critical_software_updates' do + let(:recipient) { Fabricate(:account, username: 'Bob') } + let(:mail) { described_class.with(recipient: recipient).new_critical_software_updates } + + before do + recipient.user.update(locale: :en) end - it 'renders the body' do - expect(mail.body.encoded).to match 'The following items need a review before they can be displayed publicly' + it 'renders the email' do + expect(mail) + .to be_present + .and(deliver_to(recipient.user_email)) + .and(deliver_from('notifications@localhost')) + .and(have_subject('Critical Mastodon updates are available for cb6e6126.ngrok.io!')) + .and(have_body_text('New critical versions of Mastodon have been released, you may want to update as soon as possible!')) + .and(have_header('Importance', 'high')) + .and(have_header('Priority', 'urgent')) + .and(have_header('X-Priority', '1')) end end end diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb index bf364b6253..eab196166d 100644 --- a/spec/mailers/notification_mailer_spec.rb +++ b/spec/mailers/notification_mailer_spec.rb @@ -3,104 +3,124 @@ require 'rails_helper' RSpec.describe NotificationMailer do - let(:receiver) { Fabricate(:user) } + let(:receiver) { Fabricate(:user, account_attributes: { username: 'alice' }) } let(:sender) { Fabricate(:account, username: 'bob') } let(:foreign_status) { Fabricate(:status, account: sender, text: 'The body of the foreign status') } let(:own_status) { Fabricate(:status, account: receiver.account, text: 'The body of the own status') } - shared_examples 'localized subject' do |*args, **kwrest| - it 'renders subject localized for the locale of the receiver' do - locale = :de - receiver.update!(locale: locale) - expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale)) + shared_examples 'standard headers' do |type| + it 'renders the email' do + expect(mail) + .to be_present + .and(have_header('To', "#{receiver.account.username} <#{receiver.email}>")) + .and(have_header('List-ID', "<#{type}.alice.cb6e6126.ngrok.io>")) + .and(have_header('List-Unsubscribe', %r{})) + .and(have_header('List-Unsubscribe', /&type=#{type}/)) + .and(have_header('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click')) + .and(deliver_to("#{receiver.account.username} <#{receiver.email}>")) + .and(deliver_from('notifications@localhost')) end + end - it 'renders subject localized for the default locale if the locale of the receiver is unavailable' do - receiver.update!(locale: nil) - expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: I18n.default_locale)) + shared_examples 'thread headers' do + it 'renders the email with conversation thread headers' do + conversation_header_regex = // + expect(mail) + .to be_present + .and(have_header('In-Reply-To', conversation_header_regex)) + .and(have_header('References', conversation_header_regex)) end end describe 'mention' do let(:mention) { Mention.create!(account: receiver.account, status: foreign_status) } - let(:mail) { described_class.mention(receiver.account, Notification.create!(account: receiver.account, activity: mention)) } + let(:notification) { Notification.create!(account: receiver.account, activity: mention) } + let(:mail) { prepared_mailer_for(receiver.account).mention } include_examples 'localized subject', 'notification_mailer.mention.subject', name: 'bob' + include_examples 'standard headers', 'mention' + include_examples 'thread headers' - it 'renders the headers' do - expect(mail.subject).to eq('You were mentioned by bob') - expect(mail[:to].value).to eq("#{receiver.account.username} <#{receiver.email}>") - end - - it 'renders the body' do - expect(mail.body.encoded).to match('You were mentioned by bob') - expect(mail.body.encoded).to include 'The body of the foreign status' + it 'renders the email' do + expect(mail) + .to be_present + .and(have_subject('You were mentioned by bob')) + .and(have_body_text('You were mentioned by bob')) + .and(have_body_text('The body of the foreign status')) end end describe 'follow' do let(:follow) { sender.follow!(receiver.account) } - let(:mail) { described_class.follow(receiver.account, Notification.create!(account: receiver.account, activity: follow)) } + let(:notification) { Notification.create!(account: receiver.account, activity: follow) } + let(:mail) { prepared_mailer_for(receiver.account).follow } include_examples 'localized subject', 'notification_mailer.follow.subject', name: 'bob' + include_examples 'standard headers', 'follow' - it 'renders the headers' do - expect(mail.subject).to eq('bob is now following you') - expect(mail[:to].value).to eq("#{receiver.account.username} <#{receiver.email}>") - end - - it 'renders the body' do - expect(mail.body.encoded).to match('bob is now following you') + it 'renders the email' do + expect(mail) + .to be_present + .and(have_subject('bob is now following you')) + .and(have_body_text('bob is now following you')) end end describe 'favourite' do let(:favourite) { Favourite.create!(account: sender, status: own_status) } - let(:mail) { described_class.favourite(own_status.account, Notification.create!(account: receiver.account, activity: favourite)) } + let(:notification) { Notification.create!(account: receiver.account, activity: favourite) } + let(:mail) { prepared_mailer_for(own_status.account).favourite } include_examples 'localized subject', 'notification_mailer.favourite.subject', name: 'bob' + include_examples 'standard headers', 'favourite' + include_examples 'thread headers' - it 'renders the headers' do - expect(mail.subject).to eq('bob favorited your post') - expect(mail[:to].value).to eq("#{receiver.account.username} <#{receiver.email}>") - end - - it 'renders the body' do - expect(mail.body.encoded).to match('Your post was favorited by bob') - expect(mail.body.encoded).to include 'The body of the own status' + it 'renders the email' do + expect(mail) + .to be_present + .and(have_subject('bob favorited your post')) + .and(have_body_text('Your post was favorited by bob')) + .and(have_body_text('The body of the own status')) end end describe 'reblog' do let(:reblog) { Status.create!(account: sender, reblog: own_status) } - let(:mail) { described_class.reblog(own_status.account, Notification.create!(account: receiver.account, activity: reblog)) } + let(:notification) { Notification.create!(account: receiver.account, activity: reblog) } + let(:mail) { prepared_mailer_for(own_status.account).reblog } include_examples 'localized subject', 'notification_mailer.reblog.subject', name: 'bob' + include_examples 'standard headers', 'reblog' + include_examples 'thread headers' - it 'renders the headers' do - expect(mail.subject).to eq('bob boosted your post') - expect(mail[:to].value).to eq("#{receiver.account.username} <#{receiver.email}>") - end - - it 'renders the body' do - expect(mail.body.encoded).to match('Your post was boosted by bob') - expect(mail.body.encoded).to include 'The body of the own status' + it 'renders the email' do + expect(mail) + .to be_present + .and(have_subject('bob boosted your post')) + .and(have_body_text('Your post was boosted by bob')) + .and(have_body_text('The body of the own status')) end end describe 'follow_request' do let(:follow_request) { Fabricate(:follow_request, account: sender, target_account: receiver.account) } - let(:mail) { described_class.follow_request(receiver.account, Notification.create!(account: receiver.account, activity: follow_request)) } + let(:notification) { Notification.create!(account: receiver.account, activity: follow_request) } + let(:mail) { prepared_mailer_for(receiver.account).follow_request } include_examples 'localized subject', 'notification_mailer.follow_request.subject', name: 'bob' + include_examples 'standard headers', 'follow_request' - it 'renders the headers' do - expect(mail.subject).to eq('Pending follower: bob') - expect(mail[:to].value).to eq("#{receiver.account.username} <#{receiver.email}>") - end - - it 'renders the body' do - expect(mail.body.encoded).to match('bob has requested to follow you') + it 'renders the email' do + expect(mail) + .to be_present + .and(have_subject('Pending follower: bob')) + .and(have_body_text('bob has requested to follow you')) end end + + private + + def prepared_mailer_for(recipient) + described_class.with(recipient: recipient, notification: notification) + end end diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb index 9572768cd7..942d40d568 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -3,18 +3,33 @@ # Preview all emails at http://localhost:3000/rails/mailers/admin_mailer class AdminMailerPreview < ActionMailer::Preview - # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_pending_account - def new_pending_account - AdminMailer.new_pending_account(Account.first, User.pending.first) - end - - # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends - def new_trends - AdminMailer.new_trends(Account.first, PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_report + def new_report + AdminMailer.with(recipient: Account.first).new_report(Report.first) end # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal def new_appeal - AdminMailer.new_appeal(Account.first, Appeal.first) + AdminMailer.with(recipient: Account.first).new_appeal(Appeal.first) + end + + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_pending_account + def new_pending_account + AdminMailer.with(recipient: Account.first).new_pending_account(User.pending.first) + end + + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends + def new_trends + AdminMailer.with(recipient: Account.first).new_trends(PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) + end + + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_software_updates + def new_software_updates + AdminMailer.with(recipient: Account.first).new_software_updates + end + + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_critical_software_updates + def new_critical_software_updates + AdminMailer.with(recipient: Account.first).new_critical_software_updates end end diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb index bc41662a16..a63c20c27c 100644 --- a/spec/mailers/previews/notification_mailer_preview.rb +++ b/spec/mailers/previews/notification_mailer_preview.rb @@ -5,36 +5,40 @@ class NotificationMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention def mention - m = Mention.last - NotificationMailer.mention(m.account, Notification.find_by(activity: m)) + activity = Mention.last + mailer_for(activity.account, activity).mention end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow def follow - f = Follow.last - NotificationMailer.follow(f.target_account, Notification.find_by(activity: f)) + activity = Follow.last + mailer_for(activity.target_account, activity).follow end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow_request def follow_request - f = Follow.last - NotificationMailer.follow_request(f.target_account, Notification.find_by(activity: f)) + activity = Follow.last + mailer_for(activity.target_account, activity).follow_request end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite def favourite - f = Favourite.last - NotificationMailer.favourite(f.status.account, Notification.find_by(activity: f)) + activity = Favourite.last + mailer_for(activity.status.account, activity).favourite end # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog def reblog - r = Status.where.not(reblog_of_id: nil).first - NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r)) + activity = Status.where.not(reblog_of_id: nil).first + mailer_for(activity.reblog.account, activity).reblog end - # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/digest - def digest - NotificationMailer.digest(Account.first, since: 90.days.ago) + private + + def mailer_for(account, activity) + NotificationMailer.with( + recipient: account, + notification: Notification.find_by(activity: activity) + ) end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 3c42a2bb7a..4a43928248 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -5,27 +5,17 @@ require 'rails_helper' describe UserMailer do let(:receiver) { Fabricate(:user) } - shared_examples 'localized subject' do |*args, **kwrest| - it 'renders subject localized for the locale of the receiver' do - locale = :de - receiver.update!(locale: locale) - expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale)) - end - - it 'renders subject localized for the default locale if the locale of the receiver is unavailable' do - receiver.update!(locale: nil) - expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: I18n.default_locale)) - end - end - - describe 'confirmation_instructions' do + describe '#confirmation_instructions' do let(:mail) { described_class.confirmation_instructions(receiver, 'spec') } it 'renders confirmation instructions' do receiver.update!(locale: nil) - expect(mail.body.encoded).to include I18n.t('devise.mailer.confirmation_instructions.title') - expect(mail.body.encoded).to include 'spec' - expect(mail.body.encoded).to include Rails.configuration.x.local_domain + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('devise.mailer.confirmation_instructions.title'))) + .and(have_body_text('spec')) + .and(have_body_text(Rails.configuration.x.local_domain)) end include_examples 'localized subject', @@ -33,82 +23,101 @@ describe UserMailer do instance: Rails.configuration.x.local_domain end - describe 'reconfirmation_instructions' do + describe '#reconfirmation_instructions' do let(:mail) { described_class.confirmation_instructions(receiver, 'spec') } it 'renders reconfirmation instructions' do receiver.update!(email: 'new-email@example.com', locale: nil) - expect(mail.body.encoded).to include I18n.t('devise.mailer.reconfirmation_instructions.title') - expect(mail.body.encoded).to include 'spec' - expect(mail.body.encoded).to include Rails.configuration.x.local_domain - expect(mail.subject).to eq I18n.t('devise.mailer.reconfirmation_instructions.subject', - instance: Rails.configuration.x.local_domain, - locale: I18n.default_locale) + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('devise.mailer.reconfirmation_instructions.title'))) + .and(have_body_text('spec')) + .and(have_body_text(Rails.configuration.x.local_domain)) end + + include_examples 'localized subject', + 'devise.mailer.confirmation_instructions.subject', + instance: Rails.configuration.x.local_domain end - describe 'reset_password_instructions' do + describe '#reset_password_instructions' do let(:mail) { described_class.reset_password_instructions(receiver, 'spec') } it 'renders reset password instructions' do receiver.update!(locale: nil) - expect(mail.body.encoded).to include I18n.t('devise.mailer.reset_password_instructions.title') - expect(mail.body.encoded).to include 'spec' + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('devise.mailer.reset_password_instructions.title'))) + .and(have_body_text('spec')) end include_examples 'localized subject', 'devise.mailer.reset_password_instructions.subject' end - describe 'password_change' do + describe '#password_change' do let(:mail) { described_class.password_change(receiver) } it 'renders password change notification' do receiver.update!(locale: nil) - expect(mail.body.encoded).to include I18n.t('devise.mailer.password_change.title') + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('devise.mailer.password_change.title'))) end include_examples 'localized subject', 'devise.mailer.password_change.subject' end - describe 'email_changed' do + describe '#email_changed' do let(:mail) { described_class.email_changed(receiver) } it 'renders email change notification' do receiver.update!(locale: nil) - expect(mail.body.encoded).to include I18n.t('devise.mailer.email_changed.title') + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('devise.mailer.email_changed.title'))) end include_examples 'localized subject', 'devise.mailer.email_changed.subject' end - describe 'warning' do + describe '#warning' do let(:strike) { Fabricate(:account_warning, target_account: receiver.account, text: 'dont worry its just the testsuite', action: 'suspend') } let(:mail) { described_class.warning(receiver, strike) } it 'renders warning notification' do receiver.update!(locale: nil) - expect(mail.body.encoded).to include I18n.t('user_mailer.warning.title.suspend', acct: receiver.account.acct) - expect(mail.body.encoded).to include strike.text + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('user_mailer.warning.title.suspend', acct: receiver.account.acct))) + .and(have_body_text(strike.text)) end end - describe 'webauthn_credential_deleted' do + describe '#webauthn_credential_deleted' do let(:credential) { Fabricate(:webauthn_credential, user_id: receiver.id) } let(:mail) { described_class.webauthn_credential_deleted(receiver, credential) } it 'renders webauthn credential deleted notification' do receiver.update!(locale: nil) - expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_credential.deleted.title') + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('devise.mailer.webauthn_credential.deleted.title'))) end include_examples 'localized subject', 'devise.mailer.webauthn_credential.deleted.subject' end - describe 'suspicious_sign_in' do + describe '#suspicious_sign_in' do let(:ip) { '192.168.0.1' } let(:agent) { 'NCSA_Mosaic/2.0 (Windows 3.1)' } let(:timestamp) { Time.now.utc } @@ -116,85 +125,127 @@ describe UserMailer do it 'renders suspicious sign in notification' do receiver.update!(locale: nil) - expect(mail.body.encoded).to include I18n.t('user_mailer.suspicious_sign_in.explanation') + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('user_mailer.suspicious_sign_in.explanation'))) end include_examples 'localized subject', 'user_mailer.suspicious_sign_in.subject' end - describe 'appeal_approved' do + describe '#appeal_approved' do let(:appeal) { Fabricate(:appeal, account: receiver.account, approved_at: Time.now.utc) } let(:mail) { described_class.appeal_approved(receiver, appeal) } it 'renders appeal_approved notification' do - expect(mail.subject).to eq I18n.t('user_mailer.appeal_approved.subject', date: I18n.l(appeal.created_at)) - expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_approved.title') + expect(mail) + .to be_present + .and(have_subject(I18n.t('user_mailer.appeal_approved.subject', date: I18n.l(appeal.created_at)))) + .and(have_body_text(I18n.t('user_mailer.appeal_approved.title'))) end end - describe 'appeal_rejected' do + describe '#appeal_rejected' do let(:appeal) { Fabricate(:appeal, account: receiver.account, rejected_at: Time.now.utc) } let(:mail) { described_class.appeal_rejected(receiver, appeal) } it 'renders appeal_rejected notification' do - expect(mail.subject).to eq I18n.t('user_mailer.appeal_rejected.subject', date: I18n.l(appeal.created_at)) - expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title') + expect(mail) + .to be_present + .and(have_subject(I18n.t('user_mailer.appeal_rejected.subject', date: I18n.l(appeal.created_at)))) + .and(have_body_text(I18n.t('user_mailer.appeal_rejected.title'))) end end - describe 'two_factor_enabled' do + describe '#two_factor_enabled' do let(:mail) { described_class.two_factor_enabled(receiver) } it 'renders two_factor_enabled mail' do - expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_enabled.subject') - expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_enabled.explanation') + expect(mail) + .to be_present + .and(have_subject(I18n.t('devise.mailer.two_factor_enabled.subject'))) + .and(have_body_text(I18n.t('devise.mailer.two_factor_enabled.explanation'))) end end - describe 'two_factor_disabled' do + describe '#two_factor_disabled' do let(:mail) { described_class.two_factor_disabled(receiver) } it 'renders two_factor_disabled mail' do - expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_disabled.subject') - expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_disabled.explanation') + expect(mail) + .to be_present + .and(have_subject(I18n.t('devise.mailer.two_factor_disabled.subject'))) + .and(have_body_text(I18n.t('devise.mailer.two_factor_disabled.explanation'))) end end - describe 'webauthn_enabled' do + describe '#webauthn_enabled' do let(:mail) { described_class.webauthn_enabled(receiver) } it 'renders webauthn_enabled mail' do - expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_enabled.subject') - expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_enabled.explanation') + expect(mail) + .to be_present + .and(have_subject(I18n.t('devise.mailer.webauthn_enabled.subject'))) + .and(have_body_text(I18n.t('devise.mailer.webauthn_enabled.explanation'))) end end - describe 'webauthn_disabled' do + describe '#webauthn_disabled' do let(:mail) { described_class.webauthn_disabled(receiver) } it 'renders webauthn_disabled mail' do - expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_disabled.subject') - expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_disabled.explanation') + expect(mail) + .to be_present + .and(have_subject(I18n.t('devise.mailer.webauthn_disabled.subject'))) + .and(have_body_text(I18n.t('devise.mailer.webauthn_disabled.explanation'))) end end - describe 'two_factor_recovery_codes_changed' do + describe '#two_factor_recovery_codes_changed' do let(:mail) { described_class.two_factor_recovery_codes_changed(receiver) } it 'renders two_factor_recovery_codes_changed mail' do - expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject') - expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation') + expect(mail) + .to be_present + .and(have_subject(I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject'))) + .and(have_body_text(I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation'))) end end - describe 'webauthn_credential_added' do + describe '#webauthn_credential_added' do let(:credential) { Fabricate.build(:webauthn_credential) } let(:mail) { described_class.webauthn_credential_added(receiver, credential) } it 'renders webauthn_credential_added mail' do - expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_credential.added.subject') - expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_credential.added.explanation') + expect(mail) + .to be_present + .and(have_subject(I18n.t('devise.mailer.webauthn_credential.added.subject'))) + .and(have_body_text(I18n.t('devise.mailer.webauthn_credential.added.explanation'))) + end + end + + describe '#welcome' do + let(:mail) { described_class.welcome(receiver) } + + it 'renders welcome mail' do + expect(mail) + .to be_present + .and(have_subject(I18n.t('user_mailer.welcome.subject'))) + .and(have_body_text(I18n.t('user_mailer.welcome.explanation'))) + end + end + + describe '#backup_ready' do + let(:backup) { Fabricate(:backup) } + let(:mail) { described_class.backup_ready(receiver, backup) } + + it 'renders backup_ready mail' do + expect(mail) + .to be_present + .and(have_subject(I18n.t('user_mailer.backup_ready.subject'))) + .and(have_body_text(I18n.t('user_mailer.backup_ready.explanation'))) end end end diff --git a/spec/models/account_alias_spec.rb b/spec/models/account_alias_spec.rb deleted file mode 100644 index f0cd274ea9..0000000000 --- a/spec/models/account_alias_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AccountAlias do -end diff --git a/spec/models/account_deletion_request_spec.rb b/spec/models/account_deletion_request_spec.rb deleted file mode 100644 index 8bbfb695d3..0000000000 --- a/spec/models/account_deletion_request_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AccountDeletionRequest do -end diff --git a/spec/models/account_migration_spec.rb b/spec/models/account_migration_spec.rb index f4544740b1..1f32c6082e 100644 --- a/spec/models/account_migration_spec.rb +++ b/spec/models/account_migration_spec.rb @@ -4,11 +4,11 @@ require 'rails_helper' RSpec.describe AccountMigration do describe 'validations' do + subject { described_class.new(account: source_account, acct: target_acct) } + let(:source_account) { Fabricate(:account) } let(:target_acct) { target_account.acct } - let(:subject) { described_class.new(account: source_account, acct: target_acct) } - context 'with valid properties' do let(:target_account) { Fabricate(:account, username: 'target', domain: 'remote.org') } diff --git a/spec/models/account_moderation_note_spec.rb b/spec/models/account_moderation_note_spec.rb deleted file mode 100644 index 9d683ca528..0000000000 --- a/spec/models/account_moderation_note_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AccountModerationNote do -end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 7cd914774a..87aa8bc754 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -20,7 +20,9 @@ RSpec.describe Account do end context 'when the account is of a local user' do - let!(:subject) { Fabricate(:user, email: 'foo+bar@domain.org').account } + subject { local_user_account } + + let!(:local_user_account) { Fabricate(:user, email: 'foo+bar@domain.org').account } it 'creates a canonical domain block' do subject.suspend! @@ -207,9 +209,13 @@ RSpec.describe Account do expect(account.refresh!).to be_nil end - it 'calls not ResolveAccountService#call' do - expect_any_instance_of(ResolveAccountService).to_not receive(:call).with(acct) + it 'does not call ResolveAccountService#call' do + service = instance_double(ResolveAccountService, call: nil) + allow(ResolveAccountService).to receive(:new).and_return(service) + account.refresh! + + expect(service).to_not have_received(:call).with(acct) end end @@ -217,8 +223,12 @@ RSpec.describe Account do let(:domain) { 'example.com' } it 'calls ResolveAccountService#call' do - expect_any_instance_of(ResolveAccountService).to receive(:call).with(acct).once + service = instance_double(ResolveAccountService, call: nil) + allow(ResolveAccountService).to receive(:new).and_return(service) + account.refresh! + + expect(service).to have_received(:call).with(acct).once end end end @@ -354,7 +364,7 @@ RSpec.describe Account do end it 'does not return suspended users' do - match = Fabricate( + Fabricate( :account, display_name: 'Display Name', username: 'username', @@ -440,10 +450,11 @@ RSpec.describe Account do expect(results).to eq [match] end - it 'limits by 10 by default' do - 11.times.each { Fabricate(:account, display_name: 'Display Name') } + it 'limits via constant by default' do + stub_const('Account::Search::DEFAULT_LIMIT', 1) + 2.times.each { Fabricate(:account, display_name: 'Display Name') } results = described_class.search_for('display') - expect(results.size).to eq 10 + expect(results.size).to eq 1 end it 'accepts arbitrary limits' do @@ -481,7 +492,7 @@ RSpec.describe Account do end it 'does not return non-followed accounts' do - match = Fabricate( + Fabricate( :account, display_name: 'A & l & i & c & e', username: 'username', @@ -493,7 +504,7 @@ RSpec.describe Account do end it 'does not return suspended users' do - match = Fabricate( + Fabricate( :account, display_name: 'Display Name', username: 'username', @@ -533,7 +544,7 @@ RSpec.describe Account do end it 'does not return suspended users' do - match = Fabricate( + Fabricate( :account, display_name: 'Display Name', username: 'username', @@ -584,9 +595,10 @@ RSpec.describe Account do end it 'limits by 10 by default' do - 11.times { Fabricate(:account, display_name: 'Display Name') } + stub_const('Account::Search::DEFAULT_LIMIT', 1) + 2.times { Fabricate(:account, display_name: 'Display Name') } results = described_class.advanced_search_for('display', account) - expect(results.size).to eq 10 + expect(results.size).to eq 1 end it 'accepts arbitrary limits' do @@ -698,7 +710,7 @@ RSpec.describe Account do expect(subject.match('Check this out https://medium.com/@alice/some-article#.abcdef123')).to be_nil end - xit 'does not match URL query string' do + it 'does not match URL query string' do expect(subject.match('https://example.com/?x=@alice')).to be_nil end end @@ -717,10 +729,10 @@ RSpec.describe Account do context 'when is local' do it 'is invalid if the username is not unique in case-insensitive comparison among local accounts' do - account_1 = Fabricate(:account, username: 'the_doctor') - account_2 = Fabricate.build(:account, username: 'the_Doctor') - account_2.valid? - expect(account_2).to model_have_error_on_field(:username) + _account = Fabricate(:account, username: 'the_doctor') + non_unique_account = Fabricate.build(:account, username: 'the_Doctor') + non_unique_account.valid? + expect(non_unique_account).to model_have_error_on_field(:username) end it 'is invalid if the username is reserved' do @@ -741,9 +753,9 @@ RSpec.describe Account do end it 'is valid if we are creating a possibly-conflicting instance actor account' do - account_1 = Fabricate(:account, username: 'examplecom') - account_2 = Fabricate.build(:account, id: -99, actor_type: 'Application', locked: true, username: 'example.com') - expect(account_2.valid?).to be true + _account = Fabricate(:account, username: 'examplecom') + instance_account = Fabricate.build(:account, id: -99, actor_type: 'Application', locked: true, username: 'example.com') + expect(instance_account.valid?).to be true end it 'is invalid if the username doesn\'t only contains letters, numbers and underscores' do @@ -875,17 +887,17 @@ RSpec.describe Account do describe 'remote' do it 'returns an array of accounts who have a domain' do - account_1 = Fabricate(:account, domain: nil) - account_2 = Fabricate(:account, domain: 'example.com') - expect(described_class.remote).to contain_exactly(account_2) + _account = Fabricate(:account, domain: nil) + account_with_domain = Fabricate(:account, domain: 'example.com') + expect(described_class.remote).to contain_exactly(account_with_domain) end end describe 'local' do it 'returns an array of accounts who do not have a domain' do - account_1 = Fabricate(:account, domain: nil) - account_2 = Fabricate(:account, domain: 'example.com') - expect(described_class.where('id > 0').local).to contain_exactly(account_1) + local_account = Fabricate(:account, domain: nil) + _account_with_domain = Fabricate(:account, domain: 'example.com') + expect(described_class.where('id > 0').local).to contain_exactly(local_account) end end @@ -909,17 +921,17 @@ RSpec.describe Account do describe 'silenced' do it 'returns an array of accounts who are silenced' do - account_1 = Fabricate(:account, silenced: true) - account_2 = Fabricate(:account, silenced: false) - expect(described_class.silenced).to contain_exactly(account_1) + silenced_account = Fabricate(:account, silenced: true) + _account = Fabricate(:account, silenced: false) + expect(described_class.silenced).to contain_exactly(silenced_account) end end describe 'suspended' do it 'returns an array of accounts who are suspended' do - account_1 = Fabricate(:account, suspended: true) - account_2 = Fabricate(:account, suspended: false) - expect(described_class.suspended).to contain_exactly(account_1) + suspended_account = Fabricate(:account, suspended: true) + _account = Fabricate(:account, suspended: false) + expect(described_class.suspended).to contain_exactly(suspended_account) end end @@ -942,6 +954,7 @@ RSpec.describe Account do it 'returns every usable non-suspended account' do expect(described_class.searchable).to contain_exactly(silenced_local, silenced_remote, local_account, remote_account) + expect(described_class.searchable).to_not include(suspended_local, suspended_remote, unconfirmed, unapproved) end it 'does not mess with previously-applied scopes' do @@ -961,12 +974,12 @@ RSpec.describe Account do context 'when is remote' do it 'does not generate keys' do key = OpenSSL::PKey::RSA.new(1024).public_key - account = described_class.create!(domain: 'remote', username: Faker::Internet.user_name(separators: ['_']), public_key: key.to_pem) + account = described_class.create!(domain: 'remote', uri: 'https://remote/actor', username: Faker::Internet.user_name(separators: ['_']), public_key: key.to_pem) expect(account.keypair.params).to eq key.params end it 'normalizes domain' do - account = described_class.create!(domain: 'にゃん', username: Faker::Internet.user_name(separators: ['_'])) + account = described_class.create!(domain: 'にゃん', uri: 'https://xn--r9j5b5b/actor', username: Faker::Internet.user_name(separators: ['_'])) expect(account.domain).to eq 'xn--r9j5b5b' end end diff --git a/spec/models/account_statuses_cleanup_policy_spec.rb b/spec/models/account_statuses_cleanup_policy_spec.rb index 7405bdfa2d..da2a774b2d 100644 --- a/spec/models/account_statuses_cleanup_policy_spec.rb +++ b/spec/models/account_statuses_cleanup_policy_spec.rb @@ -235,13 +235,17 @@ RSpec.describe AccountStatusesCleanupPolicy do describe '#compute_cutoff_id' do subject { account_statuses_cleanup_policy.compute_cutoff_id } - let!(:unrelated_status) { Fabricate(:status, created_at: 3.years.ago) } let(:account_statuses_cleanup_policy) { Fabricate(:account_statuses_cleanup_policy, account: account) } + before { Fabricate(:status, created_at: 3.years.ago) } + context 'when the account has posted multiple toots' do - let!(:very_old_status) { Fabricate(:status, created_at: 3.years.ago, account: account) } - let!(:old_status) { Fabricate(:status, created_at: 3.weeks.ago, account: account) } - let!(:recent_status) { Fabricate(:status, created_at: 2.days.ago, account: account) } + let!(:old_status) { Fabricate(:status, created_at: 3.weeks.ago, account: account) } + + before do + Fabricate(:status, created_at: 3.years.ago, account: account) + Fabricate(:status, created_at: 2.days.ago, account: account) + end it 'returns the most recent id that is still below policy age' do expect(subject).to eq old_status.id @@ -270,20 +274,20 @@ RSpec.describe AccountStatusesCleanupPolicy do let!(:faved_secondary) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:reblogged_primary) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:reblogged_secondary) { Fabricate(:status, created_at: 1.year.ago, account: account) } - let!(:recent_status) { Fabricate(:status, created_at: 2.days.ago, account: account) } - - let!(:media_attachment) { Fabricate(:media_attachment, account: account, status: status_with_media) } - let!(:status_pin) { Fabricate(:status_pin, account: account, status: pinned_status) } - let!(:favourite) { Fabricate(:favourite, account: account, status: self_faved) } - let!(:bookmark) { Fabricate(:bookmark, account: account, status: self_bookmarked) } + let!(:recent_status) { Fabricate(:status, created_at: 2.days.ago, account: account) } let(:account_statuses_cleanup_policy) { Fabricate(:account_statuses_cleanup_policy, account: account) } before do - 4.times { faved_primary.increment_count!(:favourites_count) } - 5.times { faved_secondary.increment_count!(:favourites_count) } - 4.times { reblogged_primary.increment_count!(:reblogs_count) } - 5.times { reblogged_secondary.increment_count!(:reblogs_count) } + Fabricate(:media_attachment, account: account, status: status_with_media) + Fabricate(:status_pin, account: account, status: pinned_status) + Fabricate(:favourite, account: account, status: self_faved) + Fabricate(:bookmark, account: account, status: self_bookmarked) + + faved_primary.status_stat.update(favourites_count: 4) + faved_secondary.status_stat.update(favourites_count: 5) + reblogged_primary.status_stat.update(reblogs_count: 4) + reblogged_secondary.status_stat.update(reblogs_count: 5) end context 'when passed a max_id' do diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb index b47561dd48..5d64d565fc 100644 --- a/spec/models/admin/account_action_spec.rb +++ b/spec/models/admin/account_action_spec.rb @@ -46,12 +46,10 @@ RSpec.describe Admin::AccountAction do expect(target_account).to be_suspended end - it 'queues Admin::SuspensionWorker by 1' do - Sidekiq::Testing.fake! do - expect do - subject - end.to change { Admin::SuspensionWorker.jobs.size }.by 1 - end + it 'queues Admin::SuspensionWorker by 1', :sidekiq_fake do + expect do + subject + end.to change { Admin::SuspensionWorker.jobs.size }.by 1 end end @@ -78,13 +76,15 @@ RSpec.describe Admin::AccountAction do end it 'calls process_email!' do - expect(account_action).to receive(:process_email!) + allow(account_action).to receive(:process_email!) subject + expect(account_action).to have_received(:process_email!) end it 'calls process_reports!' do - expect(account_action).to receive(:process_reports!) + allow(account_action).to receive(:process_reports!) subject + expect(account_action).to have_received(:process_reports!) end end diff --git a/spec/models/announcement_mute_spec.rb b/spec/models/announcement_mute_spec.rb deleted file mode 100644 index 1937da3aa7..0000000000 --- a/spec/models/announcement_mute_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AnnouncementMute do -end diff --git a/spec/models/announcement_reaction_spec.rb b/spec/models/announcement_reaction_spec.rb deleted file mode 100644 index 43cc0e1489..0000000000 --- a/spec/models/announcement_reaction_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AnnouncementReaction do -end diff --git a/spec/models/announcement_spec.rb b/spec/models/announcement_spec.rb index 32d398213c..e37b81a52e 100644 --- a/spec/models/announcement_spec.rb +++ b/spec/models/announcement_spec.rb @@ -2,5 +2,209 @@ require 'rails_helper' -RSpec.describe Announcement do +describe Announcement do + describe 'Scopes' do + context 'with published and unpublished records' do + let!(:published) { Fabricate(:announcement, published: true) } + let!(:unpublished) { Fabricate(:announcement, published: false, scheduled_at: 10.days.from_now) } + + describe '#unpublished' do + it 'returns records with published false' do + results = described_class.unpublished + + expect(results).to eq([unpublished]) + end + end + + describe '#published' do + it 'returns records with published true' do + results = described_class.published + + expect(results).to eq([published]) + end + end + end + + describe '#without_muted' do + let!(:announcement) { Fabricate(:announcement) } + let(:account) { Fabricate(:account) } + let(:muted_announcement) { Fabricate(:announcement) } + + before do + Fabricate(:announcement_mute, account: account, announcement: muted_announcement) + end + + it 'returns the announcements not muted by the account' do + results = described_class.without_muted(account) + expect(results).to include(announcement) + expect(results).to_not include(muted_announcement) + end + end + + context 'with timestamped announcements' do + let!(:adam_announcement) { Fabricate(:announcement, starts_at: 100.days.ago, scheduled_at: 10.days.ago, published_at: 10.days.ago, ends_at: 5.days.from_now) } + let!(:brenda_announcement) { Fabricate(:announcement, starts_at: 10.days.ago, scheduled_at: 100.days.ago, published_at: 10.days.ago, ends_at: 5.days.from_now) } + let!(:clara_announcement) { Fabricate(:announcement, starts_at: 10.days.ago, scheduled_at: 10.days.ago, published_at: 100.days.ago, ends_at: 5.days.from_now) } + let!(:darnelle_announcement) { Fabricate(:announcement, starts_at: 10.days.ago, scheduled_at: 10.days.ago, published_at: 10.days.ago, ends_at: 5.days.from_now, created_at: 100.days.ago) } + + describe '#chronological' do + it 'orders the records correctly' do + results = described_class.chronological + + expect(results).to eq( + [ + adam_announcement, + brenda_announcement, + clara_announcement, + darnelle_announcement, + ] + ) + end + end + + describe '#reverse_chronological' do + it 'orders the records correctly' do + results = described_class.reverse_chronological + + expect(results).to eq( + [ + darnelle_announcement, + clara_announcement, + brenda_announcement, + adam_announcement, + ] + ) + end + end + end + end + + describe 'Validations' do + describe 'text' do + it 'validates presence of attribute' do + record = Fabricate.build(:announcement, text: nil) + + expect(record).to_not be_valid + expect(record.errors[:text]).to be_present + end + end + + describe 'ends_at' do + it 'validates presence when starts_at is present' do + record = Fabricate.build(:announcement, starts_at: 1.day.ago) + + expect(record).to_not be_valid + expect(record.errors[:ends_at]).to be_present + end + + it 'does not validate presence when starts_at is missing' do + record = Fabricate.build(:announcement, starts_at: nil) + + expect(record).to be_valid + expect(record.errors[:ends_at]).to_not be_present + end + end + end + + describe '#publish!' do + it 'publishes an unpublished record' do + announcement = Fabricate(:announcement, published: false, scheduled_at: 10.days.from_now) + + announcement.publish! + + expect(announcement).to be_published + expect(announcement.published_at).to_not be_nil + expect(announcement.scheduled_at).to be_nil + end + end + + describe '#unpublish!' do + it 'unpublishes a published record' do + announcement = Fabricate(:announcement, published: true) + + announcement.unpublish! + + expect(announcement).to_not be_published + expect(announcement.scheduled_at).to be_nil + end + end + + describe '#time_range?' do + it 'returns false when starts_at and ends_at are missing' do + record = Fabricate.build(:announcement, starts_at: nil, ends_at: nil) + + expect(record.time_range?).to be(false) + end + + it 'returns false when starts_at is present and ends_at is missing' do + record = Fabricate.build(:announcement, starts_at: 5.days.from_now, ends_at: nil) + + expect(record.time_range?).to be(false) + end + + it 'returns false when starts_at is missing and ends_at is present' do + record = Fabricate.build(:announcement, starts_at: nil, ends_at: 5.days.from_now) + + expect(record.time_range?).to be(false) + end + + it 'returns true when starts_at and ends_at are present' do + record = Fabricate.build(:announcement, starts_at: 5.days.from_now, ends_at: 10.days.from_now) + + expect(record.time_range?).to be(true) + end + end + + describe '#reactions' do + context 'with announcement_reactions present' do + let!(:account) { Fabricate(:account) } + let!(:announcement) { Fabricate(:announcement) } + let!(:announcement_reaction) { Fabricate(:announcement_reaction, announcement: announcement, created_at: 10.days.ago) } + let!(:announcement_reaction_account) { Fabricate(:announcement_reaction, announcement: announcement, created_at: 5.days.ago, account: account) } + + before do + Fabricate(:announcement_reaction) + end + + it 'returns the announcement reactions for the announcement' do + results = announcement.reactions + + expect(results.first.name).to eq(announcement_reaction.name) + expect(results.last.name).to eq(announcement_reaction_account.name) + end + + it 'returns the announcement reactions for the announcement limited to account' do + results = announcement.reactions(account) + + expect(results.first.name).to eq(announcement_reaction.name) + end + end + end + + describe '#statuses' do + let(:announcement) { Fabricate(:announcement, status_ids: status_ids) } + + context 'with empty status_ids' do + let(:status_ids) { nil } + + it 'returns empty array' do + results = announcement.statuses + + expect(results).to eq([]) + end + end + + context 'with relevant status_ids' do + let(:status) { Fabricate(:status, visibility: :public) } + let(:direct_status) { Fabricate(:status, visibility: :direct) } + let(:status_ids) { [status.id, direct_status.id] } + + it 'returns public and unlisted statuses' do + results = announcement.statuses + + expect(results).to include(status) + expect(results).to_not include(direct_status) + end + end + end end diff --git a/spec/models/backup_spec.rb b/spec/models/backup_spec.rb deleted file mode 100644 index 1303117528..0000000000 --- a/spec/models/backup_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Backup do -end diff --git a/spec/models/canonical_email_block_spec.rb b/spec/models/canonical_email_block_spec.rb index 0acff82377..c63483f968 100644 --- a/spec/models/canonical_email_block_spec.rb +++ b/spec/models/canonical_email_block_spec.rb @@ -28,7 +28,7 @@ RSpec.describe CanonicalEmailBlock do end describe '.block?' do - let!(:canonical_email_block) { Fabricate(:canonical_email_block, email: 'foo@bar.com') } + before { Fabricate(:canonical_email_block, email: 'foo@bar.com') } it 'returns true for the same email' do expect(described_class.block?('foo@bar.com')).to be true diff --git a/spec/models/concerns/account_counters_spec.rb b/spec/models/concerns/account/counters_spec.rb similarity index 98% rename from spec/models/concerns/account_counters_spec.rb rename to spec/models/concerns/account/counters_spec.rb index fb02d79f11..2e1cd700bc 100644 --- a/spec/models/concerns/account_counters_spec.rb +++ b/spec/models/concerns/account/counters_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe AccountCounters do +describe Account::Counters do let!(:account) { Fabricate(:account) } describe '#increment_count!' do diff --git a/spec/models/concerns/account_finder_concern_spec.rb b/spec/models/concerns/account/finder_concern_spec.rb similarity index 86% rename from spec/models/concerns/account_finder_concern_spec.rb rename to spec/models/concerns/account/finder_concern_spec.rb index 25f4fdec4b..ab5149e987 100644 --- a/spec/models/concerns/account_finder_concern_spec.rb +++ b/spec/models/concerns/account/finder_concern_spec.rb @@ -2,19 +2,17 @@ require 'rails_helper' -describe AccountFinderConcern do +describe Account::FinderConcern do describe 'local finders' do - before do - @account = Fabricate(:account, username: 'Alice') - end + let!(:account) { Fabricate(:account, username: 'Alice') } describe '.find_local' do it 'returns case-insensitive result' do - expect(Account.find_local('alice')).to eq(@account) + expect(Account.find_local('alice')).to eq(account) end it 'returns correctly cased result' do - expect(Account.find_local('Alice')).to eq(@account) + expect(Account.find_local('Alice')).to eq(account) end it 'returns nil without a match' do @@ -36,7 +34,7 @@ describe AccountFinderConcern do describe '.find_local!' do it 'returns matching result' do - expect(Account.find_local!('alice')).to eq(@account) + expect(Account.find_local!('alice')).to eq(account) end it 'raises on non-matching result' do @@ -54,17 +52,15 @@ describe AccountFinderConcern do end describe 'remote finders' do - before do - @account = Fabricate(:account, username: 'Alice', domain: 'mastodon.social') - end + let!(:account) { Fabricate(:account, username: 'Alice', domain: 'mastodon.social') } describe '.find_remote' do it 'returns exact match result' do - expect(Account.find_remote('alice', 'mastodon.social')).to eq(@account) + expect(Account.find_remote('alice', 'mastodon.social')).to eq(account) end it 'returns case-insensitive result' do - expect(Account.find_remote('ALICE', 'MASTODON.SOCIAL')).to eq(@account) + expect(Account.find_remote('ALICE', 'MASTODON.SOCIAL')).to eq(account) end it 'returns nil when username does not match' do @@ -90,7 +86,7 @@ describe AccountFinderConcern do describe '.find_remote!' do it 'returns matching result' do - expect(Account.find_remote!('alice', 'mastodon.social')).to eq(@account) + expect(Account.find_remote!('alice', 'mastodon.social')).to eq(account) end it 'raises on non-matching result' do diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account/interactions_spec.rb similarity index 98% rename from spec/models/concerns/account_interactions_spec.rb rename to spec/models/concerns/account/interactions_spec.rb index 41aedd18b2..8e8142140f 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account/interactions_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe AccountInteractions do +describe Account::Interactions do let(:account) { Fabricate(:account, username: 'account') } let(:account_id) { account.id } let(:account_ids) { [account_id] } @@ -655,38 +655,36 @@ describe AccountInteractions do end describe 'ignoring reblogs from an account' do - before do - @me = Fabricate(:account, username: 'Me') - @you = Fabricate(:account, username: 'You') - end + let!(:me) { Fabricate(:account, username: 'Me') } + let!(:you) { Fabricate(:account, username: 'You') } context 'with the reblogs option unspecified' do before do - @me.follow!(@you) + me.follow!(you) end it 'defaults to showing reblogs' do - expect(@me.muting_reblogs?(@you)).to be(false) + expect(me.muting_reblogs?(you)).to be(false) end end context 'with the reblogs option set to false' do before do - @me.follow!(@you, reblogs: false) + me.follow!(you, reblogs: false) end it 'does mute reblogs' do - expect(@me.muting_reblogs?(@you)).to be(true) + expect(me.muting_reblogs?(you)).to be(true) end end context 'with the reblogs option set to true' do before do - @me.follow!(@you, reblogs: true) + me.follow!(you, reblogs: true) end it 'does not mute reblogs' do - expect(@me.muting_reblogs?(@you)).to be(false) + expect(me.muting_reblogs?(you)).to be(false) end end end diff --git a/spec/models/concerns/account/statuses_search_spec.rb b/spec/models/concerns/account/statuses_search_spec.rb new file mode 100644 index 0000000000..ab249d62d0 --- /dev/null +++ b/spec/models/concerns/account/statuses_search_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Account::StatusesSearch do + let(:account) { Fabricate(:account, indexable: indexable) } + + before do + allow(Chewy).to receive(:enabled?).and_return(true) + end + + describe '#enqueue_update_public_statuses_index' do + before do + allow(account).to receive(:enqueue_add_to_public_statuses_index) + allow(account).to receive(:enqueue_remove_from_public_statuses_index) + end + + context 'when account is indexable' do + let(:indexable) { true } + + it 'enqueues add_to_public_statuses_index and not to remove_from_public_statuses_index' do + account.enqueue_update_public_statuses_index + expect(account).to have_received(:enqueue_add_to_public_statuses_index) + expect(account).to_not have_received(:enqueue_remove_from_public_statuses_index) + end + end + + context 'when account is not indexable' do + let(:indexable) { false } + + it 'enqueues remove_from_public_statuses_index and not to add_to_public_statuses_index' do + account.enqueue_update_public_statuses_index + expect(account).to have_received(:enqueue_remove_from_public_statuses_index) + expect(account).to_not have_received(:enqueue_add_to_public_statuses_index) + end + end + end + + describe '#enqueue_add_to_public_statuses_index' do + let(:indexable) { true } + let(:worker) { AddToPublicStatusesIndexWorker } + + before do + allow(worker).to receive(:perform_async) + end + + it 'enqueues AddToPublicStatusesIndexWorker' do + account.enqueue_add_to_public_statuses_index + expect(worker).to have_received(:perform_async).with(account.id) + end + end + + describe '#enqueue_remove_from_public_statuses_index' do + let(:indexable) { false } + let(:worker) { RemoveFromPublicStatusesIndexWorker } + + before do + allow(worker).to receive(:perform_async) + end + + it 'enqueues RemoveFromPublicStatusesIndexWorker' do + account.enqueue_remove_from_public_statuses_index + expect(worker).to have_received(:perform_async).with(account.id) + end + end +end diff --git a/spec/models/concerns/remotable_spec.rb b/spec/models/concerns/remotable_spec.rb index b2aa56a704..097e6bf006 100644 --- a/spec/models/concerns/remotable_spec.rb +++ b/spec/models/concerns/remotable_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Remotable do end end - let(:attribute_name) { "#{hoge}_remote_url".to_sym } + let(:attribute_name) { :"#{hoge}_remote_url" } let(:code) { 200 } let(:file) { 'filename="foo.txt"' } let(:foo) { foo_class.new } @@ -69,7 +69,9 @@ RSpec.describe Remotable do context 'with an invalid URL' do before do - allow(Addressable::URI).to receive_message_chain(:parse, :normalize).with(url).with(no_args).and_raise(Addressable::URI::InvalidURIError) + parsed = instance_double(Addressable::URI) + allow(parsed).to receive(:normalize).with(no_args).and_raise(Addressable::URI::InvalidURIError) + allow(Addressable::URI).to receive(:parse).with(url).and_return(parsed) end it 'makes no request' do @@ -120,8 +122,11 @@ RSpec.describe Remotable do end it 'does not try to write attribute' do - expect(foo).to_not receive('[]=').with(attribute_name, url) + allow(foo).to receive('[]=').with(attribute_name, url) + foo.hoge_remote_url = url + + expect(foo).to_not have_received('[]=').with(attribute_name, url) end end @@ -131,8 +136,11 @@ RSpec.describe Remotable do end it 'does not try to write attribute' do - expect(foo).to receive('[]=').with(attribute_name, url) + allow(foo).to receive('[]=').with(attribute_name, url) + foo.hoge_remote_url = url + + expect(foo).to have_received('[]=').with(attribute_name, url) end end @@ -146,10 +154,13 @@ RSpec.describe Remotable do let(:code) { 500 } it 'does not assign file' do - expect(foo).to_not receive(:public_send).with("#{hoge}=", any_args) - expect(foo).to_not receive(:public_send).with("#{hoge}_file_name=", any_args) + allow(foo).to receive(:public_send) + allow(foo).to receive(:public_send) foo.hoge_remote_url = url + + expect(foo).to_not have_received(:public_send).with("#{hoge}=", any_args) + expect(foo).to_not have_received(:public_send).with("#{hoge}_file_name=", any_args) end end @@ -165,13 +176,13 @@ RSpec.describe Remotable do allow(ResponseWithLimit).to receive(:new).with(anything, anything).and_return(response_with_limit) - expect(foo).to receive(:public_send).with("download_#{hoge}!", url) - + allow(foo).to receive(:public_send) foo.hoge_remote_url = url + expect(foo).to have_received(:public_send).with(:"download_#{hoge}!", url) - expect(foo).to receive(:public_send).with("#{hoge}=", response_with_limit) - + allow(foo).to receive(:public_send) foo.download_hoge!(url) + expect(foo).to have_received(:public_send).with(:"#{hoge}=", response_with_limit) end end end @@ -193,10 +204,13 @@ RSpec.describe Remotable do let(:error_class) { error_class } it 'calls Rails.logger.debug' do - expect(Rails.logger).to receive(:debug) do |&block| + allow(Rails.logger).to receive(:debug) + + foo.hoge_remote_url = url + + expect(Rails.logger).to have_received(:debug) do |&block| expect(block.call).to match(/^Error fetching remote #{hoge}: /) end - foo.hoge_remote_url = url end end end diff --git a/spec/models/concerns/status_threading_concern_spec.rb b/spec/models/concerns/status/threading_concern_spec.rb similarity index 99% rename from spec/models/concerns/status_threading_concern_spec.rb rename to spec/models/concerns/status/threading_concern_spec.rb index 2eac1ca6e5..09fb218566 100644 --- a/spec/models/concerns/status_threading_concern_spec.rb +++ b/spec/models/concerns/status/threading_concern_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -describe StatusThreadingConcern do +describe Status::ThreadingConcern do describe '#ancestors' do let!(:alice) { Fabricate(:account, username: 'alice') } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } diff --git a/spec/models/conversation_mute_spec.rb b/spec/models/conversation_mute_spec.rb deleted file mode 100644 index 3d5504a65c..0000000000 --- a/spec/models/conversation_mute_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe ConversationMute do -end diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb index 8a6487c321..06affd634a 100644 --- a/spec/models/custom_emoji_spec.rb +++ b/spec/models/custom_emoji_spec.rb @@ -59,7 +59,7 @@ RSpec.describe CustomEmoji do describe '.from_text' do subject { described_class.from_text(text, nil) } - let!(:emojo) { Fabricate(:custom_emoji) } + let!(:emojo) { Fabricate(:custom_emoji, shortcode: 'coolcat') } context 'with plain text' do let(:text) { 'Hello :coolcat:' } diff --git a/spec/models/custom_filter_keyword_spec.rb b/spec/models/custom_filter_keyword_spec.rb deleted file mode 100644 index 09a36fa3ed..0000000000 --- a/spec/models/custom_filter_keyword_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe CustomFilterKeyword do -end diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb deleted file mode 100644 index f5e9b7ea96..0000000000 --- a/spec/models/custom_filter_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe CustomFilter do -end diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb deleted file mode 100644 index 4e7b8f2e20..0000000000 --- a/spec/models/device_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Device do -end diff --git a/spec/models/domain_block_spec.rb b/spec/models/domain_block_spec.rb index 67f53fa785..d595441fd3 100644 --- a/spec/models/domain_block_spec.rb +++ b/spec/models/domain_block_spec.rb @@ -11,10 +11,10 @@ RSpec.describe DomainBlock do end it 'is invalid if the same normalized domain already exists' do - domain_block_1 = Fabricate(:domain_block, domain: 'にゃん') - domain_block_2 = Fabricate.build(:domain_block, domain: 'xn--r9j5b5b') - domain_block_2.valid? - expect(domain_block_2).to model_have_error_on_field(:domain) + _domain_block = Fabricate(:domain_block, domain: 'にゃん') + domain_block_with_normalized_value = Fabricate.build(:domain_block, domain: 'xn--r9j5b5b') + domain_block_with_normalized_value.valid? + expect(domain_block_with_normalized_value).to model_have_error_on_field(:domain) end end diff --git a/spec/models/encrypted_message_spec.rb b/spec/models/encrypted_message_spec.rb deleted file mode 100644 index c38142be1e..0000000000 --- a/spec/models/encrypted_message_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe EncryptedMessage do -end diff --git a/spec/models/featured_tag_spec.rb b/spec/models/featured_tag_spec.rb deleted file mode 100644 index 58865f94a5..0000000000 --- a/spec/models/featured_tag_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe FeaturedTag do -end diff --git a/spec/models/follow_recommendation_suppression_spec.rb b/spec/models/follow_recommendation_suppression_spec.rb deleted file mode 100644 index d437d170d3..0000000000 --- a/spec/models/follow_recommendation_suppression_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe FollowRecommendationSuppression do -end diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb index 01faec0e70..f30e27e701 100644 --- a/spec/models/follow_request_spec.rb +++ b/spec/models/follow_request_spec.rb @@ -21,12 +21,17 @@ RSpec.describe FollowRequest do end it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do - expect(account).to receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri, languages: nil, bypass_limit: true) do + allow(account).to receive(:follow!) do account.active_relationships.create!(target_account: target_account) end - expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id) - expect(follow_request).to receive(:destroy!) + allow(MergeWorker).to receive(:perform_async) + allow(follow_request).to receive(:destroy!) + follow_request.authorize! + + expect(account).to have_received(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri, languages: nil, bypass_limit: true) + expect(MergeWorker).to have_received(:perform_async).with(target_account.id, account.id) + expect(follow_request).to have_received(:destroy!) end it 'generates a Follow' do diff --git a/spec/models/form/account_batch_spec.rb b/spec/models/form/account_batch_spec.rb index fd8e909010..26fb1b953a 100644 --- a/spec/models/form/account_batch_spec.rb +++ b/spec/models/form/account_batch_spec.rb @@ -37,12 +37,10 @@ RSpec.describe Form::AccountBatch do let(:select_all_matching) { '0' } let(:account_ids) { [target_account.id, target_account2.id] } - it 'suspends the expected users' do - expect { subject }.to change { [target_account.reload.suspended?, target_account2.reload.suspended?] }.from([false, false]).to([true, true]) - end - - it 'closes open reports targeting the suspended users' do - expect { subject }.to change { Report.unresolved.where(target_account: [target_account, target_account2]).count }.from(2).to(0) + it 'suspends the expected users and closes open reports' do + expect { subject } + .to change_account_suspensions + .and change_open_reports_for_accounts end end @@ -50,13 +48,33 @@ RSpec.describe Form::AccountBatch do let(:select_all_matching) { '1' } let(:query) { Account.where(id: [target_account.id, target_account2.id]) } - it 'suspends the expected users' do - expect { subject }.to change { [target_account.reload.suspended?, target_account2.reload.suspended?] }.from([false, false]).to([true, true]) + it 'suspends the expected users and closes open reports' do + expect { subject } + .to change_account_suspensions + .and change_open_reports_for_accounts end + end - it 'closes open reports targeting the suspended users' do - expect { subject }.to change { Report.unresolved.where(target_account: [target_account, target_account2]).count }.from(2).to(0) - end + private + + def change_account_suspensions + change { relevant_account_suspension_statuses } + .from([false, false]) + .to([true, true]) + end + + def change_open_reports_for_accounts + change(relevant_account_unresolved_reports, :count) + .from(2) + .to(0) + end + + def relevant_account_unresolved_reports + Report.unresolved.where(target_account: [target_account, target_account2]) + end + + def relevant_account_suspension_statuses + [target_account.reload, target_account2.reload].map(&:suspended?) end end end diff --git a/spec/models/form/custom_emoji_batch_spec.rb b/spec/models/form/custom_emoji_batch_spec.rb new file mode 100644 index 0000000000..abeada5d50 --- /dev/null +++ b/spec/models/form/custom_emoji_batch_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Form::CustomEmojiBatch do + describe '#save' do + subject { described_class.new({ current_account: account }.merge(options)) } + + let(:options) { {} } + let(:account) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + + context 'with empty custom_emoji_ids' do + let(:options) { { custom_emoji_ids: [] } } + + it 'does nothing if custom_emoji_ids is empty' do + expect(subject.save).to be_nil + end + end + + describe 'the update action' do + let(:custom_emoji) { Fabricate(:custom_emoji, category: Fabricate(:custom_emoji_category)) } + let(:custom_emoji_category) { Fabricate(:custom_emoji_category) } + + context 'without anything to change' do + let(:options) { { action: 'update' } } + + it 'silently exits without updating any custom emojis' do + expect { subject.save }.to_not change(Admin::ActionLog, :count) + end + end + + context 'with a category_id' do + let(:options) { { action: 'update', custom_emoji_ids: [custom_emoji.id], category_id: custom_emoji_category.id } } + + it 'updates the category of the emoji' do + subject.save + + expect(custom_emoji.reload.category).to eq(custom_emoji_category) + end + end + + context 'with a category_name' do + let(:options) { { action: 'update', custom_emoji_ids: [custom_emoji.id], category_name: custom_emoji_category.name } } + + it 'updates the category of the emoji' do + subject.save + + expect(custom_emoji.reload.category).to eq(custom_emoji_category) + end + end + end + + describe 'the list action' do + let(:custom_emoji) { Fabricate(:custom_emoji, visible_in_picker: false) } + let(:options) { { action: 'list', custom_emoji_ids: [custom_emoji.id] } } + + it 'updates the picker visibility of the emoji' do + subject.save + + expect(custom_emoji.reload.visible_in_picker).to be(true) + end + end + + describe 'the unlist action' do + let(:custom_emoji) { Fabricate(:custom_emoji, visible_in_picker: true) } + let(:options) { { action: 'unlist', custom_emoji_ids: [custom_emoji.id] } } + + it 'updates the picker visibility of the emoji' do + subject.save + + expect(custom_emoji.reload.visible_in_picker).to be(false) + end + end + + describe 'the enable action' do + let(:custom_emoji) { Fabricate(:custom_emoji, disabled: true) } + let(:options) { { action: 'enable', custom_emoji_ids: [custom_emoji.id] } } + + it 'updates the disabled value of the emoji' do + subject.save + + expect(custom_emoji.reload).to_not be_disabled + end + end + + describe 'the disable action' do + let(:custom_emoji) { Fabricate(:custom_emoji, visible_in_picker: false) } + let(:options) { { action: 'disable', custom_emoji_ids: [custom_emoji.id] } } + + it 'updates the disabled value of the emoji' do + subject.save + + expect(custom_emoji.reload).to be_disabled + end + end + + describe 'the copy action' do + let(:custom_emoji) { Fabricate(:custom_emoji) } + let(:options) { { action: 'copy', custom_emoji_ids: [custom_emoji.id] } } + + it 'makes a copy of the emoji' do + expect { subject.save } + .to change(CustomEmoji, :count).by(1) + end + end + + describe 'the delete action' do + let(:custom_emoji) { Fabricate(:custom_emoji) } + let(:options) { { action: 'delete', custom_emoji_ids: [custom_emoji.id] } } + + it 'destroys the emoji' do + subject.save + + expect { custom_emoji.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/models/form/import_spec.rb b/spec/models/form/import_spec.rb index 2b70e396b9..872697485e 100644 --- a/spec/models/form/import_spec.rb +++ b/spec/models/form/import_spec.rb @@ -296,7 +296,7 @@ RSpec.describe Form::Import do it_behaves_like 'on successful import', 'following', 'merge', 'following_accounts.csv', [ { 'acct' => 'user@example.com', 'show_reblogs' => true, 'notify' => false, 'languages' => nil }, - { 'acct' => 'user@test.com', 'show_reblogs' => true, 'notify' => true, 'languages' => ['en', 'fr'] }, + { 'acct' => 'user@test.com', 'show_reblogs' => true, 'notify' => true, 'languages' => %w(en fr) }, ] it_behaves_like 'on successful import', 'muting', 'merge', 'muted_accounts.csv', [ diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb index 2fca1e1c14..7022454443 100644 --- a/spec/models/identity_spec.rb +++ b/spec/models/identity_spec.rb @@ -7,8 +7,11 @@ RSpec.describe Identity do let(:auth) { Fabricate(:identity, user: Fabricate(:user)) } it 'calls .find_or_create_by' do - expect(described_class).to receive(:find_or_create_by).with(uid: auth.uid, provider: auth.provider) + allow(described_class).to receive(:find_or_create_by) + described_class.find_for_oauth(auth) + + expect(described_class).to have_received(:find_or_create_by).with(uid: auth.uid, provider: auth.provider) end it 'returns an instance of Identity' do diff --git a/spec/models/list_account_spec.rb b/spec/models/list_account_spec.rb deleted file mode 100644 index c9853d4a04..0000000000 --- a/spec/models/list_account_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe ListAccount do -end diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb deleted file mode 100644 index 621ad3968d..0000000000 --- a/spec/models/list_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe List do -end diff --git a/spec/models/login_activity_spec.rb b/spec/models/login_activity_spec.rb deleted file mode 100644 index 2214d3ef39..0000000000 --- a/spec/models/login_activity_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe LoginActivity do -end diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index 90e4f2f47b..89916f9f50 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -2,11 +2,11 @@ require 'rails_helper' -RSpec.describe MediaAttachment, paperclip_processing: true do +RSpec.describe MediaAttachment, :paperclip_processing do describe 'local?' do subject { media_attachment.local? } - let(:media_attachment) { Fabricate(:media_attachment, remote_url: remote_url) } + let(:media_attachment) { described_class.new(remote_url: remote_url) } context 'when remote_url is blank' do let(:remote_url) { '' } @@ -28,7 +28,7 @@ RSpec.describe MediaAttachment, paperclip_processing: true do describe 'needs_redownload?' do subject { media_attachment.needs_redownload? } - let(:media_attachment) { Fabricate(:media_attachment, remote_url: remote_url, file: file) } + let(:media_attachment) { described_class.new(remote_url: remote_url, file: file) } context 'when file is blank' do let(:file) { nil } @@ -64,11 +64,11 @@ RSpec.describe MediaAttachment, paperclip_processing: true do end describe '#to_param' do - let(:media_attachment) { Fabricate(:media_attachment, shortcode: shortcode) } - let(:shortcode) { nil } + let(:media_attachment) { Fabricate.build(:media_attachment, shortcode: shortcode, id: id) } context 'when media attachment has a shortcode' do let(:shortcode) { 'foo' } + let(:id) { 123 } it 'returns shortcode' do expect(media_attachment.to_param).to eq shortcode @@ -77,31 +77,104 @@ RSpec.describe MediaAttachment, paperclip_processing: true do context 'when media attachment does not have a shortcode' do let(:shortcode) { nil } + let(:id) { 123 } it 'returns string representation of id' do - expect(media_attachment.to_param).to eq media_attachment.id.to_s + expect(media_attachment.to_param).to eq id.to_s end end end - describe 'animated gif conversion' do - let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('avatar.gif')) } + shared_examples 'static 600x400 image' do |content_type, extension| + after do + media.destroy + end - it 'sets type to gifv' do + it 'saves media attachment with correct file metadata' do + expect(media.persisted?).to be true + expect(media.file).to_not be_nil + + # completes processing + expect(media.processing_complete?).to be true + + # sets type + expect(media.type).to eq 'image' + + # sets content type + expect(media.file_content_type).to eq content_type + + # sets file extension + expect(media.file_file_name).to end_with extension + + # Rack::Mime (used by PublicFileServerMiddleware) recognizes file extension + expect(Rack::Mime.mime_type(extension, nil)).to eq content_type + end + + it 'saves media attachment with correct size metadata' do + # strips original file name + expect(media.file_file_name).to_not start_with '600x400' + + # sets meta for original + expect(media.file.meta['original']['width']).to eq 600 + expect(media.file.meta['original']['height']).to eq 400 + expect(media.file.meta['original']['aspect']).to eq 1.5 + + # sets meta for thumbnail + expect(media.file.meta['small']['width']).to eq 588 + expect(media.file.meta['small']['height']).to eq 392 + expect(media.file.meta['small']['aspect']).to eq 1.5 + end + end + + describe 'jpeg' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('600x400.jpeg')) } + + it_behaves_like 'static 600x400 image', 'image/jpeg', '.jpeg' + end + + describe 'png' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('600x400.png')) } + + it_behaves_like 'static 600x400 image', 'image/png', '.png' + end + + describe 'webp' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('600x400.webp')) } + + it_behaves_like 'static 600x400 image', 'image/webp', '.webp' + end + + describe 'avif' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('600x400.avif')) } + + it_behaves_like 'static 600x400 image', 'image/jpeg', '.jpeg' + end + + describe 'heic' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('600x400.heic')) } + + it_behaves_like 'static 600x400 image', 'image/jpeg', '.jpeg' + end + + describe 'base64-encoded image' do + let(:base64_attachment) { "data:image/jpeg;base64,#{Base64.encode64(attachment_fixture('600x400.jpeg').read)}" } + let(:media) { Fabricate(:media_attachment, file: base64_attachment) } + + it_behaves_like 'static 600x400 image', 'image/jpeg', '.jpeg' + end + + describe 'animated gif' do + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('avatar.gif')) } + + it 'sets correct file metadata' do expect(media.type).to eq 'gifv' - end - - it 'converts original file to mp4' do expect(media.file_content_type).to eq 'video/mp4' - end - - it 'sets meta' do expect(media.file.meta['original']['width']).to eq 128 expect(media.file.meta['original']['height']).to eq 128 end end - describe 'non-animated gif non-conversion' do + describe 'static gif' do fixtures = [ { filename: 'attachment.gif', width: 600, height: 400, aspect: 1.5 }, { filename: 'mini-static.gif', width: 32, height: 32, aspect: 1.0 }, @@ -109,17 +182,11 @@ RSpec.describe MediaAttachment, paperclip_processing: true do fixtures.each do |fixture| context fixture[:filename] do - let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture(fixture[:filename])) } + let(:media) { Fabricate(:media_attachment, file: attachment_fixture(fixture[:filename])) } - it 'sets type to image' do + it 'sets correct file metadata' do expect(media.type).to eq 'image' - end - - it 'leaves original file as-is' do expect(media.file_content_type).to eq 'image/gif' - end - - it 'sets meta' do expect(media.file.meta['original']['width']).to eq fixture[:width] expect(media.file.meta['original']['height']).to eq fixture[:height] expect(media.file.meta['original']['aspect']).to eq fixture[:aspect] @@ -129,31 +196,19 @@ RSpec.describe MediaAttachment, paperclip_processing: true do end describe 'ogg with cover art' do - let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('boop.ogg')) } + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.ogg')) } - it 'detects it as an audio file' do + it 'sets correct file metadata' do expect(media.type).to eq 'audio' - end - - it 'sets meta for the duration' do expect(media.file.meta['original']['duration']).to be_within(0.05).of(0.235102) - end - - it 'extracts thumbnail' do expect(media.thumbnail.present?).to be true - end - - it 'extracts colors from thumbnail' do expect(media.file.meta['colors']['background']).to eq '#3088d4' - end - - it 'gives the file a random name' do expect(media.file_file_name).to_not eq 'boop.ogg' end end describe 'mp3 with large cover art' do - let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('boop.mp3')) } + let(:media) { Fabricate(:media_attachment, file: attachment_fixture('boop.mp3')) } it 'detects it as an audio file' do expect(media.type).to eq 'audio' @@ -172,66 +227,37 @@ RSpec.describe MediaAttachment, paperclip_processing: true do end end - describe 'jpeg' do - let(:media) { described_class.create(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) } - - it 'sets meta for different style' do - expect(media.file.meta['original']['width']).to eq 600 - expect(media.file.meta['original']['height']).to eq 400 - expect(media.file.meta['original']['aspect']).to eq 1.5 - expect(media.file.meta['small']['width']).to eq 588 - expect(media.file.meta['small']['height']).to eq 392 - expect(media.file.meta['small']['aspect']).to eq 1.5 - end - - it 'gives the file a random name' do - expect(media.file_file_name).to_not eq 'attachment.jpg' - end - end - - describe 'base64-encoded jpeg' do - let(:base64_attachment) { "data:image/jpeg;base64,#{Base64.encode64(attachment_fixture('attachment.jpg').read)}" } - let(:media) { described_class.create(account: Fabricate(:account), file: base64_attachment) } - - it 'saves media attachment' do - expect(media.persisted?).to be true - expect(media.file).to_not be_nil - end - - it 'gives the file a file name' do - expect(media.file_file_name).to_not be_blank - end - end - it 'is invalid without file' do - media = described_class.new(account: Fabricate(:account)) + media = described_class.new + expect(media.valid?).to be false + expect(media).to model_have_error_on_field(:file) end describe 'size limit validation' do it 'rejects video files that are too large' do stub_const 'MediaAttachment::IMAGE_LIMIT', 100.megabytes stub_const 'MediaAttachment::VIDEO_LIMIT', 1.kilobyte - expect { described_class.create!(account: Fabricate(:account), file: attachment_fixture('attachment.webm')) }.to raise_error(ActiveRecord::RecordInvalid) + expect { Fabricate(:media_attachment, file: attachment_fixture('attachment.webm')) }.to raise_error(ActiveRecord::RecordInvalid) end it 'accepts video files that are small enough' do stub_const 'MediaAttachment::IMAGE_LIMIT', 1.kilobyte stub_const 'MediaAttachment::VIDEO_LIMIT', 100.megabytes - media = described_class.create!(account: Fabricate(:account), file: attachment_fixture('attachment.webm')) + media = Fabricate(:media_attachment, file: attachment_fixture('attachment.webm')) expect(media.valid?).to be true end it 'rejects image files that are too large' do stub_const 'MediaAttachment::IMAGE_LIMIT', 1.kilobyte stub_const 'MediaAttachment::VIDEO_LIMIT', 100.megabytes - expect { described_class.create!(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) }.to raise_error(ActiveRecord::RecordInvalid) + expect { Fabricate(:media_attachment, file: attachment_fixture('attachment.jpg')) }.to raise_error(ActiveRecord::RecordInvalid) end it 'accepts image files that are small enough' do stub_const 'MediaAttachment::IMAGE_LIMIT', 100.megabytes stub_const 'MediaAttachment::VIDEO_LIMIT', 1.kilobyte - media = described_class.create!(account: Fabricate(:account), file: attachment_fixture('attachment.jpg')) + media = Fabricate(:media_attachment, file: attachment_fixture('attachment.jpg')) expect(media.valid?).to be true end end diff --git a/spec/models/mute_spec.rb b/spec/models/mute_spec.rb deleted file mode 100644 index 050083d0f8..0000000000 --- a/spec/models/mute_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Mute do -end diff --git a/spec/models/poll_spec.rb b/spec/models/poll_spec.rb index 8ae04ca41f..5aa5548cc8 100644 --- a/spec/models/poll_spec.rb +++ b/spec/models/poll_spec.rb @@ -29,4 +29,23 @@ describe Poll do end end end + + describe 'validations' do + context 'when valid' do + let(:poll) { Fabricate.build(:poll) } + + it 'is valid with valid attributes' do + expect(poll).to be_valid + end + end + + context 'when not valid' do + let(:poll) { Fabricate.build(:poll, expires_at: nil) } + + it 'is invalid without an expire date' do + poll.valid? + expect(poll).to model_have_error_on_field(:expires_at) + end + end + end end diff --git a/spec/models/preview_card_spec.rb b/spec/models/preview_card_spec.rb index 7d687d16ff..a17c7532e9 100644 --- a/spec/models/preview_card_spec.rb +++ b/spec/models/preview_card_spec.rb @@ -2,5 +2,27 @@ require 'rails_helper' -RSpec.describe PreviewCard do +describe PreviewCard do + describe 'validations' do + describe 'urls' do + it 'allows http schemes' do + record = described_class.new(url: 'http://example.host/path') + + expect(record).to be_valid + end + + it 'allows https schemes' do + record = described_class.new(url: 'https://example.host/path') + + expect(record).to be_valid + end + + it 'does not allow javascript: schemes' do + record = described_class.new(url: 'javascript:alert()') + + expect(record).to_not be_valid + expect(record).to model_have_error_on_field(:url) + end + end + end end diff --git a/spec/models/preview_card_trend_spec.rb b/spec/models/preview_card_trend_spec.rb deleted file mode 100644 index a31bf71cc0..0000000000 --- a/spec/models/preview_card_trend_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe PreviewCardTrend do -end diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb index fbbdf62584..8a1a01e892 100644 --- a/spec/models/public_feed_spec.rb +++ b/spec/models/public_feed_spec.rb @@ -199,15 +199,13 @@ RSpec.describe PublicFeed do end describe 'with an account passed in' do - subject { described_class.new(@account).get(20).map(&:id) } + subject { described_class.new(account).get(20).map(&:id) } - before do - @account = Fabricate(:account) - end + let!(:account) { Fabricate(:account) } it 'excludes statuses from accounts blocked by the account' do blocked = Fabricate(:account) - @account.block!(blocked) + account.block!(blocked) blocked_status = Fabricate(:status, account: blocked) expect(subject).to_not include(blocked_status.id) @@ -215,7 +213,7 @@ RSpec.describe PublicFeed do it 'excludes statuses from accounts who have blocked the account' do blocker = Fabricate(:account) - blocker.block!(@account) + blocker.block!(account) blocked_status = Fabricate(:status, account: blocker) expect(subject).to_not include(blocked_status.id) @@ -223,7 +221,7 @@ RSpec.describe PublicFeed do it 'excludes statuses from accounts muted by the account' do muted = Fabricate(:account) - @account.mute!(muted) + account.mute!(muted) muted_status = Fabricate(:status, account: muted) expect(subject).to_not include(muted_status.id) @@ -231,7 +229,7 @@ RSpec.describe PublicFeed do it 'excludes statuses from accounts from personally blocked domains' do blocked = Fabricate(:account, domain: 'example.com') - @account.block_domain!(blocked.domain) + account.block_domain!(blocked.domain) blocked_status = Fabricate(:status, account: blocked) expect(subject).to_not include(blocked_status.id) @@ -239,7 +237,7 @@ RSpec.describe PublicFeed do context 'with language preferences' do it 'excludes statuses in languages not allowed by the account user' do - @account.user.update(chosen_languages: [:en, :es]) + account.user.update(chosen_languages: [:en, :es]) en_status = Fabricate(:status, language: 'en') es_status = Fabricate(:status, language: 'es') fr_status = Fabricate(:status, language: 'fr') @@ -250,7 +248,7 @@ RSpec.describe PublicFeed do end it 'includes all languages when user does not have a setting' do - @account.user.update(chosen_languages: nil) + account.user.update(chosen_languages: nil) en_status = Fabricate(:status, language: 'en') es_status = Fabricate(:status, language: 'es') @@ -260,7 +258,7 @@ RSpec.describe PublicFeed do end it 'includes all languages when account does not have a user' do - @account.update(user: nil) + account.update(user: nil) en_status = Fabricate(:status, language: 'en') es_status = Fabricate(:status, language: 'es') diff --git a/spec/models/relationship_filter_spec.rb b/spec/models/relationship_filter_spec.rb index b3e855c122..fccd42aaad 100644 --- a/spec/models/relationship_filter_spec.rb +++ b/spec/models/relationship_filter_spec.rb @@ -6,32 +6,60 @@ describe RelationshipFilter do let(:account) { Fabricate(:account) } describe '#results' do - context 'when default params are used' do - let(:subject) do - described_class.new(account, 'order' => 'active').results + let(:account_of_7_months) { Fabricate(:account_stat, statuses_count: 1, last_status_at: 7.months.ago).account } + let(:account_of_1_day) { Fabricate(:account_stat, statuses_count: 1, last_status_at: 1.day.ago).account } + let(:account_of_3_days) { Fabricate(:account_stat, statuses_count: 1, last_status_at: 3.days.ago).account } + let(:silent_account) { Fabricate(:account_stat, statuses_count: 0, last_status_at: nil).account } + + before do + account.follow!(account_of_7_months) + account.follow!(account_of_1_day) + account.follow!(account_of_3_days) + account.follow!(silent_account) + end + + context 'when ordering by last activity' do + context 'when not filtering' do + subject do + described_class.new(account, 'order' => 'active').results + end + + it 'returns followings ordered by last activity' do + expect(subject).to eq [account_of_1_day, account_of_3_days, account_of_7_months, silent_account] + end end - before do - add_following_account_with(last_status_at: 7.days.ago) - add_following_account_with(last_status_at: 1.day.ago) - add_following_account_with(last_status_at: 3.days.ago) + context 'when filtering for dormant accounts' do + subject do + described_class.new(account, 'order' => 'active', 'activity' => 'dormant').results + end + + it 'returns dormant followings ordered by last activity' do + expect(subject).to eq [account_of_7_months, silent_account] + end + end + end + + context 'when ordering by account creation' do + context 'when not filtering' do + subject do + described_class.new(account, 'order' => 'recent').results + end + + it 'returns followings ordered by last account creation' do + expect(subject).to eq [silent_account, account_of_3_days, account_of_1_day, account_of_7_months] + end end - it 'returns followings ordered by last activity' do - expected_result = account.following.eager_load(:account_stat).reorder(nil).by_recent_status + context 'when filtering for dormant accounts' do + subject do + described_class.new(account, 'order' => 'recent', 'activity' => 'dormant').results + end - expect(subject).to eq expected_result + it 'returns dormant followings ordered by last activity' do + expect(subject).to eq [silent_account, account_of_7_months] + end end end end - - def add_following_account_with(last_status_at:) - following_account = Fabricate(:account) - Fabricate(:account_stat, account: following_account, - last_status_at: last_status_at, - statuses_count: 1, - following_count: 0, - followers_count: 0) - Fabricate(:follow, account: account, target_account: following_account).account - end end diff --git a/spec/models/relay_spec.rb b/spec/models/relay_spec.rb deleted file mode 100644 index 7ed49e7334..0000000000 --- a/spec/models/relay_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Relay do -end diff --git a/spec/models/report_filter_spec.rb b/spec/models/report_filter_spec.rb index 4b0852f081..6baf0ea421 100644 --- a/spec/models/report_filter_spec.rb +++ b/spec/models/report_filter_spec.rb @@ -23,8 +23,7 @@ describe ReportFilter do it 'combines filters on Report' do filter = described_class.new(account_id: '123', resolved: true, target_account_id: '456') - allow(Report).to receive(:where).and_return(Report.none) - allow(Report).to receive(:resolved).and_return(Report.none) + allow(Report).to receive_messages(where: Report.none, resolved: Report.none) filter.results expect(Report).to have_received(:where).with(account_id: '123') expect(Report).to have_received(:where).with(target_account_id: '456') diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 0093dcd8de..c514c63b39 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -110,9 +110,9 @@ describe Report do let(:status) { Fabricate(:status) } before do - Fabricate('Admin::ActionLog', target_type: 'Report', account_id: target_account.id, target_id: report.id, created_at: 2.days.ago) - Fabricate('Admin::ActionLog', target_type: 'Account', account_id: target_account.id, target_id: report.target_account_id, created_at: 2.days.ago) - Fabricate('Admin::ActionLog', target_type: 'Status', account_id: target_account.id, target_id: status.id, created_at: 2.days.ago) + Fabricate(:action_log, target_type: 'Report', account_id: target_account.id, target_id: report.id, created_at: 2.days.ago) + Fabricate(:action_log, target_type: 'Account', account_id: target_account.id, target_id: report.target_account_id, created_at: 2.days.ago) + Fabricate(:action_log, target_type: 'Status', account_id: target_account.id, target_id: status.id, created_at: 2.days.ago) end it 'returns right logs' do diff --git a/spec/models/scheduled_status_spec.rb b/spec/models/scheduled_status_spec.rb deleted file mode 100644 index 286c17e696..0000000000 --- a/spec/models/scheduled_status_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe ScheduledStatus do -end diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb index 75842e25ba..bed411c369 100644 --- a/spec/models/session_activation_spec.rb +++ b/spec/models/session_activation_spec.rb @@ -74,9 +74,13 @@ RSpec.describe SessionActivation do let(:options) { { user: Fabricate(:user), session_id: '1' } } it 'calls create! and purge_old' do - expect(described_class).to receive(:create!).with(**options) - expect(described_class).to receive(:purge_old) + allow(described_class).to receive(:create!).with(**options) + allow(described_class).to receive(:purge_old) + described_class.activate(**options) + + expect(described_class).to have_received(:create!).with(**options) + expect(described_class).to have_received(:purge_old) end it 'returns an instance of SessionActivation' do @@ -94,34 +98,44 @@ RSpec.describe SessionActivation do end context 'when id exists' do - let(:id) { '1' } + let!(:session_activation) { Fabricate(:session_activation) } - it 'calls where.destroy_all' do - expect(described_class).to receive_message_chain(:where, :destroy_all) - .with(session_id: id).with(no_args) + it 'destroys the record' do + described_class.deactivate(session_activation.session_id) - described_class.deactivate(id) + expect { session_activation.reload }.to raise_error(ActiveRecord::RecordNotFound) end end end describe '.purge_old' do - it 'calls order.offset.destroy_all' do - expect(described_class).to receive_message_chain(:order, :offset, :destroy_all) - .with('created_at desc').with(Rails.configuration.x.max_session_activations).with(no_args) + around do |example| + before = Rails.configuration.x.max_session_activations + Rails.configuration.x.max_session_activations = 1 + example.run + Rails.configuration.x.max_session_activations = before + end + let!(:oldest_session_activation) { Fabricate(:session_activation, created_at: 10.days.ago) } + let!(:newest_session_activation) { Fabricate(:session_activation, created_at: 5.days.ago) } + + it 'preserves the newest X records based on config' do described_class.purge_old + + expect { oldest_session_activation.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { newest_session_activation.reload }.to_not raise_error end end describe '.exclusive' do - let(:id) { '1' } + let!(:unwanted_session_activation) { Fabricate(:session_activation) } + let!(:wanted_session_activation) { Fabricate(:session_activation) } - it 'calls where.destroy_all' do - expect(described_class).to receive_message_chain(:where, :not, :destroy_all) - .with(session_id: id).with(no_args) + it 'preserves supplied record and destroys all others' do + described_class.exclusive(wanted_session_activation.session_id) - described_class.exclusive(id) + expect { unwanted_session_activation.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { wanted_session_activation.reload }.to_not raise_error end end end diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb index 5ed5c5d766..e98062810e 100644 --- a/spec/models/setting_spec.rb +++ b/spec/models/setting_spec.rb @@ -23,8 +23,11 @@ RSpec.describe Setting do let(:rails_initialized) { false } it 'calls RailsSettings::Base#[]' do - expect(RailsSettings::Base).to receive(:[]).with(key) + allow(RailsSettings::Base).to receive(:[]).with(key) + described_class[key] + + expect(RailsSettings::Base).to have_received(:[]).with(key) end end @@ -38,15 +41,19 @@ RSpec.describe Setting do let(:cache_value) { 'cache-value' } it 'calls not RailsSettings::Base#[]' do - expect(RailsSettings::Base).to_not receive(:[]).with(key) + allow(RailsSettings::Base).to receive(:[]).with(key) + described_class[key] + + expect(RailsSettings::Base).to_not have_received(:[]).with(key) end context 'when Rails.cache does not exists' do before do allow(RailsSettings::Settings).to receive(:object).with(key).and_return(object) allow(described_class).to receive(:default_settings).and_return(default_settings) - allow_any_instance_of(Settings::ScopedSettings).to receive(:thing_scoped).and_return(records) + settings_double = instance_double(Settings::ScopedSettings, thing_scoped: records) + allow(Settings::ScopedSettings).to receive(:new).and_return(settings_double) Rails.cache.delete(cache_key) end @@ -56,8 +63,11 @@ RSpec.describe Setting do let(:records) { [Fabricate(:setting, var: key, value: nil)] } it 'calls RailsSettings::Settings.object' do - expect(RailsSettings::Settings).to receive(:object).with(key) + allow(RailsSettings::Settings).to receive(:object).with(key) + described_class[key] + + expect(RailsSettings::Settings).to have_received(:object).with(key) end context 'when RailsSettings::Settings.object returns truthy' do @@ -68,10 +78,13 @@ RSpec.describe Setting do let(:default_value) { { default_value: 'default_value' } } it 'calls default_value.with_indifferent_access.merge!' do - expect(default_value).to receive_message_chain(:with_indifferent_access, :merge!) - .with(db_val.value) + indifferent_hash = instance_double(Hash, merge!: nil) + allow(default_value).to receive(:with_indifferent_access).and_return(indifferent_hash) described_class[key] + + expect(default_value).to have_received(:with_indifferent_access) + expect(indifferent_hash).to have_received(:merge!).with(db_val.value) end end @@ -116,7 +129,8 @@ RSpec.describe Setting do describe '.all_as_records' do before do - allow_any_instance_of(Settings::ScopedSettings).to receive(:thing_scoped).and_return(records) + settings_double = instance_double(Settings::ScopedSettings, thing_scoped: records) + allow(Settings::ScopedSettings).to receive(:new).and_return(settings_double) allow(described_class).to receive(:default_settings).and_return(default_settings) end diff --git a/spec/models/software_update_spec.rb b/spec/models/software_update_spec.rb new file mode 100644 index 0000000000..0a494b0c4c --- /dev/null +++ b/spec/models/software_update_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SoftwareUpdate do + describe '.pending_to_a' do + before do + allow(Mastodon::Version).to receive(:gem_version).and_return(Gem::Version.new(mastodon_version)) + + Fabricate(:software_update, version: '3.4.42', type: 'patch', urgent: true) + Fabricate(:software_update, version: '3.5.0', type: 'minor', urgent: false) + Fabricate(:software_update, version: '4.2.0', type: 'major', urgent: false) + end + + context 'when the Mastodon version is an outdated release' do + let(:mastodon_version) { '3.4.0' } + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('3.4.42', '3.5.0', '4.2.0') + end + end + + context 'when the Mastodon version is more recent than anything last returned by the server' do + let(:mastodon_version) { '5.0.0' } + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to eq [] + end + end + + context 'when the Mastodon version is an outdated nightly' do + let(:mastodon_version) { '4.3.0-nightly.2023-09-10' } + + before do + Fabricate(:software_update, version: '4.3.0-nightly.2023-09-12', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-nightly.2023-09-12') + end + end + + context 'when the Mastodon version is a very outdated nightly' do + let(:mastodon_version) { '4.2.0-nightly.2023-07-10' } + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.2.0') + end + end + + context 'when the Mastodon version is an outdated dev version' do + let(:mastodon_version) { '4.3.0-0.dev.0' } + + before do + Fabricate(:software_update, version: '4.3.0-0.dev.2', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-0.dev.2') + end + end + + context 'when the Mastodon version is an outdated beta version' do + let(:mastodon_version) { '4.3.0-beta1' } + + before do + Fabricate(:software_update, version: '4.3.0-beta2', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-beta2') + end + end + + context 'when the Mastodon version is an outdated beta version and there is a rc' do + let(:mastodon_version) { '4.3.0-beta1' } + + before do + Fabricate(:software_update, version: '4.3.0-rc1', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-rc1') + end + end + end +end diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb index 660b2e92ac..da375009ae 100644 --- a/spec/models/status_pin_spec.rb +++ b/spec/models/status_pin_spec.rb @@ -40,35 +40,34 @@ RSpec.describe StatusPin do expect(described_class.new(account: account, status: status).save).to be false end - max_pins = 5 - it 'does not allow pins above the max' do - account = Fabricate(:account) - status = [] + context 'with a pin limit' do + before { stub_const('StatusPinValidator::PIN_LIMIT', 2) } - (max_pins + 1).times do |i| - status[i] = Fabricate(:status, account: account) + it 'does not allow pins above the max' do + account = Fabricate(:account) + + Fabricate.times(StatusPinValidator::PIN_LIMIT, :status_pin, account: account) + + pin = described_class.new(account: account, status: Fabricate(:status, account: account)) + expect(pin.save) + .to be(false) + + expect(pin.errors[:base]) + .to contain_exactly(I18n.t('statuses.pin_errors.limit')) end - max_pins.times do |i| - expect(described_class.new(account: account, status: status[i]).save).to be true + it 'allows pins above the max for remote accounts' do + account = Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') + + Fabricate.times(StatusPinValidator::PIN_LIMIT, :status_pin, account: account) + + pin = described_class.new(account: account, status: Fabricate(:status, account: account)) + expect(pin.save) + .to be(true) + + expect(pin.errors[:base]) + .to be_empty end - - expect(described_class.new(account: account, status: status[max_pins]).save).to be false - end - - it 'allows pins above the max for remote accounts' do - account = Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') - status = [] - - (max_pins + 1).times do |i| - status[i] = Fabricate(:status, account: account) - end - - max_pins.times do |i| - expect(described_class.new(account: account, status: status[i]).save).to be true - end - - expect(described_class.new(account: account, status: status[max_pins]).save).to be true end end end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index f38e7c2371..4c6d886a5c 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -166,7 +166,7 @@ RSpec.describe Status do describe '#replies_count' do it 'is the number of replies' do - reply = Fabricate(:status, account: bob, thread: subject) + Fabricate(:status, account: bob, thread: subject) expect(subject.replies_count).to eq 1 end @@ -352,17 +352,29 @@ RSpec.describe Status do context 'when given one tag' do it 'returns the expected statuses' do - expect(described_class.tagged_with([tag_cats.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_cats.id, status_with_all_tags.id) - expect(described_class.tagged_with([tag_dogs.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_dogs.id, status_with_all_tags.id) - expect(described_class.tagged_with([tag_zebras.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_tagged_with_zebras.id, status_with_all_tags.id) + expect(described_class.tagged_with([tag_cats.id])) + .to include(status_with_tag_cats, status_with_all_tags) + .and not_include(status_without_tags) + expect(described_class.tagged_with([tag_dogs.id])) + .to include(status_with_tag_dogs, status_with_all_tags) + .and not_include(status_without_tags) + expect(described_class.tagged_with([tag_zebras.id])) + .to include(status_tagged_with_zebras, status_with_all_tags) + .and not_include(status_without_tags) end end context 'when given multiple tags' do it 'returns the expected statuses' do - expect(described_class.tagged_with([tag_cats.id, tag_dogs.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_cats.id, status_with_tag_dogs.id, status_with_all_tags.id) - expect(described_class.tagged_with([tag_cats.id, tag_zebras.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_cats.id, status_tagged_with_zebras.id, status_with_all_tags.id) - expect(described_class.tagged_with([tag_dogs.id, tag_zebras.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_dogs.id, status_tagged_with_zebras.id, status_with_all_tags.id) + expect(described_class.tagged_with([tag_cats.id, tag_dogs.id])) + .to include(status_with_tag_cats, status_with_tag_dogs, status_with_all_tags) + .and not_include(status_without_tags) + expect(described_class.tagged_with([tag_cats.id, tag_zebras.id])) + .to include(status_with_tag_cats, status_tagged_with_zebras, status_with_all_tags) + .and not_include(status_without_tags) + expect(described_class.tagged_with([tag_dogs.id, tag_zebras.id])) + .to include(status_with_tag_dogs, status_tagged_with_zebras, status_with_all_tags) + .and not_include(status_without_tags) end end end @@ -379,17 +391,26 @@ RSpec.describe Status do context 'when given one tag' do it 'returns the expected statuses' do - expect(described_class.tagged_with_all([tag_cats.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_cats.id, status_with_all_tags.id) - expect(described_class.tagged_with_all([tag_dogs.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_dogs.id, status_with_all_tags.id) - expect(described_class.tagged_with_all([tag_zebras.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_tagged_with_zebras.id) + expect(described_class.tagged_with_all([tag_cats.id])) + .to include(status_with_tag_cats, status_with_all_tags) + .and not_include(status_without_tags) + expect(described_class.tagged_with_all([tag_dogs.id])) + .to include(status_with_tag_dogs, status_with_all_tags) + .and not_include(status_without_tags) + expect(described_class.tagged_with_all([tag_zebras.id])) + .to include(status_tagged_with_zebras) + .and not_include(status_without_tags) end end context 'when given multiple tags' do it 'returns the expected statuses' do - expect(described_class.tagged_with_all([tag_cats.id, tag_dogs.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_all_tags.id) - expect(described_class.tagged_with_all([tag_cats.id, tag_zebras.id]).reorder(:id).pluck(:id).uniq).to eq [] - expect(described_class.tagged_with_all([tag_dogs.id, tag_zebras.id]).reorder(:id).pluck(:id).uniq).to eq [] + expect(described_class.tagged_with_all([tag_cats.id, tag_dogs.id])) + .to include(status_with_all_tags) + expect(described_class.tagged_with_all([tag_cats.id, tag_zebras.id])) + .to eq [] + expect(described_class.tagged_with_all([tag_dogs.id, tag_zebras.id])) + .to eq [] end end end @@ -406,17 +427,29 @@ RSpec.describe Status do context 'when given one tag' do it 'returns the expected statuses' do - expect(described_class.tagged_with_none([tag_cats.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_dogs.id, status_tagged_with_zebras.id, status_without_tags.id) - expect(described_class.tagged_with_none([tag_dogs.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_cats.id, status_tagged_with_zebras.id, status_without_tags.id) - expect(described_class.tagged_with_none([tag_zebras.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_cats.id, status_with_tag_dogs.id, status_without_tags.id) + expect(described_class.tagged_with_none([tag_cats.id])) + .to include(status_with_tag_dogs, status_tagged_with_zebras, status_without_tags) + .and not_include(status_with_all_tags) + expect(described_class.tagged_with_none([tag_dogs.id])) + .to include(status_with_tag_cats, status_tagged_with_zebras, status_without_tags) + .and not_include(status_with_all_tags) + expect(described_class.tagged_with_none([tag_zebras.id])) + .to include(status_with_tag_cats, status_with_tag_dogs, status_without_tags) + .and not_include(status_with_all_tags) end end context 'when given multiple tags' do it 'returns the expected statuses' do - expect(described_class.tagged_with_none([tag_cats.id, tag_dogs.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_tagged_with_zebras.id, status_without_tags.id) - expect(described_class.tagged_with_none([tag_cats.id, tag_zebras.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_dogs.id, status_without_tags.id) - expect(described_class.tagged_with_none([tag_dogs.id, tag_zebras.id]).reorder(:id).pluck(:id).uniq).to contain_exactly(status_with_tag_cats.id, status_without_tags.id) + expect(described_class.tagged_with_none([tag_cats.id, tag_dogs.id])) + .to include(status_tagged_with_zebras, status_without_tags) + .and not_include(status_with_all_tags) + expect(described_class.tagged_with_none([tag_cats.id, tag_zebras.id])) + .to include(status_with_tag_dogs, status_without_tags) + .and not_include(status_with_all_tags) + expect(described_class.tagged_with_none([tag_dogs.id, tag_zebras.id])) + .to include(status_with_tag_cats, status_without_tags) + .and not_include(status_with_all_tags) end end end diff --git a/spec/models/status_stat_spec.rb b/spec/models/status_stat_spec.rb deleted file mode 100644 index 9679c836a7..0000000000 --- a/spec/models/status_stat_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe StatusStat do -end diff --git a/spec/models/status_trend_spec.rb b/spec/models/status_trend_spec.rb deleted file mode 100644 index dbb3d4bb3f..0000000000 --- a/spec/models/status_trend_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe StatusTrend do -end diff --git a/spec/models/system_key_spec.rb b/spec/models/system_key_spec.rb deleted file mode 100644 index 5bd630aaa5..0000000000 --- a/spec/models/system_key_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe SystemKey do -end diff --git a/spec/models/tag_follow_spec.rb b/spec/models/tag_follow_spec.rb deleted file mode 100644 index 240147ecc8..0000000000 --- a/spec/models/tag_follow_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe TagFollow do -end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 4d6e5c380b..6177b7a25a 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -32,44 +32,52 @@ RSpec.describe Tag do expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit')).to be_nil end + it 'does not match URLs with hashtag-like anchors after a numeral' do + expect(subject.match('https://gcc.gnu.org/bugzilla/show_bug.cgi?id=111895#c4')).to be_nil + end + + it 'does not match URLs with hashtag-like anchors after an empty query parameter' do + expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)?foo=#Lawsuit')).to be_nil + end + it 'matches #aesthetic' do - expect(subject.match('this is #aesthetic').to_s).to eq ' #aesthetic' + expect(subject.match('this is #aesthetic').to_s).to eq '#aesthetic' end it 'matches digits at the start' do - expect(subject.match('hello #3d').to_s).to eq ' #3d' + expect(subject.match('hello #3d').to_s).to eq '#3d' end it 'matches digits in the middle' do - expect(subject.match('hello #l33ts35k').to_s).to eq ' #l33ts35k' + expect(subject.match('hello #l33ts35k').to_s).to eq '#l33ts35k' end it 'matches digits at the end' do - expect(subject.match('hello #world2016').to_s).to eq ' #world2016' + expect(subject.match('hello #world2016').to_s).to eq '#world2016' end it 'matches underscores at the beginning' do - expect(subject.match('hello #_test').to_s).to eq ' #_test' + expect(subject.match('hello #_test').to_s).to eq '#_test' end it 'matches underscores at the end' do - expect(subject.match('hello #test_').to_s).to eq ' #test_' + expect(subject.match('hello #test_').to_s).to eq '#test_' end it 'matches underscores in the middle' do - expect(subject.match('hello #one_two_three').to_s).to eq ' #one_two_three' + expect(subject.match('hello #one_two_three').to_s).to eq '#one_two_three' end it 'matches middle dots' do - expect(subject.match('hello #one·two·three').to_s).to eq ' #one·two·three' + expect(subject.match('hello #one·two·three').to_s).to eq '#one·two·three' end it 'matches ・unicode in ぼっち・ざ・ろっく correctly' do - expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq ' #ぼっち・ざ・ろっく' + expect(subject.match('testing #ぼっち・ざ・ろっく').to_s).to eq '#ぼっち・ざ・ろっく' end it 'matches ZWNJ' do - expect(subject.match('just add #نرم‌افزار and').to_s).to eq ' #نرم‌افزار' + expect(subject.match('just add #نرم‌افزار and').to_s).to eq '#نرم‌افزار' end it 'does not match middle dots at the start' do @@ -77,7 +85,7 @@ RSpec.describe Tag do end it 'does not match middle dots at the end' do - expect(subject.match('hello #one·two·three·').to_s).to eq ' #one·two·three' + expect(subject.match('hello #one·two·three·').to_s).to eq '#one·two·three' end it 'does not match purely-numeric hashtags' do diff --git a/spec/models/unavailable_domain_spec.rb b/spec/models/unavailable_domain_spec.rb deleted file mode 100644 index b868779f2b..0000000000 --- a/spec/models/unavailable_domain_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe UnavailableDomain do -end diff --git a/spec/models/user_invite_request_spec.rb b/spec/models/user_invite_request_spec.rb deleted file mode 100644 index ee0efbb431..0000000000 --- a/spec/models/user_invite_request_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe UserInviteRequest do -end diff --git a/spec/models/user_role_spec.rb b/spec/models/user_role_spec.rb index 27b9674146..d5234ebe8d 100644 --- a/spec/models/user_role_spec.rb +++ b/spec/models/user_role_spec.rb @@ -60,7 +60,7 @@ RSpec.describe UserRole do end describe '#permissions_as_keys=' do - let(:input) {} + let(:input) { nil } before do subject.permissions_as_keys = input @@ -93,7 +93,7 @@ RSpec.describe UserRole do describe '#computed_permissions' do context 'when the role is nobody' do - let(:subject) { described_class.nobody } + subject { described_class.nobody } it 'returns none' do expect(subject.computed_permissions).to eq UserRole::Flags::NONE @@ -101,7 +101,7 @@ RSpec.describe UserRole do end context 'when the role is everyone' do - let(:subject) { described_class.everyone } + subject { described_class.everyone } it 'returns permissions' do expect(subject.computed_permissions).to eq subject.permissions diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index bb61c02a63..ab5bd39b7b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -27,12 +27,6 @@ RSpec.describe User do expect(user).to model_have_error_on_field(:account) end - it 'is invalid without a valid locale' do - user = Fabricate.build(:user, locale: 'toto') - user.valid? - expect(user).to model_have_error_on_field(:locale) - end - it 'is invalid without a valid email' do user = Fabricate.build(:user, email: 'john@') user.valid? @@ -45,6 +39,18 @@ RSpec.describe User do expect(user.valid?).to be true end + it 'cleans out invalid locale' do + user = Fabricate.build(:user, locale: 'toto') + expect(user.valid?).to be true + expect(user.locale).to be_nil + end + + it 'cleans out invalid timezone' do + user = Fabricate.build(:user, time_zone: 'toto') + expect(user.valid?).to be true + expect(user.time_zone).to be_nil + end + it 'cleans out empty string from languages' do user = Fabricate.build(:user, chosen_languages: ['']) user.valid? @@ -55,17 +61,17 @@ RSpec.describe User do describe 'scopes' do describe 'recent' do it 'returns an array of recent users ordered by id' do - user_1 = Fabricate(:user) - user_2 = Fabricate(:user) - expect(described_class.recent).to eq [user_2, user_1] + first_user = Fabricate(:user) + second_user = Fabricate(:user) + expect(described_class.recent).to eq [second_user, first_user] end end describe 'confirmed' do it 'returns an array of users who are confirmed' do - user_1 = Fabricate(:user, confirmed_at: nil) - user_2 = Fabricate(:user, confirmed_at: Time.zone.now) - expect(described_class.confirmed).to contain_exactly(user_2) + Fabricate(:user, confirmed_at: nil) + confirmed_user = Fabricate(:user, confirmed_at: Time.zone.now) + expect(described_class.confirmed).to contain_exactly(confirmed_user) end end @@ -102,7 +108,7 @@ RSpec.describe User do end describe 'blacklist' do - around(:each) do |example| + around do |example| old_blacklist = Rails.configuration.x.email_blacklist Rails.configuration.x.email_domains_blacklist = 'mvrht.com' @@ -169,16 +175,8 @@ RSpec.describe User do let(:user) { Fabricate(:user, confirmed_at: nil, unconfirmed_email: new_email) } context 'when the user is already approved' do - around(:example) do |example| - registrations_mode = Setting.registrations_mode - Setting.registrations_mode = 'approved' - - example.run - - Setting.registrations_mode = registrations_mode - end - before do + Setting.registrations_mode = 'approved' user.approve! end @@ -193,13 +191,8 @@ RSpec.describe User do end context 'when the user does not require explicit approval' do - around(:example) do |example| - registrations_mode = Setting.registrations_mode + before do Setting.registrations_mode = 'open' - - example.run - - Setting.registrations_mode = registrations_mode end it 'sets email to unconfirmed_email' do @@ -213,13 +206,8 @@ RSpec.describe User do end context 'when the user requires explicit approval but is not approved' do - around(:example) do |example| - registrations_mode = Setting.registrations_mode + before do Setting.registrations_mode = 'approved' - - example.run - - Setting.registrations_mode = registrations_mode end it 'sets email to unconfirmed_email' do @@ -237,16 +225,8 @@ RSpec.describe User do describe '#approve!' do subject { user.approve! } - around(:example) do |example| - registrations_mode = Setting.registrations_mode - Setting.registrations_mode = 'approved' - - example.run - - Setting.registrations_mode = registrations_mode - end - before do + Setting.registrations_mode = 'approved' allow(TriggerWebhookWorker).to receive(:perform_async) end @@ -338,7 +318,7 @@ RSpec.describe User do end describe 'whitelist' do - around(:each) do |example| + around do |example| old_whitelist = Rails.configuration.x.email_domains_whitelist Rails.configuration.x.email_domains_whitelist = 'mastodon.space' @@ -442,6 +422,7 @@ RSpec.describe User do it 'deactivates all sessions' do expect(user.session_activations.count).to eq 0 + expect { session_activation.reload }.to raise_error(ActiveRecord::RecordNotFound) end it 'revokes all access tokens' do @@ -450,6 +431,7 @@ RSpec.describe User do it 'removes push subscriptions' do expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0 + expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/spec/models/web/setting_spec.rb b/spec/models/web/setting_spec.rb deleted file mode 100644 index 3182c67217..0000000000 --- a/spec/models/web/setting_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Web::Setting do -end diff --git a/spec/models/webauthn_credentials_spec.rb b/spec/models/webauthn_credentials_spec.rb index 4579ebb82e..9631245e11 100644 --- a/spec/models/webauthn_credentials_spec.rb +++ b/spec/models/webauthn_credentials_spec.rb @@ -37,7 +37,7 @@ RSpec.describe WebauthnCredential do end it 'is invalid if already exist a webauthn credential with the same external id' do - existing_webauthn_credential = Fabricate(:webauthn_credential, external_id: '_Typ0ygudDnk9YUVWLQayw') + Fabricate(:webauthn_credential, external_id: '_Typ0ygudDnk9YUVWLQayw') new_webauthn_credential = Fabricate.build(:webauthn_credential, external_id: '_Typ0ygudDnk9YUVWLQayw') new_webauthn_credential.valid? @@ -47,7 +47,7 @@ RSpec.describe WebauthnCredential do it 'is invalid if user already registered a webauthn credential with the same nickname' do user = Fabricate(:user) - existing_webauthn_credential = Fabricate(:webauthn_credential, user_id: user.id, nickname: 'USB Key') + Fabricate(:webauthn_credential, user_id: user.id, nickname: 'USB Key') new_webauthn_credential = Fabricate.build(:webauthn_credential, user_id: user.id, nickname: 'USB Key') new_webauthn_credential.valid? diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index 715dd7574f..effaf92e9c 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -5,6 +5,37 @@ require 'rails_helper' RSpec.describe Webhook do let(:webhook) { Fabricate(:webhook) } + describe 'Validations' do + it 'requires presence of events' do + record = described_class.new(events: nil) + record.valid? + + expect(record).to model_have_error_on_field(:events) + end + + it 'requires non-empty events value' do + record = described_class.new(events: []) + record.valid? + + expect(record).to model_have_error_on_field(:events) + end + + it 'requires valid events value from EVENTS' do + record = described_class.new(events: ['account.invalid']) + record.valid? + + expect(record).to model_have_error_on_field(:events) + end + end + + describe 'Normalizations' do + it 'cleans up events values' do + record = described_class.new(events: ['account.approved', 'account.created ', '']) + + expect(record.events).to eq(%w(account.approved account.created)) + end + end + describe '#rotate_secret!' do it 'changes the secret' do previous_value = webhook.secret diff --git a/spec/policies/account_moderation_note_policy_spec.rb b/spec/policies/account_moderation_note_policy_spec.rb index 90abdfea76..8c37acc39f 100644 --- a/spec/policies/account_moderation_note_policy_spec.rb +++ b/spec/policies/account_moderation_note_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe AccountModerationNotePolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb index 9f4e94a6c8..d7a21d8e39 100644 --- a/spec/policies/account_policy_spec.rb +++ b/spec/policies/account_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe AccountPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } let(:alice) { Fabricate(:account) } diff --git a/spec/policies/admin/status_policy_spec.rb b/spec/policies/admin/status_policy_spec.rb index 9e81a4f5f1..af9f7716be 100644 --- a/spec/policies/admin/status_policy_spec.rb +++ b/spec/policies/admin/status_policy_spec.rb @@ -7,7 +7,8 @@ describe Admin::StatusPolicy do let(:policy) { described_class } let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } - let(:status) { Fabricate(:status) } + let(:status) { Fabricate(:status, visibility: status_visibility) } + let(:status_visibility) { :public } permissions :index?, :update?, :review?, :destroy? do context 'with an admin' do @@ -26,7 +27,7 @@ describe Admin::StatusPolicy do permissions :show? do context 'with an admin' do context 'with a public visible status' do - before { allow(status).to receive(:public_visibility?).and_return(true) } + let(:status_visibility) { :public } it 'permits' do expect(policy).to permit(admin, status) @@ -34,11 +35,21 @@ describe Admin::StatusPolicy do end context 'with a not public visible status' do - before { allow(status).to receive(:public_visibility?).and_return(false) } + let(:status_visibility) { :direct } it 'denies' do expect(policy).to_not permit(admin, status) end + + context 'when the status mentions the admin' do + before do + status.mentions.create!(account: admin) + end + + it 'permits' do + expect(policy).to permit(admin, status) + end + end end end diff --git a/spec/policies/backup_policy_spec.rb b/spec/policies/backup_policy_spec.rb index 488d48f52a..28cb65d789 100644 --- a/spec/policies/backup_policy_spec.rb +++ b/spec/policies/backup_policy_spec.rb @@ -4,8 +4,9 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe BackupPolicy do - let(:subject) { described_class } - let(:john) { Fabricate(:account) } + subject { described_class } + + let(:john) { Fabricate(:account) } permissions :create? do context 'when not user_signed_in?' do diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb index cf7e7d924b..cb869c7d9a 100644 --- a/spec/policies/custom_emoji_policy_spec.rb +++ b/spec/policies/custom_emoji_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe CustomEmojiPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/domain_block_policy_spec.rb b/spec/policies/domain_block_policy_spec.rb index e254e2cf4d..4c89f3f374 100644 --- a/spec/policies/domain_block_policy_spec.rb +++ b/spec/policies/domain_block_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe DomainBlockPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/email_domain_block_policy_spec.rb b/spec/policies/email_domain_block_policy_spec.rb index 6e57b1372f..7ecff4be49 100644 --- a/spec/policies/email_domain_block_policy_spec.rb +++ b/spec/policies/email_domain_block_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe EmailDomainBlockPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/instance_policy_spec.rb b/spec/policies/instance_policy_spec.rb index 3e047bbe9e..a0d9a008b7 100644 --- a/spec/policies/instance_policy_spec.rb +++ b/spec/policies/instance_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe InstancePolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/invite_policy_spec.rb b/spec/policies/invite_policy_spec.rb index 50a312f44f..cbe3735d80 100644 --- a/spec/policies/invite_policy_spec.rb +++ b/spec/policies/invite_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe InvitePolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:user).account } diff --git a/spec/policies/relay_policy_spec.rb b/spec/policies/relay_policy_spec.rb index 0d479e0ca7..29ba02c26a 100644 --- a/spec/policies/relay_policy_spec.rb +++ b/spec/policies/relay_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe RelayPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/report_note_policy_spec.rb b/spec/policies/report_note_policy_spec.rb index a657fce4bd..b40a878887 100644 --- a/spec/policies/report_note_policy_spec.rb +++ b/spec/policies/report_note_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe ReportNotePolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb index 8f2533fa6b..4fc4178075 100644 --- a/spec/policies/report_policy_spec.rb +++ b/spec/policies/report_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe ReportPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/settings_policy_spec.rb b/spec/policies/settings_policy_spec.rb index 576bfa4ab7..4a99314905 100644 --- a/spec/policies/settings_policy_spec.rb +++ b/spec/policies/settings_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe SettingsPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/software_update_policy_spec.rb b/spec/policies/software_update_policy_spec.rb new file mode 100644 index 0000000000..e19ba61612 --- /dev/null +++ b/spec/policies/software_update_policy_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +RSpec.describe SoftwareUpdatePolicy do + subject { described_class } + + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')).account } + let(:john) { Fabricate(:account) } + + permissions :index? do + context 'when owner' do + it 'permits' do + expect(subject).to permit(admin, SoftwareUpdate) + end + end + + context 'when not owner' do + it 'denies' do + expect(subject).to_not permit(john, SoftwareUpdate) + end + end + end +end diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb index 7791cde152..35da3cc62a 100644 --- a/spec/policies/tag_policy_spec.rb +++ b/spec/policies/tag_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe TagPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 384119f250..fa476a9fc3 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -4,7 +4,8 @@ require 'rails_helper' require 'pundit/rspec' RSpec.describe UserPolicy do - let(:subject) { described_class } + subject { described_class } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } diff --git a/spec/presenters/account_relationships_presenter_spec.rb b/spec/presenters/account_relationships_presenter_spec.rb index 5c2ba54e00..282cae4f06 100644 --- a/spec/presenters/account_relationships_presenter_spec.rb +++ b/spec/presenters/account_relationships_presenter_spec.rb @@ -5,30 +5,57 @@ require 'rails_helper' RSpec.describe AccountRelationshipsPresenter do describe '.initialize' do before do - allow(Account).to receive(:following_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:followed_by_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:blocking_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:muting_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:requested_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:requested_by_map).with(account_ids, current_account_id).and_return(default_map) - allow(Account).to receive(:domain_blocking_map).with(account_ids, current_account_id).and_return(default_map) + allow(Account).to receive(:following_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:followed_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:blocking_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:muting_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:requested_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) + allow(Account).to receive(:requested_by_map).with(accounts.pluck(:id), current_account_id).and_return(default_map) end - let(:presenter) { described_class.new(account_ids, current_account_id, **options) } + let(:presenter) { described_class.new(accounts, current_account_id, **options) } let(:current_account_id) { Fabricate(:account).id } - let(:account_ids) { [Fabricate(:account).id] } - let(:default_map) { { 1 => true } } + let(:accounts) { [Fabricate(:account)] } + let(:default_map) { { accounts[0].id => true } } context 'when options are not set' do let(:options) { {} } it 'sets default maps' do - expect(presenter.following).to eq default_map - expect(presenter.followed_by).to eq default_map - expect(presenter.blocking).to eq default_map - expect(presenter.muting).to eq default_map - expect(presenter.requested).to eq default_map - expect(presenter.domain_blocking).to eq default_map + expect(presenter).to have_attributes( + following: default_map, + followed_by: default_map, + blocking: default_map, + muting: default_map, + requested: default_map, + domain_blocking: { accounts[0].id => nil } + ) + end + end + + context 'with a warm cache' do + let(:options) { {} } + + before do + described_class.new(accounts, current_account_id, **options) + + allow(Account).to receive(:following_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:followed_by_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:blocking_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:muting_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:requested_map).with([], current_account_id).and_return({}) + allow(Account).to receive(:requested_by_map).with([], current_account_id).and_return({}) + end + + it 'sets returns expected values' do + expect(presenter).to have_attributes( + following: default_map, + followed_by: default_map, + blocking: default_map, + muting: default_map, + requested: default_map, + domain_blocking: { accounts[0].id => nil } + ) end end @@ -84,7 +111,7 @@ RSpec.describe AccountRelationshipsPresenter do let(:options) { { domain_blocking_map: { 7 => true } } } it 'sets @domain_blocking merged with default_map and options[:domain_blocking_map]' do - expect(presenter.domain_blocking).to eq default_map.merge(options[:domain_blocking_map]) + expect(presenter.domain_blocking).to eq({ accounts[0].id => nil }.merge(options[:domain_blocking_map])) end end end diff --git a/spec/presenters/familiar_followers_presenter_spec.rb b/spec/presenters/familiar_followers_presenter_spec.rb index c21ffd36ec..853babb84b 100644 --- a/spec/presenters/familiar_followers_presenter_spec.rb +++ b/spec/presenters/familiar_followers_presenter_spec.rb @@ -22,9 +22,12 @@ RSpec.describe FamiliarFollowersPresenter do it 'returns followers you follow' do result = subject.accounts.first - expect(result).to_not be_nil - expect(result.id).to eq requested_accounts.first.id - expect(result.accounts).to contain_exactly(familiar_follower) + expect(result) + .to be_present + .and have_attributes( + id: requested_accounts.first.id, + accounts: contain_exactly(familiar_follower) + ) end context 'when requested account hides followers' do @@ -35,9 +38,12 @@ RSpec.describe FamiliarFollowersPresenter do it 'does not return followers you follow' do result = subject.accounts.first - expect(result).to_not be_nil - expect(result.id).to eq requested_accounts.first.id - expect(result.accounts).to be_empty + expect(result) + .to be_present + .and have_attributes( + id: requested_accounts.first.id, + accounts: be_empty + ) end end @@ -49,9 +55,12 @@ RSpec.describe FamiliarFollowersPresenter do it 'does not return followers you follow' do result = subject.accounts.first - expect(result).to_not be_nil - expect(result.id).to eq requested_accounts.first.id - expect(result.accounts).to be_empty + expect(result) + .to be_present + .and have_attributes( + id: requested_accounts.first.id, + accounts: be_empty + ) end end end diff --git a/spec/presenters/instance_presenter_spec.rb b/spec/presenters/instance_presenter_spec.rb index f20dce5931..3e8a2c9f7a 100644 --- a/spec/presenters/instance_presenter_spec.rb +++ b/spec/presenters/instance_presenter_spec.rb @@ -6,12 +6,6 @@ describe InstancePresenter do let(:instance_presenter) { described_class.new } describe '#description' do - around do |example| - site_description = Setting.site_short_description - example.run - Setting.site_short_description = site_description - end - it 'delegates site_description to Setting' do Setting.site_short_description = 'Site desc' expect(instance_presenter.description).to eq 'Site desc' @@ -19,12 +13,6 @@ describe InstancePresenter do end describe '#extended_description' do - around do |example| - site_extended_description = Setting.site_extended_description - example.run - Setting.site_extended_description = site_extended_description - end - it 'delegates site_extended_description to Setting' do Setting.site_extended_description = 'Extended desc' expect(instance_presenter.extended_description).to eq 'Extended desc' @@ -32,12 +20,6 @@ describe InstancePresenter do end describe '#email' do - around do |example| - site_contact_email = Setting.site_contact_email - example.run - Setting.site_contact_email = site_contact_email - end - it 'delegates contact_email to Setting' do Setting.site_contact_email = 'admin@example.com' expect(instance_presenter.contact.email).to eq 'admin@example.com' @@ -45,12 +27,6 @@ describe InstancePresenter do end describe '#account' do - around do |example| - site_contact_username = Setting.site_contact_username - example.run - Setting.site_contact_username = site_contact_username - end - it 'returns the account for the site contact username' do Setting.site_contact_username = 'aaa' account = Fabricate(:account, username: 'aaa') diff --git a/spec/presenters/status_relationships_presenter_spec.rb b/spec/presenters/status_relationships_presenter_spec.rb index 7746c8cd78..af6a93b82b 100644 --- a/spec/presenters/status_relationships_presenter_spec.rb +++ b/spec/presenters/status_relationships_presenter_spec.rb @@ -22,11 +22,13 @@ RSpec.describe StatusRelationshipsPresenter do let(:options) { {} } it 'sets default maps' do - expect(presenter.reblogs_map).to eq default_map - expect(presenter.favourites_map).to eq default_map - expect(presenter.bookmarks_map).to eq default_map - expect(presenter.mutes_map).to eq default_map - expect(presenter.pins_map).to eq default_map + expect(presenter).to have_attributes( + reblogs_map: eq(default_map), + favourites_map: eq(default_map), + bookmarks_map: eq(default_map), + mutes_map: eq(default_map), + pins_map: eq(default_map) + ) end end @@ -80,18 +82,30 @@ RSpec.describe StatusRelationshipsPresenter do it 'sets @filters_map to filter top-level status' do matched_filters = presenter.filters_map[statuses[0].id] - expect(matched_filters.size).to eq 1 - expect(matched_filters[0].filter.title).to eq 'filter1' - expect(matched_filters[0].keyword_matches).to eq ['banned'] + expect(matched_filters) + .to be_an(Array) + .and have_attributes(size: 1) + .and contain_exactly( + have_attributes( + filter: have_attributes(title: 'filter1'), + keyword_matches: contain_exactly('banned') + ) + ) end it 'sets @filters_map to filter reblogged status' do matched_filters = presenter.filters_map[statuses[1].reblog_of_id] - expect(matched_filters.size).to eq 1 - expect(matched_filters[0].filter.title).to eq 'filter1' - expect(matched_filters[0].keyword_matches).to eq ['irrelevant'] + expect(matched_filters) + .to be_an(Array) + .and have_attributes(size: 1) + .and contain_exactly( + have_attributes( + filter: have_attributes(title: 'filter1'), + keyword_matches: contain_exactly('irrelevant') + ) + ) end end @@ -107,18 +121,30 @@ RSpec.describe StatusRelationshipsPresenter do it 'sets @filters_map to filter top-level status' do matched_filters = presenter.filters_map[statuses[0].id] - expect(matched_filters.size).to eq 1 - expect(matched_filters[0].filter.title).to eq 'filter1' - expect(matched_filters[0].status_matches).to eq [statuses[0].id] + expect(matched_filters) + .to be_an(Array) + .and have_attributes(size: 1) + .and contain_exactly( + have_attributes( + filter: have_attributes(title: 'filter1'), + status_matches: contain_exactly(statuses.first.id) + ) + ) end it 'sets @filters_map to filter reblogged status' do matched_filters = presenter.filters_map[statuses[1].reblog_of_id] - expect(matched_filters.size).to eq 1 - expect(matched_filters[0].filter.title).to eq 'filter1' - expect(matched_filters[0].status_matches).to eq [statuses[1].reblog_of_id] + expect(matched_filters) + .to be_an(Array) + .and have_attributes(size: 1) + .and contain_exactly( + have_attributes( + filter: have_attributes(title: 'filter1'), + status_matches: contain_exactly(statuses.second.reblog_of_id) + ) + ) end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 2645f74e40..c9352a4d5d 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true ENV['RAILS_ENV'] ||= 'test' + +# This needs to be defined before Rails is initialized +RUN_SYSTEM_SPECS = ENV.fetch('RUN_SYSTEM_SPECS', false) + +if RUN_SYSTEM_SPECS + STREAMING_PORT = ENV.fetch('TEST_STREAMING_PORT', '4020') + ENV['STREAMING_API_BASE_URL'] = "http://localhost:#{STREAMING_PORT}" +end + require File.expand_path('../config/environment', __dir__) abort('The Rails environment is running in production mode!') if Rails.env.production? @@ -11,14 +20,18 @@ require 'webmock/rspec' require 'paperclip/matchers' require 'capybara/rspec' require 'chewy/rspec' +require 'email_spec/rspec' Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } ActiveRecord::Migration.maintain_test_schema! -WebMock.disable_net_connect!(allow: Chewy.settings[:host]) +WebMock.disable_net_connect!(allow: Chewy.settings[:host], allow_localhost: RUN_SYSTEM_SPECS) Sidekiq::Testing.inline! Sidekiq.logger = nil +# System tests config +DatabaseCleaner.strategy = [:deletion] + Devise::Test::ControllerHelpers.module_eval do alias_method :original_sign_in, :sign_in @@ -35,37 +48,36 @@ Devise::Test::ControllerHelpers.module_eval do end end -module SignedRequestHelpers - def get(path, headers: nil, sign_with: nil, **args) - return super path, headers: headers, **args if sign_with.nil? - - headers ||= {} - headers['Date'] = Time.now.utc.httpdate - headers['Host'] = ENV.fetch('LOCAL_DOMAIN') - signed_headers = headers.merge('(request-target)' => "get #{path}").slice('(request-target)', 'Host', 'Date') - - key_id = ActivityPub::TagManager.instance.key_uri_for(sign_with) - keypair = sign_with.keypair - signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") - signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) - - headers['Signature'] = "keyId=\"#{key_id}\",algorithm=\"rsa-sha256\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" - - super path, headers: headers, **args - end -end - RSpec.configure do |config| - config.fixture_path = Rails.root.join('spec', 'fixtures') + # This is set before running spec:system, see lib/tasks/tests.rake + config.filter_run_excluding type: lambda { |type| + case type + when :system + !RUN_SYSTEM_SPECS + end + } + + # By default, skip the elastic search integration specs + config.filter_run_excluding search: true + + config.fixture_paths = [ + Rails.root.join('spec', 'fixtures'), + ] config.use_transactional_fixtures = true config.order = 'random' config.infer_spec_type_from_file_location! config.filter_rails_from_backtrace! + # Set type to `cli` for all CLI specs config.define_derived_metadata(file_path: Regexp.new('spec/lib/mastodon/cli')) do |metadata| metadata[:type] = :cli end + # Set `search` metadata true for all specs in spec/search/ + config.define_derived_metadata(file_path: Regexp.new('spec/search/*')) do |metadata| + metadata[:search] = true + end + config.include Devise::Test::ControllerHelpers, type: :controller config.include Devise::Test::ControllerHelpers, type: :helper config.include Devise::Test::ControllerHelpers, type: :view @@ -76,35 +88,64 @@ RSpec.configure do |config| config.include Chewy::Rspec::Helpers config.include Redisable config.include SignedRequestHelpers, type: :request + config.include CommandLineHelpers, type: :cli + + config.around(:each, use_transactional_tests: false) do |example| + self.use_transactional_tests = false + example.run + self.use_transactional_tests = true + end + + config.around(:each, :sidekiq_fake) do |example| + Sidekiq::Testing.fake! do + example.run + Sidekiq::Worker.clear_all + end + end config.before :each, type: :cli do - stub_stdout stub_reset_connection_pools end config.before :each, type: :feature do - https = ENV['LOCAL_HTTPS'] == 'true' - Capybara.app_host = "http#{https ? 's' : ''}://#{ENV.fetch('LOCAL_DOMAIN')}" + Capybara.current_driver = :rack_test end - config.before :each, type: :controller do - stub_jsonld_contexts! + config.around :each, type: :system do |example| + driven_by :selenium, using: :headless_chrome, screen_size: [1600, 1200] + + # The streaming server needs access to the database + # but with use_transactional_tests every transaction + # is rolled-back, so the streaming server never sees the data + # So we disable this feature for system tests, and use DatabaseCleaner to clean + # the database tables between each test + self.use_transactional_tests = false + + DatabaseCleaner.cleaning do + example.run + end + + self.use_transactional_tests = true end - config.before :each, type: :service do - stub_jsonld_contexts! - end - - config.before(:each) do |example| + config.before do |example| unless example.metadata[:paperclip_processing] allow_any_instance_of(Paperclip::Attachment).to receive(:post_process).and_return(true) # rubocop:disable RSpec/AnyInstance end end - config.after :each do + config.after do Rails.cache.clear redis.del(redis.keys) end + + # Assign types based on dir name for non-inferred types + config.define_derived_metadata(file_path: %r{/spec/}) do |metadata| + unless metadata.key?(:type) + match = metadata[:location].match(%r{/spec/([^/]+)/}) + metadata[:type] = match[1].singularize.to_sym + end + end end RSpec::Sidekiq.configure do |config| @@ -112,6 +153,7 @@ RSpec::Sidekiq.configure do |config| end RSpec::Matchers.define_negated_matcher :not_change, :change +RSpec::Matchers.define_negated_matcher :not_include, :include def request_fixture(name) Rails.root.join('spec', 'fixtures', 'requests', name).read @@ -121,23 +163,9 @@ def attachment_fixture(name) Rails.root.join('spec', 'fixtures', 'files', name).open end -def stub_stdout - # TODO: Is there a bettery way to: - # - Avoid CLI command output being printed out - # - Allow rspec to assert things against STDOUT - # - Avoid disabling stdout for other desirable output (deprecation warnings, for example) - allow($stdout).to receive(:write) -end - def stub_reset_connection_pools # TODO: Is there a better way to correctly run specs without stubbing this? # (Avoids reset_connection_pools! in test env) allow(ActiveRecord::Base).to receive(:establish_connection) allow(RedisConfiguration).to receive(:establish_pool) end - -def stub_jsonld_contexts! - stub_request(:get, 'https://www.w3.org/ns/activitystreams').to_return(request_fixture('json-ld.activitystreams.txt')) - stub_request(:get, 'https://w3id.org/identity/v1').to_return(request_fixture('json-ld.identity.txt')) - stub_request(:get, 'https://w3id.org/security/v1').to_return(request_fixture('json-ld.security.txt')) -end diff --git a/spec/requests/accounts_spec.rb b/spec/requests/accounts_spec.rb new file mode 100644 index 0000000000..bf067cdc38 --- /dev/null +++ b/spec/requests/accounts_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Accounts show response' do + let(:account) { Fabricate(:account) } + + context 'with an unapproved account' do + before { account.user.update(approved: false) } + + it 'returns http not found' do + %w(html json rss).each do |format| + get short_account_path(username: account.username), as: format + + expect(response).to have_http_status(404) + end + end + end + + context 'with a permanently suspended account' do + before do + account.suspend! + account.deletion_request.destroy + end + + it 'returns http gone' do + %w(html json rss).each do |format| + get short_account_path(username: account.username), as: format + + expect(response).to have_http_status(410) + end + end + end + + context 'with a temporarily suspended account' do + before { account.suspend! } + + it 'returns appropriate http response code' do + { html: 403, json: 200, rss: 403 }.each do |format, code| + get short_account_path(username: account.username), as: format + + expect(response).to have_http_status(code) + end + end + end + + describe 'GET to short username paths' do + context 'with existing statuses' do + let!(:status) { Fabricate(:status, account: account) } + let!(:status_reply) { Fabricate(:status, account: account, thread: Fabricate(:status)) } + let!(:status_self_reply) { Fabricate(:status, account: account, thread: status) } + let!(:status_media) { Fabricate(:status, account: account) } + let!(:status_pinned) { Fabricate(:status, account: account) } + let!(:status_private) { Fabricate(:status, account: account, visibility: :private) } + let!(:status_direct) { Fabricate(:status, account: account, visibility: :direct) } + let!(:status_reblog) { Fabricate(:status, account: account, reblog: Fabricate(:status)) } + + before do + status_media.media_attachments << Fabricate(:media_attachment, account: account, type: :image) + account.pinned_statuses << status_pinned + account.pinned_statuses << status_private + end + + context 'with HTML' do + let(:format) { 'html' } + + shared_examples 'common HTML response' do + it 'returns a standard HTML response', :aggregate_failures do + expect(response) + .to have_http_status(200) + .and render_template(:show) + + expect(response.headers['Link'].to_s).to include ActivityPub::TagManager.instance.uri_for(account) + end + end + + context 'with a normal account in an HTML request' do + before do + get short_account_path(username: account.username), as: format + end + + it_behaves_like 'common HTML response' + end + + context 'with replies' do + before do + get short_account_with_replies_path(username: account.username), as: format + end + + it_behaves_like 'common HTML response' + end + + context 'with media' do + before do + get short_account_media_path(username: account.username), as: format + end + + it_behaves_like 'common HTML response' + end + + context 'with tag' do + let(:tag) { Fabricate(:tag) } + + let!(:status_tag) { Fabricate(:status, account: account) } + + before do + status_tag.tags << tag + get short_account_tag_path(username: account.username, tag: tag), as: format + end + + it_behaves_like 'common HTML response' + end + end + + context 'with JSON' do + let(:authorized_fetch_mode) { false } + let(:headers) { { 'ACCEPT' => 'application/json' } } + + around do |example| + ClimateControl.modify AUTHORIZED_FETCH: authorized_fetch_mode.to_s do + example.run + end + end + + context 'with a normal account in a JSON request' do + before do + get short_account_path(username: account.username), headers: headers + end + + it 'returns a JSON version of the account', :aggregate_failures do + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: eq('application/activity+json') + ) + + expect(body_as_json).to include(:id, :type, :preferredUsername, :inbox, :publicKey, :name, :summary) + end + + it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie' + + context 'with authorized fetch mode' do + let(:authorized_fetch_mode) { true } + + it 'returns http unauthorized' do + expect(response).to have_http_status(401) + end + end + end + + context 'when signed in' do + let(:user) { Fabricate(:user) } + + before do + sign_in(user) + get short_account_path(username: account.username), headers: headers.merge({ 'Cookie' => '123' }) + end + + it 'returns a private JSON version of the account', :aggregate_failures do + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: eq('application/activity+json') + ) + + expect(response.headers['Cache-Control']).to include 'private' + + expect(body_as_json).to include(:id, :type, :preferredUsername, :inbox, :publicKey, :name, :summary) + end + end + + context 'with signature' do + let(:remote_account) { Fabricate(:account, domain: 'example.com') } + + before do + get short_account_path(username: account.username), headers: headers, sign_with: remote_account + end + + it 'returns a JSON version of the account', :aggregate_failures do + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: eq('application/activity+json') + ) + + expect(body_as_json).to include(:id, :type, :preferredUsername, :inbox, :publicKey, :name, :summary) + end + + it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie' + + context 'with authorized fetch mode' do + let(:authorized_fetch_mode) { true } + + it 'returns a private signature JSON version of the account', :aggregate_failures do + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: eq('application/activity+json') + ) + + expect(response.headers['Cache-Control']).to include 'private' + expect(response.headers['Vary']).to include 'Signature' + + expect(body_as_json).to include(:id, :type, :preferredUsername, :inbox, :publicKey, :name, :summary) + end + end + end + end + + context 'with RSS' do + let(:format) { 'rss' } + + context 'with a normal account in an RSS request' do + before do + get short_account_path(username: account.username, format: format) + end + + it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie' + + it 'responds with correct statuses', :aggregate_failures do + expect(response).to have_http_status(200) + expect(response.body).to include(status_tag_for(status_media)) + expect(response.body).to include(status_tag_for(status_self_reply)) + expect(response.body).to include(status_tag_for(status)) + expect(response.body).to_not include(status_tag_for(status_direct)) + expect(response.body).to_not include(status_tag_for(status_private)) + expect(response.body).to_not include(status_tag_for(status_reblog.reblog)) + expect(response.body).to_not include(status_tag_for(status_reply)) + end + end + + context 'with replies' do + before do + get short_account_with_replies_path(username: account.username, format: format) + end + + it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie' + + it 'responds with correct statuses with replies', :aggregate_failures do + expect(response).to have_http_status(200) + expect(response.body).to include(status_tag_for(status_media)) + expect(response.body).to include(status_tag_for(status_reply)) + expect(response.body).to include(status_tag_for(status_self_reply)) + expect(response.body).to include(status_tag_for(status)) + expect(response.body).to_not include(status_tag_for(status_direct)) + expect(response.body).to_not include(status_tag_for(status_private)) + expect(response.body).to_not include(status_tag_for(status_reblog.reblog)) + end + end + + context 'with media' do + before do + get short_account_media_path(username: account.username, format: format) + end + + it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie' + + it 'responds with correct statuses with media', :aggregate_failures do + expect(response).to have_http_status(200) + expect(response.body).to include(status_tag_for(status_media)) + expect(response.body).to_not include(status_tag_for(status_direct)) + expect(response.body).to_not include(status_tag_for(status_private)) + expect(response.body).to_not include(status_tag_for(status_reblog.reblog)) + expect(response.body).to_not include(status_tag_for(status_reply)) + expect(response.body).to_not include(status_tag_for(status_self_reply)) + expect(response.body).to_not include(status_tag_for(status)) + end + end + + context 'with tag' do + let(:tag) { Fabricate(:tag) } + + let!(:status_tag) { Fabricate(:status, account: account) } + + before do + status_tag.tags << tag + get short_account_tag_path(username: account.username, tag: tag, format: format) + end + + it_behaves_like 'cacheable response', expects_vary: 'Accept, Accept-Language, Cookie' + + it 'responds with correct statuses with a tag', :aggregate_failures do + expect(response).to have_http_status(200) + expect(response.body).to include(status_tag_for(status_tag)) + expect(response.body).to_not include(status_tag_for(status_direct)) + expect(response.body).to_not include(status_tag_for(status_media)) + expect(response.body).to_not include(status_tag_for(status_private)) + expect(response.body).to_not include(status_tag_for(status_reblog.reblog)) + expect(response.body).to_not include(status_tag_for(status_reply)) + expect(response.body).to_not include(status_tag_for(status_self_reply)) + expect(response.body).to_not include(status_tag_for(status)) + end + end + end + end + end + + def status_tag_for(status) + ActivityPub::TagManager.instance.url_for(status) + end +end diff --git a/spec/requests/api/v1/accounts/credentials_spec.rb b/spec/requests/api/v1/accounts/credentials_spec.rb new file mode 100644 index 0000000000..b13e79b12b --- /dev/null +++ b/spec/requests/api/v1/accounts/credentials_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'credentials API' do + let(:user) { Fabricate(:user, account_attributes: { discoverable: false, locked: true, indexable: false }) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts write:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/accounts/verify_credentials' do + subject do + get '/api/v1/accounts/verify_credentials', headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the expected content' do + subject + + expect(body_as_json).to include({ + source: hash_including({ + discoverable: false, + indexable: false, + }), + locked: true, + }) + end + end + + describe 'POST /api/v1/accounts/update_credentials' do + subject do + patch '/api/v1/accounts/update_credentials', headers: headers, params: params + end + + let(:params) { { discoverable: true, locked: false, indexable: true } } + + it_behaves_like 'forbidden for wrong scope', 'read read:accounts' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns JSON with updated attributes' do + subject + + expect(body_as_json).to include({ + source: hash_including({ + discoverable: true, + indexable: true, + }), + locked: false, + }) + end + end +end diff --git a/spec/requests/api/v1/accounts/familiar_followers_spec.rb b/spec/requests/api/v1/accounts/familiar_followers_spec.rb new file mode 100644 index 0000000000..fdc0a3a932 --- /dev/null +++ b/spec/requests/api/v1/accounts/familiar_followers_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Accounts Familiar Followers API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:follows' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/accounts/familiar_followers' do + it 'returns http success' do + get '/api/v1/accounts/familiar_followers', params: { account_id: account.id, limit: 2 }, headers: headers + + expect(response).to have_http_status(200) + end + + context 'when there are duplicate account IDs in the params' do + let(:account_a) { Fabricate(:account) } + let(:account_b) { Fabricate(:account) } + + it 'removes duplicate account IDs from params' do + account_ids = [account_a, account_b, account_b, account_a, account_a].map { |a| a.id.to_s } + get '/api/v1/accounts/familiar_followers', params: { id: account_ids }, headers: headers + + expect(body_as_json.pluck(:id)).to contain_exactly(account_a.id.to_s, account_b.id.to_s) + end + end + end +end diff --git a/spec/requests/api/v1/accounts/featured_tags_spec.rb b/spec/requests/api/v1/accounts/featured_tags_spec.rb new file mode 100644 index 0000000000..bae7d448b6 --- /dev/null +++ b/spec/requests/api/v1/accounts/featured_tags_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'account featured tags API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/accounts/:id/featured_tags' do + subject do + get "/api/v1/accounts/#{account.id}/featured_tags", headers: headers + end + + before do + account.featured_tags.create!(name: 'foo') + account.featured_tags.create!(name: 'bar') + end + + it 'returns the expected tags', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to contain_exactly(a_hash_including({ + name: 'bar', + url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/bar", + }), a_hash_including({ + name: 'foo', + url: "https://cb6e6126.ngrok.io/@#{account.username}/tagged/foo", + })) + end + + context 'when the account is remote' do + it 'returns the expected tags', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to contain_exactly(a_hash_including({ + name: 'bar', + url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/bar", + }), a_hash_including({ + name: 'foo', + url: "https://cb6e6126.ngrok.io/@#{account.pretty_acct}/tagged/foo", + })) + end + end + end +end diff --git a/spec/requests/api/v1/accounts/identity_proofs_spec.rb b/spec/requests/api/v1/accounts/identity_proofs_spec.rb new file mode 100644 index 0000000000..3727af7e89 --- /dev/null +++ b/spec/requests/api/v1/accounts/identity_proofs_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Accounts Identity Proofs API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/accounts/identity_proofs' do + it 'returns http success' do + get "/api/v1/accounts/#{account.id}/identity_proofs", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v1/accounts/lists_spec.rb b/spec/requests/api/v1/accounts/lists_spec.rb new file mode 100644 index 0000000000..48c0337e54 --- /dev/null +++ b/spec/requests/api/v1/accounts/lists_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Accounts Lists API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:lists' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + let(:list) { Fabricate(:list, account: user.account) } + + before do + user.account.follow!(account) + list.accounts << account + end + + describe 'GET /api/v1/accounts/lists' do + it 'returns http success' do + get "/api/v1/accounts/#{account.id}/lists", headers: headers + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v1/accounts/lookup_spec.rb b/spec/requests/api/v1/accounts/lookup_spec.rb new file mode 100644 index 0000000000..4c022c7c13 --- /dev/null +++ b/spec/requests/api/v1/accounts/lookup_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Accounts Lookup API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/accounts/lookup' do + it 'returns http success' do + get '/api/v1/accounts/lookup', params: { account_id: account.id, acct: account.acct }, headers: headers + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/controllers/api/v1/accounts/notes_controller_spec.rb b/spec/requests/api/v1/accounts/notes_spec.rb similarity index 52% rename from spec/controllers/api/v1/accounts/notes_controller_spec.rb rename to spec/requests/api/v1/accounts/notes_spec.rb index 4107105afd..4f3ac68c74 100644 --- a/spec/controllers/api/v1/accounts/notes_controller_spec.rb +++ b/spec/requests/api/v1/accounts/notes_spec.rb @@ -2,47 +2,37 @@ require 'rails_helper' -describe Api::V1::Accounts::NotesController do - render_views - - let(:user) { Fabricate(:user) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:accounts') } +describe 'Accounts Notes API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'write:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } let(:account) { Fabricate(:account) } let(:comment) { 'foo' } - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'POST #create' do + describe 'POST /api/v1/accounts/:account_id/note' do subject do - post :create, params: { account_id: account.id, comment: comment } + post "/api/v1/accounts/#{account.id}/note", params: { comment: comment }, headers: headers end - context 'when account note has reasonable length' do + context 'when account note has reasonable length', :aggregate_failures do let(:comment) { 'foo' } - it 'returns http success' do - subject - expect(response).to have_http_status(200) - end - it 'updates account note' do subject + + expect(response).to have_http_status(200) expect(AccountNote.find_by(account_id: user.account.id, target_account_id: account.id).comment).to eq comment end end - context 'when account note exceeds allowed length' do + context 'when account note exceeds allowed length', :aggregate_failures do let(:comment) { 'a' * 2_001 } - it 'returns 422' do - subject - expect(response).to have_http_status(422) - end - it 'does not create account note' do subject + + expect(response).to have_http_status(422) expect(AccountNote.where(account_id: user.account.id, target_account_id: account.id)).to_not exist end end diff --git a/spec/requests/api/v1/accounts/pins_spec.rb b/spec/requests/api/v1/accounts/pins_spec.rb new file mode 100644 index 0000000000..c293715f7e --- /dev/null +++ b/spec/requests/api/v1/accounts/pins_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Accounts Pins API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'write:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:kevin) { Fabricate(:user) } + + before do + kevin.account.followers << user.account + end + + describe 'POST /api/v1/accounts/:account_id/pin' do + subject { post "/api/v1/accounts/#{kevin.account.id}/pin", headers: headers } + + it 'creates account_pin', :aggregate_failures do + expect do + subject + end.to change { AccountPin.where(account: user.account, target_account: kevin.account).count }.by(1) + expect(response).to have_http_status(200) + end + end + + describe 'POST /api/v1/accounts/:account_id/unpin' do + subject { post "/api/v1/accounts/#{kevin.account.id}/unpin", headers: headers } + + before do + Fabricate(:account_pin, account: user.account, target_account: kevin.account) + end + + it 'destroys account_pin', :aggregate_failures do + expect do + subject + end.to change { AccountPin.where(account: user.account, target_account: kevin.account).count }.by(-1) + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v1/accounts/relationships_spec.rb b/spec/requests/api/v1/accounts/relationships_spec.rb new file mode 100644 index 0000000000..b06ce0509d --- /dev/null +++ b/spec/requests/api/v1/accounts/relationships_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'GET /api/v1/accounts/relationships' do + subject do + get '/api/v1/accounts/relationships', headers: headers, params: params + end + + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:follows' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + let(:simon) { Fabricate(:account) } + let(:lewis) { Fabricate(:account) } + let(:bob) { Fabricate(:account, suspended: true) } + + before do + user.account.follow!(simon) + lewis.follow!(user.account) + end + + context 'when provided only one ID' do + let(:params) { { id: simon.id } } + + it 'returns JSON with correct data', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + expect(body_as_json) + .to be_an(Enumerable) + .and contain_exactly( + include( + following: true, + followed_by: false + ) + ) + end + end + + context 'when provided multiple IDs' do + let(:params) { { id: [simon.id, lewis.id, bob.id] } } + + context 'when there is returned JSON data' do + context 'with default parameters' do + it 'returns an enumerable json with correct elements, excluding suspended accounts', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + expect(body_as_json) + .to be_an(Enumerable) + .and have_attributes( + size: 2 + ) + .and contain_exactly( + include(simon_item), + include(lewis_item) + ) + end + end + + context 'with `with_suspended` parameter' do + let(:params) { { id: [simon.id, lewis.id, bob.id], with_suspended: true } } + + it 'returns an enumerable json with correct elements, including suspended accounts', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + expect(body_as_json) + .to be_an(Enumerable) + .and have_attributes( + size: 3 + ) + .and contain_exactly( + include(simon_item), + include(lewis_item), + include(bob_item) + ) + end + end + + context 'when there are duplicate IDs in the params' do + let(:params) { { id: [simon.id, lewis.id, lewis.id, lewis.id, simon.id] } } + + it 'removes duplicate account IDs from params' do + subject + + expect(body_as_json) + .to be_an(Enumerable) + .and have_attributes( + size: 2 + ) + .and contain_exactly( + include(simon_item), + include(lewis_item) + ) + end + end + + def simon_item + { + id: simon.id.to_s, + following: true, + showing_reblogs: true, + followed_by: false, + muting: false, + requested: false, + domain_blocking: false, + } + end + + def lewis_item + { + id: lewis.id.to_s, + following: false, + showing_reblogs: false, + followed_by: true, + muting: false, + requested: false, + domain_blocking: false, + } + end + + def bob_item + { + id: bob.id.to_s, + following: false, + showing_reblogs: false, + followed_by: false, + muting: false, + requested: false, + domain_blocking: false, + } + end + end + + it 'returns JSON with correct data on previously cached requests' do + # Initial request including multiple accounts in params + get '/api/v1/accounts/relationships', headers: headers, params: { id: [simon.id, lewis.id] } + expect(body_as_json) + .to have_attributes(size: 2) + + # Subsequent request with different id, should override cache from first request + get '/api/v1/accounts/relationships', headers: headers, params: { id: [simon.id] } + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_an(Enumerable) + .and have_attributes( + size: 1 + ) + .and contain_exactly( + include( + following: true, + showing_reblogs: true + ) + ) + end + + it 'returns JSON with correct data after change too' do + subject + user.account.unfollow!(simon) + + get '/api/v1/accounts/relationships', headers: headers, params: { id: [simon.id] } + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_an(Enumerable) + .and contain_exactly( + include( + following: false, + showing_reblogs: false + ) + ) + end + end +end diff --git a/spec/requests/api/v1/accounts/search_spec.rb b/spec/requests/api/v1/accounts/search_spec.rb new file mode 100644 index 0000000000..76b32e7b2c --- /dev/null +++ b/spec/requests/api/v1/accounts/search_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Accounts Search API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/accounts/search' do + it 'returns http success' do + get '/api/v1/accounts/search', params: { q: 'query' }, headers: headers + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v1/accounts_show_spec.rb b/spec/requests/api/v1/accounts_show_spec.rb deleted file mode 100644 index ee6e925aa9..0000000000 --- a/spec/requests/api/v1/accounts_show_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe 'GET /api/v1/accounts/{account_id}' do - it 'returns account entity as 200 OK' do - account = Fabricate(:account) - - get "/api/v1/accounts/#{account.id}" - - aggregate_failures do - expect(response).to have_http_status(200) - expect(body_as_json[:id]).to eq(account.id.to_s) - end - end - - it 'returns 404 if account not found' do - get '/api/v1/accounts/1' - - aggregate_failures do - expect(response).to have_http_status(404) - expect(body_as_json[:error]).to eq('Record not found') - end - end - - context 'when with token' do - it 'returns account entity as 200 OK if token is valid' do - account = Fabricate(:account) - user = Fabricate(:user, account: account) - token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts').token - - get "/api/v1/accounts/#{account.id}", headers: { Authorization: "Bearer #{token}" } - - aggregate_failures do - expect(response).to have_http_status(200) - expect(body_as_json[:id]).to eq(account.id.to_s) - end - end - - it 'returns 403 if scope of token is invalid' do - account = Fabricate(:account) - user = Fabricate(:user, account: account) - token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:statuses').token - - get "/api/v1/accounts/#{account.id}", headers: { Authorization: "Bearer #{token}" } - - aggregate_failures do - expect(response).to have_http_status(403) - expect(body_as_json[:error]).to eq('This action is outside the authorized scopes') - end - end - end -end diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/requests/api/v1/accounts_spec.rb similarity index 56% rename from spec/controllers/api/v1/accounts_controller_spec.rb rename to spec/requests/api/v1/accounts_spec.rb index 0daec691a5..e543c41360 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/requests/api/v1/accounts_spec.rb @@ -2,75 +2,107 @@ require 'rails_helper' -RSpec.describe Api::V1::AccountsController do - render_views +describe '/api/v1/accounts' do + let(:user) { Fabricate(:user) } + let(:scopes) { '' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } - let(:user) { Fabricate(:user) } - let(:scopes) { '' } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + describe 'GET /api/v1/accounts/:id' do + context 'when logged out' do + let(:account) { Fabricate(:account) } - before do - allow(controller).to receive(:doorkeeper_token) { token } + it 'returns account entity as 200 OK', :aggregate_failures do + get "/api/v1/accounts/#{account.id}" + + expect(response).to have_http_status(200) + expect(body_as_json[:id]).to eq(account.id.to_s) + end + end + + context 'when the account does not exist' do + it 'returns http not found' do + get '/api/v1/accounts/1' + + expect(response).to have_http_status(404) + expect(body_as_json[:error]).to eq('Record not found') + end + end + + context 'when logged in' do + subject do + get "/api/v1/accounts/#{account.id}", headers: headers + end + + let(:account) { Fabricate(:account) } + let(:scopes) { 'read:accounts' } + + it 'returns account entity as 200 OK', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:id]).to eq(account.id.to_s) + end + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + end end - describe 'POST #create' do - let(:app) { Fabricate(:application) } - let(:token) { Doorkeeper::AccessToken.find_or_create_for(application: app, resource_owner: nil, scopes: 'read write', use_refresh_token: false) } - let(:agreement) { nil } - - before do - post :create, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement } + describe 'POST /api/v1/accounts' do + subject do + post '/api/v1/accounts', headers: headers, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement } end + let(:client_app) { Fabricate(:application) } + let(:token) { Doorkeeper::AccessToken.find_or_create_for(application: client_app, resource_owner: nil, scopes: 'read write', use_refresh_token: false) } + let(:agreement) { nil } + context 'when given truthy agreement' do let(:agreement) { 'true' } - it 'returns http success' do + it 'creates a user', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'returns a new access token as JSON' do expect(body_as_json[:access_token]).to_not be_blank - end - it 'creates a user' do user = User.find_by(email: 'hello@world.tld') expect(user).to_not be_nil - expect(user.created_by_application_id).to eq app.id + expect(user.created_by_application_id).to eq client_app.id end end context 'when given no agreement' do it 'returns http unprocessable entity' do + subject + expect(response).to have_http_status(422) end end end - describe 'POST #follow' do + describe 'POST /api/v1/accounts/:id/follow' do let(:scopes) { 'write:follows' } let(:other_account) { Fabricate(:account, username: 'bob', locked: locked) } context 'when posting to an other account' do - before do - post :follow, params: { id: other_account.id } + subject do + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers end context 'with unlocked account' do let(:locked) { false } - it 'returns http success' do - expect(response).to have_http_status(200) - end + it 'creates a following relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) - it 'returns JSON with following=true and requested=false' do json = body_as_json expect(json[:following]).to be true expect(json[:requested]).to be false - end - it 'creates a following relation between user and target user' do expect(user.account.following?(other_account)).to be true end @@ -80,18 +112,16 @@ RSpec.describe Api::V1::AccountsController do context 'with locked account' do let(:locked) { true } - it 'returns http success' do - expect(response).to have_http_status(200) - end + it 'creates a follow request relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) - it 'returns JSON with following=false and requested=true' do json = body_as_json expect(json[:following]).to be false expect(json[:requested]).to be true - end - it 'creates a follow request relation between user and target user' do expect(user.account.requested?(other_account)).to be true end @@ -107,220 +137,215 @@ RSpec.describe Api::V1::AccountsController do end it 'changes reblogs option' do - post :follow, params: { id: other_account.id, reblogs: true } + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers, params: { reblogs: true } - json = body_as_json - - expect(json[:following]).to be true - expect(json[:showing_reblogs]).to be true - expect(json[:notifying]).to be false + expect(body_as_json).to include({ + following: true, + showing_reblogs: true, + notifying: false, + }) end it 'changes notify option' do - post :follow, params: { id: other_account.id, notify: true } + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers, params: { notify: true } - json = body_as_json - - expect(json[:following]).to be true - expect(json[:showing_reblogs]).to be false - expect(json[:notifying]).to be true + expect(body_as_json).to include({ + following: true, + showing_reblogs: false, + notifying: true, + }) end it 'changes languages option' do - post :follow, params: { id: other_account.id, languages: %w(en es) } + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers, params: { languages: %w(en es) } - json = body_as_json - - expect(json[:following]).to be true - expect(json[:showing_reblogs]).to be false - expect(json[:notifying]).to be false - expect(json[:languages]).to match_array %w(en es) + expect(body_as_json).to include({ + following: true, + showing_reblogs: false, + notifying: false, + languages: match_array(%w(en es)), + }) end end end - describe 'POST #unfollow' do + describe 'POST /api/v1/accounts/:id/unfollow' do + subject do + post "/api/v1/accounts/#{other_account.id}/unfollow", headers: headers + end + let(:scopes) { 'write:follows' } let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) - post :unfollow, params: { id: other_account.id } end - it 'returns http success' do + it 'removes the following relation between user and target user', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'removes the following relation between user and target user' do expect(user.account.following?(other_account)).to be false end it_behaves_like 'forbidden for wrong scope', 'read:accounts' end - describe 'POST #remove_from_followers' do + describe 'POST /api/v1/accounts/:id/remove_from_followers' do + subject do + post "/api/v1/accounts/#{other_account.id}/remove_from_followers", headers: headers + end + let(:scopes) { 'write:follows' } let(:other_account) { Fabricate(:account, username: 'bob') } before do other_account.follow!(user.account) - post :remove_from_followers, params: { id: other_account.id } end - it 'returns http success' do + it 'removes the followed relation between user and target user', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'removes the followed relation between user and target user' do expect(user.account.followed_by?(other_account)).to be false end it_behaves_like 'forbidden for wrong scope', 'read:accounts' end - describe 'POST #block' do + describe 'POST /api/v1/accounts/:id/block' do + subject do + post "/api/v1/accounts/#{other_account.id}/block", headers: headers + end + let(:scopes) { 'write:blocks' } let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) - post :block, params: { id: other_account.id } end - it 'returns http success' do + it 'creates a blocking relation', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'removes the following relation between user and target user' do expect(user.account.following?(other_account)).to be false - end - - it 'creates a blocking relation' do expect(user.account.blocking?(other_account)).to be true end it_behaves_like 'forbidden for wrong scope', 'read:accounts' end - describe 'POST #unblock' do + describe 'POST /api/v1/accounts/:id/unblock' do + subject do + post "/api/v1/accounts/#{other_account.id}/unblock", headers: headers + end + let(:scopes) { 'write:blocks' } let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.block!(other_account) - post :unblock, params: { id: other_account.id } end - it 'returns http success' do + it 'removes the blocking relation between user and target user', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'removes the blocking relation between user and target user' do expect(user.account.blocking?(other_account)).to be false end it_behaves_like 'forbidden for wrong scope', 'read:accounts' end - describe 'POST #mute' do + describe 'POST /api/v1/accounts/:id/mute' do + subject do + post "/api/v1/accounts/#{other_account.id}/mute", headers: headers + end + let(:scopes) { 'write:mutes' } let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) - post :mute, params: { id: other_account.id } end - it 'returns http success' do + it 'mutes notifications', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'does not remove the following relation between user and target user' do expect(user.account.following?(other_account)).to be true - end - - it 'creates a muting relation' do expect(user.account.muting?(other_account)).to be true - end - - it 'mutes notifications' do expect(user.account.muting_notifications?(other_account)).to be true end it_behaves_like 'forbidden for wrong scope', 'read:accounts' end - describe 'POST #mute with notifications set to false' do + describe 'POST /api/v1/accounts/:id/mute with notifications set to false' do + subject do + post "/api/v1/accounts/#{other_account.id}/mute", headers: headers, params: { notifications: false } + end + let(:scopes) { 'write:mutes' } let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) - post :mute, params: { id: other_account.id, notifications: false } end - it 'returns http success' do + it 'does not mute notifications', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'does not remove the following relation between user and target user' do expect(user.account.following?(other_account)).to be true - end - - it 'creates a muting relation' do expect(user.account.muting?(other_account)).to be true - end - - it 'does not mute notifications' do expect(user.account.muting_notifications?(other_account)).to be false end it_behaves_like 'forbidden for wrong scope', 'read:accounts' end - describe 'POST #mute with nonzero duration set' do + describe 'POST /api/v1/accounts/:id/mute with nonzero duration set' do + subject do + post "/api/v1/accounts/#{other_account.id}/mute", headers: headers, params: { duration: 300 } + end + let(:scopes) { 'write:mutes' } let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.follow!(other_account) - post :mute, params: { id: other_account.id, duration: 300 } end - it 'returns http success' do + it 'mutes notifications', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'does not remove the following relation between user and target user' do expect(user.account.following?(other_account)).to be true - end - - it 'creates a muting relation' do expect(user.account.muting?(other_account)).to be true - end - - it 'mutes notifications' do expect(user.account.muting_notifications?(other_account)).to be true end it_behaves_like 'forbidden for wrong scope', 'read:accounts' end - describe 'POST #unmute' do + describe 'POST /api/v1/accounts/:id/unmute' do + subject do + post "/api/v1/accounts/#{other_account.id}/unmute", headers: headers + end + let(:scopes) { 'write:mutes' } let(:other_account) { Fabricate(:account, username: 'bob') } before do user.account.mute!(other_account) - post :unmute, params: { id: other_account.id } end - it 'returns http success' do + it 'removes the muting relation between user and target user', :aggregate_failures do + subject + expect(response).to have_http_status(200) - end - - it 'removes the muting relation between user and target user' do expect(user.account.muting?(other_account)).to be false end diff --git a/spec/requests/api/v1/admin/account_actions_spec.rb b/spec/requests/api/v1/admin/account_actions_spec.rb index 9295d262d6..4167911a13 100644 --- a/spec/requests/api/v1/admin/account_actions_spec.rb +++ b/spec/requests/api/v1/admin/account_actions_spec.rb @@ -8,18 +8,12 @@ RSpec.describe 'Account actions' do let(:scopes) { 'admin:write admin:write:accounts' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } - let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later!: nil) } - - before do - allow(UserMailer).to receive(:warning).with(target_account.user, anything).and_return(mailer) - end shared_examples 'a successful notification delivery' do it 'notifies the user about the action taken' do - subject - - expect(UserMailer).to have_received(:warning).with(target_account.user, anything).once - expect(mailer).to have_received(:deliver_later!).once + expect { subject } + .to have_enqueued_job(ActionMailer::MailDeliveryJob) + .with('UserMailer', 'warning', 'deliver_now!', args: [User, AccountWarning]) end end @@ -27,12 +21,19 @@ RSpec.describe 'Account actions' do it 'logs action' do subject - log_item = Admin::ActionLog.last + expect(latest_admin_action_log) + .to be_present + .and have_attributes( + action: eq(action_type), + account_id: eq(user.account_id), + target_id: eq(target_type == :user ? target_account.user.id : target_account.id) + ) + end - expect(log_item).to be_present - expect(log_item.action).to eq(action_type) - expect(log_item.account_id).to eq(user.account_id) - expect(log_item.target_id).to eq(target_type == :user ? target_account.user.id : target_account.id) + private + + def latest_admin_action_log + Admin::ActionLog.last end end @@ -51,14 +52,9 @@ RSpec.describe 'Account actions' do it_behaves_like 'a successful notification delivery' it_behaves_like 'a successful logged action', :disable, :user - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - it 'disables the target account' do expect { subject }.to change { target_account.reload.user_disabled? }.from(false).to(true) + expect(response).to have_http_status(200) end end @@ -70,14 +66,9 @@ RSpec.describe 'Account actions' do it_behaves_like 'a successful notification delivery' it_behaves_like 'a successful logged action', :sensitive, :account - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - it 'marks the target account as sensitive' do expect { subject }.to change { target_account.reload.sensitized? }.from(false).to(true) + expect(response).to have_http_status(200) end end @@ -89,14 +80,9 @@ RSpec.describe 'Account actions' do it_behaves_like 'a successful notification delivery' it_behaves_like 'a successful logged action', :silence, :account - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - it 'marks the target account as silenced' do expect { subject }.to change { target_account.reload.silenced? }.from(false).to(true) + expect(response).to have_http_status(200) end end @@ -108,14 +94,9 @@ RSpec.describe 'Account actions' do it_behaves_like 'a successful notification delivery' it_behaves_like 'a successful logged action', :suspend, :account - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - it 'marks the target account as suspended' do expect { subject }.to change { target_account.reload.suspended? }.from(false).to(true) + expect(response).to have_http_status(200) end end diff --git a/spec/requests/api/v1/admin/accounts_spec.rb b/spec/requests/api/v1/admin/accounts_spec.rb new file mode 100644 index 0000000000..1615581f0e --- /dev/null +++ b/spec/requests/api/v1/admin/accounts_spec.rb @@ -0,0 +1,409 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Accounts' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read:accounts admin:write:accounts' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/accounts' do + subject do + get '/api/v1/admin/accounts', headers: headers, params: params + end + + shared_examples 'a successful request' do + it 'returns the correct accounts', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.pluck(:id)).to match_array(expected_results.map { |a| a.id.to_s }) + end + end + + let!(:remote_account) { Fabricate(:account, domain: 'example.org') } + let!(:suspended_account) { Fabricate(:account, suspended: true) } + let!(:disabled_account) { Fabricate(:user, disabled: true).account } + let!(:pending_account) { Fabricate(:user, approved: false).account } + let!(:admin_account) { user.account } + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read read:accounts admin:write admin:write:accounts' + it_behaves_like 'forbidden for wrong role', '' + + context 'when requesting active local staff accounts' do + let(:expected_results) { [admin_account] } + let(:params) { { active: 'true', local: 'true', staff: 'true' } } + + it_behaves_like 'a successful request' + end + + context 'when requesting remote accounts from a specified domain' do + let(:expected_results) { [remote_account] } + let(:params) { { by_domain: 'example.org', remote: 'true' } } + + before do + Fabricate(:account, domain: 'foo.bar') + end + + it_behaves_like 'a successful request' + end + + context 'when requesting suspended accounts' do + let(:expected_results) { [suspended_account] } + let(:params) { { suspended: 'true' } } + + before do + Fabricate(:account, domain: 'foo.bar', suspended: true) + end + + it_behaves_like 'a successful request' + end + + context 'when requesting disabled accounts' do + let(:expected_results) { [disabled_account] } + let(:params) { { disabled: 'true' } } + + it_behaves_like 'a successful request' + end + + context 'when requesting pending accounts' do + let(:expected_results) { [pending_account] } + let(:params) { { pending: 'true' } } + + before do + pending_account.user.update(approved: false) + end + + it_behaves_like 'a successful request' + end + + context 'when no parameter is given' do + let(:expected_results) { [disabled_account, pending_account, admin_account] } + + it_behaves_like 'a successful request' + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of accounts', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + + describe 'GET /api/v1/admin/accounts/:id' do + subject do + get "/api/v1/admin/accounts/#{account.id}", headers: headers + end + + let(:account) { Fabricate(:account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:accounts admin:write admin:write:accounts' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns the requested account successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match( + a_hash_including(id: account.id.to_s, username: account.username, email: account.user.email) + ) + end + + context 'when the account is not found' do + it 'returns http not found' do + get '/api/v1/admin/accounts/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/accounts/:id/approve' do + subject do + post "/api/v1/admin/accounts/#{account.id}/approve", headers: headers + end + + let(:account) { Fabricate(:account) } + + context 'when the account is pending' do + before do + account.user.update(approved: false) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts read admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'approves the user successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(account.reload.user_approved?).to be(true) + end + + it 'logs action', :aggregate_failures do + subject + + expect(latest_admin_action_log) + .to be_present + .and have_attributes( + action: eq(:approve), + account_id: eq(user.account_id), + target_id: eq(account.user.id) + ) + end + end + + context 'when the account is already approved' do + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + context 'when the account is not found' do + it 'returns http not found' do + post '/api/v1/admin/accounts/-1/approve', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/accounts/:id/reject' do + subject do + post "/api/v1/admin/accounts/#{account.id}/reject", headers: headers + end + + let(:account) { Fabricate(:account) } + + context 'when the account is pending' do + before do + account.user.update(approved: false) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts read admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'removes the user successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(User.where(id: account.user.id)).to_not exist + end + + it 'logs action', :aggregate_failures do + subject + + expect(latest_admin_action_log) + .to be_present + .and have_attributes( + action: eq(:reject), + account_id: eq(user.account_id), + target_id: eq(account.user.id) + ) + end + end + + context 'when account is already approved' do + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + context 'when the account is not found' do + it 'returns http not found' do + post '/api/v1/admin/accounts/-1/reject', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/accounts/:id/enable' do + subject do + post "/api/v1/admin/accounts/#{account.id}/enable", headers: headers + end + + let(:account) { Fabricate(:account) } + + before do + account.user.update(disabled: true) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts read admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'enables the user successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(account.reload.user_disabled?).to be false + end + + context 'when the account is not found' do + it 'returns http not found' do + post '/api/v1/admin/accounts/-1/enable', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/accounts/:id/unsuspend' do + subject do + post "/api/v1/admin/accounts/#{account.id}/unsuspend", headers: headers + end + + let(:account) { Fabricate(:account) } + + context 'when the account is suspended' do + before do + account.suspend! + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts read admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'unsuspends the account successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(account.reload.suspended?).to be false + end + end + + context 'when the account is not suspended' do + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + context 'when the account is not found' do + it 'returns http not found' do + post '/api/v1/admin/accounts/-1/unsuspend', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/accounts/:id/unsensitive' do + subject do + post "/api/v1/admin/accounts/#{account.id}/unsensitive", headers: headers + end + + let(:account) { Fabricate(:account) } + + before do + account.update(sensitized_at: 10.days.ago) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts read admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'unsensitizes the account successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(account.reload.sensitized?).to be false + end + + context 'when the account is not found' do + it 'returns http not found' do + post '/api/v1/admin/accounts/-1/unsensitive', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/admin/accounts/:id/unsilence' do + subject do + post "/api/v1/admin/accounts/#{account.id}/unsilence", headers: headers + end + + let(:account) { Fabricate(:account) } + + before do + account.update(silenced_at: 3.days.ago) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts read admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'unsilences the account successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(account.reload.silenced?).to be false + end + + context 'when the account is not found' do + it 'returns http not found' do + post '/api/v1/admin/accounts/-1/unsilence', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v1/admin/accounts/:id' do + subject do + delete "/api/v1/admin/accounts/#{account.id}", headers: headers + end + + let(:account) { Fabricate(:account) } + + context 'when account is suspended' do + before do + account.suspend! + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts read admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'deletes the account successfully', :aggregate_failures do + allow(Admin::AccountDeletionWorker).to receive(:perform_async) + subject + + expect(response).to have_http_status(200) + expect(Admin::AccountDeletionWorker).to have_received(:perform_async).with(account.id).once + end + end + + context 'when account is not suspended' do + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + context 'when the account is not found' do + it 'returns http not found' do + delete '/api/v1/admin/accounts/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + private + + def latest_admin_action_log + Admin::ActionLog.last + end +end diff --git a/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb b/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb index 4382cb84e5..3f33b50f39 100644 --- a/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb +++ b/spec/requests/api/v1/admin/canonical_email_blocks_spec.rb @@ -92,15 +92,10 @@ RSpec.describe 'Canonical Email Blocks' do it_behaves_like 'forbidden for wrong role', 'Moderator' context 'when the requested canonical email block exists' do - it 'returns http success' do + it 'returns the requested canonical email block data correctly', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the requested canonical email block data correctly' do - subject - json = body_as_json expect(json[:id]).to eq(canonical_email_block.id.to_s) @@ -142,29 +137,19 @@ RSpec.describe 'Canonical Email Blocks' do context 'when there is a matching canonical email block' do let!(:canonical_email_block) { CanonicalEmailBlock.create(params) } - it 'returns http success' do + it 'returns the expected canonical email hash', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the expected canonical email hash' do - subject - expect(body_as_json[0][:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) end end context 'when there is no matching canonical email block' do - it 'returns http success' do + it 'returns an empty list', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns an empty list' do - subject - expect(body_as_json).to be_empty end end @@ -183,15 +168,10 @@ RSpec.describe 'Canonical Email Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'returns the canonical_email_hash correctly', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the canonical_email_hash correctly' do - subject - expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) end @@ -208,15 +188,10 @@ RSpec.describe 'Canonical Email Blocks' do context 'when the canonical_email_hash param is provided instead of email' do let(:params) { { canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } - it 'returns http success' do + it 'returns the correct canonical_email_hash', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the correct canonical_email_hash' do - subject - expect(body_as_json[:canonical_email_hash]).to eq(params[:canonical_email_hash]) end end @@ -224,15 +199,10 @@ RSpec.describe 'Canonical Email Blocks' do context 'when both email and canonical_email_hash params are provided' do let(:params) { { email: 'example@email.com', canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } } - it 'returns http success' do + it 'ignores the canonical_email_hash param', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'ignores the canonical_email_hash param' do - subject - expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash) end end @@ -262,15 +232,10 @@ RSpec.describe 'Canonical Email Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'deletes the canonical email block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'deletes the canonical email block' do - subject - expect(CanonicalEmailBlock.find_by(id: canonical_email_block.id)).to be_nil end diff --git a/spec/requests/api/v1/admin/dimensions_spec.rb b/spec/requests/api/v1/admin/dimensions_spec.rb new file mode 100644 index 0000000000..87534a74b8 --- /dev/null +++ b/spec/requests/api/v1/admin/dimensions_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Admin Dimensions' do + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/admin/dimensions' do + context 'when not authorized' do + it 'returns http forbidden' do + post '/api/v1/admin/dimensions', params: { account_id: account.id, limit: 2 } + + expect(response) + .to have_http_status(403) + end + end + + context 'with correct scope' do + let(:scopes) { 'admin:read' } + + it 'returns http success and status json' do + post '/api/v1/admin/dimensions', params: { account_id: account.id, limit: 2 }, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_an(Array) + end + end + end +end diff --git a/spec/requests/api/v1/admin/domain_allows_spec.rb b/spec/requests/api/v1/admin/domain_allows_spec.rb index 96000e3ef4..662a8f9a8d 100644 --- a/spec/requests/api/v1/admin/domain_allows_spec.rb +++ b/spec/requests/api/v1/admin/domain_allows_spec.rb @@ -35,7 +35,7 @@ RSpec.describe 'Domain Allows' do end context 'when there are allowed domains' do - let!(:domain_allows) { Fabricate.times(5, :domain_allow) } + let!(:domain_allows) { Fabricate.times(2, :domain_allow) } let(:expected_response) do domain_allows.map do |domain_allow| { @@ -53,7 +53,7 @@ RSpec.describe 'Domain Allows' do end context 'with limit param' do - let(:params) { { limit: 2 } } + let(:params) { { limit: 1 } } it 'returns only the requested number of allowed domains' do subject @@ -75,15 +75,10 @@ RSpec.describe 'Domain Allows' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'returns the expected allowed domain name', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the expected allowed domain name' do - subject - expect(body_as_json[:domain]).to eq domain_allow.domain end @@ -108,21 +103,11 @@ RSpec.describe 'Domain Allows' do it_behaves_like 'forbidden for wrong role', 'Moderator' context 'with a valid domain name' do - it 'returns http success' do + it 'returns the expected domain name', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the expected domain name' do - subject - expect(body_as_json[:domain]).to eq 'foo.bar.com' - end - - it 'creates a domain allow' do - subject - expect(DomainAllow.find_by(domain: 'foo.bar.com')).to be_present end end @@ -171,15 +156,10 @@ RSpec.describe 'Domain Allows' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'deletes the allowed domain', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'deletes the allowed domain' do - subject - expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil end diff --git a/spec/requests/api/v1/admin/domain_blocks_spec.rb b/spec/requests/api/v1/admin/domain_blocks_spec.rb index 7a5ac28c56..1fb6fc8228 100644 --- a/spec/requests/api/v1/admin/domain_blocks_spec.rb +++ b/spec/requests/api/v1/admin/domain_blocks_spec.rb @@ -89,15 +89,10 @@ RSpec.describe 'Domain Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'returns the expected domain block content', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the expected domain block content' do - subject - expect(body_as_json).to eq( { id: domain_block.id.to_s, @@ -133,27 +128,18 @@ RSpec.describe 'Domain Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - - it 'returns expected domain name and severity' do + it 'returns expected domain name and severity', :aggregate_failures do subject body = body_as_json + expect(response).to have_http_status(200) expect(body).to match a_hash_including( { domain: 'foo.bar.com', severity: 'silence', } ) - end - - it 'creates a domain block' do - subject expect(DomainBlock.find_by(domain: 'foo.bar.com')).to be_present end @@ -163,15 +149,10 @@ RSpec.describe 'Domain Blocks' do Fabricate(:domain_block, domain: 'bar.com', severity: :suspend) end - it 'returns http unprocessable entity' do + it 'returns existing domain block in error', :aggregate_failures do subject expect(response).to have_http_status(422) - end - - it 'returns existing domain block in error' do - subject - expect(body_as_json[:existing_domain_block][:domain]).to eq('bar.com') end end @@ -199,15 +180,10 @@ RSpec.describe 'Domain Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'returns the updated domain block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the updated domain block' do - subject - expect(body_as_json).to match a_hash_including( { id: domain_block.id.to_s, @@ -241,15 +217,10 @@ RSpec.describe 'Domain Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'deletes the domain block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'deletes the domain block' do - subject - expect(DomainBlock.find_by(id: domain_block.id)).to be_nil end diff --git a/spec/requests/api/v1/admin/email_domain_blocks_spec.rb b/spec/requests/api/v1/admin/email_domain_blocks_spec.rb index d512def866..16656e0202 100644 --- a/spec/requests/api/v1/admin/email_domain_blocks_spec.rb +++ b/spec/requests/api/v1/admin/email_domain_blocks_spec.rb @@ -93,15 +93,10 @@ RSpec.describe 'Email Domain Blocks' do it_behaves_like 'forbidden for wrong role', 'Moderator' context 'when email domain block exists' do - it 'returns http success' do + it 'returns the correct blocked domain', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the correct blocked domain' do - subject - expect(body_as_json[:domain]).to eq(email_domain_block.domain) end end @@ -126,15 +121,10 @@ RSpec.describe 'Email Domain Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'returns the correct blocked email domain', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the correct blocked email domain' do - subject - expect(body_as_json[:domain]).to eq(params[:domain]) end @@ -182,21 +172,11 @@ RSpec.describe 'Email Domain Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'deletes email domain block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns an empty body' do - subject - expect(body_as_json).to be_empty - end - - it 'deletes email domain block' do - subject - expect(EmailDomainBlock.find_by(id: email_domain_block.id)).to be_nil end diff --git a/spec/requests/api/v1/admin/ip_blocks_spec.rb b/spec/requests/api/v1/admin/ip_blocks_spec.rb index d03886c51b..fbcb39e3be 100644 --- a/spec/requests/api/v1/admin/ip_blocks_spec.rb +++ b/spec/requests/api/v1/admin/ip_blocks_spec.rb @@ -84,15 +84,10 @@ RSpec.describe 'IP Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'returns the correct ip block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the correct ip block' do - subject - json = body_as_json expect(json[:ip]).to eq("#{ip_block.ip}/#{ip_block.ip.prefix}") @@ -119,15 +114,10 @@ RSpec.describe 'IP Blocks' do it_behaves_like 'forbidden for wrong role', '' it_behaves_like 'forbidden for wrong role', 'Moderator' - it 'returns http success' do + it 'returns the correct ip block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the correct ip block' do - subject - json = body_as_json expect(json[:ip]).to eq("#{params[:ip]}/32") @@ -186,15 +176,10 @@ RSpec.describe 'IP Blocks' do let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access', comment: 'Spam', expires_in: 48.hours) } let(:params) { { severity: 'sign_up_requires_approval', comment: 'Decreasing severity' } } - it 'returns http success' do + it 'returns the correct ip block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the correct ip block' do - subject - expect(body_as_json).to match(hash_including({ ip: "#{ip_block.ip}/#{ip_block.ip.prefix}", severity: 'sign_up_requires_approval', @@ -226,21 +211,11 @@ RSpec.describe 'IP Blocks' do let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access') } - it 'returns http success' do + it 'deletes the ip block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns an empty body' do - subject - expect(body_as_json).to be_empty - end - - it 'deletes the ip block' do - subject - expect(IpBlock.find_by(id: ip_block.id)).to be_nil end diff --git a/spec/requests/api/v1/admin/measures_spec.rb b/spec/requests/api/v1/admin/measures_spec.rb new file mode 100644 index 0000000000..15f2df84c6 --- /dev/null +++ b/spec/requests/api/v1/admin/measures_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Admin Measures' do + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/admin/measures' do + context 'when not authorized' do + it 'returns http forbidden' do + post '/api/v1/admin/measures', params: { account_id: account.id, limit: 2 } + + expect(response) + .to have_http_status(403) + end + end + + context 'with correct scope' do + let(:scopes) { 'admin:read' } + + it 'returns http success and status json' do + post '/api/v1/admin/measures', params: { account_id: account.id, limit: 2 }, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_an(Array) + end + end + end +end diff --git a/spec/requests/api/v1/admin/reports_spec.rb b/spec/requests/api/v1/admin/reports_spec.rb index 91c3c11f5d..5403457db0 100644 --- a/spec/requests/api/v1/admin/reports_spec.rb +++ b/spec/requests/api/v1/admin/reports_spec.rb @@ -122,15 +122,10 @@ RSpec.describe 'Reports' do it_behaves_like 'forbidden for wrong scope', 'write:statuses' it_behaves_like 'forbidden for wrong role', '' - it 'returns http success' do + it 'returns the requested report content', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the requested report content' do - subject - expect(body_as_json).to include( { id: report.id.to_s, @@ -155,18 +150,10 @@ RSpec.describe 'Reports' do let!(:report) { Fabricate(:report, category: :other) } let(:params) { { category: 'spam' } } - it 'returns http success' do - subject + it 'updates the report category', :aggregate_failures do + expect { subject }.to change { report.reload.category }.from('other').to('spam') expect(response).to have_http_status(200) - end - - it 'updates the report category' do - expect { subject }.to change { report.reload.category }.from('other').to('spam') - end - - it 'returns the updated report content' do - subject report.reload @@ -196,14 +183,9 @@ RSpec.describe 'Reports' do it_behaves_like 'forbidden for wrong scope', 'write:statuses' it_behaves_like 'forbidden for wrong role', '' - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - - it 'marks report as resolved' do + it 'marks report as resolved', :aggregate_failures do expect { subject }.to change { report.reload.unresolved? }.from(true).to(false) + expect(response).to have_http_status(200) end end @@ -217,14 +199,9 @@ RSpec.describe 'Reports' do it_behaves_like 'forbidden for wrong scope', 'write:statuses' it_behaves_like 'forbidden for wrong role', '' - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - - it 'marks report as unresolved' do + it 'marks report as unresolved', :aggregate_failures do expect { subject }.to change { report.reload.unresolved? }.from(false).to(true) + expect(response).to have_http_status(200) end end @@ -238,14 +215,9 @@ RSpec.describe 'Reports' do it_behaves_like 'forbidden for wrong scope', 'write:statuses' it_behaves_like 'forbidden for wrong role', '' - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - - it 'assigns report to the requesting user' do + it 'assigns report to the requesting user', :aggregate_failures do expect { subject }.to change { report.reload.assigned_account_id }.from(nil).to(user.account.id) + expect(response).to have_http_status(200) end end @@ -259,14 +231,9 @@ RSpec.describe 'Reports' do it_behaves_like 'forbidden for wrong scope', 'write:statuses' it_behaves_like 'forbidden for wrong role', '' - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - - it 'unassigns report from assignee' do + it 'unassigns report from assignee', :aggregate_failures do expect { subject }.to change { report.reload.assigned_account_id }.from(user.account.id).to(nil) + expect(response).to have_http_status(200) end end end diff --git a/spec/requests/api/v1/admin/retention_spec.rb b/spec/requests/api/v1/admin/retention_spec.rb new file mode 100644 index 0000000000..9178335ba5 --- /dev/null +++ b/spec/requests/api/v1/admin/retention_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Admin Retention' do + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/admin/retention' do + context 'when not authorized' do + it 'returns http forbidden' do + post '/api/v1/admin/retention', params: { account_id: account.id, limit: 2 } + + expect(response) + .to have_http_status(403) + end + end + + context 'with correct scope' do + let(:scopes) { 'admin:read' } + + it 'returns http success and status json' do + post '/api/v1/admin/retention', params: { account_id: account.id, limit: 2 }, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_an(Array) + end + end + end +end diff --git a/spec/requests/api/v1/admin/tags_spec.rb b/spec/requests/api/v1/admin/tags_spec.rb new file mode 100644 index 0000000000..031be17f52 --- /dev/null +++ b/spec/requests/api/v1/admin/tags_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Tags' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read admin:write' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:tag) { Fabricate(:tag) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/tags' do + subject do + get '/api/v1/admin/tags', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when there are no tags' do + it 'returns an empty list' do + subject + + expect(body_as_json).to be_empty + end + end + + context 'when there are tagss' do + let!(:tags) do + [ + Fabricate(:tag), + Fabricate(:tag), + Fabricate(:tag), + Fabricate(:tag), + ] + end + + it 'returns the expected tags' do + subject + tags.each do |tag| + expect(body_as_json.find { |item| item[:id] == tag.id.to_s && item[:name] == tag.name }).to_not be_nil + end + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of tags' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + end + + describe 'GET /api/v1/admin/tags/:id' do + subject do + get "/api/v1/admin/tags/#{tag.id}", headers: headers + end + + let!(:tag) { Fabricate(:tag) } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns expected tag content' do + subject + + expect(body_as_json[:id].to_i).to eq(tag.id) + expect(body_as_json[:name]).to eq(tag.name) + end + + context 'when the requested tag does not exist' do + it 'returns http not found' do + get '/api/v1/admin/tags/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end + + describe 'PUT /api/v1/admin/tags/:id' do + subject do + put "/api/v1/admin/tags/#{tag.id}", headers: headers, params: params + end + + let!(:tag) { Fabricate(:tag) } + let(:params) { { display_name: tag.name.upcase } } + + it_behaves_like 'forbidden for wrong scope', 'write:statuses' + it_behaves_like 'forbidden for wrong scope', 'admin:read' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns updated tag' do + subject + + expect(body_as_json[:id].to_i).to eq(tag.id) + expect(body_as_json[:name]).to eq(tag.name.upcase) + end + + context 'when the updated display name is invalid' do + let(:params) { { display_name: tag.name + tag.id.to_s } } + + it 'returns http unprocessable content' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the requested tag does not exist' do + it 'returns http not found' do + get '/api/v1/admin/tags/-1', headers: headers + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/admin/trends/links/links_spec.rb b/spec/requests/api/v1/admin/trends/links/links_spec.rb new file mode 100644 index 0000000000..05020b0fd0 --- /dev/null +++ b/spec/requests/api/v1/admin/trends/links/links_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Links' do + let(:role) { UserRole.find_by(name: 'Admin') } + let(:user) { Fabricate(:user, role: role) } + let(:scopes) { 'admin:read admin:write' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/admin/trends/links' do + subject do + get '/api/v1/admin/trends/links', headers: headers + end + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + describe 'POST /api/v1/admin/trends/links/:id/approve' do + subject do + post "/api/v1/admin/trends/links/#{preview_card.id}/approve", headers: headers + end + + let(:preview_card) { Fabricate(:preview_card, trendable: false) } + + it_behaves_like 'forbidden for wrong scope', 'read write' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'sets the link as trendable' do + expect { subject }.to change { preview_card.reload.trendable }.from(false).to(true) + end + + it 'returns the link data' do + subject + + expect(body_as_json).to match( + a_hash_including( + url: preview_card.url, + title: preview_card.title, + description: preview_card.description, + type: 'link', + requires_review: false + ) + ) + end + + context 'when the link does not exist' do + it 'returns http not found' do + post '/api/v1/admin/trends/links/-1/approve', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + end + + describe 'POST /api/v1/admin/trends/links/:id/reject' do + subject do + post "/api/v1/admin/trends/links/#{preview_card.id}/reject", headers: headers + end + + let(:preview_card) { Fabricate(:preview_card, trendable: false) } + + it_behaves_like 'forbidden for wrong scope', 'read write' + it_behaves_like 'forbidden for wrong role', '' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'does not set the link as trendable' do + expect { subject }.to_not(change { preview_card.reload.trendable }) + end + + it 'returns the link data' do + subject + + expect(body_as_json).to match( + a_hash_including( + url: preview_card.url, + title: preview_card.title, + description: preview_card.description, + type: 'link', + requires_review: false + ) + ) + end + + context 'when the link does not exist' do + it 'returns http not found' do + post '/api/v1/admin/trends/links/-1/reject', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + end +end diff --git a/spec/requests/api/v1/apps/credentials_spec.rb b/spec/requests/api/v1/apps/credentials_spec.rb index dafe168c56..e1455fe799 100644 --- a/spec/requests/api/v1/apps/credentials_spec.rb +++ b/spec/requests/api/v1/apps/credentials_spec.rb @@ -9,7 +9,30 @@ describe 'Credentials' do end context 'with an oauth token' do - let(:token) { Fabricate(:accessible_access_token, scopes: 'read', application: Fabricate(:application)) } + let(:application) { Fabricate(:application, scopes: 'read') } + let(:token) { Fabricate(:accessible_access_token, application: application) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + it 'returns the app information correctly', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + + expect(body_as_json).to match( + a_hash_including( + name: token.application.name, + website: token.application.website, + vapid_key: Rails.configuration.x.vapid_public_key, + scopes: token.application.scopes.map(&:to_s), + client_id: token.application.uid + ) + ) + end + end + + context 'with a non-read scoped oauth token' do + let(:application) { Fabricate(:application, scopes: 'admin:write') } + let(:token) { Fabricate(:accessible_access_token, application: application) } let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } it 'returns http success' do @@ -25,7 +48,9 @@ describe 'Credentials' do a_hash_including( name: token.application.name, website: token.application.website, - vapid_key: Rails.configuration.x.vapid_public_key + vapid_key: Rails.configuration.x.vapid_public_key, + scopes: token.application.scopes.map(&:to_s), + client_id: token.application.uid ) ) end @@ -40,5 +65,49 @@ describe 'Credentials' do expect(response).to have_http_status(401) end end + + context 'with a revoked oauth token' do + let(:application) { Fabricate(:application, scopes: 'read') } + let(:token) { Fabricate(:accessible_access_token, application: application, revoked_at: DateTime.now.utc) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + it 'returns http authorization error' do + subject + + expect(response).to have_http_status(401) + end + + it 'returns the error in the json response' do + subject + + expect(body_as_json).to match( + a_hash_including( + error: 'The access token was revoked' + ) + ) + end + end + + context 'with an invalid oauth token' do + let(:application) { Fabricate(:application, scopes: 'read') } + let(:token) { Fabricate(:accessible_access_token, application: application) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}-invalid" } } + + it 'returns http authorization error' do + subject + + expect(response).to have_http_status(401) + end + + it 'returns the error in the json response' do + subject + + expect(body_as_json).to match( + a_hash_including( + error: 'The access token is invalid' + ) + ) + end + end end end diff --git a/spec/requests/api/v1/apps_spec.rb b/spec/requests/api/v1/apps_spec.rb index 88f9eee360..acabbc93f0 100644 --- a/spec/requests/api/v1/apps_spec.rb +++ b/spec/requests/api/v1/apps_spec.rb @@ -23,20 +23,11 @@ RSpec.describe 'Apps' do end context 'with valid params' do - it 'returns http success' do + it 'creates an OAuth app', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'creates an OAuth app' do - subject - expect(Doorkeeper::Application.find_by(name: client_name)).to be_present - end - - it 'returns client ID and client secret' do - subject body = body_as_json @@ -58,15 +49,10 @@ RSpec.describe 'Apps' do context 'with many duplicate scopes' do let(:scopes) { (%w(read) * 40).join(' ') } - it 'returns http success' do + it 'only saves the scope once', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'only saves the scope once' do - subject - expect(Doorkeeper::Application.find_by(name: client_name).scopes.to_s).to eq 'read' end end diff --git a/spec/requests/api/v1/blocks_spec.rb b/spec/requests/api/v1/blocks_spec.rb new file mode 100644 index 0000000000..62543157c3 --- /dev/null +++ b/spec/requests/api/v1/blocks_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Blocks' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:blocks' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/blocks' do + subject do + get '/api/v1/blocks', headers: headers, params: params + end + + let!(:blocks) { Fabricate.times(3, :block, account: user.account) } + let(:params) { {} } + + let(:expected_response) do + blocks.map { |block| a_hash_including(id: block.target_account.id.to_s, username: block.target_account.username) } + end + + it_behaves_like 'forbidden for wrong scope', 'write write:blocks' + + it 'returns the blocked accounts', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match_array(expected_response) + end + + context 'with limit param' do + let(:params) { { limit: 2 } } + + it 'returns only the requested number of blocked accounts' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination header for the prev path' do + subject + + expect(response.headers['Link'].find_link(%w(rel prev)).href).to eq(api_v1_blocks_url(limit: params[:limit], since_id: blocks.last.id)) + end + + it 'sets the correct pagination header for the next path' do + subject + + expect(response.headers['Link'].find_link(%w(rel next)).href).to eq(api_v1_blocks_url(limit: params[:limit], max_id: blocks[1].id)) + end + end + + context 'with max_id param' do + let(:params) { { max_id: blocks[1].id } } + + it 'queries the blocks in range according to max_id', :aggregate_failures do + subject + + response_body = body_as_json + + expect(response_body.size).to be 1 + expect(response_body[0][:id]).to eq(blocks[0].target_account.id.to_s) + end + end + + context 'with since_id param' do + let(:params) { { since_id: blocks[1].id } } + + it 'queries the blocks in range according to since_id', :aggregate_failures do + subject + + response_body = body_as_json + + expect(response_body.size).to be 1 + expect(response_body[0][:id]).to eq(blocks[2].target_account.id.to_s) + end + end + end +end diff --git a/spec/requests/api/v1/bookmarks_spec.rb b/spec/requests/api/v1/bookmarks_spec.rb new file mode 100644 index 0000000000..18f4fddc29 --- /dev/null +++ b/spec/requests/api/v1/bookmarks_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Bookmarks' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:bookmarks' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/bookmarks' do + subject do + get '/api/v1/bookmarks', headers: headers, params: params + end + + let(:params) { {} } + let!(:bookmarks) { Fabricate.times(2, :bookmark, account: user.account) } + + let(:expected_response) do + bookmarks.map do |bookmark| + a_hash_including(id: bookmark.status.id.to_s, account: a_hash_including(id: bookmark.status.account.id.to_s)) + end + end + + it_behaves_like 'forbidden for wrong scope', 'write' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the bookmarked statuses' do + subject + + expect(body_as_json).to match_array(expected_response) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'paginates correctly', :aggregate_failures do + subject + + expect(body_as_json.size).to eq(params[:limit]) + expect(response.headers['Link'].find_link(%w(rel prev)).href).to eq(api_v1_bookmarks_url(limit: params[:limit], min_id: bookmarks.last.id)) + expect(response.headers['Link'].find_link(%w(rel next)).href).to eq(api_v1_bookmarks_url(limit: params[:limit], max_id: bookmarks[1].id)) + end + end + + context 'without the authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v1/csp_spec.rb b/spec/requests/api/v1/csp_spec.rb new file mode 100644 index 0000000000..2db52ac725 --- /dev/null +++ b/spec/requests/api/v1/csp_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'API namespace minimal Content-Security-Policy' do + before { stub_tests_controller } + + after { Rails.application.reload_routes! } + + it 'returns the correct CSP headers' do + get '/api/v1/tests' + + expect(response).to have_http_status(200) + expect(response.headers['Content-Security-Policy']).to eq(minimal_csp_headers) + end + + private + + def stub_tests_controller + stub_const('Api::V1::TestsController', api_tests_controller) + + Rails.application.routes.draw do + get '/api/v1/tests', to: 'api/v1/tests#index' + end + end + + def api_tests_controller + Class.new(Api::BaseController) do + def index + head 200 + end + + private + + def user_signed_in? = false + def current_user = nil + end + end + + def minimal_csp_headers + "default-src 'none'; frame-ancestors 'none'; form-action 'none'" + end +end diff --git a/spec/requests/api/v1/custom_emojis_spec.rb b/spec/requests/api/v1/custom_emojis_spec.rb new file mode 100644 index 0000000000..2f0dc72944 --- /dev/null +++ b/spec/requests/api/v1/custom_emojis_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Custom Emojis' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/custom_emojis' do + before do + Fabricate(:custom_emoji, domain: nil, disabled: false, visible_in_picker: true, shortcode: 'coolcat') + end + + context 'when logged out' do + it 'returns http success and json' do + get api_v1_custom_emojis_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and have_attributes( + first: include(shortcode: 'coolcat') + ) + end + end + + context 'when logged in' do + it 'returns http success and json' do + get api_v1_custom_emojis_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and have_attributes( + first: include(shortcode: 'coolcat') + ) + end + end + end +end diff --git a/spec/controllers/api/v1/directories_controller_spec.rb b/spec/requests/api/v1/directories_spec.rb similarity index 56% rename from spec/controllers/api/v1/directories_controller_spec.rb rename to spec/requests/api/v1/directories_spec.rb index 5e21802e7a..0a1864d136 100644 --- a/spec/controllers/api/v1/directories_controller_spec.rb +++ b/spec/requests/api/v1/directories_spec.rb @@ -2,25 +2,22 @@ require 'rails_helper' -describe Api::V1::DirectoriesController do - render_views - +describe 'Directories API' do let(:user) { Fabricate(:user, confirmed_at: nil) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:follows') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:follows' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'GET #show' do + describe 'GET /api/v1/directories' do context 'with no params' do before do - _local_unconfirmed_account = Fabricate( + local_unconfirmed_account = Fabricate( :account, domain: nil, user: Fabricate(:user, confirmed_at: nil, approved: true), username: 'local_unconfirmed' ) + local_unconfirmed_account.create_account_stat! local_unapproved_account = Fabricate( :account, @@ -28,15 +25,17 @@ describe Api::V1::DirectoriesController do user: Fabricate(:user, confirmed_at: 10.days.ago), username: 'local_unapproved' ) + local_unapproved_account.create_account_stat! local_unapproved_account.user.update(approved: false) - _local_undiscoverable_account = Fabricate( + local_undiscoverable_account = Fabricate( :account, domain: nil, user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true), discoverable: false, username: 'local_undiscoverable' ) + local_undiscoverable_account.create_account_stat! excluded_from_timeline_account = Fabricate( :account, @@ -44,49 +43,62 @@ describe Api::V1::DirectoriesController do discoverable: true, username: 'remote_excluded_from_timeline' ) + excluded_from_timeline_account.create_account_stat! Fabricate(:block, account: user.account, target_account: excluded_from_timeline_account) - _domain_blocked_account = Fabricate( + domain_blocked_account = Fabricate( :account, domain: 'test.example', discoverable: true, username: 'remote_domain_blocked' ) + domain_blocked_account.create_account_stat! Fabricate(:account_domain_block, account: user.account, domain: 'test.example') + + local_discoverable_account.create_account_stat! + eligible_remote_account.create_account_stat! end - it 'returns only the local discoverable account' do - local_discoverable_account = Fabricate( + let(:local_discoverable_account) do + Fabricate( :account, domain: nil, user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true), discoverable: true, username: 'local_discoverable' ) + end - eligible_remote_account = Fabricate( + let(:eligible_remote_account) do + Fabricate( :account, domain: 'host.example', discoverable: true, username: 'eligible_remote' ) + end - get :show + it 'returns the local discoverable account and the remote discoverable account' do + get '/api/v1/directory', headers: headers expect(response).to have_http_status(200) expect(body_as_json.size).to eq(2) - expect(body_as_json.first[:id]).to include(eligible_remote_account.id.to_s) - expect(body_as_json.second[:id]).to include(local_discoverable_account.id.to_s) + expect(body_as_json.pluck(:id)).to contain_exactly(eligible_remote_account.id.to_s, local_discoverable_account.id.to_s) end end context 'when asking for local accounts only' do - it 'returns only the local accounts' do - user = Fabricate(:user, confirmed_at: 10.days.ago, approved: true) - local_account = Fabricate(:account, domain: nil, user: user) - remote_account = Fabricate(:account, domain: 'host.example') + let(:user) { Fabricate(:user, confirmed_at: 10.days.ago, approved: true) } + let(:local_account) { Fabricate(:account, domain: nil, user: user) } + let(:remote_account) { Fabricate(:account, domain: 'host.example') } - get :show, params: { local: '1' } + before do + local_account.create_account_stat! + remote_account.create_account_stat! + end + + it 'returns only the local accounts' do + get '/api/v1/directory', headers: headers, params: { local: '1' } expect(response).to have_http_status(200) expect(body_as_json.size).to eq(1) @@ -97,26 +109,25 @@ describe Api::V1::DirectoriesController do context 'when ordered by active' do it 'returns accounts in order of most recent status activity' do - status_old = Fabricate(:status) - travel_to 10.seconds.from_now - status_new = Fabricate(:status) + old_stat = Fabricate(:account_stat, last_status_at: 1.day.ago) + new_stat = Fabricate(:account_stat, last_status_at: 1.minute.ago) - get :show, params: { order: 'active' } + get '/api/v1/directory', headers: headers, params: { order: 'active' } expect(response).to have_http_status(200) expect(body_as_json.size).to eq(2) - expect(body_as_json.first[:id]).to include(status_new.account.id.to_s) - expect(body_as_json.second[:id]).to include(status_old.account.id.to_s) + expect(body_as_json.first[:id]).to include(new_stat.account_id.to_s) + expect(body_as_json.second[:id]).to include(old_stat.account_id.to_s) end end context 'when ordered by new' do it 'returns accounts in order of creation' do - account_old = Fabricate(:account) + account_old = Fabricate(:account_stat).account travel_to 10.seconds.from_now - account_new = Fabricate(:account) + account_new = Fabricate(:account_stat).account - get :show, params: { order: 'new' } + get '/api/v1/directory', headers: headers, params: { order: 'new' } expect(response).to have_http_status(200) expect(body_as_json.size).to eq(2) diff --git a/spec/requests/api/v1/domain_blocks_spec.rb b/spec/requests/api/v1/domain_blocks_spec.rb index 0f4fd4e90e..954497ebe1 100644 --- a/spec/requests/api/v1/domain_blocks_spec.rb +++ b/spec/requests/api/v1/domain_blocks_spec.rb @@ -22,15 +22,10 @@ RSpec.describe 'Domain blocks' do it_behaves_like 'forbidden for wrong scope', 'write:blocks' - it 'returns http success' do + it 'returns the domains blocked by the requesting user', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the domains blocked by the requesting user' do - subject - expect(body_as_json).to match_array(blocked_domains) end @@ -54,15 +49,10 @@ RSpec.describe 'Domain blocks' do it_behaves_like 'forbidden for wrong scope', 'read read:blocks' - it 'returns http success' do + it 'creates a domain block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'creates a domain block' do - subject - expect(user.account.domain_blocking?(params[:domain])).to be(true) end @@ -100,15 +90,10 @@ RSpec.describe 'Domain blocks' do it_behaves_like 'forbidden for wrong scope', 'read read:blocks' - it 'returns http success' do + it 'deletes the specified domain block', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'deletes the specified domain block' do - subject - expect(user.account.domain_blocking?('example.com')).to be(false) end diff --git a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb b/spec/requests/api/v1/emails/confirmations_spec.rb similarity index 50% rename from spec/controllers/api/v1/emails/confirmations_controller_spec.rb rename to spec/requests/api/v1/emails/confirmations_spec.rb index 80d6c8799d..8f5171ee78 100644 --- a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb +++ b/spec/requests/api/v1/emails/confirmations_spec.rb @@ -2,27 +2,34 @@ require 'rails_helper' -RSpec.describe Api::V1::Emails::ConfirmationsController do +RSpec.describe 'Confirmations' do let(:confirmed_at) { nil } let(:user) { Fabricate(:user, confirmed_at: confirmed_at) } - let(:app) { Fabricate(:application) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes, application: app) } - let(:scopes) { 'write' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts write:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/emails/confirmations' do + subject do + post '/api/v1/emails/confirmations', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read read:accounts' - describe '#create' do context 'with an oauth token' do - before do - allow(controller).to receive(:doorkeeper_token) { token } - end + context 'when user was created by a different application' do + let(:user) { Fabricate(:user, confirmed_at: confirmed_at, created_by_application: Fabricate(:application)) } - context 'when from a random app' do it 'returns http forbidden' do - post :create + subject + expect(response).to have_http_status(403) end end - context 'when from an app that created the account' do + context 'when user was created by the same application' do before do user.update(created_by_application: token.application) end @@ -31,55 +38,79 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do let(:confirmed_at) { Time.now.utc } it 'returns http forbidden' do - post :create + subject + expect(response).to have_http_status(403) end - context 'with user changed e-mail and has not confirmed it' do + context 'when user changed e-mail and has not confirmed it' do before do user.update(email: 'foo@bar.com') end it 'returns http success' do - post :create - expect(response).to have_http_status(:success) + subject + + expect(response).to have_http_status(200) end end end context 'when the account is unconfirmed' do it 'returns http success' do - post :create - expect(response).to have_http_status(:success) + subject + + expect(response).to have_http_status(200) + end + end + + context 'with email param' do + let(:params) { { email: 'foo@bar.com' } } + + it "updates the user's e-mail address", :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.reload.unconfirmed_email).to eq('foo@bar.com') + end + end + + context 'with invalid email param' do + let(:params) { { email: 'invalid' } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) end end end end context 'without an oauth token' do + let(:headers) { {} } + it 'returns http unauthorized' do - post :create + subject + expect(response).to have_http_status(401) end end end - describe '#check' do - let(:scopes) { 'read' } + describe 'GET /api/v1/emails/check_confirmation' do + subject do + get '/api/v1/emails/check_confirmation', headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'write' context 'with an oauth token' do - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - context 'when the account is not confirmed' do - it 'returns http success' do - get :check - expect(response).to have_http_status(200) - end + it 'returns the confirmation status successfully', :aggregate_failures do + subject - it 'returns false' do - get :check + expect(response).to have_http_status(200) expect(body_as_json).to be false end end @@ -87,31 +118,27 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do context 'when the account is confirmed' do let(:confirmed_at) { Time.now.utc } - it 'returns http success' do - get :check - expect(response).to have_http_status(200) - end + it 'returns the confirmation status successfully', :aggregate_failures do + subject - it 'returns true' do - get :check + expect(response).to have_http_status(200) expect(body_as_json).to be true end end end context 'with an authentication cookie' do + let(:headers) { {} } + before do sign_in user, scope: :user end context 'when the account is not confirmed' do - it 'returns http success' do - get :check - expect(response).to have_http_status(200) - end + it 'returns the confirmation status successfully', :aggregate_failures do + subject - it 'returns false' do - get :check + expect(response).to have_http_status(200) expect(body_as_json).to be false end end @@ -119,21 +146,20 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do context 'when the account is confirmed' do let(:confirmed_at) { Time.now.utc } - it 'returns http success' do - get :check - expect(response).to have_http_status(200) - end + it 'returns the confirmation status successfully', :aggregate_failures do + subject - it 'returns true' do - get :check + expect(response).to have_http_status(200) expect(body_as_json).to be true end end end context 'without an oauth token and an authentication cookie' do + let(:headers) { {} } + it 'returns http unauthorized' do - get :check + subject expect(response).to have_http_status(401) end diff --git a/spec/requests/api/v1/endorsements_spec.rb b/spec/requests/api/v1/endorsements_spec.rb new file mode 100644 index 0000000000..e267f2abd2 --- /dev/null +++ b/spec/requests/api/v1/endorsements_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Endorsements' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/endorsements' do + context 'when not authorized' do + it 'returns http unauthorized' do + get api_v1_endorsements_path + + expect(response) + .to have_http_status(401) + end + end + + context 'with wrong scope' do + before do + get api_v1_endorsements_path, headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts' + end + + context 'with correct scope' do + let(:scopes) { 'read:accounts' } + + context 'with endorsed accounts' do + let!(:account_pin) { Fabricate(:account_pin, account: user.account) } + + it 'returns http success and accounts json' do + get api_v1_endorsements_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and have_attributes( + first: include(acct: account_pin.target_account.acct) + ) + end + end + + context 'without endorsed accounts without json' do + it 'returns http success' do + get api_v1_endorsements_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to_not be_present + end + end + end + end +end diff --git a/spec/requests/api/v1/favourites_spec.rb b/spec/requests/api/v1/favourites_spec.rb new file mode 100644 index 0000000000..2d8a42e716 --- /dev/null +++ b/spec/requests/api/v1/favourites_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Favourites' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:favourites' } + let(:headers) { { Authorization: "Bearer #{token.token}" } } + + describe 'GET /api/v1/favourites' do + subject do + get '/api/v1/favourites', headers: headers, params: params + end + + let(:params) { {} } + let!(:favourites) { Fabricate.times(2, :favourite, account: user.account) } + + let(:expected_response) do + favourites.map do |favourite| + a_hash_including(id: favourite.status.id.to_s, account: a_hash_including(id: favourite.status.account.id.to_s)) + end + end + + it_behaves_like 'forbidden for wrong scope', 'write' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the favourites' do + subject + + expect(body_as_json).to match_array(expected_response) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of favourites' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination header for the prev path' do + subject + + expect(response.headers['Link'].find_link(%w(rel prev)).href).to eq(api_v1_favourites_url(limit: params[:limit], min_id: favourites.last.id)) + end + + it 'sets the correct pagination header for the next path' do + subject + + expect(response.headers['Link'].find_link(%w(rel next)).href).to eq(api_v1_favourites_url(limit: params[:limit], max_id: favourites[1].id)) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v1/featured_tags/suggestions_spec.rb b/spec/requests/api/v1/featured_tags/suggestions_spec.rb new file mode 100644 index 0000000000..f7b453b740 --- /dev/null +++ b/spec/requests/api/v1/featured_tags/suggestions_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Featured Tags Suggestions API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:account) { Fabricate(:account) } + + describe 'GET /api/v1/featured_tags/suggestions' do + it 'returns http success' do + get '/api/v1/featured_tags/suggestions', params: { account_id: account.id, limit: 2 }, headers: headers + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v1/featured_tags_spec.rb b/spec/requests/api/v1/featured_tags_spec.rb index 6c171f6e47..c4aa2c0a2d 100644 --- a/spec/requests/api/v1/featured_tags_spec.rb +++ b/spec/requests/api/v1/featured_tags_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'FeaturedTags' do end context 'when the requesting user has no featured tag' do - before { Fabricate.times(3, :featured_tag) } + before { Fabricate(:featured_tag) } it 'returns an empty body' do get '/api/v1/featured_tags', headers: headers @@ -44,7 +44,7 @@ RSpec.describe 'FeaturedTags' do end context 'when the requesting user has featured tags' do - let!(:user_featured_tags) { Fabricate.times(5, :featured_tag, account: user.account) } + let!(:user_featured_tags) { Fabricate.times(1, :featured_tag, account: user.account) } it 'returns only the featured tags belonging to the requesting user' do get '/api/v1/featured_tags', headers: headers diff --git a/spec/requests/api/v1/follow_requests_spec.rb b/spec/requests/api/v1/follow_requests_spec.rb index 9d4ef8cd55..a8898ccb3e 100644 --- a/spec/requests/api/v1/follow_requests_spec.rb +++ b/spec/requests/api/v1/follow_requests_spec.rb @@ -13,7 +13,7 @@ RSpec.describe 'Follow requests' do get '/api/v1/follow_requests', headers: headers, params: params end - let(:accounts) { Fabricate.times(5, :account) } + let(:accounts) { Fabricate.times(2, :account) } let(:params) { {} } let(:expected_response) do @@ -32,20 +32,15 @@ RSpec.describe 'Follow requests' do it_behaves_like 'forbidden for wrong scope', 'write write:follows' - it 'returns http success' do + it 'returns the expected content from accounts requesting to follow', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the expected content from accounts requesting to follow' do - subject - expect(body_as_json).to match_array(expected_response) end context 'with limit param' do - let(:params) { { limit: 2 } } + let(:params) { { limit: 1 } } it 'returns only the requested number of follow requests' do subject @@ -68,19 +63,9 @@ RSpec.describe 'Follow requests' do it_behaves_like 'forbidden for wrong scope', 'read read:follows' - it 'returns http success' do - subject - - expect(response).to have_http_status(200) - end - - it 'allows the requesting follower to follow' do + it 'allows the requesting follower to follow', :aggregate_failures do expect { subject }.to change { follower.following?(user.account) }.from(false).to(true) - end - - it 'returns JSON with followed_by set to true' do - subject - + expect(response).to have_http_status(200) expect(body_as_json[:followed_by]).to be true end end @@ -98,21 +83,11 @@ RSpec.describe 'Follow requests' do it_behaves_like 'forbidden for wrong scope', 'read read:follows' - it 'returns http success' do + it 'removes the follow request', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'removes the follow request' do - subject - expect(FollowRequest.where(target_account: user.account, account: follower)).to_not exist - end - - it 'returns JSON with followed_by set to false' do - subject - expect(body_as_json[:followed_by]).to be false end end diff --git a/spec/requests/api/v1/followed_tags_spec.rb b/spec/requests/api/v1/followed_tags_spec.rb new file mode 100644 index 0000000000..52ed1ba4bb --- /dev/null +++ b/spec/requests/api/v1/followed_tags_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Followed tags' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:follows' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/followed_tags' do + subject do + get '/api/v1/followed_tags', headers: headers, params: params + end + + let!(:tag_follows) { Fabricate.times(2, :tag_follow, account: user.account) } + let(:params) { {} } + + let(:expected_response) do + tag_follows.map do |tag_follow| + a_hash_including(name: tag_follow.tag.name, following: true) + end + end + + before do + Fabricate(:tag_follow) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:follows' + + it 'returns http success' do + subject + + expect(response).to have_http_status(:success) + end + + it 'returns the followed tags correctly' do + subject + + expect(body_as_json).to match_array(expected_response) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of follow tags' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination header for the prev path' do + subject + + expect(response.headers['Link'].find_link(%w(rel prev)).href).to eq(api_v1_followed_tags_url(limit: params[:limit], since_id: tag_follows.last.id)) + end + + it 'sets the correct pagination header for the next path' do + subject + + expect(response.headers['Link'].find_link(%w(rel next)).href).to eq(api_v1_followed_tags_url(limit: params[:limit], max_id: tag_follows.last.id)) + end + end + end +end diff --git a/spec/requests/api/v1/instance_spec.rb b/spec/requests/api/v1/instance_spec.rb new file mode 100644 index 0000000000..600584eccb --- /dev/null +++ b/spec/requests/api/v1/instance_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Instances' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/instance' do + context 'when not logged in' do + it 'returns http success and json' do + get api_v1_instance_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and include(title: 'Mastodon Glitch Edition') + end + end + + context 'when logged in' do + it 'returns http success and json' do + get api_v1_instance_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and include(title: 'Mastodon Glitch Edition') + end + end + end +end diff --git a/spec/requests/api/v1/instances/activity_spec.rb b/spec/requests/api/v1/instances/activity_spec.rb new file mode 100644 index 0000000000..4f2bc91ad6 --- /dev/null +++ b/spec/requests/api/v1/instances/activity_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Activity' do + describe 'GET /api/v1/instance/activity' do + context 'with activity api enabled' do + before { Setting.activity_api_enabled = true } + + it 'returns http success' do + get api_v1_instance_activity_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and(be_an(Array)) + .and(have_attributes(size: Api::V1::Instances::ActivityController::WEEKS_OF_ACTIVITY)) + end + end + + context 'with activity api diabled' do + before { Setting.activity_api_enabled = false } + + it 'returns not found' do + get api_v1_instance_activity_path + + expect(response) + .to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/instances/domain_blocks_spec.rb b/spec/requests/api/v1/instances/domain_blocks_spec.rb new file mode 100644 index 0000000000..397ecff084 --- /dev/null +++ b/spec/requests/api/v1/instances/domain_blocks_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Domain Blocks' do + describe 'GET /api/v1/instance/domain_blocks' do + before do + Fabricate(:domain_block) + end + + context 'with domain blocks set to all' do + before { Setting.show_domain_blocks = 'all' } + + it 'returns http success' do + get api_v1_instance_domain_blocks_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and(be_an(Array)) + .and(have_attributes(size: 1)) + end + end + + context 'with domain blocks set to users' do + before { Setting.show_domain_blocks = 'users' } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path + + expect(response) + .to have_http_status(404) + end + end + + context 'with domain blocks set to disabled' do + before { Setting.show_domain_blocks = 'disabled' } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path + + expect(response) + .to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/instances/extended_descriptions_spec.rb b/spec/requests/api/v1/instances/extended_descriptions_spec.rb new file mode 100644 index 0000000000..64982de686 --- /dev/null +++ b/spec/requests/api/v1/instances/extended_descriptions_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Extended Descriptions' do + describe 'GET /api/v1/instance/extended_description' do + it 'returns http success' do + get api_v1_instance_extended_description_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and include(:content) + end + end +end diff --git a/spec/requests/api/v1/instances/languages_spec.rb b/spec/requests/api/v1/instances/languages_spec.rb new file mode 100644 index 0000000000..8ab8bf99ce --- /dev/null +++ b/spec/requests/api/v1/instances/languages_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Languages' do + describe 'GET /api/v1/instance/languages' do + before do + get '/api/v1/instance/languages' + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'returns the supported languages' do + expect(body_as_json.pluck(:code)).to match_array LanguagesHelper::SUPPORTED_LOCALES.keys.map(&:to_s) + end + end +end diff --git a/spec/requests/api/v1/instances/peers_spec.rb b/spec/requests/api/v1/instances/peers_spec.rb new file mode 100644 index 0000000000..1a7975f8b7 --- /dev/null +++ b/spec/requests/api/v1/instances/peers_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Peers' do + describe 'GET /api/v1/instance/peers' do + context 'with peers api enabled' do + before { Setting.peers_api_enabled = true } + + it 'returns http success' do + get api_v1_instance_peers_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_an(Array) + end + end + + context 'with peers api diabled' do + before { Setting.peers_api_enabled = false } + + it 'returns http not found' do + get api_v1_instance_peers_path + + expect(response) + .to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/instances/privacy_policies_spec.rb b/spec/requests/api/v1/instances/privacy_policies_spec.rb new file mode 100644 index 0000000000..24de98d880 --- /dev/null +++ b/spec/requests/api/v1/instances/privacy_policies_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Privacy Policy' do + describe 'GET /api/v1/instance/privacy_policy' do + it 'returns http success' do + get api_v1_instance_privacy_policy_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and include(:content) + end + end +end diff --git a/spec/requests/api/v1/instances/rules_spec.rb b/spec/requests/api/v1/instances/rules_spec.rb new file mode 100644 index 0000000000..65b8d78c7d --- /dev/null +++ b/spec/requests/api/v1/instances/rules_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Rules' do + describe 'GET /api/v1/instance/rules' do + it 'returns http success' do + get api_v1_instance_rules_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_an(Array) + end + end +end diff --git a/spec/requests/api/v1/instances/translation_languages_spec.rb b/spec/requests/api/v1/instances/translation_languages_spec.rb new file mode 100644 index 0000000000..0b7dd8314d --- /dev/null +++ b/spec/requests/api/v1/instances/translation_languages_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Translation Languages' do + describe 'GET /api/v1/instances/translation_languages' do + context 'when no translation service is configured' do + it 'returns empty language matrix', :aggregate_failures do + get api_v1_instance_translation_languages_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to eq({}) + end + end + + context 'when a translation service is configured' do + before { configure_translation_service } + + it 'returns language matrix', :aggregate_failures do + get api_v1_instance_translation_languages_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to eq({ und: %w(en de), en: ['de'] }) + end + + private + + def configure_translation_service + allow(TranslationService).to receive_messages(configured?: true, configured: service_double) + end + + def service_double + instance_double(TranslationService::DeepL, languages: { nil => %w(en de), 'en' => ['de'] }) + end + end + end +end diff --git a/spec/requests/api/v1/lists/accounts_spec.rb b/spec/requests/api/v1/lists/accounts_spec.rb new file mode 100644 index 0000000000..de49982351 --- /dev/null +++ b/spec/requests/api/v1/lists/accounts_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Accounts' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:lists write:lists' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/lists/:id/accounts' do + subject do + get "/api/v1/lists/#{list.id}/accounts", headers: headers, params: params + end + + let(:params) { { limit: 0 } } + let(:list) { Fabricate(:list, account: user.account) } + let(:accounts) { Fabricate.times(2, :account) } + + let(:expected_response) do + accounts.map do |account| + a_hash_including(id: account.id.to_s, username: account.username, acct: account.acct) + end + end + + before do + accounts.each { |account| user.account.follow!(account) } + list.accounts << accounts + end + + it_behaves_like 'forbidden for wrong scope', 'write write:lists' + + it 'returns the accounts in the requested list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match_array(expected_response) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of accounts' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + end + end + + describe 'POST /api/v1/lists/:id/accounts' do + subject do + post "/api/v1/lists/#{list.id}/accounts", headers: headers, params: params + end + + let(:list) { Fabricate(:list, account: user.account) } + let(:bob) { Fabricate(:account, username: 'bob') } + let(:params) { { account_ids: [bob.id] } } + + it_behaves_like 'forbidden for wrong scope', 'read read:lists' + + context 'when the added account is followed' do + before do + user.account.follow!(bob) + end + + it 'adds account to the list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(list.accounts).to include(bob) + end + end + + context 'when the added account has been sent a follow request' do + before do + user.account.follow_requests.create!(target_account: bob) + end + + it 'adds account to the list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(list.accounts).to include(bob) + end + end + + context 'when the added account is not followed' do + it 'does not add the account to the list', :aggregate_failures do + subject + + expect(response).to have_http_status(404) + expect(list.accounts).to_not include(bob) + end + end + + context 'when the list is not owned by the requesting user' do + let(:list) { Fabricate(:list) } + + before do + user.account.follow!(bob) + end + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when account is already in the list' do + before do + user.account.follow!(bob) + list.accounts << bob + end + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'DELETE /api/v1/lists/:id/accounts' do + subject do + delete "/api/v1/lists/#{list.id}/accounts", headers: headers, params: params + end + + context 'when the list is owned by the requesting user' do + let(:list) { Fabricate(:list, account: user.account) } + let(:bob) { Fabricate(:account, username: 'bob') } + let(:peter) { Fabricate(:account, username: 'peter') } + let(:params) { { account_ids: [bob.id] } } + + before do + user.account.follow!(bob) + user.account.follow!(peter) + list.accounts << [bob, peter] + end + + it 'removes the specified account from the list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(list.accounts).to_not include(bob) + end + + it 'does not remove any other account from the list' do + subject + + expect(list.accounts).to include(peter) + end + + context 'when the specified account is not in the list' do + let(:params) { { account_ids: [0] } } + + it 'does not remove any account from the list', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(list.accounts).to contain_exactly(bob, peter) + end + end + end + + context 'when the list is not owned by the requesting user' do + let(:list) { Fabricate(:list) } + let(:params) { {} } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/lists_spec.rb b/spec/requests/api/v1/lists_spec.rb index 383e09d0c3..4635e936f5 100644 --- a/spec/requests/api/v1/lists_spec.rb +++ b/spec/requests/api/v1/lists_spec.rb @@ -39,15 +39,10 @@ RSpec.describe 'Lists' do it_behaves_like 'forbidden for wrong scope', 'write write:lists' - it 'returns http success' do + it 'returns the expected lists', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the expected lists' do - subject - expect(body_as_json).to match_array(expected_response) end end @@ -61,15 +56,10 @@ RSpec.describe 'Lists' do it_behaves_like 'forbidden for wrong scope', 'write write:lists' - it 'returns http success' do + it 'returns the requested list correctly', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the requested list correctly' do - subject - expect(body_as_json).to eq({ id: list.id.to_s, title: list.title, @@ -106,21 +96,11 @@ RSpec.describe 'Lists' do it_behaves_like 'forbidden for wrong scope', 'read read:lists' - it 'returns http success' do + it 'returns the new list', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the new list' do - subject - expect(body_as_json).to match(a_hash_including(title: 'my list', replies_policy: 'none', exclusive: true)) - end - - it 'creates a list' do - subject - expect(List.where(account: user.account).count).to eq(1) end @@ -155,15 +135,13 @@ RSpec.describe 'Lists' do it_behaves_like 'forbidden for wrong scope', 'read read:lists' - it 'returns http success' do - subject + it 'returns the updated list and updates values', :aggregate_failures do + expect { subject } + .to change_list_title + .and change_list_replies_policy + .and change_list_exclusive expect(response).to have_http_status(200) - end - - it 'returns the updated list' do - subject - list.reload expect(body_as_json).to eq({ @@ -174,16 +152,16 @@ RSpec.describe 'Lists' do }) end - it 'updates the list title' do - expect { subject }.to change { list.reload.title }.from('my list').to('list') + def change_list_title + change { list.reload.title }.from('my list').to('list') end - it 'updates the list replies_policy' do - expect { subject }.to change { list.reload.replies_policy }.from('list').to('followed') + def change_list_replies_policy + change { list.reload.replies_policy }.from('list').to('followed') end - it 'updates the list exclusive' do - expect { subject }.to change { list.reload.exclusive }.from(false).to(true) + def change_list_exclusive + change { list.reload.exclusive }.from(false).to(true) end context 'when the list does not exist' do @@ -214,15 +192,10 @@ RSpec.describe 'Lists' do it_behaves_like 'forbidden for wrong scope', 'read read:lists' - it 'returns http success' do + it 'deletes the list', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'deletes the list' do - subject - expect(List.where(id: list.id)).to_not exist end diff --git a/spec/requests/api/v1/media_spec.rb b/spec/requests/api/v1/media_spec.rb new file mode 100644 index 0000000000..2c29328087 --- /dev/null +++ b/spec/requests/api/v1/media_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Media' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'write:media' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/media/:id' do + subject do + get "/api/v1/media/#{media.id}", headers: headers + end + + let(:media) { Fabricate(:media_attachment, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the media information' do + subject + + expect(body_as_json).to match( + a_hash_including( + id: media.id.to_s, + description: media.description, + type: media.type + ) + ) + end + + context 'when the media is still being processed' do + before do + media.update(processing: :in_progress) + end + + it 'returns http partial content' do + subject + + expect(response).to have_http_status(206) + end + end + + context 'when the media belongs to somebody else' do + let(:media) { Fabricate(:media_attachment) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when media is attached to a status' do + let(:media) { Fabricate(:media_attachment, account: user.account, status: Fabricate.build(:status)) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/media' do + subject do + post '/api/v1/media', headers: headers, params: params + end + + let(:params) { {} } + + shared_examples 'a successful media upload' do |media_type| + it 'uploads the file successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(MediaAttachment.first).to be_present + expect(MediaAttachment.first).to have_attached_file(:file) + end + + it 'returns the correct media content' do + subject + + body = body_as_json + + expect(body).to match( + a_hash_including(id: MediaAttachment.first.id.to_s, description: params[:description], type: media_type) + ) + end + end + + it_behaves_like 'forbidden for wrong scope', 'read read:media' + + describe 'when paperclip errors occur' do + let(:media_attachments) { double } + let(:params) { { file: fixture_file_upload('attachment.jpg', 'image/jpeg') } } + + before do + allow(User).to receive(:find).with(token.resource_owner_id).and_return(user) + allow(user.account).to receive(:media_attachments).and_return(media_attachments) + end + + context 'when imagemagick cannot identify the file type' do + it 'returns http unprocessable entity' do + allow(media_attachments).to receive(:create!).and_raise(Paperclip::Errors::NotIdentifiedByImageMagickError) + + subject + + expect(response).to have_http_status(422) + end + end + + context 'when there is a generic error' do + it 'returns http 500' do + allow(media_attachments).to receive(:create!).and_raise(Paperclip::Error) + + subject + + expect(response).to have_http_status(500) + end + end + end + + context 'with image/jpeg', :paperclip_processing do + let(:params) { { file: fixture_file_upload('attachment.jpg', 'image/jpeg'), description: 'jpeg image' } } + + it_behaves_like 'a successful media upload', 'image' + end + + context 'with image/gif', :paperclip_processing do + let(:params) { { file: fixture_file_upload('attachment.gif', 'image/gif') } } + + it_behaves_like 'a successful media upload', 'image' + end + + context 'with video/webm', :paperclip_processing do + let(:params) { { file: fixture_file_upload('attachment.webm', 'video/webm') } } + + it_behaves_like 'a successful media upload', 'gifv' + end + end + + describe 'PUT /api/v1/media/:id' do + subject do + put "/api/v1/media/#{media.id}", headers: headers, params: params + end + + let(:params) { {} } + let(:media) { Fabricate(:media_attachment, status: status, account: user.account, description: 'old') } + + it_behaves_like 'forbidden for wrong scope', 'read read:media' + + context 'when the media belongs to somebody else' do + let(:media) { Fabricate(:media_attachment, status: nil) } + let(:params) { { description: 'Lorem ipsum!!!' } } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the requesting user owns the media' do + let(:status) { nil } + let(:params) { { description: 'Lorem ipsum!!!' } } + + it 'updates the description' do + expect { subject }.to change { media.reload.description }.from('old').to('Lorem ipsum!!!') + end + + context 'when the media is attached to a status' do + let(:status) { Fabricate(:status, account: user.account) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + end +end diff --git a/spec/requests/api/v1/mutes_spec.rb b/spec/requests/api/v1/mutes_spec.rb new file mode 100644 index 0000000000..b2782a0c22 --- /dev/null +++ b/spec/requests/api/v1/mutes_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Mutes' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:mutes' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/mutes' do + subject do + get '/api/v1/mutes', headers: headers, params: params + end + + let!(:mutes) { Fabricate.times(2, :mute, account: user.account) } + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write write:mutes' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the muted accounts' do + subject + + muted_accounts = mutes.map(&:target_account) + + expect(body_as_json.pluck(:id)).to match_array(muted_accounts.map { |account| account.id.to_s }) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of muted accounts' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination headers', :aggregate_failures do + subject + + headers = response.headers['Link'] + + expect(headers.find_link(%w(rel prev)).href).to eq(api_v1_mutes_url(limit: params[:limit], since_id: mutes.last.id.to_s)) + expect(headers.find_link(%w(rel next)).href).to eq(api_v1_mutes_url(limit: params[:limit], max_id: mutes.last.id.to_s)) + end + end + + context 'with max_id param' do + let(:params) { { max_id: mutes[1].id } } + + it 'queries mutes in range according to max_id', :aggregate_failures do + subject + + body = body_as_json + + expect(body.size).to eq 1 + expect(body[0][:id]).to eq mutes[0].target_account_id.to_s + end + end + + context 'with since_id param' do + let(:params) { { since_id: mutes[0].id } } + + it 'queries mutes in range according to since_id', :aggregate_failures do + subject + + body = body_as_json + + expect(body.size).to eq 1 + expect(body[0][:id]).to eq mutes[1].target_account_id.to_s + end + end + + context 'without an authentication header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v1/notifications_spec.rb b/spec/requests/api/v1/notifications_spec.rb new file mode 100644 index 0000000000..7a904816e0 --- /dev/null +++ b/spec/requests/api/v1/notifications_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Notifications' do + let(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:notifications write:notifications' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/notifications' do + subject do + get '/api/v1/notifications', headers: headers, params: params + end + + let(:bob) { Fabricate(:user) } + let(:tom) { Fabricate(:user) } + let(:params) { {} } + + before do + first_status = PostStatusService.new.call(user.account, text: 'Test') + ReblogService.new.call(bob.account, first_status) + mentioning_status = PostStatusService.new.call(bob.account, text: 'Hello @alice') + mentioning_status.mentions.first + FavouriteService.new.call(bob.account, first_status) + FavouriteService.new.call(tom.account, first_status) + FollowService.new.call(bob.account, user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + context 'with no options' do + it 'returns expected notification types', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_types).to include 'reblog' + expect(body_json_types).to include 'mention' + expect(body_json_types).to include 'favourite' + expect(body_json_types).to include 'follow' + end + end + + context 'with account_id param' do + let(:params) { { account_id: tom.account.id } } + + it 'returns only notifications from specified user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_account_ids.uniq).to eq [tom.account.id.to_s] + end + + def body_json_account_ids + body_as_json.map { |x| x[:account][:id] } + end + end + + context 'with invalid account_id param' do + let(:params) { { account_id: 'foo' } } + + it 'returns nothing', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq 0 + end + end + + context 'with exclude_types param' do + let(:params) { { exclude_types: %w(mention) } } + + it 'returns everything but excluded type', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.size).to_not eq 0 + expect(body_json_types.uniq).to_not include 'mention' + end + end + + context 'with types param' do + let(:params) { { types: %w(mention) } } + + it 'returns only requested type', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_json_types.uniq).to eq ['mention'] + end + end + + context 'with limit param' do + let(:params) { { limit: 3 } } + + it 'returns the requested number of notifications paginated', :aggregate_failures do + subject + + notifications = user.account.notifications + + expect(body_as_json.size).to eq(params[:limit]) + expect(response.headers['Link'].find_link(%w(rel prev)).href).to eq(api_v1_notifications_url(limit: params[:limit], min_id: notifications.last.id.to_s)) + expect(response.headers['Link'].find_link(%w(rel next)).href).to eq(api_v1_notifications_url(limit: params[:limit], max_id: notifications[2].id.to_s)) + end + end + + def body_json_types + body_as_json.pluck(:type) + end + end + + describe 'GET /api/v1/notifications/:id' do + subject do + get "/api/v1/notifications/#{notification.id}", headers: headers + end + + let(:notification) { Fabricate(:notification, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + context 'when notification belongs to someone else' do + let(:notification) { Fabricate(:notification) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/notifications/:id/dismiss' do + subject do + post "/api/v1/notifications/#{notification.id}/dismiss", headers: headers + end + + let!(:notification) { Fabricate(:notification, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:notifications' + + it 'destroys the notification' do + subject + + expect(response).to have_http_status(200) + expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'when notification belongs to someone else' do + let(:notification) { Fabricate(:notification) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /api/v1/notifications/clear' do + subject do + post '/api/v1/notifications/clear', headers: headers + end + + before do + Fabricate(:notification, account: user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'read read:notifications' + + it 'clears notifications for the account' do + subject + + expect(user.account.reload.notifications).to be_empty + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/v1/polls_spec.rb b/spec/requests/api/v1/polls_spec.rb new file mode 100644 index 0000000000..1c8a818d59 --- /dev/null +++ b/spec/requests/api/v1/polls_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Polls' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/polls/:id' do + subject do + get "/api/v1/polls/#{poll.id}", headers: headers + end + + let(:poll) { Fabricate(:poll, status: Fabricate(:status, visibility: visibility)) } + let(:visibility) { 'public' } + + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + + context 'when parent status is public' do + it 'returns the poll data successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match( + a_hash_including( + id: poll.id.to_s, + voted: false, + voters_count: poll.voters_count, + votes_count: poll.votes_count + ) + ) + end + end + + context 'when parent status is private' do + let(:visibility) { 'private' } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/preferences_spec.rb b/spec/requests/api/v1/preferences_spec.rb new file mode 100644 index 0000000000..6f4188c35a --- /dev/null +++ b/spec/requests/api/v1/preferences_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Preferences' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/preferences' do + context 'when not authorized' do + it 'returns http unauthorized' do + get api_v1_preferences_path + + expect(response) + .to have_http_status(401) + end + end + + context 'with wrong scope' do + before do + get api_v1_preferences_path, headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'write write:accounts' + end + + context 'with correct scope' do + let(:scopes) { 'read:accounts' } + + it 'returns http success' do + get api_v1_preferences_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + end + end + end +end diff --git a/spec/requests/api/v1/profiles_spec.rb b/spec/requests/api/v1/profiles_spec.rb new file mode 100644 index 0000000000..26a9b848e5 --- /dev/null +++ b/spec/requests/api/v1/profiles_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Deleting profile images' do + let(:account) do + Fabricate( + :account, + avatar: fixture_file_upload('avatar.gif', 'image/gif'), + header: fixture_file_upload('attachment.jpg', 'image/jpeg') + ) + end + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: account.user.id, scopes: scopes) } + let(:scopes) { 'write:accounts' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'DELETE /api/v1/profile' do + before do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + end + + context 'when deleting an avatar' do + context 'with wrong scope' do + before do + delete '/api/v1/profile/avatar', headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'read' + end + + it 'returns http success' do + delete '/api/v1/profile/avatar', headers: headers + + expect(response).to have_http_status(200) + end + + it 'deletes the avatar' do + delete '/api/v1/profile/avatar', headers: headers + + account.reload + + expect(account.avatar).to_not exist + end + + it 'does not delete the header' do + delete '/api/v1/profile/avatar', headers: headers + + account.reload + + expect(account.header).to exist + end + + it 'queues up an account update distribution' do + delete '/api/v1/profile/avatar', headers: headers + + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + end + end + + context 'when deleting a header' do + context 'with wrong scope' do + before do + delete '/api/v1/profile/header', headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'read' + end + + it 'returns http success' do + delete '/api/v1/profile/header', headers: headers + + expect(response).to have_http_status(200) + end + + it 'does not delete the avatar' do + delete '/api/v1/profile/header', headers: headers + + account.reload + + expect(account.avatar).to exist + end + + it 'deletes the header' do + delete '/api/v1/profile/header', headers: headers + + account.reload + + expect(account.header).to_not exist + end + + it 'queues up an account update distribution' do + delete '/api/v1/profile/header', headers: headers + + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + end + end + end +end diff --git a/spec/requests/api/v1/reports_spec.rb b/spec/requests/api/v1/reports_spec.rb new file mode 100644 index 0000000000..ba3d2b3060 --- /dev/null +++ b/spec/requests/api/v1/reports_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Reports' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'write:reports' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/reports' do + subject do + post '/api/v1/reports', headers: headers, params: params + end + + let!(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } + let(:status) { Fabricate(:status) } + let(:target_account) { status.account } + let(:category) { 'other' } + let(:forward) { nil } + let(:rule_ids) { nil } + + let(:params) do + { + status_ids: [status.id], + account_id: target_account.id, + comment: 'reasons', + category: category, + rule_ids: rule_ids, + forward: forward, + } + end + + it_behaves_like 'forbidden for wrong scope', 'read read:reports' + + it 'creates a report', :aggregate_failures do + perform_enqueued_jobs do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to match( + a_hash_including( + status_ids: [status.id.to_s], + category: category, + comment: 'reasons' + ) + ) + + expect(target_account.targeted_reports).to_not be_empty + expect(target_account.targeted_reports.first.comment).to eq 'reasons' + + expect(ActionMailer::Base.deliveries.first.to).to eq([admin.email]) + end + end + + context 'when a status does not belong to the reported account' do + let(:target_account) { Fabricate(:account) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when a category is chosen' do + let(:category) { 'spam' } + + it 'saves category' do + subject + + expect(target_account.targeted_reports.first.spam?).to be true + end + end + + context 'when violated rules are chosen' do + let(:rule) { Fabricate(:rule) } + let(:category) { 'violation' } + let(:rule_ids) { [rule.id] } + + it 'saves category and rule_ids' do + subject + + expect(target_account.targeted_reports.first.violation?).to be true + expect(target_account.targeted_reports.first.rule_ids).to contain_exactly(rule.id) + end + end + end +end diff --git a/spec/requests/api/v1/scheduled_status_spec.rb b/spec/requests/api/v1/scheduled_status_spec.rb new file mode 100644 index 0000000000..49ccde275c --- /dev/null +++ b/spec/requests/api/v1/scheduled_status_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Scheduled Statuses' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/scheduled_statuses' do + context 'when not authorized' do + it 'returns http unauthorized' do + get api_v1_scheduled_statuses_path + + expect(response) + .to have_http_status(401) + end + end + + context 'with wrong scope' do + before do + get api_v1_scheduled_statuses_path, headers: headers + end + + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + end + + context 'with correct scope' do + let(:scopes) { 'read:statuses' } + + context 'without scheduled statuses' do + it 'returns http success without json' do + get api_v1_scheduled_statuses_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to_not be_present + end + end + + context 'with scheduled statuses' do + let!(:scheduled_status) { Fabricate(:scheduled_status, account: user.account) } + + it 'returns http success and status json' do + get api_v1_scheduled_statuses_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and have_attributes( + first: include(id: scheduled_status.id.to_s) + ) + end + end + end + end +end diff --git a/spec/requests/api/v1/statuses/bookmarks_spec.rb b/spec/requests/api/v1/statuses/bookmarks_spec.rb new file mode 100644 index 0000000000..d3007740a5 --- /dev/null +++ b/spec/requests/api/v1/statuses/bookmarks_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Bookmarks' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:bookmarks' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/statuses/:status_id/bookmark' do + subject do + post "/api/v1/statuses/#{status.id}/bookmark", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'read' + + context 'with public status' do + it 'bookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be true + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, bookmarked: true) + ) + end + end + + context 'with private status of not-followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'with private status of followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + before do + user.account.follow!(status.account) + end + + it 'bookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be true + end + end + + context 'when the status does not exist' do + it 'returns http not found' do + post '/api/v1/statuses/-1/bookmark', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /api/v1/statuses/:status_id/unbookmark' do + subject do + post "/api/v1/statuses/#{status.id}/unbookmark", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'read' + + context 'with public status' do + context 'when the status was previously bookmarked' do + before do + Bookmark.find_or_create_by!(account: user.account, status: status) + end + + it 'unbookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, bookmarked: false) + ) + end + end + + context 'when the requesting user was blocked by the status author' do + let(:status) { Fabricate(:status) } + + before do + Bookmark.find_or_create_by!(account: user.account, status: status) + status.account.block!(user.account) + end + + it 'unbookmarks the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.bookmarked?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, bookmarked: false) + ) + end + end + + context 'when the status is not bookmarked' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + end + + context 'with private status that was not bookmarked' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/statuses/favourites_spec.rb b/spec/requests/api/v1/statuses/favourites_spec.rb new file mode 100644 index 0000000000..8f3e6e5c42 --- /dev/null +++ b/spec/requests/api/v1/statuses/favourites_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Favourites' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:favourites' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/statuses/:status_id/favourite' do + subject do + post "/api/v1/statuses/#{status.id}/favourite", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'read read:favourites' + + context 'with public status' do + it 'favourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, favourites_count: 1, favourited: true) + ) + end + end + + context 'with private status of not-followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'with private status of followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + before do + user.account.follow!(status.account) + end + + it 'favourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /api/v1/statuses/:status_id/unfavourite', :sidekiq_fake do + subject do + post "/api/v1/statuses/#{status.id}/unfavourite", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'read read:favourites' + + context 'with public status' do + before do + FavouriteService.new.call(user.account, status) + end + + it 'unfavourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + + UnfavouriteWorker.drain + expect(user.account.favourited?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, favourites_count: 0, favourited: false) + ) + end + end + + context 'when the requesting user was blocked by the status author' do + before do + FavouriteService.new.call(user.account, status) + status.account.block!(user.account) + end + + it 'unfavourites the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.favourited?(status)).to be true + + UnfavouriteWorker.drain + expect(user.account.favourited?(status)).to be false + end + + it 'returns json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, favourites_count: 0, favourited: false) + ) + end + end + + context 'when status is not favourited' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'with private status that was not favourited' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v1/statuses/pins_spec.rb b/spec/requests/api/v1/statuses/pins_spec.rb new file mode 100644 index 0000000000..db07fa424f --- /dev/null +++ b/spec/requests/api/v1/statuses/pins_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Pins' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:accounts' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'POST /api/v1/statuses/:status_id/pin' do + subject do + post "/api/v1/statuses/#{status.id}/pin", headers: headers + end + + let(:status) { Fabricate(:status, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:accounts' + + context 'when the status is public' do + it 'pins the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.pinned?(status)).to be true + end + + it 'return json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, pinned: true) + ) + end + end + + context 'when the status is private' do + let(:status) { Fabricate(:status, account: user.account, visibility: :private) } + + it 'pins the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.pinned?(status)).to be true + end + end + + context 'when the status belongs to somebody else' do + let(:status) { Fabricate(:status) } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the status does not exist' do + it 'returns http not found' do + post '/api/v1/statuses/-1/pin', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /api/v1/statuses/:status_id/unpin' do + subject do + post "/api/v1/statuses/#{status.id}/unpin", headers: headers + end + + let(:status) { Fabricate(:status, account: user.account) } + + context 'when the status is pinned' do + before do + Fabricate(:status_pin, status: status, account: user.account) + end + + it 'unpins the status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.pinned?(status)).to be false + end + + it 'return json with updated attributes' do + subject + + expect(body_as_json).to match( + a_hash_including(id: status.id.to_s, pinned: false) + ) + end + end + + context 'when the status is not pinned' do + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + end + + context 'when the status does not exist' do + it 'returns http not found' do + post '/api/v1/statuses/-1/unpin', headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v1/statuses/sources_spec.rb b/spec/requests/api/v1/statuses/sources_spec.rb new file mode 100644 index 0000000000..c79ec89648 --- /dev/null +++ b/spec/requests/api/v1/statuses/sources_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Sources' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/statuses/:status_id/source' do + subject do + get "/api/v1/statuses/#{status.id}/source", headers: headers + end + + let(:status) { Fabricate(:status) } + + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + + context 'with public status' do + it 'returns the source properties of the status', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to eq({ + id: status.id.to_s, + text: status.text, + spoiler_text: status.spoiler_text, + content_type: nil, + }) + end + end + + context 'with private status of non-followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'with private status of followed account' do + let(:status) { Fabricate(:status, visibility: :private) } + + before do + user.account.follow!(status.account) + end + + it 'returns the source properties of the status', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to eq({ + id: status.id.to_s, + text: status.text, + spoiler_text: status.spoiler_text, + content_type: nil, + }) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/requests/api/v1/statuses_spec.rb similarity index 61% rename from spec/controllers/api/v1/statuses_controller_spec.rb rename to spec/requests/api/v1/statuses_spec.rb index c2bdba9ace..201674fccd 100644 --- a/spec/controllers/api/v1/statuses_controller_spec.rb +++ b/spec/requests/api/v1/statuses_spec.rb @@ -2,24 +2,26 @@ require 'rails_helper' -RSpec.describe Api::V1::StatusesController do - render_views - - let(:user) { Fabricate(:user) } - let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: scopes) } - +describe '/api/v1/statuses' do context 'with an oauth token' do - before do - allow(controller).to receive(:doorkeeper_token) { token } - end + let(:user) { Fabricate(:user) } + let(:client_app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: client_app, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/statuses/:id' do + subject do + get "/api/v1/statuses/#{status.id}", headers: headers + end - describe 'GET #show' do let(:scopes) { 'read:statuses' } let(:status) { Fabricate(:status, account: user.account) } + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + it 'returns http success' do - get :show, params: { id: status.id } + subject + expect(response).to have_http_status(200) end @@ -30,15 +32,11 @@ RSpec.describe Api::V1::StatusesController do user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }]) end - it 'returns http success' do - get :show, params: { id: status.id } - expect(response).to have_http_status(200) - end + it 'returns filter information', :aggregate_failures do + subject - it 'returns filter information' do - get :show, params: { id: status.id } - json = body_as_json - expect(json[:filtered][0]).to include({ + expect(response).to have_http_status(200) + expect(body_as_json[:filtered][0]).to include({ filter: a_hash_including({ id: user.account.custom_filters.first.id.to_s, title: 'filter1', @@ -57,15 +55,11 @@ RSpec.describe Api::V1::StatusesController do filter.statuses.create!(status_id: status.id) end - it 'returns http success' do - get :show, params: { id: status.id } - expect(response).to have_http_status(200) - end + it 'returns filter information', :aggregate_failures do + subject - it 'returns filter information' do - get :show, params: { id: status.id } - json = body_as_json - expect(json[:filtered][0]).to include({ + expect(response).to have_http_status(200) + expect(body_as_json[:filtered][0]).to include({ filter: a_hash_including({ id: user.account.custom_filters.first.id.to_s, title: 'filter1', @@ -83,15 +77,11 @@ RSpec.describe Api::V1::StatusesController do user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }]) end - it 'returns http success' do - get :show, params: { id: status.id } - expect(response).to have_http_status(200) - end + it 'returns filter information', :aggregate_failures do + subject - it 'returns filter information' do - get :show, params: { id: status.id } - json = body_as_json - expect(json[:reblog][:filtered][0]).to include({ + expect(response).to have_http_status(200) + expect(body_as_json[:reblog][:filtered][0]).to include({ filter: a_hash_including({ id: user.account.custom_filters.first.id.to_s, title: 'filter1', @@ -103,7 +93,7 @@ RSpec.describe Api::V1::StatusesController do end end - describe 'GET #context' do + describe 'GET /api/v1/statuses/:id/context' do let(:scopes) { 'read:statuses' } let(:status) { Fabricate(:status, account: user.account) } @@ -112,24 +102,27 @@ RSpec.describe Api::V1::StatusesController do end it 'returns http success' do - get :context, params: { id: status.id } + get "/api/v1/statuses/#{status.id}/context", headers: headers + expect(response).to have_http_status(200) end end - describe 'POST #create' do + describe 'POST /api/v1/statuses' do + subject do + post '/api/v1/statuses', headers: headers, params: params + end + let(:scopes) { 'write:statuses' } + let(:params) { { status: 'Hello world' } } + + it_behaves_like 'forbidden for wrong scope', 'read read:statuses' context 'with a basic status body' do - before do - post :create, params: { status: 'Hello world' } - end + it 'returns rate limit headers', :aggregate_failures do + subject - it 'returns http success' do expect(response).to have_http_status(200) - end - - it 'returns rate limit headers' do expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s expect(response.headers['X-RateLimit-Remaining']).to eq (RateLimiter::FAMILIES[:statuses][:limit] - 1).to_s end @@ -139,29 +132,23 @@ RSpec.describe Api::V1::StatusesController do let!(:alice) { Fabricate(:account, username: 'alice') } let!(:bob) { Fabricate(:account, username: 'bob') } - before do - post :create, params: { status: '@alice hm, @bob is really annoying lately', allowed_mentions: [alice.id] } - end + let(:params) { { status: '@alice hm, @bob is really annoying lately', allowed_mentions: [alice.id] } } + + it 'returns serialized extra accounts in body', :aggregate_failures do + subject - it 'returns http unprocessable entity' do expect(response).to have_http_status(422) - end - - it 'returns serialized extra accounts in body' do expect(body_as_json[:unexpected_accounts].map { |a| a.slice(:id, :acct) }).to eq [{ id: bob.id.to_s, acct: bob.acct }] end end context 'with missing parameters' do - before do - post :create, params: {} - end + let(:params) { {} } + + it 'returns rate limit headers', :aggregate_failures do + subject - it 'returns http unprocessable entity' do expect(response).to have_http_status(422) - end - - it 'returns rate limit headers' do expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s end end @@ -169,100 +156,100 @@ RSpec.describe Api::V1::StatusesController do context 'when exceeding rate limit' do before do rate_limiter = RateLimiter.new(user.account, family: :statuses) - 300.times { rate_limiter.record! } - post :create, params: { status: 'Hello world' } + RateLimiter::FAMILIES[:statuses][:limit].times { rate_limiter.record! } end - it 'returns http too many requests' do + it 'returns rate limit headers', :aggregate_failures do + subject + expect(response).to have_http_status(429) - end - - it 'returns rate limit headers' do expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s expect(response.headers['X-RateLimit-Remaining']).to eq '0' end end end - describe 'DELETE #destroy' do + describe 'DELETE /api/v1/statuses/:id' do + subject do + delete "/api/v1/statuses/#{status.id}", headers: headers + end + let(:scopes) { 'write:statuses' } let(:status) { Fabricate(:status, account: user.account) } - before do - post :destroy, params: { id: status.id } - end + it_behaves_like 'forbidden for wrong scope', 'read read:statuses' + + it 'removes the status', :aggregate_failures do + subject - it 'returns http success' do expect(response).to have_http_status(200) - end - - it 'removes the status' do expect(Status.find_by(id: status.id)).to be_nil end end - describe 'PUT #update' do + describe 'PUT /api/v1/statuses/:id' do + subject do + put "/api/v1/statuses/#{status.id}", headers: headers, params: { status: 'I am updated' } + end + let(:scopes) { 'write:statuses' } let(:status) { Fabricate(:status, account: user.account) } - before do - put :update, params: { id: status.id, status: 'I am updated' } - end + it_behaves_like 'forbidden for wrong scope', 'read read:statuses' + + it 'updates the status', :aggregate_failures do + subject - it 'returns http success' do expect(response).to have_http_status(200) - end - - it 'updates the status' do expect(status.reload.text).to eq 'I am updated' end end end context 'without an oauth token' do - before do - allow(controller).to receive(:doorkeeper_token).and_return(nil) - end - context 'with a private status' do - let(:status) { Fabricate(:status, account: user.account, visibility: :private) } + let(:status) { Fabricate(:status, visibility: :private) } - describe 'GET #show' do + describe 'GET /api/v1/statuses/:id' do it 'returns http unauthorized' do - get :show, params: { id: status.id } + get "/api/v1/statuses/#{status.id}" + expect(response).to have_http_status(404) end end - describe 'GET #context' do + describe 'GET /api/v1/statuses/:id/context' do before do - Fabricate(:status, account: user.account, thread: status) + Fabricate(:status, thread: status) end it 'returns http unauthorized' do - get :context, params: { id: status.id } + get "/api/v1/statuses/#{status.id}/context" + expect(response).to have_http_status(404) end end end context 'with a public status' do - let(:status) { Fabricate(:status, account: user.account, visibility: :public) } + let(:status) { Fabricate(:status, visibility: :public) } - describe 'GET #show' do + describe 'GET /api/v1/statuses/:id' do it 'returns http success' do - get :show, params: { id: status.id } + get "/api/v1/statuses/#{status.id}" + expect(response).to have_http_status(200) end end - describe 'GET #context' do + describe 'GET /api/v1/statuses/:id/context' do before do - Fabricate(:status, account: user.account, thread: status) + Fabricate(:status, thread: status) end it 'returns http success' do - get :context, params: { id: status.id } + get "/api/v1/statuses/#{status.id}/context" + expect(response).to have_http_status(200) end end diff --git a/spec/requests/api/v1/suggestions_spec.rb b/spec/requests/api/v1/suggestions_spec.rb index 42b7f86629..dc89613fc5 100644 --- a/spec/requests/api/v1/suggestions_spec.rb +++ b/spec/requests/api/v1/suggestions_spec.rb @@ -13,13 +13,12 @@ RSpec.describe 'Suggestions' do get '/api/v1/suggestions', headers: headers, params: params end - let(:bob) { Fabricate(:account) } - let(:jeff) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + let(:jeff) { Fabricate(:account) } let(:params) { {} } before do - PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) - PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite) + Setting.bootstrap_timeline_accounts = [bob, jeff].map(&:acct).join(',') end it_behaves_like 'forbidden for wrong scope', 'write' @@ -65,17 +64,15 @@ RSpec.describe 'Suggestions' do delete "/api/v1/suggestions/#{jeff.id}", headers: headers end - let(:suggestions_source) { instance_double(AccountSuggestions::PastInteractionsSource, remove: nil) } - let(:bob) { Fabricate(:account) } - let(:jeff) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + let(:jeff) { Fabricate(:account) } + let(:scopes) { 'write' } before do - PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog) - PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite) - allow(AccountSuggestions::PastInteractionsSource).to receive(:new).and_return(suggestions_source) + Setting.bootstrap_timeline_accounts = [bob, jeff].map(&:acct).join(',') end - it_behaves_like 'forbidden for wrong scope', 'write' + it_behaves_like 'forbidden for wrong scope', 'read' it 'returns http success' do subject @@ -86,8 +83,7 @@ RSpec.describe 'Suggestions' do it 'removes the specified suggestion' do subject - expect(suggestions_source).to have_received(:remove).with(user.account, jeff.id.to_s).once - expect(suggestions_source).to_not have_received(:remove).with(user.account, bob.id.to_s) + expect(FollowRecommendationMute.exists?(account: user.account, target_account: jeff)).to be true end context 'without an authorization header' do diff --git a/spec/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb index 300ddf805c..db74a6f037 100644 --- a/spec/requests/api/v1/tags_spec.rb +++ b/spec/requests/api/v1/tags_spec.rb @@ -17,15 +17,10 @@ RSpec.describe 'Tags' do let!(:tag) { Fabricate(:tag) } let(:name) { tag.name } - it 'returns http success' do + it 'returns the tag', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'returns the tag' do - subject - expect(body_as_json[:name]).to eq(name) end end @@ -62,15 +57,10 @@ RSpec.describe 'Tags' do it_behaves_like 'forbidden for wrong scope', 'read read:follows' context 'when the tag exists' do - it 'returns http success' do + it 'creates follow', :aggregate_failures do subject expect(response).to have_http_status(:success) - end - - it 'creates follow' do - subject - expect(TagFollow.where(tag: tag, account: user.account)).to exist end end @@ -78,21 +68,11 @@ RSpec.describe 'Tags' do context 'when the tag does not exist' do let(:name) { 'hoge' } - it 'returns http success' do + it 'creates a new tag with the specified name', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'creates a new tag with the specified name' do - subject - expect(Tag.where(name: name)).to exist - end - - it 'creates follow' do - subject - expect(TagFollow.where(tag: Tag.find_by(name: name), account: user.account)).to exist end end @@ -133,15 +113,10 @@ RSpec.describe 'Tags' do it_behaves_like 'forbidden for wrong scope', 'read read:follows' - it 'returns http success' do + it 'removes the follow', :aggregate_failures do subject expect(response).to have_http_status(200) - end - - it 'removes the follow' do - subject - expect(TagFollow.where(tag: tag, account: user.account)).to_not exist end diff --git a/spec/requests/api/v1/timelines/home_spec.rb b/spec/requests/api/v1/timelines/home_spec.rb new file mode 100644 index 0000000000..5834b90955 --- /dev/null +++ b/spec/requests/api/v1/timelines/home_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Home' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/timelines/home' do + subject do + get '/api/v1/timelines/home', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'write write:statuses' + + context 'when the timeline is available' do + let(:home_statuses) { bob.statuses + ana.statuses } + let!(:bob) { Fabricate(:account) } + let!(:tim) { Fabricate(:account) } + let!(:ana) { Fabricate(:account) } + + before do + user.account.follow!(bob) + user.account.follow!(ana) + PostStatusService.new.call(bob, text: 'New toot from bob.') + PostStatusService.new.call(tim, text: 'New toot from tim.') + PostStatusService.new.call(ana, text: 'New toot from ana.') + end + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns the statuses of followed users' do + subject + + expect(body_as_json.pluck(:id)).to match_array(home_statuses.map { |status| status.id.to_s }) + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of statuses' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination headers', :aggregate_failures do + subject + + headers = response.headers['Link'] + + expect(headers.find_link(%w(rel prev)).href).to eq(api_v1_timelines_home_url(limit: 1, min_id: ana.statuses.first.id.to_s)) + expect(headers.find_link(%w(rel next)).href).to eq(api_v1_timelines_home_url(limit: 1, max_id: ana.statuses.first.id.to_s)) + end + end + end + + context 'when the timeline is regenerating' do + let(:timeline) { instance_double(HomeFeed, regenerating?: true, get: []) } + + before do + allow(HomeFeed).to receive(:new).and_return(timeline) + end + + it 'returns http partial content' do + subject + + expect(response).to have_http_status(206) + end + end + + context 'without an authorization header' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + + context 'without a user context' do + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: scopes) } + + it 'returns http unprocessable entity', :aggregate_failures do + subject + + expect(response).to have_http_status(422) + expect(response.headers['Link']).to be_nil + end + end + end +end diff --git a/spec/requests/api/v1/timelines/public_spec.rb b/spec/requests/api/v1/timelines/public_spec.rb new file mode 100644 index 0000000000..4afa40e580 --- /dev/null +++ b/spec/requests/api/v1/timelines/public_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Public' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'a successful request to the public timeline' do + it 'returns the expected statuses successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.pluck(:id)).to match_array(expected_statuses.map { |status| status.id.to_s }) + end + end + + describe 'GET /api/v1/timelines/public' do + subject do + get '/api/v1/timelines/public', headers: headers, params: params + end + + let!(:private_status) { Fabricate(:status, visibility: :private) } # rubocop:disable RSpec/LetSetup + let!(:local_status) { Fabricate(:status, account: Fabricate.build(:account, domain: nil)) } + let!(:remote_status) { Fabricate(:status, account: Fabricate.build(:account, domain: 'example.com')) } + let!(:media_status) { Fabricate(:status, media_attachments: [Fabricate.build(:media_attachment)]) } + + let(:params) { {} } + + context 'when the instance allows public preview' do + let(:expected_statuses) { [local_status, remote_status, media_status] } + + before do + Setting.timeline_preview = true + end + + context 'with an authorized user' do + it_behaves_like 'a successful request to the public timeline' + end + + context 'with an anonymous user' do + let(:headers) { {} } + + it_behaves_like 'a successful request to the public timeline' + end + + context 'with local param' do + let(:params) { { local: true } } + let(:expected_statuses) { [local_status, media_status] } + + it_behaves_like 'a successful request to the public timeline' + end + + context 'with remote param' do + let(:params) { { remote: true } } + let(:expected_statuses) { [remote_status] } + + it_behaves_like 'a successful request to the public timeline' + end + + context 'with local and remote params' do + let(:params) { { local: true, remote: true } } + let(:expected_statuses) { [local_status, remote_status, media_status] } + + it_behaves_like 'a successful request to the public timeline' + end + + context 'with only_media param' do + let(:params) { { only_media: true } } + let(:expected_statuses) { [media_status] } + + it_behaves_like 'a successful request to the public timeline' + end + + context 'with limit param' do + let(:params) { { limit: 1 } } + + it 'returns only the requested number of statuses', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination headers', :aggregate_failures do + subject + + headers = response.headers['Link'] + + expect(headers.find_link(%w(rel prev)).href).to eq(api_v1_timelines_public_url(limit: 1, min_id: media_status.id.to_s)) + expect(headers.find_link(%w(rel next)).href).to eq(api_v1_timelines_public_url(limit: 1, max_id: media_status.id.to_s)) + end + end + end + + context 'when the instance does not allow public preview' do + before do + Form::AdminSettings.new(timeline_preview: false).save + end + + context 'with an authenticated user' do + let(:expected_statuses) { [local_status, remote_status, media_status] } + + it_behaves_like 'a successful request to the public timeline' + end + + context 'with an unauthenticated user' do + let(:headers) { {} } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + end +end diff --git a/spec/requests/api/v1/timelines/tag_spec.rb b/spec/requests/api/v1/timelines/tag_spec.rb new file mode 100644 index 0000000000..a8f20213eb --- /dev/null +++ b/spec/requests/api/v1/timelines/tag_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Tag' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'a successful request to the tag timeline' do + it 'returns the expected statuses', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.pluck(:id)).to match_array(expected_statuses.map { |status| status.id.to_s }) + end + end + + describe 'GET /api/v1/timelines/tag/:hashtag' do + subject do + get "/api/v1/timelines/tag/#{hashtag}", headers: headers, params: params + end + + before do + Setting.timeline_preview = true + end + + let(:account) { Fabricate(:account) } + let!(:private_status) { PostStatusService.new.call(account, visibility: :private, text: '#life could be a dream') } # rubocop:disable RSpec/LetSetup + let!(:life_status) { PostStatusService.new.call(account, text: 'tell me what is my #life without your #love') } + let!(:war_status) { PostStatusService.new.call(user.account, text: '#war, war never changes') } + let!(:love_status) { PostStatusService.new.call(account, text: 'what is #love?') } + let(:params) { {} } + let(:hashtag) { 'life' } + + context 'when given only one hashtag' do + let(:expected_statuses) { [life_status] } + + it_behaves_like 'a successful request to the tag timeline' + end + + context 'with any param' do + let(:expected_statuses) { [life_status, love_status] } + let(:params) { { any: %(love) } } + + it_behaves_like 'a successful request to the tag timeline' + end + + context 'with all param' do + let(:expected_statuses) { [life_status] } + let(:params) { { all: %w(love) } } + + it_behaves_like 'a successful request to the tag timeline' + end + + context 'with none param' do + let(:expected_statuses) { [war_status] } + let(:hashtag) { 'war' } + let(:params) { { none: %w(life love) } } + + it_behaves_like 'a successful request to the tag timeline' + end + + context 'with limit param' do + let(:hashtag) { 'love' } + let(:params) { { limit: 1 } } + + it 'returns only the requested number of statuses' do + subject + + expect(body_as_json.size).to eq(params[:limit]) + end + + it 'sets the correct pagination headers', :aggregate_failures do + subject + + headers = response.headers['Link'] + + expect(headers.find_link(%w(rel prev)).href).to eq(api_v1_timelines_tag_url(limit: 1, min_id: love_status.id.to_s)) + expect(headers.find_link(%w(rel next)).href).to eq(api_v1_timelines_tag_url(limit: 1, max_id: love_status.id.to_s)) + end + end + + context 'when the instance allows public preview' do + context 'when the user is not authenticated' do + let(:headers) { {} } + let(:expected_statuses) { [life_status] } + + it_behaves_like 'a successful request to the tag timeline' + end + end + + context 'when the instance does not allow public preview' do + before do + Form::AdminSettings.new(timeline_preview: false).save + end + + context 'when the user is not authenticated' do + let(:headers) { {} } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + + context 'when the user is authenticated' do + let(:expected_statuses) { [life_status] } + + it_behaves_like 'a successful request to the tag timeline' + end + end + end +end diff --git a/spec/requests/api/v2/filters_spec.rb b/spec/requests/api/v2/filters_spec.rb new file mode 100644 index 0000000000..fd0483abbe --- /dev/null +++ b/spec/requests/api/v2/filters_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Filters' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:filters write:filters' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + shared_examples 'unauthorized for invalid token' do + let(:headers) { { 'Authorization' => '' } } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end + + describe 'GET /api/v2/filters' do + subject do + get '/api/v2/filters', headers: headers + end + + let!(:filters) { Fabricate.times(2, :custom_filter, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'write write:filters' + it_behaves_like 'unauthorized for invalid token' + + it 'returns the existing filters successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json.pluck(:id)).to match_array(filters.map { |filter| filter.id.to_s }) + end + end + + describe 'POST /api/v2/filters' do + subject do + post '/api/v2/filters', params: params, headers: headers + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read read:filters' + it_behaves_like 'unauthorized for invalid token' + + context 'with valid params' do + let(:params) { { title: 'magic', context: %w(home), filter_action: 'hide', keywords_attributes: [keyword: 'magic'] } } + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'returns a filter with keywords', :aggregate_failures do + subject + + json = body_as_json + + expect(json[:title]).to eq 'magic' + expect(json[:filter_action]).to eq 'hide' + expect(json[:context]).to eq ['home'] + expect(json[:keywords].map { |keyword| keyword.slice(:keyword, :whole_word) }).to eq [{ keyword: 'magic', whole_word: true }] + end + + it 'creates a filter', :aggregate_failures do + subject + + filter = user.account.custom_filters.first + + expect(filter).to be_present + expect(filter.keywords.pluck(:keyword)).to eq ['magic'] + expect(filter.context).to eq %w(home) + expect(filter.irreversible?).to be true + expect(filter.expires_at).to be_nil + end + end + + context 'when the required title param is missing' do + let(:params) { { context: %w(home), filter_action: 'hide', keywords_attributes: [keyword: 'magic'] } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the required context param is missing' do + let(:params) { { title: 'magic', filter_action: 'hide', keywords_attributes: [keyword: 'magic'] } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + + context 'when the given context value is invalid' do + let(:params) { { title: 'magic', context: %w(shaolin), filter_action: 'hide', keywords_attributes: [keyword: 'magic'] } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + describe 'GET /api/v2/filters/:id' do + subject do + get "/api/v2/filters/#{filter.id}", headers: headers + end + + let(:filter) { Fabricate(:custom_filter, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'write write:filters' + it_behaves_like 'unauthorized for invalid token' + + it 'returns the filter successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:id]).to eq(filter.id.to_s) + end + + context 'when the filter belongs to someone else' do + let(:filter) { Fabricate(:custom_filter) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'PUT /api/v2/filters/:id' do + subject do + put "/api/v2/filters/#{filter.id}", params: params, headers: headers + end + + let!(:filter) { Fabricate(:custom_filter, account: user.account) } + let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) } + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read read:filters' + it_behaves_like 'unauthorized for invalid token' + + context 'when updating filter parameters' do + context 'with valid params' do + let(:params) { { title: 'updated', context: %w(home public) } } + + it 'updates the filter successfully', :aggregate_failures do + subject + + filter.reload + + expect(response).to have_http_status(200) + expect(filter.title).to eq 'updated' + expect(filter.reload.context).to eq %w(home public) + end + end + + context 'with invalid params' do + let(:params) { { title: 'updated', context: %w(word) } } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + end + end + end + + context 'when updating keywords in bulk' do + let(:params) { { keywords_attributes: [{ id: keyword.id, keyword: 'updated' }] } } + + before do + allow(redis).to receive_messages(publish: nil) + end + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'updates the keyword' do + subject + + expect(keyword.reload.keyword).to eq 'updated' + end + + it 'sends exactly one filters_changed event' do + subject + + expect(redis).to have_received(:publish).with("timeline:#{user.account.id}", Oj.dump(event: :filters_changed)).once + end + end + + context 'when the filter belongs to someone else' do + let(:filter) { Fabricate(:custom_filter) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /api/v2/filters/:id' do + subject do + delete "/api/v2/filters/#{filter.id}", headers: headers + end + + let(:filter) { Fabricate(:custom_filter, account: user.account) } + + it_behaves_like 'forbidden for wrong scope', 'read read:filters' + it_behaves_like 'unauthorized for invalid token' + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + + it 'removes the filter' do + subject + + expect { filter.reload }.to raise_error ActiveRecord::RecordNotFound + end + + context 'when the filter belongs to someone else' do + let(:filter) { Fabricate(:custom_filter) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v2/instance_spec.rb b/spec/requests/api/v2/instance_spec.rb new file mode 100644 index 0000000000..bbe8299ac5 --- /dev/null +++ b/spec/requests/api/v2/instance_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Instances' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v2/instance' do + context 'when logged out' do + it 'returns http success and json' do + get api_v2_instance_path + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and include(title: 'Mastodon Glitch Edition') + end + end + + context 'when logged in' do + it 'returns http success and json' do + get api_v2_instance_path, headers: headers + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_present + .and include(title: 'Mastodon Glitch Edition') + end + end + end +end diff --git a/spec/requests/api/v2/media_spec.rb b/spec/requests/api/v2/media_spec.rb index 89384d0ca3..990fa5d0ba 100644 --- a/spec/requests/api/v2/media_spec.rb +++ b/spec/requests/api/v2/media_spec.rb @@ -2,17 +2,89 @@ require 'rails_helper' -RSpec.describe 'Media API', paperclip_processing: true do +RSpec.describe 'Media API', :paperclip_processing do let(:user) { Fabricate(:user) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } let(:scopes) { 'write' } let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } describe 'POST /api/v2/media' do - it 'returns http success' do - post '/api/v2/media', headers: headers, params: { file: fixture_file_upload('attachment-jpg.123456_abcd', 'image/jpeg') } - expect(File.exist?(user.account.media_attachments.first.file.path(:small))).to be true - expect(response).to have_http_status(200) + context 'when small media format attachment is processed immediately' do + let(:params) { { file: fixture_file_upload('attachment-jpg.123456_abcd', 'image/jpeg') } } + + it 'returns http success' do + post '/api/v2/media', headers: headers, params: params + + expect(File.exist?(user.account.media_attachments.first.file.path(:small))) + .to be true + + expect(response) + .to have_http_status(200) + + expect(body_as_json) + .to be_a(Hash) + end + end + + context 'when large format media attachment has not been processed' do + let(:params) { { file: fixture_file_upload('attachment.webm', 'video/webm') } } + + it 'returns http accepted' do + post '/api/v2/media', headers: headers, params: params + + expect(File.exist?(user.account.media_attachments.first.file.path(:small))) + .to be true + + expect(response) + .to have_http_status(202) + + expect(body_as_json) + .to be_a(Hash) + end + end + + describe 'when paperclip errors occur' do + let(:media_attachments) { double } + let(:params) { { file: fixture_file_upload('attachment.jpg', 'image/jpeg') } } + + before do + allow(User).to receive(:find).with(token.resource_owner_id).and_return(user) + allow(user.account).to receive(:media_attachments).and_return(media_attachments) + end + + context 'when imagemagick cannot identify the file type' do + before do + allow(media_attachments).to receive(:create!).and_raise(Paperclip::Errors::NotIdentifiedByImageMagickError) + end + + it 'returns http unprocessable entity' do + post '/api/v2/media', headers: headers, params: params + + expect(response) + .to have_http_status(422) + + expect(body_as_json) + .to be_a(Hash) + .and include(error: /File type/) + end + end + + context 'when there is a generic error' do + before do + allow(media_attachments).to receive(:create!).and_raise(Paperclip::Error) + end + + it 'returns http 500' do + post '/api/v2/media', headers: headers, params: params + + expect(response) + .to have_http_status(500) + + expect(body_as_json) + .to be_a(Hash) + .and include(error: /processing/) + end + end end end end diff --git a/spec/requests/api/v2/search_spec.rb b/spec/requests/api/v2/search_spec.rb new file mode 100644 index 0000000000..13bcf17984 --- /dev/null +++ b/spec/requests/api/v2/search_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Search API' do + context 'with token' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:search' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v2/search' do + let!(:bob) { Fabricate(:account, username: 'bob_test') } + let!(:ana) { Fabricate(:account, username: 'ana_test') } + let!(:tom) { Fabricate(:account, username: 'tom_test') } + let(:params) { { q: 'test' } } + + it 'returns http success' do + get '/api/v2/search', headers: headers, params: params + + expect(response).to have_http_status(200) + end + + context 'when searching accounts' do + let(:params) { { q: 'test', type: 'accounts' } } + + it 'returns all matching accounts' do + get '/api/v2/search', headers: headers, params: params + + expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(bob.id.to_s, ana.id.to_s, tom.id.to_s) + end + + context 'with truthy `resolve`' do + let(:params) { { q: 'test1', resolve: '1' } } + + it 'returns http unauthorized' do + get '/api/v2/search', headers: headers, params: params + + expect(response).to have_http_status(200) + end + end + + context 'with valid `offset` value' do + let(:params) { { q: 'test1', offset: 1 } } + + it 'returns http unauthorized' do + get '/api/v2/search', headers: headers, params: params + + expect(response).to have_http_status(200) + end + end + + context 'with negative `offset` value' do + let(:params) { { q: 'test1', offset: '-100', type: 'accounts' } } + + it 'returns http bad_request' do + get '/api/v2/search', headers: headers, params: params + + expect(response).to have_http_status(400) + end + end + + context 'with negative `limit` value' do + let(:params) { { q: 'test1', limit: '-100', type: 'accounts' } } + + it 'returns http bad_request' do + get '/api/v2/search', headers: headers, params: params + + expect(response).to have_http_status(400) + end + end + + context 'with following=true' do + let(:params) { { q: 'test', type: 'accounts', following: 'true' } } + + before do + user.account.follow!(ana) + end + + it 'returns only the followed accounts' do + get '/api/v2/search', headers: headers, params: params + + expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(ana.id.to_s) + end + end + end + + context 'when search raises syntax error' do + before { allow(Search).to receive(:new).and_raise(Mastodon::SyntaxError) } + + it 'returns http unprocessable_entity' do + get '/api/v2/search', headers: headers, params: params + + expect(response).to have_http_status(422) + end + end + + context 'when search raises not found error' do + before { allow(Search).to receive(:new).and_raise(ActiveRecord::RecordNotFound) } + + it 'returns http not_found' do + get '/api/v2/search', headers: headers, params: params + + expect(response).to have_http_status(404) + end + end + end + end + + context 'without token' do + describe 'GET /api/v2/search' do + let(:search_params) { nil } + + before do + get '/api/v2/search', params: search_params + end + + context 'without a `q` param' do + it 'returns http bad_request' do + expect(response).to have_http_status(400) + end + end + + context 'with a `q` shorter than 5 characters' do + let(:search_params) { { q: 'test' } } + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end + + context 'with a `q` equal to or longer than 5 characters' do + let(:search_params) { { q: 'test1' } } + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + context 'with truthy `resolve`' do + let(:search_params) { { q: 'test1', resolve: '1' } } + + it 'returns http unauthorized' do + expect(response).to have_http_status(401) + expect(response.body).to match('resolve remote resources') + end + end + + context 'with `offset`' do + let(:search_params) { { q: 'test1', offset: 1 } } + + it 'returns http unauthorized' do + expect(response).to have_http_status(401) + expect(response.body).to match('pagination is not supported') + end + end + end + end + end +end diff --git a/spec/requests/api/v2/suggestions_spec.rb b/spec/requests/api/v2/suggestions_spec.rb new file mode 100644 index 0000000000..5f1c97b8ae --- /dev/null +++ b/spec/requests/api/v2/suggestions_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Suggestions API' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v2/suggestions' do + it 'returns http success' do + get '/api/v2/suggestions', headers: headers + + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/requests/api/web/embeds_spec.rb b/spec/requests/api/web/embeds_spec.rb new file mode 100644 index 0000000000..6314f43aaf --- /dev/null +++ b/spec/requests/api/web/embeds_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe '/api/web/embed' do + subject { get "/api/web/embeds/#{id}", headers: headers } + + context 'when accessed anonymously' do + let(:headers) { {} } + + context 'when the requested status is local' do + let(:id) { status.id } + + context 'when the requested status is public' do + let(:status) { Fabricate(:status, visibility: :public) } + + it 'returns JSON with an html attribute' do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:html]).to be_present + end + end + + context 'when the requested status is private' do + let(:status) { Fabricate(:status, visibility: :private) } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + context 'when the requested status is remote' do + let(:remote_account) { Fabricate(:account, domain: 'example.com') } + let(:status) { Fabricate(:status, visibility: :public, account: remote_account, url: 'https://example.com/statuses/1') } + let(:id) { status.id } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when the requested status does not exist' do + let(:id) { -1 } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + context 'with an API token' do + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + context 'when the requested status is local' do + let(:id) { status.id } + + context 'when the requested status is public' do + let(:status) { Fabricate(:status, visibility: :public) } + + it 'returns JSON with an html attribute' do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:html]).to be_present + end + + context 'when the requesting user is blocked' do + before do + status.account.block!(user.account) + end + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + context 'when the requested status is private' do + let(:status) { Fabricate(:status, visibility: :private) } + + before do + user.account.follow!(status.account) + end + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + context 'when the requested status is remote' do + let(:remote_account) { Fabricate(:account, domain: 'example.com') } + let(:status) { Fabricate(:status, visibility: :public, account: remote_account, url: 'https://example.com/statuses/1') } + let(:id) { status.id } + + let(:service_instance) { instance_double(FetchOEmbedService) } + + before do + allow(FetchOEmbedService).to receive(:new) { service_instance } + allow(service_instance).to receive(:call) { call_result } + end + + context 'when the requesting user is blocked' do + before do + status.account.block!(user.account) + end + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + + context 'when successfully fetching OEmbed' do + let(:call_result) { { html: 'ok' } } + + it 'returns JSON with an html attribute' do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:html]).to be_present + end + end + + context 'when failing to fetch OEmbed' do + let(:call_result) { nil } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end + + context 'when the requested status does not exist' do + let(:id) { -1 } + + it 'returns http not found' do + subject + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/cache_spec.rb b/spec/requests/cache_spec.rb index 902f21db4b..dbba228740 100644 --- a/spec/requests/cache_spec.rb +++ b/spec/requests/cache_spec.rb @@ -30,6 +30,7 @@ module TestEndpoints /directory /@alice /@alice/110224538612341312 + /deck/home ).freeze # Endpoints that should be cachable when accessed anonymously but have a Vary @@ -118,40 +119,39 @@ module TestEndpoints end describe 'Caching behavior' do - shared_examples 'cachable response' do - it 'does not set cookies' do + shared_examples 'cachable response' do |http_success: false| + it 'does not set cookies or set public cache control', :aggregate_failures do expect(response.cookies).to be_empty - end - it 'sets public cache control' do # expect(response.cache_control[:max_age]&.to_i).to be_positive expect(response.cache_control[:public]).to be_truthy expect(response.cache_control[:private]).to be_falsy expect(response.cache_control[:no_store]).to be_falsy expect(response.cache_control[:no_cache]).to be_falsy + + expect(response).to have_http_status(200) if http_success end end - shared_examples 'non-cacheable response' do + shared_examples 'non-cacheable response' do |http_success: false| it 'sets private cache control' do expect(response.cache_control[:private]).to be_truthy expect(response.cache_control[:no_store]).to be_truthy + + expect(response).to have_http_status(200) if http_success end end shared_examples 'non-cacheable error' do - it 'does not return HTTP success' do + it 'does not return HTTP success and does not have cache headers', :aggregate_failures do expect(response).to_not have_http_status(200) - end - - it 'does not have cache headers' do expect(response.cache_control[:public]).to be_falsy end end shared_examples 'language-dependent' do it 'has a Vary on Accept-Language' do - expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('accept-language') + expect(response_vary_headers).to include('accept-language') end end @@ -170,17 +170,24 @@ describe 'Caching behavior' do let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Moderator')) } before do - # rubocop:disable Style/NumericLiterals - status = Fabricate(:status, account: alice, id: 110224538612341312) - Fabricate(:status, account: alice, id: 110224538643211312, visibility: :private) + status = Fabricate(:status, account: alice, id: '110224538612341312') + Fabricate(:status, account: alice, id: '110224538643211312', visibility: :private) Fabricate(:invite, code: 'abcdef') - Fabricate(:poll, status: status, account: alice, id: 12345) - # rubocop:enable Style/NumericLiterals + Fabricate(:poll, status: status, account: alice, id: '12345') user.account.follow!(alice) end context 'when anonymously accessed' do + describe '/users/alice' do + it 'redirects with proper cache header', :aggregate_failures do + get '/users/alice' + + expect(response).to redirect_to('/@alice') + expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('accept') + end + end + TestEndpoints::ALWAYS_CACHED.each do |endpoint| describe endpoint do before { get endpoint } @@ -197,7 +204,7 @@ describe 'Caching behavior' do it_behaves_like 'cachable response' it 'has a Vary on Cookie' do - expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('cookie') + expect(response_vary_headers).to include('cookie') end it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) @@ -211,7 +218,7 @@ describe 'Caching behavior' do it_behaves_like 'cachable response' it 'has a Vary on Authorization' do - expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('authorization') + expect(response_vary_headers).to include('authorization') end it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint) @@ -235,17 +242,11 @@ describe 'Caching behavior' do end describe '/api/v1/instance/domain_blocks' do - around do |example| - old_setting = Setting.show_domain_blocks + before do Setting.show_domain_blocks = show_domain_blocks - - example.run - - Setting.show_domain_blocks = old_setting + get '/api/v1/instance/domain_blocks' end - before { get '/api/v1/instance/domain_blocks' } - context 'when set to be publicly-available' do let(:show_domain_blocks) { 'all' } @@ -297,7 +298,7 @@ describe 'Caching behavior' do it_behaves_like 'non-cacheable response' it 'has a Vary on Cookie' do - expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('cookie') + expect(response_vary_headers).to include('cookie') end end end @@ -306,11 +307,7 @@ describe 'Caching behavior' do describe endpoint do before { get endpoint } - it_behaves_like 'non-cacheable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'non-cacheable response', http_success: true end end @@ -346,7 +343,7 @@ describe 'Caching behavior' do it_behaves_like 'non-cacheable response' it 'has a Vary on Authorization' do - expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('authorization') + expect(response_vary_headers).to include('authorization') end end end @@ -357,25 +354,13 @@ describe 'Caching behavior' do get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" } end - it_behaves_like 'non-cacheable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'non-cacheable response', http_success: true end end describe '/api/v1/instance/domain_blocks' do - around do |example| - old_setting = Setting.show_domain_blocks - Setting.show_domain_blocks = show_domain_blocks - - example.run - - Setting.show_domain_blocks = old_setting - end - before do + Setting.show_domain_blocks = show_domain_blocks get '/api/v1/instance/domain_blocks', headers: { 'Authorization' => "Bearer #{token.token}" } end @@ -388,11 +373,7 @@ describe 'Caching behavior' do context 'when allowed for local users only' do let(:show_domain_blocks) { 'users' } - it_behaves_like 'non-cacheable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'non-cacheable response', http_success: true end context 'when disabled' do @@ -416,11 +397,7 @@ describe 'Caching behavior' do get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'cachable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'cachable response', http_success: true end TestEndpoints::REQUIRE_SIGNATURE.each do |endpoint| @@ -429,11 +406,7 @@ describe 'Caching behavior' do get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'non-cacheable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'non-cacheable response', http_success: true end end end @@ -451,11 +424,7 @@ describe 'Caching behavior' do get '/actor', headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'cachable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'cachable response', http_success: true end (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| @@ -482,11 +451,7 @@ describe 'Caching behavior' do get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'cachable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'cachable response', http_success: true end (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| @@ -495,11 +460,7 @@ describe 'Caching behavior' do get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'non-cacheable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'non-cacheable response', http_success: true end end end @@ -508,12 +469,12 @@ describe 'Caching behavior' do context 'when enabling LIMITED_FEDERATION_MODE mode' do around do |example| ClimateControl.modify LIMITED_FEDERATION_MODE: 'true' do - old_whitelist_mode = Rails.configuration.x.whitelist_mode - Rails.configuration.x.whitelist_mode = true + old_limited_federation_mode = Rails.configuration.x.limited_federation_mode + Rails.configuration.x.limited_federation_mode = true example.run - Rails.configuration.x.whitelist_mode = old_whitelist_mode + Rails.configuration.x.limited_federation_mode = old_limited_federation_mode end end @@ -523,11 +484,7 @@ describe 'Caching behavior' do get '/actor', headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'cachable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'cachable response', http_success: true end (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| @@ -555,11 +512,7 @@ describe 'Caching behavior' do get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'cachable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'cachable response', http_success: true end (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| @@ -568,11 +521,7 @@ describe 'Caching behavior' do get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'non-cacheable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'non-cacheable response', http_success: true end end end @@ -586,11 +535,7 @@ describe 'Caching behavior' do get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' } end - it_behaves_like 'cachable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'cachable response', http_success: true end (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint| @@ -662,7 +607,7 @@ describe 'Caching behavior' do it_behaves_like 'non-cacheable response' it 'has a Vary on Authorization' do - expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('authorization') + expect(response_vary_headers).to include('authorization') end end end @@ -673,13 +618,15 @@ describe 'Caching behavior' do get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" } end - it_behaves_like 'non-cacheable response' - - it 'returns HTTP success' do - expect(response).to have_http_status(200) - end + it_behaves_like 'non-cacheable response', http_success: true end end end end + + private + + def response_vary_headers + response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase } + end end diff --git a/spec/requests/content_security_policy_spec.rb b/spec/requests/content_security_policy_spec.rb new file mode 100644 index 0000000000..7610e698cd --- /dev/null +++ b/spec/requests/content_security_policy_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Content-Security-Policy' do + it 'sets the expected CSP headers' do + allow(SecureRandom).to receive(:base64).with(16).and_return('ZbA+JmE7+bK8F5qvADZHuQ==') + + get '/' + expect(response.headers['Content-Security-Policy'].split(';').map(&:strip)).to contain_exactly( + "base-uri 'none'", + "default-src 'none'", + "frame-ancestors 'none'", + "font-src 'self' https://cb6e6126.ngrok.io", + "img-src 'self' data: blob: https://cb6e6126.ngrok.io", + "style-src 'self' https://cb6e6126.ngrok.io 'nonce-ZbA+JmE7+bK8F5qvADZHuQ=='", + "media-src 'self' data: https://cb6e6126.ngrok.io", + "frame-src 'self' https:", + "manifest-src 'self' https://cb6e6126.ngrok.io", + "form-action 'self'", + "child-src 'self' blob: https://cb6e6126.ngrok.io", + "worker-src 'self' blob: https://cb6e6126.ngrok.io", + "connect-src 'self' data: blob: https://cb6e6126.ngrok.io ws://localhost:4000", + "script-src 'self' https://cb6e6126.ngrok.io 'wasm-unsafe-eval'" + ) + end +end diff --git a/spec/requests/host_meta_request_spec.rb b/spec/requests/host_meta_request_spec.rb deleted file mode 100644 index ec26ecba7d..0000000000 --- a/spec/requests/host_meta_request_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe 'The host_meta route' do - describe 'requested without accepts headers' do - it 'returns an xml response' do - get host_meta_url - - expect(response).to have_http_status(200) - expect(response.media_type).to eq 'application/xrd+xml' - end - end -end diff --git a/spec/requests/invite_spec.rb b/spec/requests/invite_spec.rb new file mode 100644 index 0000000000..c44ef2419c --- /dev/null +++ b/spec/requests/invite_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'invites' do + let(:invite) { Fabricate(:invite) } + + context 'when requesting a JSON document' do + it 'returns a JSON document with expected attributes' do + get "/invite/#{invite.code}", headers: { 'Accept' => 'application/activity+json' } + + expect(response).to have_http_status(200) + expect(response.media_type).to eq 'application/json' + + expect(body_as_json[:invite_code]).to eq invite.code + end + end + + context 'when not requesting a JSON document' do + it 'returns an HTML page' do + get "/invite/#{invite.code}" + + expect(response).to have_http_status(200) + expect(response.media_type).to eq 'text/html' + end + end +end diff --git a/spec/requests/mail_subscriptions_spec.rb b/spec/requests/mail_subscriptions_spec.rb new file mode 100644 index 0000000000..cc6557cab0 --- /dev/null +++ b/spec/requests/mail_subscriptions_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'MailSubscriptionsController' do + let(:user) { Fabricate(:user) } + let(:token) { user.to_sgid(for: 'unsubscribe').to_s } + let(:type) { 'follow' } + + shared_examples 'not found with invalid token' do + context 'with invalid token' do + let(:token) { 'invalid-token' } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + shared_examples 'not found with invalid type' do + context 'with invalid type' do + let(:type) { 'invalid_type' } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'on the unsubscribe confirmation page' do + before do + get unsubscribe_url(token: token, type: type) + end + + it_behaves_like 'not found with invalid token' + it_behaves_like 'not found with invalid type' + + it 'shows unsubscribe form' do + expect(response).to have_http_status(200) + + expect(response.body).to include( + I18n.t('mail_subscriptions.unsubscribe.action') + ) + expect(response.body).to include(user.email) + end + end + + describe 'submitting the unsubscribe confirmation page' do + before do + user.settings.update('notification_emails.follow': true) + user.save! + + post unsubscribe_url, params: { token: token, type: type } + end + + it_behaves_like 'not found with invalid token' + it_behaves_like 'not found with invalid type' + + it 'shows confirmation page' do + expect(response).to have_http_status(200) + + expect(response.body).to include( + I18n.t('mail_subscriptions.unsubscribe.complete') + ) + expect(response.body).to include(user.email) + end + + it 'updates notification settings' do + user.reload + expect(user.settings['notification_emails.follow']).to be false + end + end + + describe 'unsubscribing with List-Unsubscribe-Post' do + around do |example| + old = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + + example.run + + ActionController::Base.allow_forgery_protection = old + end + + before do + user.settings.update('notification_emails.follow': true) + user.save! + + post unsubscribe_url(token: token, type: type), params: { 'List-Unsubscribe' => 'One-Click' } + end + + it_behaves_like 'not found with invalid token' + it_behaves_like 'not found with invalid type' + + it 'return http success' do + expect(response).to have_http_status(200) + end + + it 'updates notification settings' do + user.reload + expect(user.settings['notification_emails.follow']).to be false + end + end +end diff --git a/spec/requests/omniauth_callbacks_spec.rb b/spec/requests/omniauth_callbacks_spec.rb new file mode 100644 index 0000000000..0d37c41140 --- /dev/null +++ b/spec/requests/omniauth_callbacks_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'OmniAuth callbacks' do + shared_examples 'omniauth provider callbacks' do |provider| + subject { post send :"user_#{provider}_omniauth_callback_path" } + + context 'with full information in response' do + before do + mock_omniauth(provider, { + provider: provider.to_s, + uid: '123', + info: { + verified: 'true', + email: 'user@host.example', + }, + }) + end + + context 'without a matching user' do + it 'creates a user and an identity and redirects to root path' do + expect { subject } + .to change(User, :count) + .by(1) + .and change(Identity, :count) + .by(1) + .and change(LoginActivity, :count) + .by(1) + + expect(User.last.email).to eq('user@host.example') + expect(Identity.find_by(user: User.last).uid).to eq('123') + expect(response).to redirect_to(root_path) + end + end + + context 'with a matching user and no matching identity' do + before do + Fabricate(:user, email: 'user@host.example') + end + + it 'matches the existing user, creates an identity, and redirects to root path' do + expect { subject } + .to not_change(User, :count) + .and change(Identity, :count) + .by(1) + .and change(LoginActivity, :count) + .by(1) + + expect(Identity.find_by(user: User.last).uid).to eq('123') + expect(response).to redirect_to(root_path) + end + end + + context 'with a matching user and a matching identity' do + before do + user = Fabricate(:user, email: 'user@host.example') + Fabricate(:identity, user: user, uid: '123', provider: provider) + end + + it 'matches the existing records and redirects to root path' do + expect { subject } + .to not_change(User, :count) + .and not_change(Identity, :count) + .and change(LoginActivity, :count) + .by(1) + + expect(response).to redirect_to(root_path) + end + end + end + + context 'with a response missing email address' do + before do + mock_omniauth(provider, { + provider: provider.to_s, + uid: '123', + info: { + verified: 'true', + }, + }) + end + + it 'redirects to the auth setup page' do + expect { subject } + .to change(User, :count) + .by(1) + .and change(Identity, :count) + .by(1) + .and change(LoginActivity, :count) + .by(1) + + expect(response).to redirect_to(auth_setup_path(missing_email: '1')) + end + end + + context 'when a user cannot be built' do + before do + allow(User).to receive(:find_for_oauth).and_return(User.new) + end + + it 'redirects to the new user signup page' do + expect { subject } + .to not_change(User, :count) + .and not_change(Identity, :count) + .and not_change(LoginActivity, :count) + + expect(response).to redirect_to(new_user_registration_url) + end + end + end + + describe '#openid_connect', if: ENV['OIDC_ENABLED'] == 'true' && ENV['OIDC_SCOPE'].present? do + include_examples 'omniauth provider callbacks', :openid_connect + end + + describe '#cas', if: ENV['CAS_ENABLED'] == 'true' do + include_examples 'omniauth provider callbacks', :cas + end + + describe '#saml', if: ENV['SAML_ENABLED'] == 'true' do + include_examples 'omniauth provider callbacks', :saml + end +end diff --git a/spec/requests/remote_interaction_helper_spec.rb b/spec/requests/remote_interaction_helper_spec.rb new file mode 100644 index 0000000000..e6364fe8ce --- /dev/null +++ b/spec/requests/remote_interaction_helper_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Remote Interaction Helper' do + describe 'GET /remote_interaction_helper' do + it 'returns http success' do + get remote_interaction_helper_path + + expect(response) + .to have_http_status(200) + .and render_template(:index, layout: 'helper_frame') + .and have_attributes( + headers: include( + 'X-Frame-Options' => 'SAMEORIGIN', + 'Referrer-Policy' => 'no-referrer', + 'Content-Security-Policy' => expected_csp_headers + ) + ) + end + end + + private + + def expected_csp_headers + <<~CSP.squish + default-src 'none'; frame-ancestors 'self'; form-action 'none'; script-src 'self' https://cb6e6126.ngrok.io 'wasm-unsafe-eval'; connect-src https: + CSP + end +end diff --git a/spec/requests/signature_verification_spec.rb b/spec/requests/signature_verification_spec.rb new file mode 100644 index 0000000000..b753750b84 --- /dev/null +++ b/spec/requests/signature_verification_spec.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'signature verification concern' do + before do + stub_tests_controller + + # Signature checking is time-dependent, so travel to a fixed date + travel_to '2023-12-20T10:00:00Z' + end + + after { Rails.application.reload_routes! } + + # Include the private key so the tests can be easily adjusted and reviewed + let(:actor_keypair) do + OpenSSL::PKey.read(<<~PEM_TEXT) + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAqIAYvNFGbZ5g4iiK6feSdXD4bDStFM58A7tHycYXaYtzZQpI + eHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S07t0V9wNK94he01LV5EMz/GN4eNn + FmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZIFLSb96Q5w0Z/k7ntpVKV52y8kz5F + jr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSnaKvT7P9jhgC6uTre+jXyvVZjiHDrn + qvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ+OuPRI1URIWQI01DCHqcohVu9+Ar + +BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+8QIDAQABAoIBAAgySHnFWI6gItR3 + fkfiqIm80cHCN3Xk1C6iiVu+3oBOZbHpW9R7vl9e/WOA/9O+LPjiSsQOegtWnVvd + RRjrl7Hj20VDlZKv5Mssm6zOGAxksrcVbqwdj+fUJaNJCL0AyyseH0x/IE9T8rDC + I1GH+3tB3JkhkIN/qjipdX5ab8MswEPu8IC4ViTpdBgWYY/xBcAHPw4xuL0tcwzh + FBlf4DqoEVQo8GdK5GAJ2Ny0S4xbXHUURzx/R4y4CCts7niAiLGqd9jmLU1kUTMk + QcXfQYK6l+unLc7wDYAz7sFEHh04M48VjWwiIZJnlCqmQbLda7uhhu8zkF1DqZTu + ulWDGQECgYEA0TIAc8BQBVab979DHEEmMdgqBwxLY3OIAk0b+r50h7VBGWCDPRsC + STD73fQY3lNet/7/jgSGwwAlAJ5PpMXxXiZAE3bUwPmHzgF7pvIOOLhA8O07tHSO + L2mvQe6NPzjZ+6iAO2U9PkClxcvGvPx2OBvisfHqZLmxC9PIVxzruQECgYEAzjM6 + BTUXa6T/qHvLFbN699BXsUOGmHBGaLRapFDBfVvgZrwqYQcZpBBhesLdGTGSqwE7 + gWsITPIJ+Ldo+38oGYyVys+w/V67q6ud7hgSDTW3hSvm+GboCjk6gzxlt9hQ0t9X + 8vfDOYhEXvVUJNv3mYO60ENqQhILO4bQ0zi+VfECgYBb/nUccfG+pzunU0Cb6Dp3 + qOuydcGhVmj1OhuXxLFSDG84Tazo7juvHA9mp7VX76mzmDuhpHPuxN2AzB2SBEoE + cSW0aYld413JRfWukLuYTc6hJHIhBTCRwRQFFnae2s1hUdQySm8INT2xIc+fxBXo + zrp+Ljg5Wz90SAnN5TX0AQKBgDaatDOq0o/r+tPYLHiLtfWoE4Dau+rkWJDjqdk3 + lXWn/e3WyHY3Vh/vQpEqxzgju45TXjmwaVtPATr+/usSykCxzP0PMPR3wMT+Rm1F + rIoY/odij+CaB7qlWwxj0x/zRbwB7x1lZSp4HnrzBpxYL+JUUwVRxPLIKndSBTza + GvVRAoGBAIVBcNcRQYF4fvZjDKAb4fdBsEuHmycqtRCsnkGOz6ebbEQznSaZ0tZE + +JuouZaGjyp8uPjNGD5D7mIGbyoZ3KyG4mTXNxDAGBso1hrNDKGBOrGaPhZx8LgO + 4VXJ+ybXrATf4jr8ccZYsZdFpOphPzz+j55Mqg5vac5P1XjmsGTb + -----END RSA PRIVATE KEY----- + PEM_TEXT + end + + context 'without a Signature header' do + it 'does not treat the request as signed' do + get '/activitypub/success' + + expect(response).to have_http_status(200) + expect(body_as_json).to match( + signed_request: false, + signature_actor_id: nil, + error: 'Request not signed' + ) + end + + context 'when a signature is required' do + it 'returns http unauthorized with appropriate error' do + get '/activitypub/signature_required' + + expect(response).to have_http_status(401) + expect(body_as_json).to match( + error: 'Request not signed' + ) + end + end + end + + context 'with an HTTP Signature from a known account' do + let!(:actor) { Fabricate(:account, domain: 'remote.domain', uri: 'https://remote.domain/users/bob', private_key: nil, public_key: actor_keypair.public_key.to_pem) } + + context 'with a valid signature on a GET request' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength + end + + it 'successfuly verifies signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Signature' => signature_header, + } + + expect(response).to have_http_status(200) + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: actor.id.to_s + ) + end + end + + context 'with a mismatching path' do + it 'fails to verify signature', :aggregate_failures do + get '/activitypub/alternative-path', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: anything + ) + end + end + + context 'with a mismatching method' do + it 'fails to verify signature', :aggregate_failures do + post '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: anything + ) + end + end + + context 'with an unparsable date' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="d4B7nfx8RJcfdJDu1J//5WzPzK/hgtPkdzZx49lu5QhnE7qdV3lgyVimmhCFrO16bwvzIp9iRMyRLkNFxLiEeVaa1gqeKbldGSnU0B0OMjx7rFBa65vLuzWQOATDitVGiBEYqoK4v0DMuFCz2DtFaA/DIUZ3sty8bZ/Ea3U1nByLOO6MacARA3zhMSI0GNxGqsSmZmG0hPLavB3jIXoE3IDoQabMnC39jrlcO/a8h1iaxBm2WD8TejrImJullgqlJIFpKhIHI3ipQkvTGPlm9dx0y+beM06qBvWaWQcmT09eRIUefVsOAzIhUtS/7FVb/URhZvircIJDa7vtiFcmZQ=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'wrong date', 'Host' => 'www.example.com' }) + + get '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'wrong date', + 'Signature' => signature_header, + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: 'Invalid Date header: not RFC 2616 compliant date: "wrong date"' + ) + end + end + + context 'with a request older than a day' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="G1NuJv4zgoZ3B/ZIjzDWZHK4RC+5pYee74q8/LJEMCWXhcnAomcb9YHaqk1QYfQvcBUIXw3UZ3Q9xO8F9y0i8G5mzJHfQ+OgHqCoJk8EmGwsUXJMh5s1S5YFCRt8TT12TmJZz0VMqLq85ubueSYBM7QtUE/FzFIVLvz4RysgXxaXQKzdnM6+gbUEEKdCURpXdQt2NXQhp4MAmZH3+0lQoR6VxdsK0hx0Ji2PNp1nuqFTlYqNWZazVdLBN+9rETLRmvGXknvg9jOxTTppBVWnkAIl26HtLS3wwFVvz4pJzi9OQDOvLziehVyLNbU61hky+oJ215e2HuKSe2hxHNl1MA=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 18 Dec 2023 10:00:00 GMT', + 'Signature' => signature_header, + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: 'Signed request date outside acceptable time window' + ) + end + end + + context 'with a valid signature on a POST request' do + let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' } + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength + end + + it 'successfuly verifies signature', :aggregate_failures do + expect(digest_header).to eq digest_value('Hello world') + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header }) + + post '/activitypub/success', params: 'Hello world', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => digest_header, + 'Signature' => signature_header, + } + + expect(response).to have_http_status(200) + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: actor.id.to_s + ) + end + end + + context 'when the Digest of a POST request is not signed' do + let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' } + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date (request-target)",signature="CPD704CG8aCm8X8qIP8kkkiGp1qwFLk/wMVQHOGP0Txxan8c2DZtg/KK7eN8RG8tHx8br/yS2hJs51x4kXImYukGzNJd7ihE3T8lp+9RI1tCcdobTzr/VcVJHDFySdQkg266GCMijRQRZfNvqlJLiisr817PI+gNVBI5qV+vnVd1XhWCEZ+YSmMe8UqYARXAYNqMykTheojqGpTeTFGPUpTQA2Fmt2BipwIjcFDm2Hpihl2kB0MUS0x3zPmHDuadvzoBbN6m3usPDLgYrpALlh+wDs1dYMntcwdwawRKY1oE1XNtgOSum12wntDq3uYL4gya2iPdcw3c929b4koUzw=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(digest_header).to eq digest_value('Hello world') + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT' }) + + post '/activitypub/success', params: 'Hello world', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => digest_header, + 'Signature' => signature_header, + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: 'Mastodon requires the Digest header to be signed when doing a POST request' + ) + end + end + + context 'with a tampered body on a POST request' do + let(:digest_header) { 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' } + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="' # rubocop:disable Layout/LineLength + end + + it 'fails to verify signature', :aggregate_failures do + expect(digest_header).to_not eq digest_value('Hello world!') + expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'post /activitypub/success', { 'Host' => 'www.example.com', 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Digest' => digest_header }) + + post '/activitypub/success', params: 'Hello world!', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=', + 'Signature' => signature_header, + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: 'Invalid Digest value. Computed SHA-256 digest: wFNeS+K3n/2TKRMFQ2v4iTFOSj+uwF7P/Lt98xrZ5Ro=; given: ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=' + ) + end + end + + context 'with a tampered path in a POST request' do + it 'fails to verify signature', :aggregate_failures do + post '/activitypub/alternative-path', params: 'Hello world', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Digest' => 'SHA-256=ZOyIygCyaOW6GjVnihtTFtIS9PNmskdyMlNKiuyjfzw=', + 'Signature' => 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="host date digest (request-target)",signature="gmhMjgMROGElJU3fpehV2acD5kMHeELi8EFP2UPHOdQ54H0r55AxIpji+J3lPe+N2qSb/4H1KXIh6f0lRu8TGSsu12OQmg5hiO8VA9flcA/mh9Lpk+qwlQZIPRqKP9xUEfqD+Z7ti5wPzDKrWAUK/7FIqWgcT/mlqB1R1MGkpMFc/q4CIs2OSNiWgA4K+Kp21oQxzC2kUuYob04gAZ7cyE/FTia5t08uv6lVYFdRsn4XNPn1MsHgFBwBMRG79ng3SyhoG4PrqBEi5q2IdLq3zfre/M6He3wlCpyO2VJNdGVoTIzeZ0Zz8jUscPV3XtWUchpGclLGSaKaq/JyNZeiYQ=="', # rubocop:disable Layout/LineLength + } + + expect(response).to have_http_status(200) + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: anything + ) + end + end + end + + context 'with an inaccessible key' do + before do + stub_request(:get, 'https://remote.domain/users/alice#main-key').to_return(status: 404) + end + + it 'fails to verify signature', :aggregate_failures do + get '/activitypub/success', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Signature' => 'keyId="https://remote.domain/users/alice#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="', # rubocop:disable Layout/LineLength + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: 'Unable to fetch key JSON at https://remote.domain/users/alice#main-key' + ) + end + end + + private + + def stub_tests_controller + stub_const('ActivityPub::TestsController', activitypub_tests_controller) + + Rails.application.routes.draw do + # NOTE: RouteSet#draw removes all routes, so we need to re-insert one + resource :instance_actor, path: 'actor', only: [:show] + + match :via => [:get, :post], '/activitypub/success' => 'activitypub/tests#success' + match :via => [:get, :post], '/activitypub/alternative-path' => 'activitypub/tests#alternative_success' + match :via => [:get, :post], '/activitypub/signature_required' => 'activitypub/tests#signature_required' + end + end + + def activitypub_tests_controller + Class.new(ApplicationController) do + include SignatureVerification + + before_action :require_actor_signature!, only: [:signature_required] + + def success + render json: { + signed_request: signed_request?, + signature_actor_id: signed_request_actor&.id&.to_s, + }.merge(signature_verification_failure_reason || {}) + end + + alias_method :alternative_success, :success + alias_method :signature_required, :success + end + end + + def digest_value(body) + "SHA-256=#{Digest::SHA256.base64digest(body)}" + end + + def build_signature_string(keypair, key_id, request_target, headers) + algorithm = 'rsa-sha256' + signed_headers = headers.merge({ '(request-target)' => request_target }) + signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") + signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) + + "keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" + end +end diff --git a/spec/requests/webfinger_request_spec.rb b/spec/requests/webfinger_request_spec.rb deleted file mode 100644 index 68a1478bed..0000000000 --- a/spec/requests/webfinger_request_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe 'The webfinger route' do - let(:alice) { Fabricate(:account, username: 'alice') } - - describe 'requested with standard accepts headers' do - it 'returns a json response' do - get webfinger_url(resource: alice.to_webfinger_s) - - expect(response).to have_http_status(200) - expect(response.media_type).to eq 'application/jrd+json' - end - end - - describe 'asking for json format' do - it 'returns a json response for json format' do - get webfinger_url(resource: alice.to_webfinger_s, format: :json) - - expect(response).to have_http_status(200) - expect(response.media_type).to eq 'application/jrd+json' - end - - it 'returns a json response for json accept header' do - headers = { 'HTTP_ACCEPT' => 'application/jrd+json' } - get webfinger_url(resource: alice.to_webfinger_s), headers: headers - - expect(response).to have_http_status(200) - expect(response.media_type).to eq 'application/jrd+json' - end - end -end diff --git a/spec/requests/well_known/change_password_spec.rb b/spec/requests/well_known/change_password_spec.rb new file mode 100644 index 0000000000..04134b71ff --- /dev/null +++ b/spec/requests/well_known/change_password_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'The /.well-known/change-password request' do + it 'redirects to the change password page' do + get '/.well-known/change-password' + + expect(response).to redirect_to '/auth/edit' + end +end diff --git a/spec/requests/well_known/host_meta_spec.rb b/spec/requests/well_known/host_meta_spec.rb new file mode 100644 index 0000000000..ca10a51a01 --- /dev/null +++ b/spec/requests/well_known/host_meta_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'The /.well-known/host-meta request' do + it 'returns http success with valid XML response' do + get '/.well-known/host-meta' + + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: 'application/xrd+xml', + body: host_meta_xml_template + ) + end + + private + + def host_meta_xml_template + <<~XML + + + + + XML + end +end diff --git a/spec/requests/well_known/node_info_spec.rb b/spec/requests/well_known/node_info_spec.rb new file mode 100644 index 0000000000..0934b0fde6 --- /dev/null +++ b/spec/requests/well_known/node_info_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'The well-known node-info endpoints' do + describe 'The /.well-known/node-info endpoint' do + it 'returns JSON document pointing to node info' do + get '/.well-known/nodeinfo' + + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: 'application/json' + ) + + expect(body_as_json).to include( + links: be_an(Array).and( + contain_exactly( + include( + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', + href: include('nodeinfo/2.0') + ) + ) + ) + ) + end + end + + describe 'The /nodeinfo/2.0 endpoint' do + it 'returns JSON document with node info properties' do + get '/nodeinfo/2.0' + + expect(response) + .to have_http_status(200) + .and have_attributes( + media_type: 'application/json' + ) + + expect(non_matching_hash) + .to_not match_json_schema('nodeinfo_2.0') + + expect(body_as_json) + .to match_json_schema('nodeinfo_2.0') + .and include( + version: '2.0', + usage: be_a(Hash), + software: be_a(Hash), + protocols: be_a(Array) + ) + end + + private + + def non_matching_hash + { 'foo' => 0 } + end + end +end diff --git a/spec/requests/well_known/webfinger_spec.rb b/spec/requests/well_known/webfinger_spec.rb new file mode 100644 index 0000000000..779f1bba59 --- /dev/null +++ b/spec/requests/well_known/webfinger_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'The /.well-known/webfinger endpoint' do + subject(:perform_request!) { get webfinger_url(resource: resource) } + + let(:alternate_domains) { [] } + let(:alice) { Fabricate(:account, username: 'alice') } + let(:resource) { nil } + + around do |example| + tmp = Rails.configuration.x.alternate_domains + Rails.configuration.x.alternate_domains = alternate_domains + example.run + Rails.configuration.x.alternate_domains = tmp + end + + shared_examples 'a successful response' do + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'sets only a Vary Origin header' do + expect(response.headers['Vary']).to eq('Origin') + end + + it 'returns application/jrd+json' do + expect(response.media_type).to eq 'application/jrd+json' + end + + it 'returns links for the account' do + json = body_as_json + expect(json[:subject]).to eq 'acct:alice@cb6e6126.ngrok.io' + expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice') + end + end + + context 'when an account exists' do + let(:resource) { alice.to_webfinger_s } + + before do + perform_request! + end + + it_behaves_like 'a successful response' + end + + context 'when an account is temporarily suspended' do + let(:resource) { alice.to_webfinger_s } + + before do + alice.suspend! + perform_request! + end + + it_behaves_like 'a successful response' + end + + context 'when an account is permanently suspended or deleted' do + let(:resource) { alice.to_webfinger_s } + + before do + alice.suspend! + alice.deletion_request.destroy + perform_request! + end + + it 'returns http gone' do + expect(response).to have_http_status(410) + end + end + + context 'when an account is not found' do + let(:resource) { 'acct:not@existing.com' } + + before do + perform_request! + end + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + + context 'with an alternate domain' do + let(:alternate_domains) { ['foo.org'] } + + before do + perform_request! + end + + context 'when an account exists' do + let(:resource) do + username, = alice.to_webfinger_s.split('@') + "#{username}@foo.org" + end + + it_behaves_like 'a successful response' + end + + context 'when the domain is wrong' do + let(:resource) do + username, = alice.to_webfinger_s.split('@') + "#{username}@bar.org" + end + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + end + + context 'when the old name scheme is used to query the instance actor' do + let(:resource) do + "#{Rails.configuration.x.local_domain}@#{Rails.configuration.x.local_domain}" + end + + before do + perform_request! + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'sets only a Vary Origin header' do + expect(response.headers['Vary']).to eq('Origin') + end + + it 'returns application/jrd+json' do + expect(response.media_type).to eq 'application/jrd+json' + end + + it 'returns links for the internal account' do + json = body_as_json + expect(json[:subject]).to eq 'acct:mastodon.internal@cb6e6126.ngrok.io' + expect(json[:aliases]).to eq ['https://cb6e6126.ngrok.io/actor'] + end + end + + context 'with no resource parameter' do + let(:resource) { nil } + + before do + perform_request! + end + + it 'returns http bad request' do + expect(response).to have_http_status(400) + end + end + + context 'with a nonsense parameter' do + let(:resource) { 'df/:dfkj' } + + before do + perform_request! + end + + it 'returns http bad request' do + expect(response).to have_http_status(400) + end + end + + context 'when an account has an avatar' do + let(:alice) { Fabricate(:account, username: 'alice', avatar: attachment_fixture('attachment.jpg')) } + let(:resource) { alice.to_webfinger_s } + + it 'returns avatar in response' do + perform_request! + + avatar_link = get_avatar_link(body_as_json) + expect(avatar_link).to_not be_nil + expect(avatar_link[:type]).to eq alice.avatar.content_type + expect(avatar_link[:href]).to eq Addressable::URI.new(host: Rails.configuration.x.local_domain, path: alice.avatar.to_s, scheme: 'https').to_s + end + + context 'with limited federation mode' do + before do + allow(Rails.configuration.x).to receive(:limited_federation_mode).and_return(true) + end + + it 'does not return avatar in response' do + perform_request! + + avatar_link = get_avatar_link(body_as_json) + expect(avatar_link).to be_nil + end + end + + context 'when enabling DISALLOW_UNAUTHENTICATED_API_ACCESS' do + around do |example| + ClimateControl.modify DISALLOW_UNAUTHENTICATED_API_ACCESS: 'true' do + example.run + end + end + + it 'does not return avatar in response' do + perform_request! + + avatar_link = get_avatar_link(body_as_json) + expect(avatar_link).to be_nil + end + end + end + + context 'when an account does not have an avatar' do + let(:alice) { Fabricate(:account, username: 'alice', avatar: nil) } + let(:resource) { alice.to_webfinger_s } + + before do + perform_request! + end + + it 'does not return avatar in response' do + avatar_link = get_avatar_link(body_as_json) + expect(avatar_link).to be_nil + end + end + + context 'with different headers' do + describe 'requested with standard accepts headers' do + it 'returns a json response' do + get webfinger_url(resource: alice.to_webfinger_s) + + expect(response).to have_http_status(200) + expect(response.media_type).to eq 'application/jrd+json' + end + end + + describe 'asking for json format' do + it 'returns a json response for json format' do + get webfinger_url(resource: alice.to_webfinger_s, format: :json) + + expect(response).to have_http_status(200) + expect(response.media_type).to eq 'application/jrd+json' + end + + it 'returns a json response for json accept header' do + headers = { 'HTTP_ACCEPT' => 'application/jrd+json' } + get webfinger_url(resource: alice.to_webfinger_s), headers: headers + + expect(response).to have_http_status(200) + expect(response.media_type).to eq 'application/jrd+json' + end + end + end + + private + + def get_avatar_link(json) + json[:links].find { |link| link[:rel] == 'http://webfinger.net/rel/avatar' } + end +end diff --git a/spec/search/models/concerns/account/search_spec.rb b/spec/search/models/concerns/account/search_spec.rb new file mode 100644 index 0000000000..d8d7f355dd --- /dev/null +++ b/spec/search/models/concerns/account/search_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Account::Search do + describe 'a non-discoverable account becoming discoverable' do + let(:account) { Account.find_by(username: 'search_test_account_1') } + + context 'when picking a non-discoverable account' do + it 'its bio is not in the AccountsIndex' do + results = AccountsIndex.filter(term: { username: account.username }) + expect(results.count).to eq(1) + expect(results.first.text).to be_nil + end + end + + context 'when the non-discoverable account becomes discoverable' do + it 'its bio is added to the AccountsIndex' do + account.discoverable = true + account.save! + + results = AccountsIndex.filter(term: { username: account.username }) + expect(results.count).to eq(1) + expect(results.first.text).to eq(account.note) + end + end + end + + describe 'a discoverable account becoming non-discoverable' do + let(:account) { Account.find_by(username: 'search_test_account_0') } + + context 'when picking an discoverable account' do + it 'has its bio in the AccountsIndex' do + results = AccountsIndex.filter(term: { username: account.username }) + expect(results.count).to eq(1) + expect(results.first.text).to eq(account.note) + end + end + + context 'when the discoverable account becomes non-discoverable' do + it 'its bio is removed from the AccountsIndex' do + account.discoverable = false + account.save! + + results = AccountsIndex.filter(term: { username: account.username }) + expect(results.count).to eq(1) + expect(results.first.text).to be_nil + end + end + end +end diff --git a/spec/search/models/concerns/account/statuses_search_spec.rb b/spec/search/models/concerns/account/statuses_search_spec.rb new file mode 100644 index 0000000000..bf2606eece --- /dev/null +++ b/spec/search/models/concerns/account/statuses_search_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Account::StatusesSearch do + describe 'a non-indexable account becoming indexable' do + let(:account) { Account.find_by(username: 'search_test_account_1') } + + context 'when picking a non-indexable account' do + it 'has no statuses in the PublicStatusesIndex' do + expect(PublicStatusesIndex.filter(term: { account_id: account.id }).count).to eq(0) + end + + it 'has statuses in the StatusesIndex' do + expect(StatusesIndex.filter(term: { account_id: account.id }).count).to eq(account.statuses.count) + end + end + + context 'when the non-indexable account becomes indexable' do + it 'adds the public statuses to the PublicStatusesIndex' do + account.indexable = true + account.save! + + expect(PublicStatusesIndex.filter(term: { account_id: account.id }).count).to eq(account.statuses.where(visibility: :public).count) + expect(StatusesIndex.filter(term: { account_id: account.id }).count).to eq(account.statuses.count) + end + end + end + + describe 'an indexable account becoming non-indexable' do + let(:account) { Account.find_by(username: 'search_test_account_0') } + + context 'when picking an indexable account' do + it 'has statuses in the PublicStatusesIndex' do + expect(PublicStatusesIndex.filter(term: { account_id: account.id }).count).to eq(account.statuses.where(visibility: :public).count) + end + + it 'has statuses in the StatusesIndex' do + expect(StatusesIndex.filter(term: { account_id: account.id }).count).to eq(account.statuses.count) + end + end + + context 'when the indexable account becomes non-indexable' do + it 'removes the statuses from the PublicStatusesIndex' do + account.indexable = false + account.save! + + expect(PublicStatusesIndex.filter(term: { account_id: account.id }).count).to eq(0) + expect(StatusesIndex.filter(term: { account_id: account.id }).count).to eq(account.statuses.count) + end + end + end +end diff --git a/spec/serializers/activitypub/device_serializer_spec.rb b/spec/serializers/activitypub/device_serializer_spec.rb index 2a3be82121..23f0b24c4e 100644 --- a/spec/serializers/activitypub/device_serializer_spec.rb +++ b/spec/serializers/activitypub/device_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe ActivityPub::DeviceSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Fabricate(:device) } describe 'type' do diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index 4b2b8ec875..338d66b308 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -3,23 +3,26 @@ require 'rails_helper' describe ActivityPub::NoteSerializer do - subject { JSON.parse(@serialization.to_json) } + subject { serialized_record_json(parent, described_class, adapter: ActivityPub::Adapter) } let!(:account) { Fabricate(:account) } let!(:other) { Fabricate(:account) } - let!(:parent) { Fabricate(:status, account: account, visibility: :public) } + let!(:parent) { Fabricate(:status, account: account, visibility: :public, language: 'zh-TW') } let!(:reply_by_account_first) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_account_next) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_other_first) { Fabricate(:status, account: other, thread: parent, visibility: :public) } let!(:reply_by_account_third) { Fabricate(:status, account: account, thread: parent, visibility: :public) } let!(:reply_by_account_visibility_direct) { Fabricate(:status, account: account, thread: parent, visibility: :direct) } - before(:each) do - @serialization = ActiveModelSerializers::SerializableResource.new(parent, serializer: described_class, adapter: ActivityPub::Adapter) - end - - it 'has a Note type' do - expect(subject['type']).to eql('Note') + it 'has the expected shape' do + expect(subject).to include({ + '@context' => include('https://www.w3.org/ns/activitystreams'), + 'type' => 'Note', + 'attributedTo' => ActivityPub::TagManager.instance.uri_for(account), + 'contentMap' => include({ + 'zh-TW' => a_kind_of(String), + }), + }) end it 'has a replies collection' do diff --git a/spec/serializers/activitypub/one_time_key_serializer_spec.rb b/spec/serializers/activitypub/one_time_key_serializer_spec.rb index 6fe1f06185..89efe95c8c 100644 --- a/spec/serializers/activitypub/one_time_key_serializer_spec.rb +++ b/spec/serializers/activitypub/one_time_key_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe ActivityPub::OneTimeKeySerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Fabricate(:one_time_key) } describe 'type' do diff --git a/spec/serializers/activitypub/undo_like_serializer_spec.rb b/spec/serializers/activitypub/undo_like_serializer_spec.rb index 43cf7192e4..3d61e86751 100644 --- a/spec/serializers/activitypub/undo_like_serializer_spec.rb +++ b/spec/serializers/activitypub/undo_like_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe ActivityPub::UndoLikeSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Fabricate(:favourite) } describe 'type' do diff --git a/spec/serializers/activitypub/update_poll_serializer_spec.rb b/spec/serializers/activitypub/update_poll_serializer_spec.rb index 14c24c70cc..8ff4fd2701 100644 --- a/spec/serializers/activitypub/update_poll_serializer_spec.rb +++ b/spec/serializers/activitypub/update_poll_serializer_spec.rb @@ -3,16 +3,12 @@ require 'rails_helper' describe ActivityPub::UpdatePollSerializer do - subject { JSON.parse(@serialization.to_json) } + subject { serialized_record_json(status, described_class, adapter: ActivityPub::Adapter) } let(:account) { Fabricate(:account) } let(:poll) { Fabricate(:poll, account: account) } let!(:status) { Fabricate(:status, account: account, poll: poll) } - before(:each) do - @serialization = ActiveModelSerializers::SerializableResource.new(status, serializer: described_class, adapter: ActivityPub::Adapter) - end - it 'has a Update type' do expect(subject['type']).to eql('Update') end diff --git a/spec/serializers/activitypub/vote_serializer_spec.rb b/spec/serializers/activitypub/vote_serializer_spec.rb index c329542d79..b7c0b8928b 100644 --- a/spec/serializers/activitypub/vote_serializer_spec.rb +++ b/spec/serializers/activitypub/vote_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe ActivityPub::VoteSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Fabricate(:poll_vote) } describe 'type' do diff --git a/spec/serializers/rest/account_serializer_spec.rb b/spec/serializers/rest/account_serializer_spec.rb index e399e88f37..15939e484d 100644 --- a/spec/serializers/rest/account_serializer_spec.rb +++ b/spec/serializers/rest/account_serializer_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe REST::AccountSerializer do - subject { JSON.parse(ActiveModelSerializers::SerializableResource.new(account, serializer: described_class).to_json) } + subject { serialized_record_json(account, described_class) } let(:role) { Fabricate(:user_role, name: 'Role', highlighted: true) } let(:user) { Fabricate(:user, role: role) } diff --git a/spec/serializers/rest/encrypted_message_serializer_spec.rb b/spec/serializers/rest/encrypted_message_serializer_spec.rb index e0e70a3b84..01db1149af 100644 --- a/spec/serializers/rest/encrypted_message_serializer_spec.rb +++ b/spec/serializers/rest/encrypted_message_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe REST::EncryptedMessageSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Fabricate(:encrypted_message) } describe 'account' do diff --git a/spec/serializers/rest/instance_serializer_spec.rb b/spec/serializers/rest/instance_serializer_spec.rb index 15a5de18dd..d8f2536d20 100644 --- a/spec/serializers/rest/instance_serializer_spec.rb +++ b/spec/serializers/rest/instance_serializer_spec.rb @@ -3,18 +3,18 @@ require 'rails_helper' describe REST::InstanceSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { InstancePresenter.new } describe 'usage' do it 'returns recent usage data' do expect(serialization['usage']).to eq({ 'users' => { 'active_month' => 0 } }) end + + it 'returns the VAPID public key' do + expect(serialization['configuration']['vapid']).to eq({ + 'public_key' => Rails.configuration.x.vapid_public_key, + }) + end end end diff --git a/spec/serializers/rest/keys/claim_result_serializer_spec.rb b/spec/serializers/rest/keys/claim_result_serializer_spec.rb index cf9416f032..7f7fb850cd 100644 --- a/spec/serializers/rest/keys/claim_result_serializer_spec.rb +++ b/spec/serializers/rest/keys/claim_result_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe REST::Keys::ClaimResultSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Keys::ClaimService::Result.new(Account.new(id: 123), 456) } describe 'account' do diff --git a/spec/serializers/rest/keys/device_serializer_spec.rb b/spec/serializers/rest/keys/device_serializer_spec.rb index c15e197cb8..28177a3db5 100644 --- a/spec/serializers/rest/keys/device_serializer_spec.rb +++ b/spec/serializers/rest/keys/device_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe REST::Keys::DeviceSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Device.new(name: 'Device name') } describe 'name' do diff --git a/spec/serializers/rest/keys/query_result_serializer_spec.rb b/spec/serializers/rest/keys/query_result_serializer_spec.rb index 983780ae98..ef67d70675 100644 --- a/spec/serializers/rest/keys/query_result_serializer_spec.rb +++ b/spec/serializers/rest/keys/query_result_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe REST::Keys::QueryResultSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) { Keys::QueryService::Result.new(Account.new(id: 123), []) } describe 'account' do diff --git a/spec/serializers/rest/suggestion_serializer_spec.rb b/spec/serializers/rest/suggestion_serializer_spec.rb index b3c086208d..60420d8023 100644 --- a/spec/serializers/rest/suggestion_serializer_spec.rb +++ b/spec/serializers/rest/suggestion_serializer_spec.rb @@ -3,13 +3,7 @@ require 'rails_helper' describe REST::SuggestionSerializer do - let(:serialization) do - JSON.parse( - ActiveModelSerializers::SerializableResource.new( - record, serializer: described_class - ).to_json - ) - end + let(:serialization) { serialized_record_json(record, described_class) } let(:record) do AccountSuggestions::Suggestion.new( account: account, diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb index 1cd036f484..4f89cd220c 100644 --- a/spec/services/account_search_service_spec.rb +++ b/spec/services/account_search_service_spec.rb @@ -56,7 +56,7 @@ describe AccountSearchService, type: :service do service = instance_double(ResolveAccountService, call: nil) allow(ResolveAccountService).to receive(:new).and_return(service) - results = subject.call('newuser@remote.com', nil, limit: 10, resolve: true) + subject.call('newuser@remote.com', nil, limit: 10, resolve: true) expect(service).to have_received(:call).with('newuser@remote.com') end @@ -64,14 +64,14 @@ describe AccountSearchService, type: :service do service = instance_double(ResolveAccountService, call: nil) allow(ResolveAccountService).to receive(:new).and_return(service) - results = subject.call('newuser@remote.com', nil, limit: 10, resolve: false) + subject.call('newuser@remote.com', nil, limit: 10, resolve: false) expect(service).to_not have_received(:call) end end it 'returns the fuzzy match first, and does not return suspended exacts' do partial = Fabricate(:account, username: 'exactness') - exact = Fabricate(:account, username: 'exact', suspended: true) + Fabricate(:account, username: 'exact', suspended: true) results = subject.call('exact', nil, limit: 10) expect(results.size).to eq 1 @@ -79,7 +79,7 @@ describe AccountSearchService, type: :service do end it 'does not return suspended remote accounts' do - remote = Fabricate(:account, username: 'a', domain: 'remote', display_name: 'e', suspended: true) + Fabricate(:account, username: 'a', domain: 'remote', display_name: 'e', suspended: true) results = subject.call('a@example.com', nil, limit: 2) expect(results.size).to eq 0 diff --git a/spec/services/account_statuses_cleanup_service_spec.rb b/spec/services/account_statuses_cleanup_service_spec.rb index f7a88a9172..0ac113f105 100644 --- a/spec/services/account_statuses_cleanup_service_spec.rb +++ b/spec/services/account_statuses_cleanup_service_spec.rb @@ -39,6 +39,13 @@ describe AccountStatusesCleanupService, type: :service do it 'actually deletes the statuses' do subject.call(account_policy, 10) expect(Status.find_by(id: [very_old_status.id, old_status.id, another_old_status.id])).to be_nil + expect { recent_status.reload }.to_not raise_error + end + + it 'preserves recent and unrelated statuses' do + subject.call(account_policy, 10) + expect { unrelated_status.reload }.to_not raise_error + expect { recent_status.reload }.to_not raise_error end end diff --git a/spec/services/activitypub/fetch_featured_collection_service_spec.rb b/spec/services/activitypub/fetch_featured_collection_service_spec.rb index 5975c81a10..a98108cea3 100644 --- a/spec/services/activitypub/fetch_featured_collection_service_spec.rb +++ b/spec/services/activitypub/fetch_featured_collection_service_spec.rb @@ -42,12 +42,22 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do } end + let(:featured_with_null) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/account/collections/featured', + totalItems: 0, + type: 'OrderedCollection', + } + end + let(:items) do [ 'https://example.com/account/pinned/known', # known status_json_pinned_unknown_inlined, # unknown inlined 'https://example.com/account/pinned/unknown-unreachable', # unknown unreachable 'https://example.com/account/pinned/unknown-reachable', # unknown reachable + 'https://example.com/account/collections/featured', # featured with null ] end @@ -66,6 +76,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do stub_request(:get, 'https://example.com/account/pinned/unknown-inlined').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_inlined)) stub_request(:get, 'https://example.com/account/pinned/unknown-unreachable').to_return(status: 404) stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_unreachable)) + stub_request(:get, 'https://example.com/account/collections/featured').to_return(status: 200, body: Oj.dump(featured_with_null)) subject.call(actor, note: true, hashtag: false) end @@ -76,6 +87,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do 'https://example.com/account/pinned/unknown-inlined', 'https://example.com/account/pinned/unknown-reachable' ) + expect(actor.pinned_statuses).to_not include(known_status) end end diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb index cd8f29dddd..e210d20ec7 100644 --- a/spec/services/activitypub/fetch_remote_key_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_key_service_spec.rb @@ -8,7 +8,17 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } let(:public_key_pem) do - "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu3L4vnpNLzVH31MeWI39\n4F0wKeJFsLDAsNXGeOu0QF2x+h1zLWZw/agqD2R3JPU9/kaDJGPIV2Sn5zLyUA9S\n6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh\n8lDET6X4Pyw+ZJU0/OLo/41q9w+OrGtlsTm/PuPIeXnxa6BLqnDaxC+4IcjG/FiP\nahNCTINl/1F/TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq+t8nhQYkgAkt64euW\npva3qL5KD1mTIZQEP+LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3Qvu\nHQIDAQAB\n-----END PUBLIC KEY-----\n" + <<~TEXT + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu3L4vnpNLzVH31MeWI39 + 4F0wKeJFsLDAsNXGeOu0QF2x+h1zLWZw/agqD2R3JPU9/kaDJGPIV2Sn5zLyUA9S + 6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh + 8lDET6X4Pyw+ZJU0/OLo/41q9w+OrGtlsTm/PuPIeXnxa6BLqnDaxC+4IcjG/FiP + ahNCTINl/1F/TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq+t8nhQYkgAkt64euW + pva3qL5KD1mTIZQEP+LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3Qvu + HQIDAQAB + -----END PUBLIC KEY----- + TEXT end let(:public_key_id) { 'https://example.com/alice#main-key' } diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb index 826b67d884..3c64feaeec 100644 --- a/spec/services/activitypub/fetch_remote_status_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -8,7 +8,6 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do subject { described_class.new } let!(:sender) { Fabricate(:account, domain: 'foo.bar', uri: 'https://foo.bar') } - let!(:recipient) { Fabricate(:account) } let(:existing_status) { nil } diff --git a/spec/services/activitypub/fetch_replies_service_spec.rb b/spec/services/activitypub/fetch_replies_service_spec.rb index bf8e296764..d7716dd4ef 100644 --- a/spec/services/activitypub/fetch_replies_service_spec.rb +++ b/spec/services/activitypub/fetch_replies_service_spec.rb @@ -36,8 +36,11 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do context 'when the payload is a Collection with inlined replies' do context 'when passing the collection itself' do it 'spawns workers for up to 5 replies on the same server' do - expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, payload) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) end end @@ -47,8 +50,11 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do end it 'spawns workers for up to 5 replies on the same server' do - expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, collection_uri) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) end end end @@ -65,8 +71,11 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do context 'when passing the collection itself' do it 'spawns workers for up to 5 replies on the same server' do - expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, payload) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) end end @@ -76,8 +85,11 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do end it 'spawns workers for up to 5 replies on the same server' do - expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, collection_uri) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) end end end @@ -98,8 +110,11 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do context 'when passing the collection itself' do it 'spawns workers for up to 5 replies on the same server' do - expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, payload) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) end end @@ -109,8 +124,11 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do end it 'spawns workers for up to 5 replies on the same server' do - expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) + allow(FetchReplyWorker).to receive(:push_bulk) + subject.call(status, collection_uri) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5']) end end end diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb index db454d7ad9..58dd2badbb 100644 --- a/spec/services/activitypub/process_account_service_spec.rb +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -33,7 +33,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end context 'when account is not suspended' do - subject { described_class.new.call('alice', 'example.com', payload) } + subject { described_class.new.call(account.username, account.domain, payload) } let!(:account) { Fabricate(:account, username: 'alice', domain: 'example.com') } @@ -113,11 +113,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end context 'when discovering many subdomains in a short timeframe' do - before do - stub_const 'ActivityPub::ProcessAccountService::SUBDOMAINS_RATELIMIT', 5 - end - - let(:subject) do + subject do 8.times do |i| domain = "test#{i}.testdomain.com" json = { @@ -129,12 +125,14 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end - it 'creates at least some accounts' do - expect { subject }.to change { Account.remote.count }.by_at_least(2) + before do + stub_const 'ActivityPub::ProcessAccountService::SUBDOMAINS_RATELIMIT', 5 end - it 'creates no more account than the limit allows' do - expect { subject }.to change { Account.remote.count }.by_at_most(5) + it 'creates accounts without exceeding rate limit' do + expect { subject } + .to create_some_remote_accounts + .and create_fewer_than_rate_limit_accounts end end @@ -195,12 +193,20 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do end end - it 'creates at least some accounts' do - expect { subject.call('user1', 'foo.test', payload) }.to change { Account.remote.count }.by_at_least(2) - end - - it 'creates no more account than the limit allows' do - expect { subject.call('user1', 'foo.test', payload) }.to change { Account.remote.count }.by_at_most(5) + it 'creates accounts without exceeding rate limit' do + expect { subject.call('user1', 'foo.test', payload) } + .to create_some_remote_accounts + .and create_fewer_than_rate_limit_accounts end end + + private + + def create_some_remote_accounts + change(Account.remote, :count).by_at_least(2) + end + + def create_fewer_than_rate_limit_accounts + change(Account.remote, :count).by_at_most(5) + end end diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb index 1433d0c505..f4a2b8fec6 100644 --- a/spec/services/activitypub/process_collection_service_spec.rb +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -41,8 +41,11 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do end it 'does not process payload' do - expect(ActivityPub::Activity).to_not receive(:factory) + allow(ActivityPub::Activity).to receive(:factory) + subject.call(json, actor) + + expect(ActivityPub::Activity).to_not have_received(:factory) end end end @@ -59,8 +62,11 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do end it 'processes the payload' do - expect(ActivityPub::Activity).to receive(:factory) + allow(ActivityPub::Activity).to receive(:factory) + subject.call(json, actor) + + expect(ActivityPub::Activity).to have_received(:factory) end end end @@ -70,28 +76,37 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do let(:forwarder) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/other_account') } it 'does not process payload if no signature exists' do - expect_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_actor!).and_return(nil) - expect(ActivityPub::Activity).to_not receive(:factory) + signature_double = instance_double(ActivityPub::LinkedDataSignature, verify_actor!: nil) + allow(ActivityPub::LinkedDataSignature).to receive(:new).and_return(signature_double) + allow(ActivityPub::Activity).to receive(:factory) subject.call(json, forwarder) + + expect(ActivityPub::Activity).to_not have_received(:factory) end it 'processes payload with actor if valid signature exists' do payload['signature'] = { 'type' => 'RsaSignature2017' } - expect_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_actor!).and_return(actor) - expect(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), actor, instance_of(Hash)) + signature_double = instance_double(ActivityPub::LinkedDataSignature, verify_actor!: actor) + allow(ActivityPub::LinkedDataSignature).to receive(:new).and_return(signature_double) + allow(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), actor, instance_of(Hash)) subject.call(json, forwarder) + + expect(ActivityPub::Activity).to have_received(:factory).with(instance_of(Hash), actor, instance_of(Hash)) end it 'does not process payload if invalid signature exists' do payload['signature'] = { 'type' => 'RsaSignature2017' } - expect_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_actor!).and_return(nil) - expect(ActivityPub::Activity).to_not receive(:factory) + signature_double = instance_double(ActivityPub::LinkedDataSignature, verify_actor!: nil) + allow(ActivityPub::LinkedDataSignature).to receive(:new).and_return(signature_double) + allow(ActivityPub::Activity).to receive(:factory) subject.call(json, forwarder) + + expect(ActivityPub::Activity).to_not have_received(:factory) end context 'when receiving a fabricated status' do @@ -100,8 +115,18 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do username: 'bob', domain: 'example.com', uri: 'https://example.com/users/bob', - public_key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuuYyoyfsRkYnXRotMsId\nW3euBDDfiv9oVqOxUVC7bhel8KednIMrMCRWFAkgJhbrlzbIkjVr68o1MP9qLcn7\nCmH/BXHp7yhuFTr4byjdJKpwB+/i2jNEsvDH5jR8WTAeTCe0x/QHg21V3F7dSI5m\nCCZ/1dSIyOXLRTWVlfDlm3rE4ntlCo+US3/7oSWbg/4/4qEnt1HC32kvklgScxua\n4LR5ATdoXa5bFoopPWhul7MJ6NyWCyQyScUuGdlj8EN4kmKQJvphKHrI9fvhgOuG\nTvhTR1S5InA4azSSchY0tXEEw/VNxraeX0KPjbgr6DPcwhPd/m0nhVDq0zVyVBBD\nMwIDAQAB\n-----END PUBLIC KEY-----\n", - private_key: nil) + private_key: nil, + public_key: <<~TEXT) + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuuYyoyfsRkYnXRotMsId + W3euBDDfiv9oVqOxUVC7bhel8KednIMrMCRWFAkgJhbrlzbIkjVr68o1MP9qLcn7 + CmH/BXHp7yhuFTr4byjdJKpwB+/i2jNEsvDH5jR8WTAeTCe0x/QHg21V3F7dSI5m + CCZ/1dSIyOXLRTWVlfDlm3rE4ntlCo+US3/7oSWbg/4/4qEnt1HC32kvklgScxua + 4LR5ATdoXa5bFoopPWhul7MJ6NyWCyQyScUuGdlj8EN4kmKQJvphKHrI9fvhgOuG + TvhTR1S5InA4azSSchY0tXEEw/VNxraeX0KPjbgr6DPcwhPd/m0nhVDq0zVyVBBD + MwIDAQAB + -----END PUBLIC KEY----- + TEXT end let(:payload) do @@ -125,7 +150,14 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do type: 'RsaSignature2017', creator: 'https://example.com/users/bob#main-key', created: '2022-03-09T21:57:25Z', - signatureValue: 'WculK0LelTQ0MvGwU9TPoq5pFzFfGYRDCJqjZ232/Udj4CHqDTGOSw5UTDLShqBOyycCkbZGrQwXG+dpyDpQLSe1UVPZ5TPQtc/9XtI57WlS2nMNpdvRuxGnnb2btPdesXZ7n3pCxo0zjaXrJMe0mqQh5QJO22mahb4bDwwmfTHgbD3nmkD+fBfGi+UV2qWwqr+jlV4L4JqNkh0gWljF5KTePLRRZCuWiQ/FAt7c67636cdIPf7fR+usjuZltTQyLZKEGuK8VUn2Gkfsx5qns7Vcjvlz1JqlAjyO8HPBbzTTHzUG2nUOIgC3PojCSWv6mNTmRGoLZzOscCAYQA6cKw==', + signatureValue: 'WculK0LelTQ0MvGwU9TPoq5pFzFfGYRDCJqjZ232/Udj4' \ + 'CHqDTGOSw5UTDLShqBOyycCkbZGrQwXG+dpyDpQLSe1UV' \ + 'PZ5TPQtc/9XtI57WlS2nMNpdvRuxGnnb2btPdesXZ7n3p' \ + 'Cxo0zjaXrJMe0mqQh5QJO22mahb4bDwwmfTHgbD3nmkD+' \ + 'fBfGi+UV2qWwqr+jlV4L4JqNkh0gWljF5KTePLRRZCuWi' \ + 'Q/FAt7c67636cdIPf7fR+usjuZltTQyLZKEGuK8VUn2Gk' \ + 'fsx5qns7Vcjvlz1JqlAjyO8HPBbzTTHzUG2nUOIgC3Poj' \ + 'CSWv6mNTmRGoLZzOscCAYQA6cKw==', }, '@id': 'https://example.com/users/bob/statuses/107928807471117876/activity', '@type': 'https://www.w3.org/ns/activitystreams#Create', @@ -208,7 +240,12 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do end it 'does not process forged payload' do - expect(ActivityPub::Activity).to_not receive(:factory).with( + allow(ActivityPub::Activity).to receive(:factory) + + expect { subject.call(json, forwarder) } + .to_not change(actor.reload.statuses, :count) + + expect(ActivityPub::Activity).to_not have_received(:factory).with( hash_including( 'object' => hash_including( 'id' => 'https://example.com/users/bob/fake-status' @@ -218,7 +255,7 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do anything ) - expect(ActivityPub::Activity).to_not receive(:factory).with( + expect(ActivityPub::Activity).to_not have_received(:factory).with( hash_including( 'object' => hash_including( 'content' => '

puck was here

' @@ -228,8 +265,6 @@ RSpec.describe ActivityPub::ProcessCollectionService, type: :service do anything ) - subject.call(json, forwarder) - expect(Status.where(uri: 'https://example.com/users/bob/fake-status').exists?).to be false end end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 9d90e5eb80..53cbaf4cc1 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -41,12 +41,12 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do describe '#call' do it 'updates text' do - subject.call(status, json) + subject.call(status, json, json) expect(status.reload.text).to eq 'Hello universe' end it 'updates content warning' do - subject.call(status, json) + subject.call(status, json, json) expect(status.reload.spoiler_text).to eq 'Show more' end @@ -64,7 +64,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end before do - subject.call(status, json) + subject.call(status, json, json) end it 'does not create any edits' do @@ -87,7 +87,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end before do - subject.call(status, json) + subject.call(status, json, json) end it 'does not create any edits' do @@ -134,7 +134,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end before do - subject.call(status, json) + subject.call(status, json, json) end it 'does not create any edits' do @@ -186,7 +186,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end before do - subject.call(status, json) + subject.call(status, json, json) end it 'does not create any edits' do @@ -214,11 +214,11 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end it 'does not create any edits' do - expect { subject.call(status, json) }.to_not change { status.reload.edits.pluck(&:id) } + expect { subject.call(status, json, json) }.to_not(change { status.reload.edits.pluck(&:id) }) end it 'does not update the text, spoiler_text or edited_at' do - expect { subject.call(status, json) }.to_not change { s = status.reload; [s.text, s.spoiler_text, s.edited_at] } + expect { subject.call(status, json, json) }.to_not(change { s = status.reload; [s.text, s.spoiler_text, s.edited_at] }) end end @@ -233,7 +233,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end before do - subject.call(status, json) + subject.call(status, json, json) end it 'does not create any edits' do @@ -257,7 +257,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do before do status.update(ordered_media_attachment_ids: nil) - subject.call(status, json) + subject.call(status, json, json) end it 'does not create any edits' do @@ -271,7 +271,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do context 'when originally without tags' do before do - subject.call(status, json) + subject.call(status, json, json) end it 'updates tags' do @@ -297,7 +297,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end before do - subject.call(status, json) + subject.call(status, json, json) end it 'updates tags' do @@ -307,7 +307,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do context 'when originally without mentions' do before do - subject.call(status, json) + subject.call(status, json, json) end it 'updates mentions' do @@ -319,7 +319,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do let(:mentions) { [alice, bob] } before do - subject.call(status, json) + subject.call(status, json, json) end it 'updates mentions' do @@ -330,7 +330,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do context 'when originally without media attachments' do before do stub_request(:get, 'https://example.com/foo.png').to_return(body: attachment_fixture('emojo.png')) - subject.call(status, json) + subject.call(status, json, json) end let(:payload) do @@ -380,11 +380,11 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do before do allow(RedownloadMediaWorker).to receive(:perform_async) - subject.call(status, json) + subject.call(status, json, json) end it 'updates the existing media attachment in-place' do - media_attachment = status.media_attachments.reload.first + media_attachment = status.media_attachments.ordered.reload.first expect(media_attachment).to_not be_nil expect(media_attachment.remote_url).to eq 'https://example.com/foo.png' @@ -408,7 +408,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do before do poll = Fabricate(:poll, status: status) status.update(preloadable_poll: poll) - subject.call(status, json) + subject.call(status, json, json) end it 'removes poll' do @@ -438,7 +438,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end before do - subject.call(status, json) + subject.call(status, json, json) end it 'creates a poll' do @@ -454,12 +454,12 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do end it 'creates edit history' do - subject.call(status, json) + subject.call(status, json, json) expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe'] end it 'sets edited timestamp' do - subject.call(status, json) + subject.call(status, json, json) expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC' end end diff --git a/spec/services/after_block_domain_from_account_service_spec.rb b/spec/services/after_block_domain_from_account_service_spec.rb index 9bfaa35807..05af125997 100644 --- a/spec/services/after_block_domain_from_account_service_spec.rb +++ b/spec/services/after_block_domain_from_account_service_spec.rb @@ -9,7 +9,6 @@ RSpec.describe AfterBlockDomainFromAccountService, type: :service do let!(:alice) { Fabricate(:account, username: 'alice') } before do - stub_jsonld_contexts! allow(ActivityPub::DeliveryWorker).to receive(:perform_async) end diff --git a/spec/services/app_sign_up_service_spec.rb b/spec/services/app_sign_up_service_spec.rb index 2532304964..0adb473f17 100644 --- a/spec/services/app_sign_up_service_spec.rb +++ b/spec/services/app_sign_up_service_spec.rb @@ -10,46 +10,67 @@ RSpec.describe AppSignUpService, type: :service do let(:remote_ip) { IPAddr.new('198.0.2.1') } describe '#call' do - it 'returns nil when registrations are closed' do - tmp = Setting.registrations_mode - Setting.registrations_mode = 'none' - expect { subject.call(app, remote_ip, good_params) }.to raise_error Mastodon::NotPermittedError - Setting.registrations_mode = tmp + let(:params) { good_params } + + shared_examples 'successful registration' do + it 'creates an unconfirmed user with access token and the app\'s scope', :aggregate_failures do + access_token = subject.call(app, remote_ip, params) + expect(access_token).to_not be_nil + expect(access_token.scopes.to_s).to eq 'read write' + + user = User.find_by(id: access_token.resource_owner_id) + expect(user).to_not be_nil + expect(user.confirmed?).to be false + + expect(user.account).to_not be_nil + expect(user.invite_request).to be_nil + end + end + + context 'when registrations are closed' do + before do + Setting.registrations_mode = 'none' + end + + it 'raises an error', :aggregate_failures do + expect { subject.call(app, remote_ip, good_params) }.to raise_error Mastodon::NotPermittedError + end + + context 'when using a valid invite' do + let(:params) { good_params.merge({ invite_code: invite.code }) } + let(:invite) { Fabricate(:invite) } + + before do + invite.user.approve! + end + + it_behaves_like 'successful registration' + end + + context 'when using an invalid invite' do + let(:params) { good_params.merge({ invite_code: invite.code }) } + let(:invite) { Fabricate(:invite, uses: 1, max_uses: 1) } + + it 'raises an error', :aggregate_failures do + expect { subject.call(app, remote_ip, params) }.to raise_error Mastodon::NotPermittedError + end + end end it 'raises an error when params are missing' do expect { subject.call(app, remote_ip, {}) }.to raise_error ActiveRecord::RecordInvalid end - it 'creates an unconfirmed user with access token' do - access_token = subject.call(app, remote_ip, good_params) - expect(access_token).to_not be_nil - user = User.find_by(id: access_token.resource_owner_id) - expect(user).to_not be_nil - expect(user.confirmed?).to be false - end + it_behaves_like 'successful registration' - it 'creates access token with the app\'s scopes' do - access_token = subject.call(app, remote_ip, good_params) - expect(access_token).to_not be_nil - expect(access_token.scopes.to_s).to eq 'read write' - end - - it 'creates an account' do - access_token = subject.call(app, remote_ip, good_params) - expect(access_token).to_not be_nil - user = User.find_by(id: access_token.resource_owner_id) - expect(user).to_not be_nil - expect(user.account).to_not be_nil - expect(user.invite_request).to be_nil - end - - it 'creates an account with invite request text' do - access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar')) - expect(access_token).to_not be_nil - user = User.find_by(id: access_token.resource_owner_id) - expect(user).to_not be_nil - expect(user.invite_request&.text).to eq 'Foo bar' + context 'when given an invite request text' do + it 'creates an account with invite request text' do + access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar')) + expect(access_token).to_not be_nil + user = User.find_by(id: access_token.resource_owner_id) + expect(user).to_not be_nil + expect(user.invite_request&.text).to eq 'Foo bar' + end end end end diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 1363c81d05..991a852f0e 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe BatchedRemoveStatusService, type: :service do let!(:jeff) { Fabricate(:account) } let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } - let(:status_alice_hello) { PostStatusService.new.call(alice, text: 'Hello @bob@example.com') } + let(:status_alice_hello) { PostStatusService.new.call(alice, text: "Hello @#{bob.pretty_acct}") } let(:status_alice_other) { PostStatusService.new.call(alice, text: 'Another status') } before do @@ -34,11 +34,11 @@ RSpec.describe BatchedRemoveStatusService, type: :service do end it 'removes statuses from author\'s home feed' do - expect(HomeFeed.new(alice).get(10)).to_not include([status_alice_hello.id, status_alice_other.id]) + expect(HomeFeed.new(alice).get(10).pluck(:id)).to_not include(status_alice_hello.id, status_alice_other.id) end it 'removes statuses from local follower\'s home feed' do - expect(HomeFeed.new(jeff).get(10)).to_not include([status_alice_hello.id, status_alice_other.id]) + expect(HomeFeed.new(jeff).get(10).pluck(:id)).to_not include(status_alice_hello.id, status_alice_other.id) end it 'notifies streaming API of followers' do diff --git a/spec/services/block_domain_service_spec.rb b/spec/services/block_domain_service_spec.rb index 36dce9d196..fc3a1397d9 100644 --- a/spec/services/block_domain_service_spec.rb +++ b/spec/services/block_domain_service_spec.rb @@ -21,19 +21,19 @@ RSpec.describe BlockDomainService, type: :service do end it 'removes remote accounts from that domain' do - expect(Account.find_remote('badguy666', 'evil.org').suspended?).to be true + expect(bad_account.reload.suspended?).to be true end it 'records suspension date appropriately' do - expect(Account.find_remote('badguy666', 'evil.org').suspended_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at + expect(bad_account.reload.suspended_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at end it 'keeps already-banned accounts banned' do - expect(Account.find_remote('badguy', 'evil.org').suspended?).to be true + expect(already_banned_account.reload.suspended?).to be true end it 'does not overwrite suspension date of already-banned accounts' do - expect(Account.find_remote('badguy', 'evil.org').suspended_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at + expect(already_banned_account.reload.suspended_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at end it 'removes the remote accounts\'s statuses and media attachments' do @@ -53,19 +53,19 @@ RSpec.describe BlockDomainService, type: :service do end it 'silences remote accounts from that domain' do - expect(Account.find_remote('badguy666', 'evil.org').silenced?).to be true + expect(bad_account.reload.silenced?).to be true end it 'records suspension date appropriately' do - expect(Account.find_remote('badguy666', 'evil.org').silenced_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at + expect(bad_account.reload.silenced_at).to eq DomainBlock.find_by(domain: 'evil.org').created_at end it 'keeps already-banned accounts banned' do - expect(Account.find_remote('badguy', 'evil.org').silenced?).to be true + expect(already_banned_account.reload.silenced?).to be true end it 'does not overwrite suspension date of already-banned accounts' do - expect(Account.find_remote('badguy', 'evil.org').silenced_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at + expect(already_banned_account.reload.silenced_at).to_not eq DomainBlock.find_by(domain: 'evil.org').created_at end it 'leaves the domains status and attachments, but clears media' do diff --git a/spec/services/bulk_import_row_service_spec.rb b/spec/services/bulk_import_row_service_spec.rb index 5e09845b53..a77acc0732 100644 --- a/spec/services/bulk_import_row_service_spec.rb +++ b/spec/services/bulk_import_row_service_spec.rb @@ -161,6 +161,12 @@ RSpec.describe BulkImportRowService do end include_examples 'common behavior' + + it 'does not create a new list' do + account.follow!(target_account) + + expect { subject.call(import_row) }.to_not(change { List.where(title: 'my list').count }) + end end end end diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb index 281b642ea4..f703650c37 100644 --- a/spec/services/bulk_import_service_spec.rb +++ b/spec/services/bulk_import_service_spec.rb @@ -12,14 +12,7 @@ RSpec.describe BulkImportService do import.update(total_items: import.rows.count) end - describe '#call' do - around do |example| - Sidekiq::Testing.fake! do - example.run - Sidekiq::Worker.clear_all - end - end - + describe '#call', :sidekiq_fake do context 'when importing follows' do let(:import_type) { 'following' } let(:overwrite) { false } @@ -54,7 +47,7 @@ RSpec.describe BulkImportService do Import::RowWorker.drain - expect(FollowRequest.includes(:target_account).where(account: account).map(&:target_account).map(&:acct)).to contain_exactly('user@foo.bar', 'unknown@unknown.bar') + expect(FollowRequest.includes(:target_account).where(account: account).map { |follow_request| follow_request.target_account.acct }).to contain_exactly('user@foo.bar', 'unknown@unknown.bar') end end @@ -102,7 +95,7 @@ RSpec.describe BulkImportService do Import::RowWorker.drain - expect(FollowRequest.includes(:target_account).where(account: account).map(&:target_account).map(&:acct)).to contain_exactly('user@foo.bar', 'unknown@unknown.bar') + expect(FollowRequest.includes(:target_account).where(account: account).map { |follow_request| follow_request.target_account.acct }).to contain_exactly('user@foo.bar', 'unknown@unknown.bar') end end @@ -278,14 +271,15 @@ RSpec.describe BulkImportService do let(:import_type) { 'domain_blocking' } let(:overwrite) { false } - let!(:rows) do + let(:rows) do [ { 'domain' => 'blocked.com' }, { 'domain' => 'to_block.com' }, - ].map { |data| import.rows.create!(data: data) } + ] end before do + rows.each { |data| import.rows.create!(data: data) } account.block_domain!('alreadyblocked.com') account.block_domain!('blocked.com') end @@ -305,14 +299,15 @@ RSpec.describe BulkImportService do let(:import_type) { 'domain_blocking' } let(:overwrite) { true } - let!(:rows) do + let(:rows) do [ { 'domain' => 'blocked.com' }, { 'domain' => 'to_block.com' }, - ].map { |data| import.rows.create!(data: data) } + ] end before do + rows.each { |data| import.rows.create!(data: data) } account.block_domain!('alreadyblocked.com') account.block_domain!('blocked.com') end @@ -367,7 +362,7 @@ RSpec.describe BulkImportService do Import::RowWorker.drain - expect(account.bookmarks.map(&:status).map(&:uri)).to contain_exactly(already_bookmarked.uri, status.uri, bookmarked.uri, 'https://domain.unknown/foo') + expect(account.bookmarks.map { |bookmark| bookmark.status.uri }).to contain_exactly(already_bookmarked.uri, status.uri, bookmarked.uri, 'https://domain.unknown/foo') end end @@ -410,7 +405,7 @@ RSpec.describe BulkImportService do Import::RowWorker.drain - expect(account.bookmarks.map(&:status).map(&:uri)).to contain_exactly(status.uri, bookmarked.uri, 'https://domain.unknown/foo') + expect(account.bookmarks.map { |bookmark| bookmark.status.uri }).to contain_exactly(status.uri, bookmarked.uri, 'https://domain.unknown/foo') end end end diff --git a/spec/services/delete_account_service_spec.rb b/spec/services/delete_account_service_spec.rb index 61e5c3c9b6..f04ba9cf14 100644 --- a/spec/services/delete_account_service_spec.rb +++ b/spec/services/delete_account_service_spec.rb @@ -27,72 +27,102 @@ RSpec.describe DeleteAccountService, type: :service do let!(:account_note) { Fabricate(:account_note, account: account) } - it 'deletes associated owned records' do - expect { subject }.to change { - [ - account.statuses, - account.media_attachments, - account.notifications, - account.favourites, - account.active_relationships, - account.passive_relationships, - account.polls, - account.account_notes, - ].map(&:count) - }.from([2, 1, 1, 1, 1, 1, 1, 1]).to([0, 0, 0, 0, 0, 0, 0, 0]) + it 'deletes associated owned and target records and target notifications' do + subject + + expect_deletion_of_associated_owned_records + expect_deletion_of_associated_target_records + expect_deletion_of_associated_target_notifications end - it 'deletes associated target records' do - expect { subject }.to change { - [ - AccountPin.where(target_account: account), - ].map(&:count) - }.from([1]).to([0]) + def expect_deletion_of_associated_owned_records + expect { status.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { status_with_mention.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { mention.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { media_attachment.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { favourite.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { active_relationship.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { passive_relationship.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { poll.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { poll_vote.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { account_note.reload }.to raise_error(ActiveRecord::RecordNotFound) end - it 'deletes associated target notifications' do - expect { subject }.to change { - %w( - poll favourite status mention follow - ).map { |type| Notification.where(type: type).count } - }.from([1, 1, 1, 1, 1]).to([0, 0, 0, 0, 0]) + def expect_deletion_of_associated_target_records + expect { endorsement.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + def expect_deletion_of_associated_target_notifications + expect { favourite_notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { follow_notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { mention_notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { poll_notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { status_notification.reload }.to raise_error(ActiveRecord::RecordNotFound) end end describe '#call on local account' do before do - stub_request(:post, 'https://alice.com/inbox').to_return(status: 201) - stub_request(:post, 'https://bob.com/inbox').to_return(status: 201) + stub_request(:post, remote_alice.inbox_url).to_return(status: 201) + stub_request(:post, remote_bob.inbox_url).to_return(status: 201) end - let!(:remote_alice) { Fabricate(:account, inbox_url: 'https://alice.com/inbox', protocol: :activitypub) } - let!(:remote_bob) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } + let!(:remote_alice) { Fabricate(:account, inbox_url: 'https://alice.com/inbox', domain: 'alice.com', protocol: :activitypub) } + let!(:remote_bob) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', domain: 'bob.com', protocol: :activitypub) } include_examples 'common behavior' do - let!(:account) { Fabricate(:account) } - let!(:local_follower) { Fabricate(:account) } + let(:account) { Fabricate(:account) } + let(:local_follower) { Fabricate(:account) } it 'sends a delete actor activity to all known inboxes' do subject - expect(a_request(:post, 'https://alice.com/inbox')).to have_been_made.once - expect(a_request(:post, 'https://bob.com/inbox')).to have_been_made.once + expect(a_request(:post, remote_alice.inbox_url)).to have_been_made.once + expect(a_request(:post, remote_bob.inbox_url)).to have_been_made.once end end end describe '#call on remote account' do before do - stub_request(:post, 'https://alice.com/inbox').to_return(status: 201) - stub_request(:post, 'https://bob.com/inbox').to_return(status: 201) + stub_request(:post, account.inbox_url).to_return(status: 201) end include_examples 'common behavior' do - let!(:account) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } - let!(:local_follower) { Fabricate(:account) } + let(:account) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub, domain: 'bob.com') } + let(:local_follower) { Fabricate(:account) } - it 'sends a reject follow to follower inboxes' do + it 'sends expected activities to followed and follower inboxes' do subject - expect(a_request(:post, account.inbox_url)).to have_been_made.once + + expect(post_to_inbox_with_reject).to have_been_made.once + expect(post_to_inbox_with_undo).to have_been_made.once + end + + def post_to_inbox_with_undo + a_request(:post, account.inbox_url).with( + body: hash_including({ + 'type' => 'Undo', + 'object' => hash_including({ + 'type' => 'Follow', + 'actor' => ActivityPub::TagManager.instance.uri_for(local_follower), + 'object' => account.uri, + }), + }) + ) + end + + def post_to_inbox_with_reject + a_request(:post, account.inbox_url).with( + body: hash_including({ + 'type' => 'Reject', + 'object' => hash_including({ + 'type' => 'Follow', + 'actor' => account.uri, + 'object' => ActivityPub::TagManager.instance.uri_for(local_follower), + }), + }) + ) end end end diff --git a/spec/services/fan_out_on_write_service_spec.rb b/spec/services/fan_out_on_write_service_spec.rb index 3b554f9ea3..f657f298de 100644 --- a/spec/services/fan_out_on_write_service_spec.rb +++ b/spec/services/fan_out_on_write_service_spec.rb @@ -6,11 +6,12 @@ RSpec.describe FanOutOnWriteService, type: :service do subject { described_class.new } let(:last_active_at) { Time.now.utc } - let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob #hoge') } + let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob @eve #hoge') } let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at).account } let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at, account_attributes: { username: 'bob' }).account } let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at).account } + let!(:eve) { Fabricate(:user, current_sign_in_at: last_active_at, account_attributes: { username: 'eve' }).account } before do bob.follow!(alice) @@ -109,5 +110,24 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(redis).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) expect(redis).to_not have_received(:publish).with('timeline:public', anything) end + + context 'when handling status updates', :sidekiq_fake do + before do + subject.call(status) + + status.snapshot!(at_time: status.created_at, rate_limit: false) + status.update!(text: 'Hello @bob @eve #hoge (edited)') + status.snapshot!(account_id: status.account_id) + + redis.set("subscribed:timeline:#{eve.id}:notifications", '1') + + Sidekiq::Worker.clear_all + end + + it 'pushes the update to mentioned users through the notifications streaming channel' do + subject.call(status, update: true) + expect(PushUpdateWorker).to have_enqueued_sidekiq_job(anything, status.id, "timeline:#{eve.id}:notifications", { 'update' => true }) + end + end end end diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index 133d664410..d8ca310b28 100644 --- a/spec/services/fetch_link_card_service_spec.rb +++ b/spec/services/fetch_link_card_service_spec.rb @@ -5,105 +5,249 @@ require 'rails_helper' RSpec.describe FetchLinkCardService, type: :service do subject { described_class.new } + let(:html) { 'Hello world' } + let(:oembed_cache) { nil } + before do - stub_request(:get, 'http://example.xn--fiqs8s/').to_return(request_fixture('idn.txt')) + stub_request(:get, 'http://example.com/html').to_return(headers: { 'Content-Type' => 'text/html' }, body: html) + stub_request(:get, 'http://example.com/not-found').to_return(status: 404, headers: { 'Content-Type' => 'text/html' }, body: html) + stub_request(:get, 'http://example.com/text').to_return(status: 404, headers: { 'Content-Type' => 'text/plain' }, body: 'Hello') + stub_request(:get, 'http://example.com/redirect').to_return(status: 302, headers: { 'Location' => 'http://example.com/html' }) + stub_request(:get, 'http://example.com/redirect-to-404').to_return(status: 302, headers: { 'Location' => 'http://example.com/not-found' }) + stub_request(:get, 'http://example.com/oembed?url=http://example.com/html').to_return(headers: { 'Content-Type' => 'application/json' }, body: '{ "version": "1.0", "type": "link", "title": "oEmbed title" }') + stub_request(:get, 'http://example.com/oembed?format=json&url=http://example.com/html').to_return(headers: { 'Content-Type' => 'application/json' }, body: '{ "version": "1.0", "type": "link", "title": "oEmbed title" }') + + stub_request(:get, 'http://example.xn--fiqs8s') + stub_request(:get, 'http://example.com/日本語') + stub_request(:get, 'http://example.com/test?data=file.gpx%5E1') + stub_request(:get, 'http://example.com/test-') + stub_request(:get, 'http://example.com/sjis').to_return(request_fixture('sjis.txt')) stub_request(:get, 'http://example.com/sjis_with_wrong_charset').to_return(request_fixture('sjis_with_wrong_charset.txt')) stub_request(:get, 'http://example.com/koi8-r').to_return(request_fixture('koi8-r.txt')) - stub_request(:get, 'http://example.com/日本語').to_return(request_fixture('sjis.txt')) - stub_request(:get, 'https://github.com/qbi/WannaCry').to_return(status: 404) - stub_request(:get, 'http://example.com/test?data=file.gpx%5E1').to_return(status: 200) - stub_request(:get, 'http://example.com/test-').to_return(request_fixture('idn.txt')) stub_request(:get, 'http://example.com/windows-1251').to_return(request_fixture('windows-1251.txt')) + Rails.cache.write('oembed_endpoint:example.com', oembed_cache) if oembed_cache + subject.call(status) end context 'with a local status' do - context 'with an IDN url' do + context 'with URL of a regular HTML page' do + let(:status) { Fabricate(:status, text: 'http://example.com/html') } + + it 'creates preview card' do + expect(status.preview_card).to_not be_nil + expect(status.preview_card.url).to eq 'http://example.com/html' + expect(status.preview_card.title).to eq 'Hello world' + end + end + + context 'with URL of a page with no title' do + let(:status) { Fabricate(:status, text: 'http://example.com/html') } + let(:html) { '' } + + it 'does not create a preview card' do + expect(status.preview_card).to be_nil + end + end + + context 'with a URL of a plain-text page' do + let(:status) { Fabricate(:status, text: 'http://example.com/text') } + + it 'does not create a preview card' do + expect(status.preview_card).to be_nil + end + end + + context 'with multiple URLs' do + let(:status) { Fabricate(:status, text: 'ftp://example.com http://example.com/html http://example.com/text') } + + it 'fetches the first valid URL' do + expect(a_request(:get, 'http://example.com/html')).to have_been_made + end + + it 'does not fetch the second valid URL' do + expect(a_request(:get, 'http://example.com/text/')).to_not have_been_made + end + end + + context 'with a redirect URL' do + let(:status) { Fabricate(:status, text: 'http://example.com/redirect') } + + it 'follows redirect' do + expect(a_request(:get, 'http://example.com/redirect')).to have_been_made.once + expect(a_request(:get, 'http://example.com/html')).to have_been_made.once + end + + it 'creates preview card' do + expect(status.preview_card).to_not be_nil + expect(status.preview_card.url).to eq 'http://example.com/html' + expect(status.preview_card.title).to eq 'Hello world' + end + end + + context 'with a broken redirect URL' do + let(:status) { Fabricate(:status, text: 'http://example.com/redirect-to-404') } + + it 'follows redirect' do + expect(a_request(:get, 'http://example.com/redirect-to-404')).to have_been_made.once + expect(a_request(:get, 'http://example.com/not-found')).to have_been_made.once + end + + it 'does not create a preview card' do + expect(status.preview_card).to be_nil + end + end + + context 'with a 404 URL' do + let(:status) { Fabricate(:status, text: 'http://example.com/not-found') } + + it 'does not create a preview card' do + expect(status.preview_card).to be_nil + end + end + + context 'with an IDN URL' do let(:status) { Fabricate(:status, text: 'Check out http://example.中国') } - it 'works with IDN URLs' do - expect(a_request(:get, 'http://example.xn--fiqs8s/')).to have_been_made.at_least_once + it 'fetches the URL' do + expect(a_request(:get, 'http://example.xn--fiqs8s/')).to have_been_made.once end end - context 'with an SJIS url' do + context 'with a URL of a page in Shift JIS encoding' do let(:status) { Fabricate(:status, text: 'Check out http://example.com/sjis') } - it 'works with SJIS' do - expect(a_request(:get, 'http://example.com/sjis')).to have_been_made.at_least_once - expect(status.preview_cards.first.title).to eq('SJISのページ') + it 'decodes the HTML' do + expect(status.preview_card.title).to eq('SJISのページ') end end - context 'with invalid SJIS url' do + context 'with a URL of a page in Shift JIS encoding labeled as UTF-8' do let(:status) { Fabricate(:status, text: 'Check out http://example.com/sjis_with_wrong_charset') } - it 'works with SJIS even with wrong charset header' do - expect(a_request(:get, 'http://example.com/sjis_with_wrong_charset')).to have_been_made.at_least_once - expect(status.preview_cards.first.title).to eq('SJISのページ') + it 'decodes the HTML despite the wrong charset header' do + expect(status.preview_card.title).to eq('SJISのページ') end end - context 'with an koi8-r url' do + context 'with a URL of a page in KOI8-R encoding' do let(:status) { Fabricate(:status, text: 'Check out http://example.com/koi8-r') } - it 'works with koi8-r' do - expect(a_request(:get, 'http://example.com/koi8-r')).to have_been_made.at_least_once - expect(status.preview_cards.first.title).to eq('Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.') + it 'decodes the HTML' do + expect(status.preview_card.title).to eq('Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.') end end - context 'with a windows-1251 url' do + context 'with a URL of a page in Windows-1251 encoding' do let(:status) { Fabricate(:status, text: 'Check out http://example.com/windows-1251') } - it 'works with windows-1251' do - expect(a_request(:get, 'http://example.com/windows-1251')).to have_been_made.at_least_once - expect(status.preview_cards.first.title).to eq('сэмпл текст') + it 'decodes the HTML' do + expect(status.preview_card.title).to eq('сэмпл текст') end end - context 'with a japanese path url' do + context 'with a Japanese path URL' do let(:status) { Fabricate(:status, text: 'テストhttp://example.com/日本語') } - it 'works with Japanese path string' do - expect(a_request(:get, 'http://example.com/日本語')).to have_been_made.at_least_once - expect(status.preview_cards.first.title).to eq('SJISのページ') + it 'fetches the URL' do + expect(a_request(:get, 'http://example.com/日本語')).to have_been_made.once end end - context 'with a hyphen-suffixed url' do + context 'with a hyphen-suffixed URL' do let(:status) { Fabricate(:status, text: 'test http://example.com/test-') } - it 'works with a URL ending with a hyphen' do - expect(a_request(:get, 'http://example.com/test-')).to have_been_made.at_least_once + it 'fetches the URL' do + expect(a_request(:get, 'http://example.com/test-')).to have_been_made.once end end - context 'with an isolated url' do + context 'with a caret-suffixed URL' do + let(:status) { Fabricate(:status, text: 'test http://example.com/test?data=file.gpx^1') } + + it 'fetches the URL' do + expect(a_request(:get, 'http://example.com/test?data=file.gpx%5E1')).to have_been_made.once + end + + it 'does not strip the caret before fetching' do + expect(a_request(:get, 'http://example.com/test?data=file.gpx')).to_not have_been_made + end + end + + context 'with a non-isolated URL' do let(:status) { Fabricate(:status, text: 'testhttp://example.com/sjis') } - it 'does not fetch URLs with not isolated from their surroundings' do + it 'does not fetch URLs not isolated from their surroundings' do expect(a_request(:get, 'http://example.com/sjis')).to_not have_been_made end end - context 'with a url that has a caret' do - let(:status) { Fabricate(:status, text: 'test http://example.com/test?data=file.gpx^1') } + context 'with a URL of a page with oEmbed support' do + let(:html) { 'Hello world' } + let(:status) { Fabricate(:status, text: 'http://example.com/html') } - it 'does fetch URLs with a caret in search params' do - expect(a_request(:get, 'http://example.com/test?data=file.gpx')).to_not have_been_made - expect(a_request(:get, 'http://example.com/test?data=file.gpx%5E1')).to have_been_made.once + it 'fetches the oEmbed URL' do + expect(a_request(:get, 'http://example.com/oembed?url=http://example.com/html')).to have_been_made.once + end + + it 'creates preview card' do + expect(status.preview_card).to_not be_nil + expect(status.preview_card.url).to eq 'http://example.com/html' + expect(status.preview_card.title).to eq 'oEmbed title' + end + + context 'when oEmbed endpoint cache populated' do + let(:oembed_cache) { { endpoint: 'http://example.com/oembed?format=json&url={url}', format: :json } } + + it 'uses the cached oEmbed response' do + expect(a_request(:get, 'http://example.com/oembed?url=http://example.com/html')).to_not have_been_made + expect(a_request(:get, 'http://example.com/oembed?format=json&url=http://example.com/html')).to have_been_made + end + + it 'creates preview card' do + expect(status.preview_card).to_not be_nil + expect(status.preview_card.url).to eq 'http://example.com/html' + expect(status.preview_card.title).to eq 'oEmbed title' + end + end + + # If the original HTML URL for whatever reason (e.g. DOS protection) redirects to + # an error page, we can still use the cached oEmbed but should not use the + # redirect URL on the card. + context 'when oEmbed endpoint cache populated but page returns 404' do + let(:status) { Fabricate(:status, text: 'http://example.com/redirect-to-404') } + let(:oembed_cache) { { endpoint: 'http://example.com/oembed?url=http://example.com/html', format: :json } } + + it 'uses the cached oEmbed response' do + expect(a_request(:get, 'http://example.com/oembed?url=http://example.com/html')).to have_been_made + end + + it 'creates preview card' do + expect(status.preview_card).to_not be_nil + expect(status.preview_card.title).to eq 'oEmbed title' + end + + it 'uses the original URL' do + expect(status.preview_card&.url).to eq 'http://example.com/redirect-to-404' + end end end end context 'with a remote status' do - let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com'), text: 'Habt ihr ein paar gute Links zu foo #Wannacry herumfliegen? Ich will mal unter
https://github.com/qbi/WannaCry was sammeln. !security ') } + let(:status) do + Fabricate(:status, account: Fabricate(:account, domain: 'example.com'), text: <<-TEXT) + Habt ihr ein paar gute Links zu foo + #Wannacry herumfliegen? + Ich will mal unter
http://example.com/not-found was sammeln. ! + security  + TEXT + end it 'parses out URLs' do - expect(a_request(:get, 'https://github.com/qbi/WannaCry')).to have_been_made.at_least_once + expect(a_request(:get, 'http://example.com/not-found')).to have_been_made.once end it 'ignores URLs to hashtags' do diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb index 1904ac8dc9..d67da66caf 100644 --- a/spec/services/import_service_spec.rb +++ b/spec/services/import_service_spec.rb @@ -190,7 +190,7 @@ RSpec.describe ImportService, type: :service do # Make sure to not actually go to the remote server before do - stub_request(:post, 'https://թութ.հայ/inbox').to_return(status: 200) + stub_request(:post, nare.inbox_url).to_return(status: 200) end it 'follows the listed account' do @@ -208,7 +208,7 @@ RSpec.describe ImportService, type: :service do let!(:remote_status) { Fabricate(:status, uri: 'https://example.com/statuses/1312') } let!(:direct_status) { Fabricate(:status, uri: 'https://example.com/statuses/direct', visibility: :direct) } - around(:each) do |example| + around do |example| local_before = Rails.configuration.x.local_domain web_before = Rails.configuration.x.web_domain Rails.configuration.x.local_domain = 'local.com' @@ -232,9 +232,9 @@ RSpec.describe ImportService, type: :service do it 'adds the toots the user has access to to bookmarks' do local_status = Fabricate(:status, account: local_account, uri: 'https://local.com/users/foo/statuses/42', id: 42, local: true) subject.call(import) - expect(account.bookmarks.map(&:status).map(&:id)).to include(local_status.id) - expect(account.bookmarks.map(&:status).map(&:id)).to include(remote_status.id) - expect(account.bookmarks.map(&:status).map(&:id)).to_not include(direct_status.id) + expect(account.bookmarks.map { |bookmark| bookmark.status.id }).to include(local_status.id) + expect(account.bookmarks.map { |bookmark| bookmark.status.id }).to include(remote_status.id) + expect(account.bookmarks.map { |bookmark| bookmark.status.id }).to_not include(direct_status.id) expect(account.bookmarks.count).to eq 3 end end diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 8fcb586580..50055dafc7 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -67,9 +67,10 @@ RSpec.describe NotifyService, type: :service do context 'when the message chain is initiated by recipient, but is not direct message' do let(:reply_to) { Fabricate(:status, account: recipient) } - let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) } let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) } + before { Fabricate(:mention, account: sender, status: reply_to) } + it 'does not notify' do expect { subject }.to_not change(Notification, :count) end @@ -77,10 +78,11 @@ RSpec.describe NotifyService, type: :service do context 'when the message chain is initiated by recipient, but without a mention to the sender, even if the sender sends multiple messages in a row' do let(:reply_to) { Fabricate(:status, account: recipient) } - let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) } let(:dummy_reply) { Fabricate(:status, account: sender, visibility: :direct, thread: reply_to) } let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: dummy_reply)) } + before { Fabricate(:mention, account: sender, status: reply_to) } + it 'does not notify' do expect { subject }.to_not change(Notification, :count) end @@ -88,9 +90,10 @@ RSpec.describe NotifyService, type: :service do context 'when the message chain is initiated by the recipient with a mention to the sender' do let(:reply_to) { Fabricate(:status, account: recipient, visibility: :direct) } - let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) } let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) } + before { Fabricate(:mention, account: sender, status: reply_to) } + it 'does notify' do expect { subject }.to change(Notification, :count) end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index d201292e17..1e5c420a63 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -52,7 +52,7 @@ RSpec.describe PostStatusService, type: :service do end it 'does not change statuses count' do - expect { subject.call(account, text: 'Hi future!', scheduled_at: future, thread: previous_status) }.to_not change { [account.statuses_count, previous_status.replies_count] } + expect { subject.call(account, text: 'Hi future!', scheduled_at: future, thread: previous_status) }.to_not(change { [account.statuses_count, previous_status.replies_count] }) end end @@ -155,7 +155,7 @@ RSpec.describe PostStatusService, type: :service do it 'processes duplicate mentions correctly' do account = Fabricate(:account) - mentioned_account = Fabricate(:account, username: 'alice') + Fabricate(:account, username: 'alice') expect do subject.call(account, text: '@alice @alice @alice hey @alice') @@ -212,7 +212,7 @@ RSpec.describe PostStatusService, type: :service do account = Fabricate(:account) media = Fabricate(:media_attachment, account: Fabricate(:account)) - status = subject.call( + subject.call( account, text: 'test status update', media_ids: [media.id] diff --git a/spec/services/precompute_feed_service_spec.rb b/spec/services/precompute_feed_service_spec.rb index 54e0d94ee0..663babae8a 100644 --- a/spec/services/precompute_feed_service_spec.rb +++ b/spec/services/precompute_feed_service_spec.rb @@ -27,7 +27,7 @@ RSpec.describe PrecomputeFeedService, type: :service do muted_account = Fabricate(:account) Fabricate(:mute, account: account, target_account: muted_account) reblog = Fabricate(:status, account: muted_account) - status = Fabricate(:status, account: account, reblog: reblog) + Fabricate(:status, account: account, reblog: reblog) subject.call(account) diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index 77b01d3072..08ce0b0456 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -12,79 +12,78 @@ RSpec.describe RemoveStatusService, type: :service do let!(:bill) { Fabricate(:account, username: 'bill', protocol: :activitypub, domain: 'example2.com', inbox_url: 'http://example2.com/inbox') } before do - stub_request(:post, 'http://example.com/inbox').to_return(status: 200) - stub_request(:post, 'http://example2.com/inbox').to_return(status: 200) + stub_request(:post, hank.inbox_url).to_return(status: 200) + stub_request(:post, bill.inbox_url).to_return(status: 200) jeff.follow!(alice) hank.follow!(alice) end context 'when removed status is not a reblog' do + let!(:status) { PostStatusService.new.call(alice, text: "Hello @#{bob.pretty_acct} ThisIsASecret") } + before do - @status = PostStatusService.new.call(alice, text: 'Hello @bob@example.com ThisIsASecret') - FavouriteService.new.call(jeff, @status) - Fabricate(:status, account: bill, reblog: @status, uri: 'hoge') + FavouriteService.new.call(jeff, status) + Fabricate(:status, account: bill, reblog: status, uri: 'hoge') end it 'removes status from author\'s home feed' do - subject.call(@status) - expect(HomeFeed.new(alice).get(10)).to_not include(@status.id) + subject.call(status) + expect(HomeFeed.new(alice).get(10).pluck(:id)).to_not include(status.id) end it 'removes status from local follower\'s home feed' do - subject.call(@status) - expect(HomeFeed.new(jeff).get(10)).to_not include(@status.id) + subject.call(status) + expect(HomeFeed.new(jeff).get(10).pluck(:id)).to_not include(status.id) end it 'sends Delete activity to followers' do - subject.call(@status) - expect(a_request(:post, 'http://example.com/inbox').with( + subject.call(status) + expect(a_request(:post, hank.inbox_url).with( body: hash_including({ 'type' => 'Delete', 'object' => { 'type' => 'Tombstone', - 'id' => ActivityPub::TagManager.instance.uri_for(@status), - 'atomUri' => OStatus::TagManager.instance.uri_for(@status), + 'id' => ActivityPub::TagManager.instance.uri_for(status), + 'atomUri' => OStatus::TagManager.instance.uri_for(status), }, }) )).to have_been_made.once end it 'sends Delete activity to rebloggers' do - subject.call(@status) - expect(a_request(:post, 'http://example2.com/inbox').with( + subject.call(status) + expect(a_request(:post, bill.inbox_url).with( body: hash_including({ 'type' => 'Delete', 'object' => { 'type' => 'Tombstone', - 'id' => ActivityPub::TagManager.instance.uri_for(@status), - 'atomUri' => OStatus::TagManager.instance.uri_for(@status), + 'id' => ActivityPub::TagManager.instance.uri_for(status), + 'atomUri' => OStatus::TagManager.instance.uri_for(status), }, }) )).to have_been_made.once end it 'remove status from notifications' do - expect { subject.call(@status) }.to change { + expect { subject.call(status) }.to change { Notification.where(activity_type: 'Favourite', from_account: jeff, account: alice).count }.from(1).to(0) end end context 'when removed status is a private self-reblog' do - before do - @original_status = Fabricate(:status, account: alice, text: 'Hello ThisIsASecret', visibility: :private) - @status = ReblogService.new.call(alice, @original_status) - end + let!(:original_status) { Fabricate(:status, account: alice, text: 'Hello ThisIsASecret', visibility: :private) } + let!(:status) { ReblogService.new.call(alice, original_status) } it 'sends Undo activity to followers' do - subject.call(@status) - expect(a_request(:post, 'http://example.com/inbox').with( + subject.call(status) + expect(a_request(:post, hank.inbox_url).with( body: hash_including({ 'type' => 'Undo', 'object' => hash_including({ 'type' => 'Announce', - 'object' => ActivityPub::TagManager.instance.uri_for(@original_status), + 'object' => ActivityPub::TagManager.instance.uri_for(original_status), }), }) )).to have_been_made.once @@ -92,19 +91,17 @@ RSpec.describe RemoveStatusService, type: :service do end context 'when removed status is public self-reblog' do - before do - @original_status = Fabricate(:status, account: alice, text: 'Hello ThisIsASecret', visibility: :public) - @status = ReblogService.new.call(alice, @original_status) - end + let!(:original_status) { Fabricate(:status, account: alice, text: 'Hello ThisIsASecret', visibility: :public) } + let!(:status) { ReblogService.new.call(alice, original_status) } it 'sends Undo activity to followers' do - subject.call(@status) - expect(a_request(:post, 'http://example.com/inbox').with( + subject.call(status) + expect(a_request(:post, hank.inbox_url).with( body: hash_including({ 'type' => 'Undo', 'object' => hash_including({ 'type' => 'Announce', - 'object' => ActivityPub::TagManager.instance.uri_for(@original_status), + 'object' => ActivityPub::TagManager.instance.uri_for(original_status), }), }) )).to have_been_made.once diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb index b8ceedb851..2a919f6405 100644 --- a/spec/services/report_service_spec.rb +++ b/spec/services/report_service_spec.rb @@ -17,24 +17,82 @@ RSpec.describe ReportService, type: :service do context 'with a remote account' do let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } + let(:forward) { false } before do stub_request(:post, 'http://example.com/inbox').to_return(status: 200) end - it 'sends ActivityPub payload when forward is true' do - subject.call(source_account, remote_account, forward: true) - expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + context 'when forward is true' do + let(:forward) { true } + + it 'sends ActivityPub payload when forward is true' do + subject.call(source_account, remote_account, forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + end + + it 'has an uri' do + report = subject.call(source_account, remote_account, forward: forward) + expect(report.uri).to_not be_nil + end + + context 'when reporting a reply on a different remote server' do + let(:remote_thread_account) { Fabricate(:account, domain: 'foo.com', protocol: :activitypub, inbox_url: 'http://foo.com/inbox') } + let(:reported_status) { Fabricate(:status, account: remote_account, thread: Fabricate(:status, account: remote_thread_account)) } + + before do + stub_request(:post, 'http://foo.com/inbox').to_return(status: 200) + end + + context 'when forward_to_domains includes both the replied-to domain and the origin domain' do + it 'sends ActivityPub payload to both the author of the replied-to post and the reported user' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_account.domain, remote_thread_account.domain]) + expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + end + end + + context 'when forward_to_domains includes only the replied-to domain' do + it 'sends ActivityPub payload only to the author of the replied-to post' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_thread_account.domain]) + expect(a_request(:post, 'http://foo.com/inbox')).to have_been_made + expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made + end + end + + context 'when forward_to_domains does not include the replied-to domain' do + it 'does not send ActivityPub payload to the author of the replied-to post' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward) + expect(a_request(:post, 'http://foo.com/inbox')).to_not have_been_made + end + end + end + + context 'when reporting a reply on the same remote server as the person being replied-to' do + let(:remote_thread_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } + let(:reported_status) { Fabricate(:status, account: remote_account, thread: Fabricate(:status, account: remote_thread_account)) } + + context 'when forward_to_domains includes both the replied-to domain and the origin domain' do + it 'sends ActivityPub payload only once' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward, forward_to_domains: [remote_account.domain]) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end + + context 'when forward_to_domains does not include the replied-to domain' do + it 'sends ActivityPub payload only once' do + subject.call(source_account, remote_account, status_ids: [reported_status.id], forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end + end end - it 'does not send anything when forward is false' do - subject.call(source_account, remote_account, forward: false) - expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made - end - - it 'has an uri' do - report = subject.call(source_account, remote_account, forward: true) - expect(report.uri).to_not be_nil + context 'when forward is false' do + it 'does not send anything' do + subject.call(source_account, remote_account, forward: forward) + expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made + end end end @@ -98,9 +156,8 @@ RSpec.describe ReportService, type: :service do -> { described_class.new.call(source_account, target_account) } end - let!(:other_report) { Fabricate(:report, target_account: target_account) } - before do + Fabricate(:report, target_account: target_account) ActionMailer::Base.deliveries.clear source_account.user.settings['notification_emails.report'] = true source_account.user.save diff --git a/spec/services/resolve_account_service_spec.rb b/spec/services/resolve_account_service_spec.rb index ed22a8147a..3c4b182e29 100644 --- a/spec/services/resolve_account_service_spec.rb +++ b/spec/services/resolve_account_service_spec.rb @@ -20,7 +20,7 @@ RSpec.describe ResolveAccountService, type: :service do let!(:remote_account) { Fabricate(:account, username: 'foo', domain: 'ap.example.com', protocol: 'activitypub') } context 'when domain is banned' do - let!(:domain_block) { Fabricate(:domain_block, domain: 'ap.example.com', severity: :suspend) } + before { Fabricate(:domain_block, domain: 'ap.example.com', severity: :suspend) } it 'does not return an account' do expect(subject.call('foo@ap.example.com', skip_webfinger: true)).to be_nil @@ -144,6 +144,19 @@ RSpec.describe ResolveAccountService, type: :service do end end + context 'with webfinger response subject missing a host value' do + let(:body) { Oj.dump({ subject: 'user@' }) } + let(:url) { 'https://host.example/.well-known/webfinger?resource=acct:user@host.example' } + + before do + stub_request(:get, url).to_return(status: 200, body: body) + end + + it 'returns nil with incomplete subject in response' do + expect(subject.call('user@host.example')).to be_nil + end + end + context 'with an ActivityPub account' do it 'returns new remote account' do account = subject.call('foo@ap.example.com') @@ -201,6 +214,7 @@ RSpec.describe ResolveAccountService, type: :service do expect(account.domain).to eq 'ap.example.com' expect(account.inbox_url).to eq 'https://ap.example.com/users/foo/inbox' expect(account.uri).to eq 'https://ap.example.com/users/foo' + expect(status.reload.account).to eq(account) end end @@ -209,11 +223,6 @@ RSpec.describe ResolveAccountService, type: :service do fail_occurred = false return_values = Concurrent::Array.new - # Preload classes that throw circular dependency errors in threads - Account - TagManager - DomainBlock - threads = Array.new(5) do Thread.new do true while wait_for_start diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb index 99761b6c73..bcfb9dbfb0 100644 --- a/spec/services/resolve_url_service_spec.rb +++ b/spec/services/resolve_url_service_spec.rb @@ -7,8 +7,8 @@ describe ResolveURLService, type: :service do describe '#call' do it 'returns nil when there is no resource url' do - url = 'http://example.com/missing-resource' - known_account = Fabricate(:account, uri: url) + url = 'http://example.com/missing-resource' + Fabricate(:account, uri: url, domain: 'example.com') service = instance_double(FetchResourceService) allow(FetchResourceService).to receive(:new).and_return service @@ -20,7 +20,7 @@ describe ResolveURLService, type: :service do it 'returns known account on temporary error' do url = 'http://example.com/missing-resource' - known_account = Fabricate(:account, uri: url) + known_account = Fabricate(:account, uri: url, domain: 'example.com') service = instance_double(FetchResourceService) allow(FetchResourceService).to receive(:new).and_return service diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 497ec74474..39adf43876 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -19,17 +19,15 @@ describe SearchService, type: :service do end describe 'with an url query' do - before do - @query = 'http://test.host/query' - end + let(:query) { 'http://test.host/query' } context 'when it does not find anything' do it 'returns the empty results' do service = instance_double(ResolveURLService, call: nil) allow(ResolveURLService).to receive(:new).and_return(service) - results = subject.call(@query, nil, 10, resolve: true) + results = subject.call(query, nil, 10, resolve: true) - expect(service).to have_received(:call).with(@query, on_behalf_of: nil) + expect(service).to have_received(:call).with(query, on_behalf_of: nil) expect(results).to eq empty_results end end @@ -40,8 +38,8 @@ describe SearchService, type: :service do service = instance_double(ResolveURLService, call: account) allow(ResolveURLService).to receive(:new).and_return(service) - results = subject.call(@query, nil, 10, resolve: true) - expect(service).to have_received(:call).with(@query, on_behalf_of: nil) + results = subject.call(query, nil, 10, resolve: true) + expect(service).to have_received(:call).with(query, on_behalf_of: nil) expect(results).to eq empty_results.merge(accounts: [account]) end end @@ -52,8 +50,8 @@ describe SearchService, type: :service do service = instance_double(ResolveURLService, call: status) allow(ResolveURLService).to receive(:new).and_return(service) - results = subject.call(@query, nil, 10, resolve: true) - expect(service).to have_received(:call).with(@query, on_behalf_of: nil) + results = subject.call(query, nil, 10, resolve: true) + expect(service).to have_received(:call).with(query, on_behalf_of: nil) expect(results).to eq empty_results.merge(statuses: [status]) end end @@ -68,7 +66,7 @@ describe SearchService, type: :service do allow(AccountSearchService).to receive(:new).and_return(service) results = subject.call(query, nil, 10) - expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, use_searchable_text: true, following: false) + expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, start_with_hashtag: false, use_searchable_text: true, following: false) expect(results).to eq empty_results.merge(accounts: [account]) end end @@ -83,24 +81,6 @@ describe SearchService, type: :service do expect(Tag).to have_received(:search_for).with('tag', 10, 0, exclude_unreviewed: nil) expect(results).to eq empty_results.merge(hashtags: [tag]) end - - it 'does not include tag when starts with @ character' do - query = '@username' - allow(Tag).to receive(:search_for) - - results = subject.call(query, nil, 10) - expect(Tag).to_not have_received(:search_for) - expect(results).to eq empty_results - end - - it 'does not include account when starts with # character' do - query = '#tag' - allow(AccountSearchService).to receive(:new) - - results = subject.call(query, nil, 10) - expect(AccountSearchService).to_not have_received(:new) - expect(results).to eq empty_results - end end end end diff --git a/spec/services/software_update_check_service_spec.rb b/spec/services/software_update_check_service_spec.rb new file mode 100644 index 0000000000..c8821348ac --- /dev/null +++ b/spec/services/software_update_check_service_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SoftwareUpdateCheckService, type: :service do + subject { described_class.new } + + shared_examples 'when the feature is enabled' do + let(:full_update_check_url) { "#{update_check_url}?version=#{Mastodon::Version.to_s.split('+')[0]}" } + + let(:devops_role) { Fabricate(:user_role, name: 'DevOps', permissions: UserRole::FLAGS[:view_devops]) } + let(:owner_user) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) } + let(:old_devops_user) { Fabricate(:user) } + let(:none_user) { Fabricate(:user, role: devops_role) } + let(:patch_user) { Fabricate(:user, role: devops_role) } + let(:critical_user) { Fabricate(:user, role: devops_role) } + + around do |example| + queue_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + + example.run + + ActiveJob::Base.queue_adapter = queue_adapter + end + + before do + Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false) + Fabricate(:software_update, version: '42.13.12', type: 'major', urgent: false) + + owner_user.settings.update('notification_emails.software_updates': 'all') + owner_user.save! + + old_devops_user.settings.update('notification_emails.software_updates': 'all') + old_devops_user.save! + + none_user.settings.update('notification_emails.software_updates': 'none') + none_user.save! + + patch_user.settings.update('notification_emails.software_updates': 'patch') + patch_user.save! + + critical_user.settings.update('notification_emails.software_updates': 'critical') + critical_user.save! + end + + context 'when the update server errors out' do + before do + stub_request(:get, full_update_check_url).to_return(status: 404) + end + + it 'deletes outdated update records but keeps valid update records' do + expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['42.13.12']) + end + end + + context 'when the server returns new versions' do + let(:server_json) do + { + updatesAvailable: [ + { + version: '4.2.1', + urgent: false, + type: 'patch', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.2.1', + }, + { + version: '4.3.0', + urgent: false, + type: 'minor', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.3.0', + }, + { + version: '5.0.0', + urgent: false, + type: 'minor', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0', + }, + ], + } + end + + before do + stub_request(:get, full_update_check_url).to_return(body: Oj.dump(server_json)) + end + + it 'updates the list of known updates' do + expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['4.2.1', '4.3.0', '5.0.0']) + end + + context 'when no update is urgent' do + it 'sends e-mail notifications according to settings', :aggregate_failures do + expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_software_updates) + .with(hash_including(params: { recipient: owner_user.account })).once + .and(have_enqueued_mail(AdminMailer, :new_software_updates).with(hash_including(params: { recipient: patch_user.account })).once) + .and(have_enqueued_mail.at_most(2)) + end + end + + context 'when an update is urgent' do + let(:server_json) do + { + updatesAvailable: [ + { + version: '5.0.0', + urgent: true, + type: 'minor', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0', + }, + ], + } + end + + it 'sends e-mail notifications according to settings', :aggregate_failures do + expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_critical_software_updates) + .with(hash_including(params: { recipient: owner_user.account })).once + .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: patch_user.account })).once) + .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: critical_user.account })).once) + .and(have_enqueued_mail.at_most(3)) + end + end + end + end + + context 'when update checking is disabled' do + around do |example| + ClimateControl.modify UPDATE_CHECK_URL: '' do + example.run + end + end + + before do + Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false) + end + + it 'deletes outdated update records' do + expect { subject.call }.to change(SoftwareUpdate, :count).from(1).to(0) + end + end + + context 'when using the default update checking API' do + let(:update_check_url) { 'https://api.joinmastodon.org/update-check' } + + it_behaves_like 'when the feature is enabled' + end + + context 'when using a custom update check URL' do + let(:update_check_url) { 'https://api.example.com/update_check' } + + around do |example| + ClimateControl.modify UPDATE_CHECK_URL: 'https://api.example.com/update_check' do + example.run + end + end + + it_behaves_like 'when the feature is enabled' + end +end diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb index f9206b7ea2..b309ce511e 100644 --- a/spec/services/suspend_account_service_spec.rb +++ b/spec/services/suspend_account_service_spec.rb @@ -10,8 +10,7 @@ RSpec.describe SuspendAccountService, type: :service do let!(:list) { Fabricate(:list, account: local_follower) } before do - allow(FeedManager.instance).to receive(:unmerge_from_home).and_return(nil) - allow(FeedManager.instance).to receive(:unmerge_from_list).and_return(nil) + allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil) local_follower.follow!(account) list.accounts << account @@ -19,14 +18,15 @@ RSpec.describe SuspendAccountService, type: :service do account.suspend! end - it "unmerges from local followers' feeds" do - subject + it 'unmerges from feeds of local followers and preserves suspended flag' do + expect { subject } + .to_not change_suspended_flag expect(FeedManager.instance).to have_received(:unmerge_from_home).with(account, local_follower) expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list) end - it 'does not change the “suspended” flag' do - expect { subject }.to_not change(account, :suspended?) + def change_suspended_flag + change(account, :suspended?) end end @@ -44,11 +44,11 @@ RSpec.describe SuspendAccountService, type: :service do include_examples 'common behavior' do let!(:account) { Fabricate(:account) } - let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub) } - let!(:remote_reporter) { Fabricate(:account, uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } - let!(:report) { Fabricate(:report, account: remote_reporter, target_account: account) } + let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub, domain: 'alice.com') } + let!(:remote_reporter) { Fabricate(:account, uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub, domain: 'bob.com') } before do + Fabricate(:report, account: remote_reporter, target_account: account) remote_follower.follow!(account) end diff --git a/spec/services/translate_status_service_spec.rb b/spec/services/translate_status_service_spec.rb index 515dd1a997..5f6418f5d6 100644 --- a/spec/services/translate_status_service_spec.rb +++ b/spec/services/translate_status_service_spec.rb @@ -29,8 +29,7 @@ RSpec.describe TranslateStatusService, type: :service do end end - allow(TranslationService).to receive(:configured?).and_return(true) - allow(TranslationService).to receive(:configured).and_return(translation_service) + allow(TranslationService).to receive_messages(configured?: true, configured: translation_service) end it 'returns translated status content' do diff --git a/spec/services/unallow_domain_service_spec.rb b/spec/services/unallow_domain_service_spec.rb index e583a22de3..d96e354187 100644 --- a/spec/services/unallow_domain_service_spec.rb +++ b/spec/services/unallow_domain_service_spec.rb @@ -14,7 +14,7 @@ RSpec.describe UnallowDomainService, type: :service do context 'with limited federation mode' do before do - allow(subject).to receive(:whitelist_mode?).and_return(true) + allow(Rails.configuration.x).to receive(:limited_federation_mode).and_return(true) end describe '#call' do @@ -27,6 +27,7 @@ RSpec.describe UnallowDomainService, type: :service do end it 'removes remote accounts from that domain' do + expect { already_banned_account.reload }.to raise_error(ActiveRecord::RecordNotFound) expect(Account.where(domain: 'evil.org').exists?).to be false end @@ -40,7 +41,7 @@ RSpec.describe UnallowDomainService, type: :service do context 'without limited federation mode' do before do - allow(subject).to receive(:whitelist_mode?).and_return(false) + allow(Rails.configuration.x).to receive(:limited_federation_mode).and_return(false) end describe '#call' do diff --git a/spec/services/unblock_domain_service_spec.rb b/spec/services/unblock_domain_service_spec.rb index 27dbc92ada..3d6d82ff68 100644 --- a/spec/services/unblock_domain_service_spec.rb +++ b/spec/services/unblock_domain_service_spec.rb @@ -6,38 +6,36 @@ describe UnblockDomainService, type: :service do subject { described_class.new } describe 'call' do - before do - @independently_suspended = Fabricate(:account, domain: 'example.com', suspended_at: 1.hour.ago) - @independently_silenced = Fabricate(:account, domain: 'example.com', silenced_at: 1.hour.ago) - @domain_block = Fabricate(:domain_block, domain: 'example.com') - @silenced = Fabricate(:account, domain: 'example.com', silenced_at: @domain_block.created_at) - @suspended = Fabricate(:account, domain: 'example.com', suspended_at: @domain_block.created_at) - end + let!(:independently_suspended) { Fabricate(:account, domain: 'example.com', suspended_at: 1.hour.ago) } + let!(:independently_silenced) { Fabricate(:account, domain: 'example.com', silenced_at: 1.hour.ago) } + let!(:domain_block) { Fabricate(:domain_block, domain: 'example.com') } + let!(:silenced) { Fabricate(:account, domain: 'example.com', silenced_at: domain_block.created_at) } + let!(:suspended) { Fabricate(:account, domain: 'example.com', suspended_at: domain_block.created_at) } it 'unsilences accounts and removes block' do - @domain_block.update(severity: :silence) + domain_block.update(severity: :silence) - subject.call(@domain_block) + subject.call(domain_block) expect_deleted_domain_block - expect(@silenced.reload.silenced?).to be false - expect(@suspended.reload.suspended?).to be true - expect(@independently_suspended.reload.suspended?).to be true - expect(@independently_silenced.reload.silenced?).to be true + expect(silenced.reload.silenced?).to be false + expect(suspended.reload.suspended?).to be true + expect(independently_suspended.reload.suspended?).to be true + expect(independently_silenced.reload.silenced?).to be true end it 'unsuspends accounts and removes block' do - @domain_block.update(severity: :suspend) + domain_block.update(severity: :suspend) - subject.call(@domain_block) + subject.call(domain_block) expect_deleted_domain_block - expect(@suspended.reload.suspended?).to be false - expect(@silenced.reload.silenced?).to be false - expect(@independently_suspended.reload.suspended?).to be true - expect(@independently_silenced.reload.silenced?).to be true + expect(suspended.reload.suspended?).to be false + expect(silenced.reload.silenced?).to be false + expect(independently_suspended.reload.suspended?).to be true + expect(independently_silenced.reload.silenced?).to be true end end def expect_deleted_domain_block - expect { @domain_block.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { domain_block.reload }.to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/spec/services/unmute_service_spec.rb b/spec/services/unmute_service_spec.rb deleted file mode 100644 index 236f837e2d..0000000000 --- a/spec/services/unmute_service_spec.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe UnmuteService, type: :service do - subject { described_class.new } -end diff --git a/spec/services/unsuspend_account_service_spec.rb b/spec/services/unsuspend_account_service_spec.rb index 7ef2630aeb..211c39e6c7 100644 --- a/spec/services/unsuspend_account_service_spec.rb +++ b/spec/services/unsuspend_account_service_spec.rb @@ -10,8 +10,7 @@ RSpec.describe UnsuspendAccountService, type: :service do let!(:list) { Fabricate(:list, account: local_follower) } before do - allow(FeedManager.instance).to receive(:merge_into_home).and_return(nil) - allow(FeedManager.instance).to receive(:merge_into_list).and_return(nil) + allow(FeedManager.instance).to receive_messages(merge_into_home: nil, merge_into_list: nil) local_follower.follow!(account) list.accounts << account @@ -38,22 +37,27 @@ RSpec.describe UnsuspendAccountService, type: :service do include_examples 'with common context' do let!(:account) { Fabricate(:account) } - let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub) } - let!(:remote_reporter) { Fabricate(:account, uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } - let!(:report) { Fabricate(:report, account: remote_reporter, target_account: account) } + let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub, domain: 'alice.com') } + let!(:remote_reporter) { Fabricate(:account, uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub, domain: 'bob.com') } before do + Fabricate(:report, account: remote_reporter, target_account: account) remote_follower.follow!(account) end - it "merges back into local followers' feeds" do + it 'merges back into feeds of local followers and sends update' do subject + + expect_feeds_merged + expect_updates_sent + end + + def expect_feeds_merged expect(FeedManager.instance).to have_received(:merge_into_home).with(account, local_follower) expect(FeedManager.instance).to have_received(:merge_into_list).with(account, list) end - it 'sends an update actor to followers and reporters' do - subject + def expect_updates_sent expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once end @@ -74,19 +78,20 @@ RSpec.describe UnsuspendAccountService, type: :service do allow(resolve_account_service).to receive(:call).with(account).and_return(account) end - it 're-fetches the account' do - subject + it 're-fetches the account, merges feeds, and preserves suspended' do + expect { subject } + .to_not change_suspended_flag + expect_feeds_merged expect(resolve_account_service).to have_received(:call).with(account) end - it "merges back into local followers' feeds" do - subject + def expect_feeds_merged expect(FeedManager.instance).to have_received(:merge_into_home).with(account, local_follower) expect(FeedManager.instance).to have_received(:merge_into_list).with(account, list) end - it 'does not change the “suspended” flag' do - expect { subject }.to_not change(account, :suspended?) + def change_suspended_flag + change(account, :suspended?) end end @@ -98,19 +103,20 @@ RSpec.describe UnsuspendAccountService, type: :service do end end - it 're-fetches the account' do - subject + it 're-fetches the account, does not merge feeds, marks suspended' do + expect { subject } + .to change_suspended_to_true expect(resolve_account_service).to have_received(:call).with(account) + expect_feeds_not_merged end - it "does not merge back into local followers' feeds" do - subject + def expect_feeds_not_merged expect(FeedManager.instance).to_not have_received(:merge_into_home).with(account, local_follower) expect(FeedManager.instance).to_not have_received(:merge_into_list).with(account, list) end - it 'marks account as suspended' do - expect { subject }.to change(account, :suspended?).from(false).to(true) + def change_suspended_to_true + change(account, :suspended?).from(false).to(true) end end @@ -119,13 +125,14 @@ RSpec.describe UnsuspendAccountService, type: :service do allow(resolve_account_service).to receive(:call).with(account).and_return(nil) end - it 're-fetches the account' do + it 're-fetches the account and does not merge feeds' do subject + expect(resolve_account_service).to have_received(:call).with(account) + expect_feeds_not_merged end - it "does not merge back into local followers' feeds" do - subject + def expect_feeds_not_merged expect(FeedManager.instance).to_not have_received(:merge_into_home).with(account, local_follower) expect(FeedManager.instance).to_not have_received(:merge_into_list).with(account, list) end diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 9c53ebb2fd..7f9f746c2a 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -23,11 +23,11 @@ RSpec.describe UpdateStatusService, type: :service do end context 'when text changes' do - let!(:status) { Fabricate(:status, text: 'Foo') } + let(:status) { Fabricate(:status, text: 'Foo') } let(:preview_card) { Fabricate(:preview_card) } before do - status.preview_cards << preview_card + PreviewCardsStatus.create(status: status, preview_card: preview_card) subject.call(status, status.account_id, text: 'Bar') end @@ -45,11 +45,11 @@ RSpec.describe UpdateStatusService, type: :service do end context 'when content warning changes' do - let!(:status) { Fabricate(:status, text: 'Foo', spoiler_text: '') } + let(:status) { Fabricate(:status, text: 'Foo', spoiler_text: '') } let(:preview_card) { Fabricate(:preview_card) } before do - status.preview_cards << preview_card + PreviewCardsStatus.create(status: status, preview_card: preview_card) subject.call(status, status.account_id, text: 'Foo', spoiler_text: 'Bar') end @@ -111,7 +111,7 @@ RSpec.describe UpdateStatusService, type: :service do end end - context 'when poll changes' do + context 'when poll changes', :sidekiq_fake do let(:account) { Fabricate(:account) } let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: { options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) } let!(:poll) { status.poll } @@ -120,9 +120,7 @@ RSpec.describe UpdateStatusService, type: :service do before do status.update(poll: poll) VoteService.new.call(voter, poll, [0]) - Sidekiq::Testing.fake! do - subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz Foo), expires_in: 5.days.to_i }) - end + subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz Foo), expires_in: 5.days.to_i }) end it 'updates poll' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ed228abebf..dc60976d05 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,14 +1,7 @@ # frozen_string_literal: true -if ENV['DISABLE_SIMPLECOV'] != 'true' - require 'simplecov' - SimpleCov.start 'rails' do - add_group 'Policies', 'app/policies' - add_group 'Presenters', 'app/presenters' - add_group 'Serializers', 'app/serializers' - add_group 'Services', 'app/services' - add_group 'Validators', 'app/validators' - end +unless ENV['DISABLE_SIMPLECOV'] == 'true' + require 'simplecov' # Configuration details loaded from .simplecov end RSpec.configure do |config| @@ -35,6 +28,12 @@ RSpec.configure do |config| config.after :suite do FileUtils.rm_rf(Dir[Rails.root.join('spec', 'test_files')]) end + + # Use the GitHub Annotations formatter for CI + if ENV['GITHUB_ACTIONS'] == 'true' && ENV['GITHUB_RSPEC'] == 'true' + require 'rspec/github' + config.add_formatter RSpec::Github::Formatter + end end def body_as_json @@ -45,8 +44,21 @@ def json_str_to_hash(str) JSON.parse(str, symbolize_names: true) end +def serialized_record_json(record, serializer, adapter: nil) + options = { serializer: serializer } + options[:adapter] = adapter if adapter.present? + JSON.parse( + ActiveModelSerializers::SerializableResource.new( + record, + options + ).to_json + ) +end + def expect_push_bulk_to_match(klass, matcher) - expect(Sidekiq::Client).to receive(:push_bulk).with(hash_including({ + allow(Sidekiq::Client).to receive(:push_bulk) + yield + expect(Sidekiq::Client).to have_received(:push_bulk).with(hash_including({ 'class' => klass, 'args' => matcher, })) diff --git a/spec/support/command_line_helpers.rb b/spec/support/command_line_helpers.rb new file mode 100644 index 0000000000..6f9d63d939 --- /dev/null +++ b/spec/support/command_line_helpers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module CommandLineHelpers + def output_results(*args) + output( + include(*args) + ).to_stdout + end +end diff --git a/spec/support/examples/cache.rb b/spec/support/examples/cache.rb new file mode 100644 index 0000000000..43cfbade82 --- /dev/null +++ b/spec/support/examples/cache.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +shared_examples 'cacheable response' do |expects_vary: false| + it 'does not set cookies' do + expect(response.cookies).to be_empty + expect(response.headers['Set-Cookies']).to be_nil + end + + it 'does not set sessions' do + expect(session).to be_empty + end + + if expects_vary + it 'returns Vary header' do + expect(response.headers['Vary']).to include(expects_vary) + end + end + + it 'returns public Cache-Control header' do + expect(response.headers['Cache-Control']).to include('public') + end +end diff --git a/spec/support/examples/cli.rb b/spec/support/examples/cli.rb new file mode 100644 index 0000000000..091c842bd1 --- /dev/null +++ b/spec/support/examples/cli.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +shared_examples 'CLI Command' do + it 'configures Thor to exit on failure' do + expect(described_class.exit_on_failure?).to be true + end + + it 'descends from the CLI base class' do + expect(described_class.new).to be_a(Mastodon::CLI::Base) + end +end diff --git a/spec/support/examples/mailers.rb b/spec/support/examples/mailers.rb new file mode 100644 index 0000000000..213e873b4e --- /dev/null +++ b/spec/support/examples/mailers.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +shared_examples 'localized subject' do |*args, **kwrest| + it 'renders subject localized for the locale of the receiver' do + locale = :de + receiver.update!(locale: locale) + expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: locale)) + end + + it 'renders subject localized for the default locale if the locale of the receiver is unavailable' do + receiver.update!(locale: nil) + expect(mail.subject).to eq I18n.t(*args, **kwrest.merge(locale: I18n.default_locale)) + end +end diff --git a/spec/support/examples/models/concerns/account_avatar.rb b/spec/support/examples/models/concerns/account_avatar.rb index 16ebda5641..2c9b5514aa 100644 --- a/spec/support/examples/models/concerns/account_avatar.rb +++ b/spec/support/examples/models/concerns/account_avatar.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true shared_examples 'AccountAvatar' do |fabricator| - describe 'static avatars', paperclip_processing: true do + describe 'static avatars', :paperclip_processing do describe 'when GIF' do it 'creates a png static style' do account = Fabricate(fabricator, avatar: attachment_fixture('avatar.gif')) @@ -17,7 +17,7 @@ shared_examples 'AccountAvatar' do |fabricator| end end - describe 'base64-encoded files', paperclip_processing: true do + describe 'base64-encoded files', :paperclip_processing do let(:base64_attachment) { "data:image/jpeg;base64,#{Base64.encode64(attachment_fixture('attachment.jpg').read)}" } let(:account) { Fabricate(fabricator, avatar: base64_attachment) } diff --git a/spec/support/examples/models/concerns/account_header.rb b/spec/support/examples/models/concerns/account_header.rb index d65f54f00f..ce09eb006b 100644 --- a/spec/support/examples/models/concerns/account_header.rb +++ b/spec/support/examples/models/concerns/account_header.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true shared_examples 'AccountHeader' do |fabricator| - describe 'base64-encoded files', paperclip_processing: true do + describe 'base64-encoded files', :paperclip_processing do let(:base64_attachment) { "data:image/jpeg;base64,#{Base64.encode64(attachment_fixture('attachment.jpg').read)}" } let(:account) { Fabricate(fabricator, header: base64_attachment) } diff --git a/spec/support/omniauth_mocks.rb b/spec/support/omniauth_mocks.rb new file mode 100644 index 0000000000..9883adec7a --- /dev/null +++ b/spec/support/omniauth_mocks.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +OmniAuth.config.test_mode = true + +def mock_omniauth(provider, data) + OmniAuth.config.mock_auth[provider] = OmniAuth::AuthHash.new(data) +end diff --git a/spec/support/search_data_manager.rb b/spec/support/search_data_manager.rb new file mode 100644 index 0000000000..3c7140b48b --- /dev/null +++ b/spec/support/search_data_manager.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class SearchDataManager + def prepare_test_data + 4.times do |i| + username = "search_test_account_#{i}" + account = Fabricate.create(:account, username: username, indexable: i.even?, discoverable: i.even?, note: "Lover of #{i}.") + 2.times do |j| + Fabricate.create(:status, account: account, text: "#{username}'s #{j} post", visibility: j.even? ? :public : :private) + end + end + + 3.times do |i| + Fabricate.create(:tag, name: "search_test_tag_#{i}") + end + end + + def indexes + [ + AccountsIndex, + PublicStatusesIndex, + StatusesIndex, + TagsIndex, + ] + end + + def populate_indexes + indexes.each do |index_class| + index_class.purge! + index_class.import! + end + end + + def remove_indexes + indexes.each(&:delete!) + end + + def cleanup_test_data + Status.destroy_all + Account.destroy_all + Tag.destroy_all + end +end + +RSpec.configure do |config| + config.before :suite do + if search_examples_present? + # Configure chewy to use `urgent` strategy to index documents + Chewy.strategy(:urgent) + + # Create search data + search_data_manager.prepare_test_data + end + end + + config.after :suite do + if search_examples_present? + # Clean up after search data + search_data_manager.cleanup_test_data + end + end + + config.around :each, :search do |example| + search_data_manager.populate_indexes + example.run + search_data_manager.remove_indexes + end + + private + + def search_data_manager + @search_data_manager ||= SearchDataManager.new + end + + def search_examples_present? + RSpec.world.filtered_examples.values.flatten.any? { |example| example.metadata[:search] == true } + end +end diff --git a/spec/support/signed_request_helpers.rb b/spec/support/signed_request_helpers.rb new file mode 100644 index 0000000000..eba4095e43 --- /dev/null +++ b/spec/support/signed_request_helpers.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module SignedRequestHelpers + def get(path, headers: nil, sign_with: nil, **args) + return super(path, headers: headers, **args) if sign_with.nil? + + headers ||= {} + headers['Date'] = Time.now.utc.httpdate + headers['Host'] = ENV.fetch('LOCAL_DOMAIN') + signed_headers = headers.merge('(request-target)' => "get #{path}").slice('(request-target)', 'Host', 'Date') + + key_id = ActivityPub::TagManager.instance.key_uri_for(sign_with) + keypair = sign_with.keypair + signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") + signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) + + headers['Signature'] = "keyId=\"#{key_id}\",algorithm=\"rsa-sha256\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" + + super(path, headers: headers, **args) + end +end diff --git a/spec/support/stories/profile_stories.rb b/spec/support/stories/profile_stories.rb index de7ae17e63..82667ca080 100644 --- a/spec/support/stories/profile_stories.rb +++ b/spec/support/stories/profile_stories.rb @@ -9,6 +9,8 @@ module ProfileStories email: email, password: password, confirmed_at: confirmed_at, account: Fabricate(:account, username: 'bob') ) + + Web::Setting.where(user: bob).first_or_initialize(user: bob).update!(data: { introductionVersion: 201812160442020 }) if finished_onboarding # rubocop:disable Style/NumericLiterals end def as_a_logged_in_user @@ -16,7 +18,7 @@ module ProfileStories visit new_user_session_path fill_in 'user_email', with: email fill_in 'user_password', with: password - click_on I18n.t('auth.login') + click_button I18n.t('auth.login') end def with_alice_as_local_user @@ -42,4 +44,8 @@ module ProfileStories def password @password ||= 'password' end + + def finished_onboarding + @finished_onboarding || false + end end diff --git a/spec/support/streaming_server_manager.rb b/spec/support/streaming_server_manager.rb new file mode 100644 index 0000000000..2c535767e2 --- /dev/null +++ b/spec/support/streaming_server_manager.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +class StreamingServerManager + @running_thread = nil + + def initialize + at_exit { stop } + end + + def start(port: 4020) + return if @running_thread + + queue = Queue.new + + @queue = queue + + @running_thread = Thread.new do + Open3.popen2e( + { + 'REDIS_NAMESPACE' => ENV.fetch('REDIS_NAMESPACE'), + 'DB_NAME' => "#{ENV.fetch('DB_NAME', 'mastodon')}_test#{ENV.fetch('TEST_ENV_NUMBER', '')}", + 'RAILS_ENV' => ENV.fetch('RAILS_ENV', 'test'), + 'NODE_ENV' => ENV.fetch('STREAMING_NODE_ENV', 'development'), + 'PORT' => port.to_s, + }, + 'node index.js', # must not call yarn here, otherwise it will fail because yarn does not send signals to its child process + chdir: Rails.root.join('streaming') + ) do |_stdin, stdout_err, process_thread| + status = :starting + + # Spawn a thread to listen on streaming server output + output_thread = Thread.new do + stdout_err.each_line do |line| + Rails.logger.info "Streaming server: #{line}" + + if status == :starting && line.match('Streaming API now listening on') + status = :started + @queue.enq 'started' + end + end + end + + # And another thread to listen on commands from the main thread + loop do + msg = queue.pop + + case msg + when 'stop' + # we need to properly stop the reading thread + output_thread.kill + + # Then stop the node process + Process.kill('KILL', process_thread.pid) + + # And we stop ourselves + @running_thread.kill + end + end + end + end + + # wait for 10 seconds for the streaming server to start + Timeout.timeout(10) do + loop do + break if @queue.pop == 'started' + end + end + end + + def stop + return unless @running_thread + + @queue.enq 'stop' + + # Wait for the thread to end + @running_thread.join + end +end + +RSpec.configure do |config| + config.before :suite do + if streaming_examples_present? + # Compile assets + Webpacker.compile + + # Start the node streaming server + streaming_server_manager.start(port: STREAMING_PORT) + end + end + + config.after :suite do + if streaming_examples_present? + # Stop the node streaming server + streaming_server_manager.stop + end + end + + private + + def streaming_server_manager + @streaming_server_manager ||= StreamingServerManager.new + end + + def streaming_examples_present? + RUN_SYSTEM_SPECS + end +end diff --git a/spec/system/new_statuses_spec.rb b/spec/system/new_statuses_spec.rb new file mode 100644 index 0000000000..244101f4d4 --- /dev/null +++ b/spec/system/new_statuses_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'NewStatuses' do + include ProfileStories + + subject { page } + + let(:email) { 'test@example.com' } + let(:password) { 'password' } + let(:confirmed_at) { Time.zone.now } + let(:finished_onboarding) { true } + + before do + as_a_logged_in_user + visit root_path + end + + it 'can be posted' do + expect(subject).to have_css('div.app-holder') + + status_text = 'This is a new status!' + + within('.compose-form') do + fill_in "What's on your mind?", with: status_text + click_button 'Publish!' + end + + expect(subject).to have_css('.status__content__text', text: status_text) + end + + it 'can be posted again' do + expect(subject).to have_css('div.app-holder') + + status_text = 'This is a second status!' + + within('.compose-form') do + fill_in "What's on your mind?", with: status_text + click_button 'Publish!' + end + + expect(subject).to have_css('.status__content__text', text: status_text) + end +end diff --git a/spec/system/unlogged_spec.rb b/spec/system/unlogged_spec.rb new file mode 100644 index 0000000000..c3ebf51d7f --- /dev/null +++ b/spec/system/unlogged_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'UnloggedBrowsing' do + subject { page } + + before do + visit root_path + end + + it 'loads the home page' do + expect(subject).to have_css('div.app-holder') + + expect(subject).to have_css('div.columns-area__panels__main') + end +end diff --git a/spec/validators/blacklisted_email_validator_spec.rb b/spec/validators/blacklisted_email_validator_spec.rb index 3d3d50f659..6292f0737e 100644 --- a/spec/validators/blacklisted_email_validator_spec.rb +++ b/spec/validators/blacklisted_email_validator_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe BlacklistedEmailValidator, type: :validator do +RSpec.describe BlacklistedEmailValidator do describe '#validate' do subject { described_class.new.validate(user); errors } @@ -11,14 +11,15 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do before do allow(user).to receive(:valid_invitation?).and_return(false) - allow_any_instance_of(described_class).to receive(:blocked_email_provider?) { blocked_email } + allow(EmailDomainBlock).to receive(:block?) { blocked_email } end context 'when e-mail provider is blocked' do let(:blocked_email) { true } it 'adds error' do - expect(subject).to have_received(:add).with(:email, :blocked) + described_class.new.validate(user) + expect(errors).to have_received(:add).with(:email, :blocked).once end end @@ -26,7 +27,8 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do let(:blocked_email) { false } it 'does not add errors' do - expect(subject).to_not have_received(:add).with(:email, :blocked) + described_class.new.validate(user) + expect(errors).to_not have_received(:add) end context 'when canonical e-mail is blocked' do @@ -37,7 +39,8 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do end it 'adds error' do - expect(subject).to have_received(:add).with(:email, :taken) + described_class.new.validate(user) + expect(errors).to have_received(:add).with(:email, :taken).once end end end diff --git a/spec/validators/disallowed_hashtags_validator_spec.rb b/spec/validators/disallowed_hashtags_validator_spec.rb index 7144d28918..570ddb31c2 100644 --- a/spec/validators/disallowed_hashtags_validator_spec.rb +++ b/spec/validators/disallowed_hashtags_validator_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe DisallowedHashtagsValidator, type: :validator do +RSpec.describe DisallowedHashtagsValidator do let(:disallowed_tags) { [] } describe '#validate' do diff --git a/spec/validators/existing_username_validator_spec.rb b/spec/validators/existing_username_validator_spec.rb new file mode 100644 index 0000000000..4f1dd55a17 --- /dev/null +++ b/spec/validators/existing_username_validator_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ExistingUsernameValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + attr_accessor :contact, :friends + + def self.name + 'Record' + end + + validates :contact, existing_username: true + validates :friends, existing_username: { multiple: true } + end + end + let(:record) { record_class.new } + + describe '#validate_each' do + context 'with a nil value' do + it 'does not add errors' do + record.contact = nil + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + + context 'when there are no accounts' do + it 'adds errors to the record' do + record.contact = 'user@example.com' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:contact) + expect(record.errors.first.type).to eq I18n.t('existing_username_validator.not_found') + end + end + + context 'when there are accounts' do + before { Fabricate(:account, domain: 'example.com', username: 'user') } + + context 'when the value does not match' do + it 'adds errors to the record' do + record.contact = 'friend@other.host' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:contact) + expect(record.errors.first.type).to eq I18n.t('existing_username_validator.not_found') + end + + context 'when multiple is true' do + it 'adds errors to the record' do + record.friends = 'friend@other.host' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:friends) + expect(record.errors.first.type).to eq I18n.t('existing_username_validator.not_found_multiple', usernames: 'friend@other.host') + end + end + end + + context 'when the value does match' do + it 'does not add errors to the record' do + record.contact = 'user@example.com' + + expect(record).to be_valid + expect(record.errors).to be_empty + end + + context 'when multiple is true' do + it 'does not add errors to the record' do + record.friends = 'user@example.com' + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + end + end + end +end diff --git a/spec/validators/follow_limit_validator_spec.rb b/spec/validators/follow_limit_validator_spec.rb index 86b6511d65..51b0683d27 100644 --- a/spec/validators/follow_limit_validator_spec.rb +++ b/spec/validators/follow_limit_validator_spec.rb @@ -2,48 +2,76 @@ require 'rails_helper' -RSpec.describe FollowLimitValidator, type: :validator do +RSpec.describe FollowLimitValidator do describe '#validate' do - before do - allow_any_instance_of(described_class).to receive(:limit_reached?).with(account) do - limit_reached - end + context 'with a nil account' do + it 'does not add validation errors to base' do + follow = Fabricate.build(:follow, account: nil) - described_class.new.validate(follow) - end + follow.valid? - let(:follow) { instance_double(Follow, account: account, errors: errors) } - let(:errors) { instance_double(ActiveModel::Errors, add: nil) } - let(:account) { instance_double(Account, nil?: _nil, local?: local, following_count: 0, followers_count: 0) } - let(:_nil) { true } - let(:local) { false } - - context 'with follow.account.nil? || !follow.account.local?' do - let(:_nil) { true } - - it 'not calls errors.add' do - expect(errors).to_not have_received(:add).with(:base, any_args) + expect(follow.errors[:base]).to be_empty end end - context 'with !(follow.account.nil? || !follow.account.local?)' do - let(:_nil) { false } - let(:local) { true } + context 'with a non-local account' do + it 'does not add validation errors to base' do + follow = Fabricate.build(:follow, account: Account.new(domain: 'host.example')) - context 'when limit_reached?' do - let(:limit_reached) { true } + follow.valid? - it 'calls errors.add' do - expect(errors).to have_received(:add) - .with(:base, I18n.t('users.follow_limit_reached', limit: FollowLimitValidator::LIMIT)) + expect(follow.errors[:base]).to be_empty + end + end + + context 'with a local account' do + let(:account) { Account.new } + + context 'when the followers count is under the limit' do + before do + allow(account).to receive(:following_count).and_return(described_class::LIMIT - 100) + end + + it 'does not add validation errors to base' do + follow = Fabricate.build(:follow, account: account) + + follow.valid? + + expect(follow.errors[:base]).to be_empty end end - context 'with !limit_reached?' do - let(:limit_reached) { false } + context 'when the following count is over the limit' do + before do + allow(account).to receive(:following_count).and_return(described_class::LIMIT + 100) + end - it 'not calls errors.add' do - expect(errors).to_not have_received(:add).with(:base, any_args) + context 'when the followers count is low' do + before do + allow(account).to receive(:followers_count).and_return(10) + end + + it 'adds validation errors to base' do + follow = Fabricate.build(:follow, account: account) + + follow.valid? + + expect(follow.errors[:base]).to include(I18n.t('users.follow_limit_reached', limit: FollowLimitValidator::LIMIT)) + end + end + + context 'when the followers count is high' do + before do + allow(account).to receive(:followers_count).and_return(100_000) + end + + it 'does not add validation errors to base' do + follow = Fabricate.build(:follow, account: account) + + follow.valid? + + expect(follow.errors[:base]).to be_empty + end end end end diff --git a/spec/validators/language_validator_spec.rb b/spec/validators/language_validator_spec.rb new file mode 100644 index 0000000000..cb693dcd81 --- /dev/null +++ b/spec/validators/language_validator_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe LanguageValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + attr_accessor :locale + + validates :locale, language: true + end + end + let(:record) { record_class.new } + + describe '#validate_each' do + context 'with a nil value' do + it 'does not add errors' do + record.locale = nil + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + + context 'with an array of values' do + it 'does not add errors with array of existing locales' do + record.locale = %w(en fr) + + expect(record).to be_valid + expect(record.errors).to be_empty + end + + it 'adds errors with array having some non-existing locales' do + record.locale = %w(en fr missing) + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:locale) + expect(record.errors.first.type).to eq(:invalid) + end + end + + context 'with a locale string' do + it 'does not add errors when string is an existing locale' do + record.locale = 'en' + + expect(record).to be_valid + expect(record.errors).to be_empty + end + + it 'adds errors when string is non-existing locale' do + record.locale = 'missing' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:locale) + expect(record.errors.first.type).to eq(:invalid) + end + end + end +end diff --git a/spec/validators/poll_validator_spec.rb b/spec/validators/poll_validator_spec.rb index 95feb043db..f2a2534898 100644 --- a/spec/validators/poll_validator_spec.rb +++ b/spec/validators/poll_validator_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe PollValidator, type: :validator do +RSpec.describe PollValidator do describe '#validate' do before do validator.validate(poll) diff --git a/spec/validators/status_length_validator_spec.rb b/spec/validators/status_length_validator_spec.rb index 8535ddd750..614f5d3909 100644 --- a/spec/validators/status_length_validator_spec.rb +++ b/spec/validators/status_length_validator_spec.rb @@ -6,14 +6,20 @@ describe StatusLengthValidator do describe '#validate' do it 'does not add errors onto remote statuses' do status = instance_double(Status, local?: false) + allow(status).to receive(:errors) + subject.validate(status) - expect(status).to_not receive(:errors) + + expect(status).to_not have_received(:errors) end it 'does not add errors onto local reblogs' do status = instance_double(Status, local?: false, reblog?: true) + allow(status).to receive(:errors) + subject.validate(status) - expect(status).to_not receive(:errors) + + expect(status).to_not have_received(:errors) end it 'adds an error when content warning is over MAX_CHARS characters' do diff --git a/spec/validators/status_pin_validator_spec.rb b/spec/validators/status_pin_validator_spec.rb index e8f8a45434..d5109f990f 100644 --- a/spec/validators/status_pin_validator_spec.rb +++ b/spec/validators/status_pin_validator_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe StatusPinValidator, type: :validator do +RSpec.describe StatusPinValidator do describe '#validate' do before do subject.validate(pin) diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb index 6f353eeafd..0eb5f83683 100644 --- a/spec/validators/unreserved_username_validator_spec.rb +++ b/spec/validators/unreserved_username_validator_spec.rb @@ -2,41 +2,118 @@ require 'rails_helper' -RSpec.describe UnreservedUsernameValidator, type: :validator do - describe '#validate' do - before do - allow(validator).to receive(:reserved_username?) { reserved_username } - validator.validate(account) +describe UnreservedUsernameValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + attr_accessor :username + + validates_with UnreservedUsernameValidator end + end + let(:record) { record_class.new } - let(:validator) { described_class.new } - let(:account) { instance_double(Account, username: username, errors: errors) } - let(:errors) { instance_double(ActiveModel::Errors, add: nil) } + describe '#validate' do + context 'when username is nil' do + it 'does not add errors' do + record.username = nil - context 'when @username is blank?' do - let(:username) { nil } - - it 'not calls errors.add' do - expect(errors).to_not have_received(:add).with(:username, any_args) + expect(record).to be_valid + expect(record.errors).to be_empty end end - context 'when @username is not blank?' do - let(:username) { 'f' } + context 'when PAM is enabled' do + before do + allow(Devise).to receive(:pam_authentication).and_return(true) + end - context 'with reserved_username?' do - let(:reserved_username) { true } + context 'with a pam service available' do + let(:service) { double } + let(:pam_class) do + Class.new do + def self.account(service, username); end + end + end - it 'calls errors.add' do - expect(errors).to have_received(:add).with(:username, :reserved) + before do + stub_const('Rpam2', pam_class) + allow(Devise).to receive(:pam_controlled_service).and_return(service) + end + + context 'when the account exists' do + before do + allow(Rpam2).to receive(:account).with(service, 'username').and_return(true) + end + + it 'adds errors to the record' do + record.username = 'username' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:username) + expect(record.errors.first.type).to eq(:reserved) + end + end + + context 'when the account does not exist' do + before do + allow(Rpam2).to receive(:account).with(service, 'username').and_return(false) + end + + it 'does not add errors to the record' do + record.username = 'username' + + expect(record).to be_valid + expect(record.errors).to be_empty + end end end - context 'when username is not reserved' do - let(:reserved_username) { false } + context 'without a pam service' do + before do + allow(Devise).to receive(:pam_controlled_service).and_return(false) + end - it 'not calls errors.add' do - expect(errors).to_not have_received(:add).with(:username, any_args) + context 'when there are not any reserved usernames' do + before do + stub_reserved_usernames(nil) + end + + it 'does not add errors to the record' do + record.username = 'username' + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + + context 'when there are reserved usernames' do + before do + stub_reserved_usernames(%w(alice bob)) + end + + context 'when the username is reserved' do + it 'adds errors to the record' do + record.username = 'alice' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:username) + expect(record.errors.first.type).to eq(:reserved) + end + end + + context 'when the username is not reserved' do + it 'does not add errors to the record' do + record.username = 'chris' + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + end + + def stub_reserved_usernames(value) + allow(Setting).to receive(:[]).with('reserved_usernames').and_return(value) end end end diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb index f2220e32b0..4f32b7b399 100644 --- a/spec/validators/url_validator_spec.rb +++ b/spec/validators/url_validator_spec.rb @@ -2,32 +2,64 @@ require 'rails_helper' -RSpec.describe URLValidator, type: :validator do - describe '#validate_each' do - before do - allow(validator).to receive(:compliant?).with(value) { compliant } - validator.validate_each(record, attribute, value) +describe URLValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + attr_accessor :profile + + validates :profile, url: true end + end + let(:record) { record_class.new } - let(:validator) { described_class.new(attributes: [attribute]) } - let(:record) { instance_double(Webhook, errors: errors) } - let(:errors) { instance_double(ActiveModel::Errors, add: nil) } - let(:value) { '' } - let(:attribute) { :foo } + describe '#validate_each' do + context 'with a nil value' do + it 'adds errors' do + record.profile = nil - context 'when not compliant?' do - let(:compliant) { false } - - it 'calls errors.add' do - expect(errors).to have_received(:add).with(attribute, :invalid) + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:profile) + expect(record.errors.first.type).to eq(:invalid) end end - context 'when compliant?' do - let(:compliant) { true } + context 'with an invalid url scheme' do + it 'adds errors' do + record.profile = 'ftp://example.com/page' - it 'not calls errors.add' do - expect(errors).to_not have_received(:add).with(attribute, any_args) + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:profile) + expect(record.errors.first.type).to eq(:invalid) + end + end + + context 'without a hostname' do + it 'adds errors' do + record.profile = 'https:///page' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:profile) + expect(record.errors.first.type).to eq(:invalid) + end + end + + context 'with an unparseable value' do + it 'adds errors' do + record.profile = 'https://host:port/page' # non-numeric port string causes invalid uri error + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:profile) + expect(record.errors.first.type).to eq(:invalid) + end + end + + context 'with a valid url' do + it 'does not add errors' do + record.profile = 'https://example.com/page' + + expect(record).to be_valid + expect(record.errors).to be_empty end end end diff --git a/spec/views/admin/trends/links/_preview_card.html.haml_spec.rb b/spec/views/admin/trends/links/_preview_card.html.haml_spec.rb new file mode 100644 index 0000000000..82a1dee6d7 --- /dev/null +++ b/spec/views/admin/trends/links/_preview_card.html.haml_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'admin/trends/links/_preview_card.html.haml' do + it 'correctly escapes user supplied url values' do + form = instance_double(ActionView::Helpers::FormHelper, check_box: nil) + trend = PreviewCardTrend.new(allowed: false) + preview_card = Fabricate.build( + :preview_card, + url: 'https://host.example/path?query=