diff --git a/.bundler-audit.yml b/.bundler-audit.yml index f84ec8087..0671df390 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -1,3 +1,6 @@ --- ignore: - - CVE-2015-9284 # Mitigation following https://github.com/omniauth/omniauth/wiki/Resolving-CVE-2015-9284#mitigating-in-rails-applications + # devise-two-factor advisory about brute-forcing TOTP + # We have rate-limits on authentication endpoints in place (including second + # factor verification) since Mastodon v3.2.0 + - CVE-2024-0227 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f991036ad..b5e72a097 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 000000000..b32e4026d --- /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 17208a84e..ed71235b3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,32 +1,40 @@ -// 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": {} + "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"] - } - } + "extensions": ["EditorConfig.EditorConfig", "webben.browserslist"], + }, + }, } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index a2658ea8b..88979723c 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.4 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index a075cc7b3..82a2ccbb6 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 fedbea236..41da71804 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.production.sample b/.env.production.sample index 7bcce0f7e..5939c1214 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -251,6 +251,11 @@ SMTP_FROM_ADDRESS=notifications@example.com # Maximum allowed character count MAX_TOOT_CHARS=500 +# Maximum allowed hashtags to follow in a feed column +# Note that setting this value higher may cause significant +# database load +MAX_FEED_HASHTAGS=4 + # Maximum number of pinned posts MAX_PINNED_TOOTS=5 diff --git a/.env.test b/.env.test index 761d0d921..2f8c1afd6 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 32ed9b922..69c1bf1fb 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 206faa1c7..ba4c9c40b 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,19 +239,24 @@ 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', }, // Immutable / Redux / data store { - pattern: '{immutable,react-redux,react-immutable-proptypes,react-immutable-pure-component,reselect}', + pattern: '{immutable,@reduxjs/toolkit,react-redux,react-immutable-proptypes,react-immutable-pure-component}', group: 'external', position: 'before', }, // 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,7 +369,15 @@ 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 } }], + "@typescript-eslint/no-restricted-imports": [ + "warn", + { + "name": "react-redux", + "importNames": ["useSelector", "useDispatch"], + "message": "Use typed hooks `useAppDispatch` and `useAppSelector` instead." + } + ], 'jsdoc/require-jsdoc': 'off', // Those rules set stricter rules for TS files @@ -370,14 +399,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 be750a5e4..000000000 --- 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 22f51f7bd..000000000 --- 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 000000000..20e27d103 --- /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 000000000..49d5f5720 --- /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 000000000..07fd4d08d --- /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 000000000..3a6fba940 --- /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 000000000..5532c4961 --- /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 1ae40d416..dab99829a 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 000000000..e100e1582 --- /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-image.yml b/.github/workflows/build-image.yml deleted file mode 100644 index da4203e35..000000000 --- a/.github/workflows/build-image.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Build container image -on: - workflow_dispatch: - push: - branches: - - 'main' - pull_request: - paths: - - .github/workflows/build-image.yml - - Dockerfile -permissions: - contents: read - packages: write - -jobs: - build-image: - runs-on: ubuntu-latest - - concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - 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 }} - if: github.event_name != 'pull_request' - - - uses: docker/metadata-action@v4 - id: meta - with: - images: ghcr.io/${{ github.repository_owner }}/mastodon - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=edge,branch=main - type=sha,prefix=,format=long - - - name: Generate version suffix - id: version_vars - if: github.repository == 'mastodon/mastodon' && github.event_name == 'push' && github.ref_name == 'main' - run: | - echo mastodon_version_suffix=+edge-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT - - - 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.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml index f07f7447c..eddcd80f2 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 000000000..4505151e1 --- /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 000000000..8e0fe5dfa --- /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/build-security.yml b/.github/workflows/build-security.yml new file mode 100644 index 000000000..e9f1862f5 --- /dev/null +++ b/.github/workflows/build-security.yml @@ -0,0 +1,61 @@ +name: Build security nightly container image +on: + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + compute-suffix: + runs-on: ubuntu-latest + steps: + - id: version_vars + env: + TZ: Etc/UTC + run: | + echo mastodon_version_prerelease=nightly.$(date --date='next day' +'%Y-%m-%d')-security>> $GITHUB_OUTPUT + outputs: + prerelease: ${{ steps.version_vars.outputs.mastodon_version_prerelease }} + + 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=raw,value=${{ 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=raw,value=${{ needs.compute-suffix.outputs.prerelease }} + secrets: inherit diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml new file mode 100644 index 000000000..bbc31598c --- /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 b67c503e9..ceb385933 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 8534501d4..6fb93b7fe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,11 +27,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -44,7 +44,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -57,6 +57,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml new file mode 100644 index 000000000..99271b127 --- /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 000000000..75d66c2a6 --- /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 4d3c2ce5a..7229bec58 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 56d817123..8dcab845e 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 1f0cfd1e7..1c1ecc2b2 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 8712d8bd8..7796bf92c 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 d19a0470d..51c59937a 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 0395c8639..411b32348 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 295e9610b..908bdef5c 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 131a62a57..06d835c09 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 000000000..980e07189 --- /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 3306105f9..79622b6c1 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 a91fd819a..1ff5cc06b 100644 --- a/.github/workflows/test-migrations-one-step.yml +++ b/.github/workflows/test-migrations-one-step.yml @@ -70,39 +70,16 @@ 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' - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' + - name: Run historical migrations with data population + run: './bin/rails tests:migrations:prepare_database' - name: Run all remaining migrations run: './bin/rails db:migrate' diff --git a/.github/workflows/test-migrations-two-step.yml b/.github/workflows/test-migrations-two-step.yml index 50266fb8a..669884731 100644 --- a/.github/workflows/test-migrations-two-step.yml +++ b/.github/workflows/test-migrations-two-step.yml @@ -45,6 +45,7 @@ jobs: --health-retries 5 ports: - 5432:5432 + redis: image: redis:7-alpine options: >- @@ -69,44 +70,19 @@ 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' - - name: Run migrations up to v2.0.0 - run: './bin/rails db:migrate VERSION=20171010025614' - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2' - - - name: Run pre-deployment migrations up to v2.4.0 - run: './bin/rails db:migrate VERSION=20180514140000' + - name: Run historical migrations with data population + run: './bin/rails tests:migrations:prepare_database' env: SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4' - - - name: Run migrations up to v2.4.3 - run: './bin/rails db:migrate VERSION=20180707154237' - env: - SKIP_POST_DEPLOYMENT_MIGRATIONS: true - - - name: Populate database with test data - run: './bin/rails tests:migrations:populate_v2_4_3' - - name: Run all remaining pre-deployment migrations run: './bin/rails db:migrate' env: diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 07cb1d41f..346703ced 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 - - uses: actions/upload-artifact@v3 + - name: Archive asset artifacts + run: | + tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* + + - uses: actions/upload-artifact@v4 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 + - uses: actions/download-artifact@v4 with: - path: './public' + path: './' name: ${{ github.sha }} - - name: Update package index - run: sudo apt-get update + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz - - 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 libpam-dev - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' - - run: bundle exec rake rspec_chunked + - 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@v4 + 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: bundle exec rake spec:system + + - name: Archive logs + uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v4 + 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@v4 + 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@v4 + if: failure() + with: + name: test-search-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-search-screenshots + path: tmp/screenshots/ diff --git a/.gitignore b/.gitignore index c7562ae84..00adb4eba 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 d1ed30b26..8cfcaec8d 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 c60168390..af2d2e8f4 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -1,106 +1,13 @@ # 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 2024-01-09 11:30:07 -0500 using Haml-Lint version 0.53.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: 1 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' diff --git a/.nvmrc b/.nvmrc index 59ea99ee6..a3597ecbd 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.20 +20.11 diff --git a/.prettierignore b/.prettierignore index 27b6d5458..66ca9ee5b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,9 +31,6 @@ # Ignore Vagrant files .vagrant/ -# Ignore Capistrano customizations -/config/deploy/* - # Ignore IDE files .vscode/ .idea/ @@ -77,6 +74,8 @@ app/javascript/styles/mastodon/reset.scss # Ignore the generated AUTHORS.md AUTHORS.md +!lint-staged.config.js + # Ignore glitch-soc emoji map file /app/javascript/flavours/glitch/features/emoji/emoji_map.json diff --git a/.rubocop.yml b/.rubocop.yml index eff89bdae..64021b4ce 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 @@ -79,13 +72,6 @@ Metrics/ModuleLength: # Reason: Currently disabled in .rubocop_todo.yml # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize Metrics/AbcSize: - Exclude: - - 'lib/mastodon/cli/*.rb' - - db/*migrate/**/* - -# Reason: -# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting -Metrics/BlockNesting: Exclude: - 'lib/mastodon/cli/*.rb' @@ -94,7 +80,6 @@ Metrics/BlockNesting: Metrics/CyclomaticComplexity: Exclude: - lib/mastodon/cli/*.rb - - db/*migrate/**/* # Reason: # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsparameterlists @@ -118,26 +103,42 @@ Rails/Exit: - 'config/boot.rb' - 'lib/mastodon/cli/*.rb' -# Reason: Some single letter camel case files shouldn't be split +# Reason: Conflicts with `Lint/UselessMethodDefinition` for inherited controller actions +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railslexicallyscopedactionfilter +Rails/LexicallyScopedActionFilter: + Exclude: + - 'app/controllers/auth/*' + +# Reason: These tasks are doing local work which do not need full env loaded +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsrakeenvironment +Rails/RakeEnvironment: + Exclude: + - 'lib/tasks/auto_annotate_models.rake' + - 'lib/tasks/emojis.rake' + - 'lib/tasks/mastodon.rake' + - 'lib/tasks/repo.rake' + - 'lib/tasks/statistics.rake' + +# Reason: There are appropriate times to use these features +# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsskipsmodelvalidations +Rails/SkipsModelValidations: + Enabled: false + +# 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: Deprecated cop, will be removed in 3.0, replaced by SpecFilePathFormat # 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 - 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' + Enabled: false # Reason: # https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject @@ -154,6 +155,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 +203,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 c1d580e51..47c49cb9a 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.60.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 @@ -13,146 +13,13 @@ Bundler/OrderedGems: Exclude: - 'Gemfile' -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, IndentationWidth. -# SupportedStyles: with_first_argument, with_fixed_indentation -Layout/ArgumentAlignment: - 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' - 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: 90 # Configuration parameters: CountBlocks, Max. Metrics/BlockNesting: @@ -167,506 +34,33 @@ 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 # Configuration parameters: AllowSubject. RSpec/MultipleMemoizedHelpers: - Max: 21 + Max: 17 # Configuration parameters: AllowedGroups. 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/preview_card.rb' + - 'app/models/concerns/account/associations.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: - 'config/initializers/simple_form.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -# Configuration parameters: Include. -# Include: **/Rakefile, **/*.rake -Rails/RakeEnvironment: - Exclude: - - 'lib/tasks/auto_annotate_models.rake' - - 'lib/tasks/db.rake' - - 'lib/tasks/emojis.rake' - - 'lib/tasks/mastodon.rake' - - '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/expireable.rb' - - 'app/models/status.rb' - - 'app/models/trends/links.rb' - - 'app/models/trends/preview_card_batch.rb' - - 'app/models/trends/preview_card_provider_batch.rb' - - 'app/models/trends/status_batch.rb' - - 'app/models/trends/statuses.rb' - - 'app/models/trends/tag_batch.rb' - - 'app/models/trends/tags.rb' - - 'app/models/user.rb' - - 'app/services/activitypub/process_status_update_service.rb' - - 'app/services/approve_appeal_service.rb' - - 'app/services/block_domain_service.rb' - - 'app/services/delete_account_service.rb' - - 'app/services/process_mentions_service.rb' - - 'app/services/unallow_domain_service.rb' - - 'app/services/unblock_domain_service.rb' - - 'app/services/update_status_service.rb' - - 'app/workers/activitypub/post_upgrade_worker.rb' - - '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,61 +70,7 @@ 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 -Rails/WhereExists: - Exclude: - - 'app/controllers/activitypub/inboxes_controller.rb' - - 'app/controllers/admin/email_domain_blocks_controller.rb' - - 'app/controllers/auth/registrations_controller.rb' - - 'app/lib/activitypub/activity/create.rb' - - 'app/lib/delivery_failure_tracker.rb' - - '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/featured_tag.rb' - - 'app/models/poll.rb' - - 'app/models/session_activation.rb' - - 'app/models/status.rb' - - 'app/models/user.rb' - - 'app/policies/status_policy.rb' - - 'app/serializers/rest/announcement_serializer.rb' - - 'app/serializers/rest/tag_serializer.rb' - - 'app/services/activitypub/fetch_remote_status_service.rb' - - 'app/services/app_sign_up_service.rb' - - 'app/services/vote_service.rb' - - 'app/validators/reaction_validator.rb' - - 'app/validators/vote_validator.rb' - - 'app/workers/move_worker.rb' - - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb' - - 'lib/tasks/tests.rake' - - 'spec/models/account_spec.rb' - - 'spec/services/activitypub/process_collection_service_spec.rb' - - 'spec/services/purge_domain_service_spec.rb' - - 'spec/services/unallow_domain_service_spec.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowOnConstant, AllowOnSelfClass. -Style/CaseEquality: - Exclude: - - 'config/initializers/trusted_proxies.rb' - -# This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AllowedMethods, AllowedPatterns. # AllowedMethods: ==, equal?, eql? Style/ClassEqualityComparison: @@ -742,12 +82,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,16 +90,15 @@ 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/mastodon/redis_config.rb' + - 'lib/premailer_webpack_strategy.rb' - 'lib/tasks/repo.rake' - 'spec/features/profile_spec.rb' @@ -775,414 +108,12 @@ Style/FetchEnvVar: # AllowedMethods: redirect Style/FormatStringToken: Exclude: - - 'app/models/privacy_policy.rb' - '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: - - 'config/boot.rb' - 'config/environments/development.rb' - 'config/environments/production.rb' @@ -1190,17 +121,13 @@ Style/GlobalStdStream: # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. Style/GuardClause: Exclude: - - 'app/controllers/admin/confirmations_controller.rb' - - 'app/controllers/auth/confirmations_controller.rb' - - 'app/controllers/auth/passwords_controller.rb' - - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb' - 'app/lib/activitypub/activity/block.rb' - 'app/lib/request.rb' - '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' @@ -1212,10 +139,8 @@ Style/GuardClause: - 'app/workers/redownload_media_worker.rb' - 'app/workers/remote_account_refresh_worker.rb' - '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,12 +154,11 @@ 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' - - 'db/migrate/20181024224956_migrate_account_conversations.rb' # This cop supports unsafe autocorrection (--autocorrect-all). Style/HashTransformValues: @@ -1249,22 +173,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 +185,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 +226,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 +242,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,20 +275,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 -Style/TernaryParentheses: - Exclude: - - 'config/environments/development.rb' - # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyleForMultiline. # SupportedStylesForMultiline: comma, consistent_comma, no_comma @@ -1436,17 +291,8 @@ Style/TrailingCommaInHashLiteral: - 'config/environments/test.rb' # This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: EnforcedStyle, MinSize, WordRegex. +# Configuration parameters: WordRegex. # SupportedStyles: percent, brackets 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 + EnforcedStyle: percent + MinSize: 3 diff --git a/.ruby-version b/.ruby-version index be94e6f53..b347b11ea 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.2.3 diff --git a/.simplecov b/.simplecov new file mode 100644 index 000000000..fbd0207be --- /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 000000000..29e4f231e --- /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 000000000..e69de29bb 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 000000000..0b3f94d09 --- /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/.yarnclean b/.yarnclean deleted file mode 100644 index 21eb734a6..000000000 --- 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 000000000..3186f3f07 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/AUTHORS.md b/AUTHORS.md index 18b9f2d70..78cc37a17 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 425c09850..6f775fcfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,402 @@ 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 + +- Fix branding:generate_app_icons failing because of disallowed ICO coder ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25794)) +- Fix crash in admin interface when viewing a remote user with verified links ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25796)) +- Fix processing of media files with unusual names ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25788)) + ## [4.1.3] - 2023-07-06 ### Added @@ -29,7 +425,7 @@ All notable changes to this project will be documented in this file. - Fix multiple inefficiencies in automatic post cleanup worker ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24607), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24785), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/24840)) - Fix performance of streaming by parsing message JSON once ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25278), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/25361)) - Fix CSP headers when `S3_ALIAS_HOST` includes a path component ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25273)) -- Fix `tootctl accounts approve --number N` not aproving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605)) +- Fix `tootctl accounts approve --number N` not approving N earliest registrations ([danielmbrasil](https://github.com/mastodon/mastodon/pull/24605)) - Fix reports not being closed when performing batch suspensions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/24988)) - Fix being able to vote on your own polls ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25015)) - Fix race condition when reblogging a status ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/25016)) @@ -135,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)) @@ -542,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 a232915b6..2271802ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,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 86efa5bac..000000000 --- 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 cb5096581..119c266b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,104 +1,260 @@ # 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.3"] +ARG RUBY_VERSION="3.2.3" +# # 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.3-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 \ + wget \ + ; \ +# 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 cd1957cbd..2819fa935 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -1,19 +1,35 @@ -## ActivityPub federation in Mastodon +# Federation + +## Supported federation protocols and standards + +- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server) +- [WebFinger](https://webfinger.net/) +- [Http Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) +- [NodeInfo](https://nodeinfo.diaspora.software/) + +## Supported FEPs + +- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) +- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) +- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) +- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) + +## ActivityPub in Mastodon Mastodon largely follows the ActivityPub server-to-server specification but it makes uses of some non-standard extensions, some of which are required for interacting with Mastodon at all. -Supported vocabulary: https://docs.joinmastodon.org/spec/activitypub/ +- [Supported ActivityPub vocabulary](https://docs.joinmastodon.org/spec/activitypub/) ### Required extensions -#### Webfinger +#### WebFinger In Mastodon, users are identified by a `username` and `domain` pair (e.g., `Gargron@mastodon.social`). This is used both for discovery and for unambiguously mentioning users across the fediverse. Furthermore, this is part of Mastodon's database design from its very beginnings. As a result, Mastodon requires that each ActivityPub actor uniquely maps back to an `acct:` URI that can be resolved via WebFinger. -More information and examples are available at: https://docs.joinmastodon.org/spec/webfinger/ +- [WebFinger information and examples](https://docs.joinmastodon.org/spec/webfinger/) #### HTTP Signatures @@ -21,10 +37,13 @@ In order to authenticate activities, Mastodon relies on HTTP Signatures, signing Mastodon requires all `POST` requests to be signed, and MAY require `GET` requests to be signed, depending on the configuration of the Mastodon server. -More information on HTTP Signatures, as well as examples, can be found here: https://docs.joinmastodon.org/spec/security/#http +- [HTTP Signatures information and examples](https://docs.joinmastodon.org/spec/security/#http) ### Optional extensions -- 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 +- [Linked-Data Signatures](https://docs.joinmastodon.org/spec/security/#ld) +- [Bearcaps](https://docs.joinmastodon.org/spec/bearcaps/) + +### Additional documentation + +- [Mastodon documentation](https://docs.joinmastodon.org/) diff --git a/Gemfile b/Gemfile index 7a0fbdc82..ad7f0b3d4 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.18.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' @@ -35,13 +38,15 @@ group :pam_authentication, optional: true do end gem 'net-ldap', '~> 0.18' -gem 'omniauth-cas', '~> 2.0' -gem 'omniauth-saml', '~> 1.10' + +gem 'omniauth-cas', '~> 3.0.0.beta.1' +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 'csv', '~> 3.2' gem 'discard', '~> 1.2' gem 'doorkeeper', '~> 5.6' gem 'ed25519', '~> 1.3' @@ -56,8 +61,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' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' @@ -67,8 +73,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-settings-cached', '~> 0.6', git: 'https://github.com/mastodon/rails-settings-cached.git', branch: 'v0.6.6-aliases-true' +gem 'rails-i18n', '~> 7.0' gem 'redcarpet', '~> 3.6' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' @@ -82,9 +87,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.7.0' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' @@ -99,23 +103,27 @@ 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' + gem 'climate_control' # Generating fake data for specs gem 'faker', '~> 3.2' @@ -134,6 +142,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 +168,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 +202,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 b2d75e9d4..b76f449b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,86 +7,91 @@ GIT hkdf (~> 0.2) jwt (~> 2.0) -GIT - remote: https://github.com/mastodon/rails-settings-cached.git - revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab - branch: v0.6.6-aliases-true - specs: - rails-settings-cached (0.6.6) - rails (>= 4.2.0) - GEM remote: https://rubygems.org/ specs: - actioncable (6.1.7.4) - actionpack (= 6.1.7.4) - activesupport (= 6.1.7.4) + actioncable (7.1.3) + actionpack (= 7.1.3) + activesupport (= 7.1.3) 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.3) + actionpack (= 7.1.3) + activejob (= 7.1.3) + activerecord (= 7.1.3) + activestorage (= 7.1.3) + activesupport (= 7.1.3) 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.3) + actionpack (= 7.1.3) + actionview (= 7.1.3) + activejob (= 7.1.3) + activesupport (= 7.1.3) 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.3) + actionview (= 7.1.3) + activesupport (= 7.1.3) 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.3) + actionpack (= 7.1.3) + activerecord (= 7.1.3) + activestorage (= 7.1.3) + activesupport (= 7.1.3) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.1.3) + activesupport (= 7.1.3) 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.3) + activesupport (= 7.1.3) 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.3) + activesupport (= 7.1.3) + activerecord (7.1.3) + activemodel (= 7.1.3) + activesupport (= 7.1.3) + timeout (>= 0.4.0) + activestorage (7.1.3) + actionpack (= 7.1.3) + activejob (= 7.1.3) + activerecord (= 7.1.3) + activesupport (= 7.1.3) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (6.1.7.4) + activesupport (7.1.3) + 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,41 +101,53 @@ 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.873.0) + aws-sdk-core (3.190.1) + 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.75.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.142.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) - bcrypt (3.1.18) + 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.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.6) 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.18.3) msgpack (~> 1.2) - brakeman (5.4.1) + brakeman (6.1.2) + racc browser (5.3.1) brpoplpush-redis_script (0.1.3) concurrent-ruby (~> 1.0, >= 1.0.5) @@ -139,26 +156,11 @@ 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) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) @@ -167,15 +169,15 @@ GEM activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (7.3.2) + chewy (7.5.1) activesupport (>= 5.2) elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl chunky_png (1.4.0) - climate_control (0.2.0) + climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) connection_pool (2.4.1) cose (1.3.0) cbor (~> 0.5.9) @@ -185,35 +187,45 @@ GEM crass (1.0.6) css_parser (1.14.0) addressable - date (3.3.3) + csv (3.2.8) + 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) @@ -224,13 +236,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.109.0) + fabrication (2.31.0) + faker (3.2.3) i18n (>= 1.8.11, < 2) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -255,34 +271,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) + fastimage (2.3.0) ffi (1.15.5) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake - fog-core (2.1.0) + fog-core (2.4.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 @@ -291,11 +308,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.56.0) + haml (>= 5.0) parallel (~> 1.10) rainbow - rubocop (>= 0.50.0) + rubocop (>= 1.0) sysexits (~> 1.1) hashdiff (1.0.1) hashie (5.0.0) @@ -320,38 +337,41 @@ 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.2) + irb (1.11.1) + rdoc + reline (>= 0.4.2) jmespath (1.6.2) - json (2.6.3) - json-canonicalization (0.3.2) + json (2.7.1) + json-canonicalization (1.0.0) json-jwt (1.15.3) activesupport (>= 4.2) aes_key_wrap bindata httpclient - json-ld (3.2.5) + json-ld (3.3.1) htmlentities (~> 4.3) - json-canonicalization (~> 0.3, >= 0.3.2) + json-canonicalization (~> 1.0) link_header (~> 0.0, >= 0.0.8) multi_json (~> 1.15) rack (>= 2.2, < 4) - rdf (~> 3.2, >= 3.2.10) - json-ld-preloaded (3.2.2) - json-ld (~> 3.2) - rdf (~> 3.2) - json-schema (4.0.0) + rdf (~> 3.3) + json-ld-preloaded (3.3.0) + json-ld (~> 3.3) + rdf (~> 3.3) + json-schema (4.1.1) addressable (>= 2.8) jsonapi-renderer (0.2.2) jwt (2.7.1) @@ -367,12 +387,13 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - kt-paperclip (7.2.0) + kt-paperclip (7.2.2) activemodel (>= 4.2.0) activesupport (>= 4.2.0) marcel (~> 1.0.1) mime-types - terrapin (~> 0.6.0) + terrapin (>= 0.6.0, < 2.0) + language_server-protocol (3.17.0.3) launchy (2.5.2) addressable (~> 2.8) letter_opener (1.8.1) @@ -386,12 +407,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) @@ -399,56 +420,64 @@ 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.2) 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.1205) + mini_mime (1.1.5) + mini_portile2 (2.8.5) + minitest (5.21.2) + 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.1) uri - net-imap (0.3.6) + net-http-persistent (4.0.2) + connection_pool (~> 2.2) + net-imap (0.4.9.1) date net-protocol - net-ldap (0.18.0) + net-ldap (0.19.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.1) net-protocol - net-ssh (7.1.0) nio4r (2.5.9) - nokogiri (1.15.2) + nokogiri (1.16.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) - oj (3.15.0) - omniauth (1.9.2) + nsa (0.3.0) + activesupport (>= 4.2, < 7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + sidekiq (>= 3.5) + statsd-ruby (~> 1.4, >= 1.4.0) + 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-cas (3.0.0.beta.1) + addressable (~> 2.8) + nokogiri (~> 1.12) + omniauth (~> 2.1) + 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) @@ -463,20 +492,20 @@ GEM validate_email validate_url webfinger (~> 1.2) - openssl (3.1.0) + openssl (3.2.0) 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.3.0.5) 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) @@ -488,16 +517,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.2) 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) @@ -506,64 +542,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.2) + 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.3) + actioncable (= 7.1.3) + actionmailbox (= 7.1.3) + actionmailer (= 7.1.3) + actionpack (= 7.1.3) + actiontext (= 7.1.3) + actionview (= 7.1.3) + activejob (= 7.1.3) + activemodel (= 7.1.3) + activerecord (= 7.1.3) + activestorage (= 7.1.3) + activesupport (= 7.1.3) bundler (>= 1.15.0) - railties (= 6.1.7.4) - sprockets-rails (>= 2.0.0) + railties (= 7.1.3) 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.3) + actionpack (= 7.1.3) + activesupport (= 7.1.3) + 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) - rdf (3.2.11) + 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 (~> 3.2) + rdf-normalize (0.7.0) + rdf (~> 3.3) + 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.9.0) + reline (0.4.2) + 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) @@ -575,10 +625,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.1) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) @@ -586,38 +638,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.60.2) json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.3.0.2) 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.20.0) rubocop (~> 1.41) - rubocop-factory_bot (2.23.1) + rubocop-factory_bot (2.25.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.2) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + rubocop-rails (2.23.1) 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.26.1) + rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) + ruby-prof (1.7.0) ruby-progressbar (1.13.0) ruby-saml (1.15.0) nokogiri (>= 1.13.10) @@ -628,14 +684,19 @@ GEM fugit (~> 1.1, >= 1.1.6) safety_net_attestation (0.4.0) jwt (~> 2.0) - sanitize (6.0.1) + 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.17.0) + base64 (~> 0.2) + 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) @@ -645,7 +706,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) tilt (>= 1.4.0) - sidekiq-unique-jobs (7.1.29) + sidekiq-unique-jobs (7.1.31) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) redis (< 5.0) @@ -653,7 +714,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) @@ -661,36 +722,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) + stackprof (0.2.26) + statsd-ruby (1.5.0) + stoplight (3.0.2) redlock (~> 1.0) - strong_migrations (0.8.0) + stringio (3.1.0) + strong_migrations (1.7.0) 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) + terrapin (1.0.1) + climate_control + 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) @@ -710,12 +765,12 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2023.3) + tzinfo-data (1.2024.1) tzinfo (>= 1.0.0) 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) @@ -725,7 +780,7 @@ GEM public_suffix warden (1.2.9) rack (>= 2.0.9) - webauthn (3.0.0) + webauthn (3.1.0) android_key_attestation (~> 0.3.0) awrence (~> 1.1) bindata (~> 2.4) @@ -737,7 +792,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) @@ -746,14 +801,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 @@ -766,22 +823,21 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.16.0) - brakeman (~> 5.4) + bootsnap (~> 1.18.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) - climate_control (~> 0.2) + climate_control cocoon (~> 1.2) color_diff (~> 0.1) concurrent-ruby connection_pool + csv (~> 3.2) + database_cleaner-active_record + debug (~> 1.8) devise (~> 4.9) devise-two-factor (~> 4.1) devise_pam_authenticatable2 (~> 9.2) @@ -789,12 +845,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 @@ -806,6 +863,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) @@ -815,18 +873,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 (~> 3.0.0.beta.1) + omniauth-rails_csrf_protection (~> 1.0) + omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.6.1) ox (~> 2.14) parslet @@ -835,6 +894,7 @@ DEPENDENCIES posix-spawn premailer-rails private_address_check (~> 0.5) + propshaft public_suffix (~> 5.0) puma (~> 6.3) pundit (~> 2.3) @@ -842,27 +902,28 @@ 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-settings-cached (~> 0.6)! + rails-i18n (~> 7.0) 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) @@ -870,11 +931,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.7.0) + test-prof thor (~> 1.2) tty-prompt (~> 0.23) twitter-text (~> 3.1.0) @@ -889,4 +950,4 @@ RUBY VERSION ruby 3.2.2p53 BUNDLED WITH - 2.4.13 + 2.5.4 diff --git a/Procfile.dev b/Procfile.dev index ba04fb661..f81333b04 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 ba6b733dd..e51cf0e17 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 6a51c126a..81472b01b 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 @@ -12,7 +15,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through | Version | Supported | | ------- | --------- | +| 4.2.x | Yes | | 4.1.x | Yes | -| 4.0.x | Yes | -| 3.5.x | Yes | -| < 3.5 | No | +| < 4.1 | No | diff --git a/Vagrantfile b/Vagrantfile index 043bab3e9..6f0f51109 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 395797b27..000000000 --- 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 8810526cb..000000000 --- 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 0d29903e6..000000000 --- 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 d552880e3..55e70a791 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 6386f07ac..91bf4ab6f 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 4e9d08f09..000000000 --- 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 000000000..5f9a85df8 --- /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 bbf90a996..40ed63239 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 5cdc45a39..ec9d908ee 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 035e8a1de..a6a3b062d 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 15067471e..befc8a5c8 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 c2e26dbed..7e39b7cb3 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/json_ld_helper_spec.rb b/spec/helpers/json_ld_helper_spec.rb new file mode 100644 index 000000000..99857278a --- /dev/null +++ b/spec/helpers/json_ld_helper_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe JsonLdHelper do + describe '#equals_or_includes?' do + it 'returns true when value equals' do + expect(helper.equals_or_includes?('foo', 'foo')).to be true + end + + it 'returns false when value does not equal' do + expect(helper.equals_or_includes?('foo', 'bar')).to be false + end + + it 'returns true when value is included' do + expect(helper.equals_or_includes?(%w(foo baz), 'foo')).to be true + end + + it 'returns false when value is not included' do + expect(helper.equals_or_includes?(%w(foo baz), 'bar')).to be false + end + end + + describe '#first_of_value' do + context 'when value.is_a?(Array)' do + it 'returns value.first' do + value = ['a'] + expect(helper.first_of_value(value)).to be 'a' + end + end + + context 'with !value.is_a?(Array)' do + it 'returns value' do + value = 'a' + expect(helper.first_of_value(value)).to be 'a' + end + end + end + + describe '#supported_context?' do + context 'when json is present and in an activitypub tagmanager context' do + it 'returns true' do + json = { '@context' => ActivityPub::TagManager::CONTEXT }.as_json + expect(helper.supported_context?(json)).to be true + end + end + + context 'when not in activitypub tagmanager context' do + it 'returns false' do + json = nil + expect(helper.supported_context?(json)).to be false + end + end + end + + describe '#fetch_resource' do + context 'when the second argument is false' do + it 'returns resource even if the retrieved ID and the given URI does not match' do + stub_request(:get, 'https://bob.test/').to_return body: '{"id": "https://alice.test/"}' + stub_request(:get, 'https://alice.test/').to_return body: '{"id": "https://alice.test/"}' + + expect(fetch_resource('https://bob.test/', false)).to eq({ 'id' => 'https://alice.test/' }) + end + + it 'returns nil if the object identified by the given URI and the object identified by the retrieved ID does not match' do + stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://marvin.test/"}' + stub_request(:get, 'https://marvin.test/').to_return body: '{"id": "https://alice.test/"}' + + expect(fetch_resource('https://mallory.test/', false)).to be_nil + end + end + + context 'when the second argument is true' do + it 'returns nil if the retrieved ID and the given URI does not match' do + stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://alice.test/"}' + expect(fetch_resource('https://mallory.test/', true)).to be_nil + end + end + end + + describe '#fetch_resource_without_id_validation' do + it 'returns nil if the status code is not 200' do + stub_request(:get, 'https://host.test/').to_return status: 400, body: '{}' + expect(fetch_resource_without_id_validation('https://host.test/')).to be_nil + end + + it 'returns hash' do + stub_request(:get, 'https://host.test/').to_return status: 200, body: '{}' + expect(fetch_resource_without_id_validation('https://host.test/')).to eq({}) + end + end + + context 'with compaction and forwarding' do + let(:json) do + { + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + 'obsolete' => 'http://ostatus.org#', + 'convo' => 'obsolete:conversation', + 'new' => 'https://obscure-unreleased-test.joinmastodon.org/#', + }, + ], + 'type' => 'Create', + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'object' => { + 'id' => 'https://example.com/status', + 'type' => 'Note', + 'inReplyTo' => nil, + 'convo' => 'https://example.com/conversation', + 'tag' => [ + { + 'type' => 'Mention', + 'href' => ['foo'], + }, + ], + }, + 'signature' => { + 'type' => 'RsaSignature2017', + 'created' => '2022-02-02T12:00:00Z', + 'creator' => 'https://example.com/actor#main-key', + 'signatureValue' => 'some-sig', + }, + } + end + + describe '#compact' do + it 'properly compacts JSON-LD with alternative context definitions' do + expect(compact(json).dig('object', 'conversation')).to eq 'https://example.com/conversation' + end + + it 'compacts single-item arrays' do + expect(compact(json).dig('object', 'tag', 'href')).to eq 'foo' + end + + it 'compacts the activitystreams Public collection' do + expect(compact(json)['to']).to eq 'as:Public' + end + + it 'properly copies signature' do + expect(compact(json)['signature']).to eq json['signature'] + end + end + + describe 'patch_for_forwarding!' do + it 'properly patches incompatibilities' do + json['object'].delete('convo') + compacted = compact(json) + patch_for_forwarding!(json, compacted) + expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public'] + expect(compacted.dig('object', 'tag', 0, 'href')).to eq ['foo'] + expect(safe_for_forwarding?(json, compacted)).to be true + end + end + + describe 'safe_for_forwarding?' do + it 'deems a safe compacting as such' do + json['object'].delete('convo') + compacted = compact(json) + 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) + 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 + end + end +end diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb deleted file mode 100644 index 3575bba85..000000000 --- a/spec/helpers/jsonld_helper_spec.rb +++ /dev/null @@ -1,174 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe JsonLdHelper do - describe '#equals_or_includes?' do - it 'returns true when value equals' do - expect(helper.equals_or_includes?('foo', 'foo')).to be true - end - - it 'returns false when value does not equal' do - expect(helper.equals_or_includes?('foo', 'bar')).to be false - end - - it 'returns true when value is included' do - expect(helper.equals_or_includes?(%w(foo baz), 'foo')).to be true - end - - it 'returns false when value is not included' do - expect(helper.equals_or_includes?(%w(foo baz), 'bar')).to be false - end - end - - describe '#first_of_value' do - context 'when value.is_a?(Array)' do - it 'returns value.first' do - value = ['a'] - expect(helper.first_of_value(value)).to be 'a' - end - end - - context 'with !value.is_a?(Array)' do - it 'returns value' do - value = 'a' - expect(helper.first_of_value(value)).to be 'a' - end - end - end - - describe '#supported_context?' do - context 'when json is present and in an activitypub tagmanager context' do - it 'returns true' do - json = { '@context' => ActivityPub::TagManager::CONTEXT }.as_json - expect(helper.supported_context?(json)).to be true - end - end - - context 'when not in activitypub tagmanager context' do - it 'returns false' do - json = nil - expect(helper.supported_context?(json)).to be false - end - end - end - - describe '#fetch_resource' do - context 'when the second argument is false' do - it 'returns resource even if the retrieved ID and the given URI does not match' do - stub_request(:get, 'https://bob.test/').to_return body: '{"id": "https://alice.test/"}' - stub_request(:get, 'https://alice.test/').to_return body: '{"id": "https://alice.test/"}' - - expect(fetch_resource('https://bob.test/', false)).to eq({ 'id' => 'https://alice.test/' }) - end - - it 'returns nil if the object identified by the given URI and the object identified by the retrieved ID does not match' do - stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://marvin.test/"}' - stub_request(:get, 'https://marvin.test/').to_return body: '{"id": "https://alice.test/"}' - - expect(fetch_resource('https://mallory.test/', false)).to be_nil - end - end - - context 'when the second argument is true' do - it 'returns nil if the retrieved ID and the given URI does not match' do - stub_request(:get, 'https://mallory.test/').to_return body: '{"id": "https://alice.test/"}' - expect(fetch_resource('https://mallory.test/', true)).to be_nil - end - end - end - - describe '#fetch_resource_without_id_validation' do - it 'returns nil if the status code is not 200' do - stub_request(:get, 'https://host.test/').to_return status: 400, body: '{}' - expect(fetch_resource_without_id_validation('https://host.test/')).to be_nil - end - - it 'returns hash' do - stub_request(:get, 'https://host.test/').to_return status: 200, body: '{}' - expect(fetch_resource_without_id_validation('https://host.test/')).to eq({}) - end - end - - context 'with compaction and forwarding' do - let(:json) do - { - '@context' => [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - { - 'obsolete' => 'http://ostatus.org#', - 'convo' => 'obsolete:conversation', - 'new' => 'https://obscure-unreleased-test.joinmastodon.org/#', - }, - ], - 'type' => 'Create', - 'to' => ['https://www.w3.org/ns/activitystreams#Public'], - 'object' => { - 'id' => 'https://example.com/status', - 'type' => 'Note', - 'inReplyTo' => nil, - 'convo' => 'https://example.com/conversation', - 'tag' => [ - { - 'type' => 'Mention', - 'href' => ['foo'], - }, - ], - }, - 'signature' => { - 'type' => 'RsaSignature2017', - 'created' => '2022-02-02T12:00:00Z', - 'creator' => 'https://example.com/actor#main-key', - 'signatureValue' => 'some-sig', - }, - } - end - - describe '#compact' do - it 'properly compacts JSON-LD with alternative context definitions' do - expect(compact(json).dig('object', 'conversation')).to eq 'https://example.com/conversation' - end - - it 'compacts single-item arrays' do - expect(compact(json).dig('object', 'tag', 'href')).to eq 'foo' - end - - it 'compacts the activistreams Public collection' do - expect(compact(json)['to']).to eq 'as:Public' - end - - it 'properly copies signature' do - expect(compact(json)['signature']).to eq json['signature'] - end - end - - describe 'patch_for_forwarding!' do - it 'properly patches incompatibilities' do - json['object'].delete('convo') - compacted = compact(json) - patch_for_forwarding!(json, compacted) - expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public'] - expect(compacted.dig('object', 'tag', 0, 'href')).to eq ['foo'] - expect(safe_for_forwarding?(json, compacted)).to be true - end - end - - describe 'safe_for_forwarding?' do - it 'deems a safe compacting as such' do - json['object'].delete('convo') - compacted = compact(json) - deemed_compatible = 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) - expect(compacted['to']).to eq ['https://www.w3.org/ns/activitystreams#Public'] - expect(safe_for_forwarding?(json, compacted)).to be false - end - end - end -end diff --git a/spec/helpers/languages_helper_spec.rb b/spec/helpers/languages_helper_spec.rb index 98c8064a3..99461b293 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 71a9af6f3..af5d92769 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 b7824ca60..c67e1f3f2 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 7c6c20968..e5d85656a 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/lib/account_statuses_filter_spec.rb b/spec/lib/account_statuses_filter_spec.rb new file mode 100644 index 000000000..777458512 --- /dev/null +++ b/spec/lib/account_statuses_filter_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AccountStatusesFilter do + let(:account) { Fabricate(:account) } + let(:current_account) { nil } + let(:params) { {} } + + def status!(visibility) + Fabricate(:status, account: account, visibility: visibility) + end + + def status_with_tag!(visibility, tag) + Fabricate(:status, account: account, visibility: visibility, tags: [tag]) + end + + def status_with_parent!(visibility) + Fabricate(:status, account: account, visibility: visibility, thread: Fabricate(:status)) + end + + def status_with_reblog!(visibility) + Fabricate(:status, account: account, visibility: visibility, reblog: Fabricate(:status)) + end + + def status_with_mention!(visibility, mentioned_account = nil) + Fabricate(:status, account: account, visibility: visibility).tap do |status| + Fabricate(:mention, status: status, account: mentioned_account || Fabricate(:account)) + end + end + + def status_with_media_attachment!(visibility) + Fabricate(:status, account: account, visibility: visibility).tap do |status| + Fabricate(:media_attachment, account: account, status: status) + end + end + + describe '#results' do + subject { described_class.new(account, current_account, params).results } + + let(:tag) { Fabricate(:tag) } + + before do + status!(:public) + status!(:unlisted) + status!(:private) + status_with_parent!(:public) + status_with_reblog!(:public) + status_with_tag!(:public, tag) + status_with_mention!(:direct) + status_with_media_attachment!(:public) + end + + shared_examples 'filter params' do + context 'with only_media param' do + let(:params) { { only_media: true } } + + it 'returns only statuses with media' do + expect(subject.all?(&:with_media?)).to be true + end + end + + context 'with tagged param' do + let(:params) { { tagged: tag.name } } + + it 'returns only statuses with tag' do + expect(subject.all? { |s| s.tags.include?(tag) }).to be true + end + end + + context 'with exclude_replies param' do + let(:params) { { exclude_replies: true } } + + it 'returns only statuses that are not replies' do + expect(subject.none?(&:reply?)).to be true + end + end + + context 'with exclude_reblogs param' do + let(:params) { { exclude_reblogs: true } } + + it 'returns only statuses that are not reblogs' do + expect(subject.none?(&:reblog?)).to be true + end + end + end + + context 'when accessed anonymously' do + let(:current_account) { nil } + let(:direct_status) { nil } + + it 'returns only public statuses, public replies, and public reblogs' do + expect(results_unique_visibilities).to match_array %w(unlisted public) + + expect(results_in_reply_to_ids).to_not be_empty + + expect(results_reblog_of_ids).to_not be_empty + end + + it_behaves_like 'filter params' + end + + context 'when accessed with a blocked account' do + let(:current_account) { Fabricate(:account) } + + before do + account.block!(current_account) + end + + it 'returns nothing' do + expect(subject.to_a).to be_empty + end + end + + context 'when accessed by self' do + let(:current_account) { account } + + it 'returns all statuses, replies, and reblogs' do + expect(results_unique_visibilities).to match_array %w(direct private unlisted public) + + expect(results_in_reply_to_ids).to_not be_empty + + expect(results_reblog_of_ids).to_not be_empty + end + + it_behaves_like 'filter params' + end + + context 'when accessed by a follower' do + let(:current_account) { Fabricate(:account) } + + before do + current_account.follow!(account) + end + + it 'returns private statuses, replies, and reblogs' do + expect(results_unique_visibilities).to match_array %w(private unlisted public) + + expect(results_in_reply_to_ids).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(results_ids).to include(direct_status.id) + end + end + + it_behaves_like 'filter params' + end + + context 'when accessed by a non-follower' do + let(:current_account) { Fabricate(:account) } + + it 'returns only public statuses, replies, and reblogs' do + expect(results_unique_visibilities).to match_array %w(unlisted public) + + expect(results_in_reply_to_ids).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(results_ids).to include(private_status.id) + end + end + + context 'when blocking a reblogged account' do + let(:reblog) { status_with_reblog!('public') } + + before do + current_account.block!(reblog.reblog.account) + end + + it 'does not return reblog of blocked account' do + 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 + + context 'when muting a reblogged account' do + let(:reblog) { status_with_reblog!('public') } + + before do + current_account.mute!(reblog.reblog.account) + end + + it 'does not return reblog of muted account' do + expect(results_ids).to_not include(reblog.id) + end + end + + context 'when blocked by a reblogged account' do + let(:reblog) { status_with_reblog!('public') } + + before do + reblog.reblog.account.block!(current_account) + end + + it 'does not return reblog of blocked-by account' do + 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 365861bcd..8ad892975 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 d7c4c131a..5af3615c7 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' 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 @@ -799,6 +939,49 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'when object URI uses bearcaps' do + subject { described_class.new(json, sender) } + + let(:token) { 'foo' } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join, + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: Addressable::URI.new(scheme: 'bear', query_values: { t: token, u: object_json[:id] }).to_s, + }.with_indifferent_access + end + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + before do + stub_request(:get, object_json[:id]) + .with(headers: { Authorization: "Bearer #{token}" }) + .to_return(body: Oj.dump(object_json), headers: { 'Content-Type': 'application/activity+json' }) + + subject.perform + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status).to have_attributes( + visibility: 'public', + text: 'Lorem ipsum' + ) + end + end + context 'with an encrypted message' do subject { described_class.new(json, sender, delivery: true, delivered_to_account_id: recipient.id) } diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 3a73b3726..48421a116 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -47,9 +47,13 @@ RSpec.describe ActivityPub::Activity::Delete do expect(Status.find_by(id: status.id)).to be_nil end - it 'sends delete activity to followers of rebloggers' do + it 'sends delete activity to followers of rebloggers', :sidekiq_inline 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 601473069..8593d567f 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/move_spec.rb b/spec/lib/activitypub/activity/move_spec.rb index f3973c70c..4dda014a0 100644 --- a/spec/lib/activitypub/activity/move_spec.rb +++ b/spec/lib/activitypub/activity/move_spec.rb @@ -38,7 +38,7 @@ RSpec.describe ActivityPub::Activity::Move do subject.perform end - context 'when all conditions are met' do + context 'when all conditions are met', :sidekiq_inline do it 'sets moved account on old account' do expect(old_account.reload.moved_to_account_id).to eq new_account.id end diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb index f77279c02..87e96d2d1 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 f9f8b8dce..7d8d703ec 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 619d6df12..1af45673c 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') 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').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 596e91e95..55e9b4bb5 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -112,6 +112,14 @@ RSpec.describe ActivityPub::TagManager do expect(subject.cc(status)).to include(subject.uri_for(foo)) expect(subject.cc(status)).to_not include(subject.uri_for(alice)) end + + it 'returns poster of reblogged post, if reblog' do + bob = Fabricate(:account, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/bob') + alice = Fabricate(:account, username: 'alice') + status = Fabricate(:status, visibility: :public, account: bob) + reblog = Fabricate(:status, visibility: :public, account: alice, reblog: status) + expect(subject.cc(reblog)).to include(subject.uri_for(bob)) + end end describe '#local_uri?' do @@ -139,7 +147,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 1ffac89ee..a885640ce 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 000000000..de4335fc5 --- /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 000000000..78ca18349 --- /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 000000000..27a3e8025 --- /dev/null +++ b/spec/lib/content_security_policy_spec.rb @@ -0,0 +1,141 @@ +# 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 + + context 'when PAPERCLIP_ROOT_URL is configured' do + around do |example| + ClimateControl.modify PAPERCLIP_ROOT_URL: 'https://paperclip-host.example' do + example.run + end + end + + it 'uses the provided URL in the content security policy' do + expect(subject.media_hosts).to contain_exactly(subject.assets_host, 'https://paperclip-host.example') + end + end + end +end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 64cf8ebf7..6b87eda15 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 000000000..bc7c038a9 --- /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 ef501efbf..8c485cef2 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 a263d673d..26ad983bb 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,122 +644,85 @@ 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 describe '#refresh' do + let(:action) { :refresh } + context 'with --all option' do - let!(:local_account) { Fabricate(:account, domain: nil) } - let!(:remote_account_example_com) { Fabricate(:account, domain: 'example.com') } - let!(:account_example_net) { Fabricate(:account, domain: 'example.net') } - let(:scope) { Account.remote } + let(:options) { { all: true } } + let!(:local_account) { Fabricate(:account, domain: nil) } + let(:remote_com_avatar_url) { 'https://example.host/avatar/com' } + let(:remote_com_header_url) { 'https://example.host/header/com' } + let(:remote_account_example_com) { Fabricate(:account, domain: 'example.com', avatar_remote_url: remote_com_avatar_url, header_remote_url: remote_com_header_url) } + let(:remote_net_avatar_url) { 'https://example.host/avatar/net' } + let(:remote_net_header_url) { 'https://example.host/header/net' } + let(:account_example_net) { Fabricate(:account, domain: 'example.net', avatar_remote_url: remote_net_avatar_url, header_remote_url: remote_net_header_url) } + let(:scope) { Account.remote } before do - allow(cli).to receive(:parallelize_with_progress).and_yield(remote_account_example_com) - .and_yield(account_example_net) - .and_return([2, nil]) - cli.options = { all: true } + stub_parallelize_with_progress! + + stub_request(:get, remote_com_avatar_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, remote_com_header_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, remote_net_avatar_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, remote_net_header_url) + .to_return request_fixture('avatar.txt') + + remote_account_example_com + .update_column(:avatar_file_name, nil) + account_example_net + .update_column(:avatar_file_name, nil) end - it 'refreshes the avatar for all remote accounts' do - allow(remote_account_example_com).to receive(:reset_avatar!) - allow(account_example_net).to receive(:reset_avatar!) + it 'refreshes the avatar and header for all remote accounts' do + expect { subject } + .to output_results('Refreshed 2 accounts') + .and not_change(local_account, :updated_at) - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(remote_account_example_com).to have_received(:reset_avatar!).once - expect(account_example_net).to have_received(:reset_avatar!).once - end - - it 'does not refresh avatar for local accounts' do - allow(local_account).to receive(:reset_avatar!) - - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(local_account).to_not have_received(:reset_avatar!) - end - - it 'refreshes the header for all remote accounts' do - allow(remote_account_example_com).to receive(:reset_header!) - allow(account_example_net).to receive(:reset_header!) - - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(remote_account_example_com).to have_received(:reset_header!).once - expect(account_example_net).to have_received(:reset_header!).once - end - - it 'does not refresh the header for local accounts' do - allow(local_account).to receive(:reset_header!) - - cli.refresh - - 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 + # One request from factory creation, one more from task + expect(a_request(:get, remote_com_avatar_url)) + .to have_been_made.at_least_times(2) + expect(a_request(:get, remote_com_header_url)) + .to have_been_made.at_least_times(2) + expect(a_request(:get, remote_net_avatar_url)) + .to have_been_made.at_least_times(2) + expect(a_request(:get, remote_net_header_url)) + .to have_been_made.at_least_times(2) end context 'with --dry-run option' do - before do - cli.options = { all: true, dry_run: true } - end + let(:options) { { all: true, dry_run: true } } - it 'does not refresh the avatar for any account' do - allow(local_account).to receive(:reset_avatar!) - allow(remote_account_example_com).to receive(:reset_avatar!) - allow(account_example_net).to receive(:reset_avatar!) + it 'does not refresh the avatar or header for any account' do + expect { subject } + .to output_results('Refreshed 2 accounts') - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(local_account).to_not have_received(:reset_avatar!) - expect(remote_account_example_com).to_not have_received(:reset_avatar!) - expect(account_example_net).to_not have_received(:reset_avatar!) - end - - it 'does not refresh the header for any account' do - allow(local_account).to receive(:reset_header!) - allow(remote_account_example_com).to receive(:reset_header!) - allow(account_example_net).to receive(:reset_header!) - - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(local_account).to_not have_received(:reset_header!) - expect(remote_account_example_com).to_not have_received(:reset_header!) - expect(account_example_net).to_not have_received(:reset_header!) - 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 + # One request from factory creation, none from task due to dry run + expect(a_request(:get, remote_com_avatar_url)) + .to have_been_made.once + expect(a_request(:get, remote_com_header_url)) + .to have_been_made.once + expect(a_request(:get, remote_net_avatar_url)) + .to have_been_made.once + expect(a_request(:get, remote_net_header_url)) + .to have_been_made.once end end end @@ -769,6 +734,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 +745,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 { subject } + .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 +755,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 { subject } + .to output_results('OK') expect(account_example_net).to_not have_received(:reset_avatar!) end @@ -796,7 +765,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 { subject } + .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 +775,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 { subject } + .to output_results('OK') expect(account_example_net).to_not have_received(:reset_header!) end @@ -814,10 +785,8 @@ describe Mastodon::CLI::Accounts do it 'displays a failure message' 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 + expect { subject } + .to output_results("Account failed: #{account_example_com_a.username}@#{account_example_com_a.domain}") end end @@ -825,23 +794,21 @@ 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 { subject } + .to output_results('No such account') .and raise_error(SystemExit) end end context 'with --dry-run option' do - before do - cli.options = { dry_run: true } - end + let(:options) { { dry_run: true } } it 'does not refresh the avatar for any account' do allow(account_example_com_a).to receive(:reset_avatar!) allow(account_example_com_b).to receive(:reset_avatar!) - cli.refresh(*arguments) + expect { subject } + .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 +818,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 { subject } + .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!) @@ -860,89 +828,97 @@ describe Mastodon::CLI::Accounts do end context 'with --domain option' do - let!(:account_example_com_a) { Fabricate(:account, domain: 'example.com') } - let!(:account_example_com_b) { Fabricate(:account, domain: 'example.com') } - let!(:account_example_net) { Fabricate(:account, domain: 'example.net') } - let(:domain) { 'example.com' } - let(:scope) { Account.remote.where(domain: domain) } + let(:domain) { 'example.com' } + let(:options) { { domain: domain } } + + let(:com_a_avatar_url) { 'https://example.host/avatar/a' } + let(:com_a_header_url) { 'https://example.host/header/a' } + let(:account_example_com_a) { Fabricate(:account, domain: domain, avatar_remote_url: com_a_avatar_url, header_remote_url: com_a_header_url) } + + let(:com_b_avatar_url) { 'https://example.host/avatar/b' } + let(:com_b_header_url) { 'https://example.host/header/b' } + let(:account_example_com_b) { Fabricate(:account, domain: domain, avatar_remote_url: com_b_avatar_url, header_remote_url: com_b_header_url) } + + let(:net_avatar_url) { 'https://example.host/avatar/net' } + let(:net_header_url) { 'https://example.host/header/net' } + let(:account_example_net) { Fabricate(:account, domain: 'example.net', avatar_remote_url: net_avatar_url, header_remote_url: net_header_url) } before do - allow(cli).to receive(:parallelize_with_progress).and_yield(account_example_com_a) - .and_yield(account_example_com_b) - .and_return([2, nil]) + stub_parallelize_with_progress! - cli.options = { domain: domain } + stub_request(:get, com_a_avatar_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, com_a_header_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, com_b_avatar_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, com_b_header_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, net_avatar_url) + .to_return request_fixture('avatar.txt') + stub_request(:get, net_header_url) + .to_return request_fixture('avatar.txt') + + account_example_com_a + .update_column(:avatar_file_name, nil) + account_example_com_b + .update_column(:avatar_file_name, nil) + account_example_net + .update_column(:avatar_file_name, nil) end - it 'refreshes the avatar for all accounts on specified domain' do - allow(account_example_com_a).to receive(:reset_avatar!) - allow(account_example_com_b).to receive(:reset_avatar!) + it 'refreshes the avatar and header for all accounts on specified domain' do + expect { subject } + .to output_results('Refreshed 2 accounts') - cli.refresh + # One request from factory creation, one more from task + expect(a_request(:get, com_a_avatar_url)) + .to have_been_made.at_least_times(2) + expect(a_request(:get, com_a_header_url)) + .to have_been_made.at_least_times(2) + expect(a_request(:get, com_b_avatar_url)) + .to have_been_made.at_least_times(2) + expect(a_request(:get, com_b_header_url)) + .to have_been_made.at_least_times(2) - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(account_example_com_a).to have_received(:reset_avatar!).once - expect(account_example_com_b).to have_received(:reset_avatar!).once - end - - it 'does not refresh the avatar for accounts outside specified domain' do - allow(account_example_net).to receive(:reset_avatar!) - - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(account_example_net).to_not have_received(:reset_avatar!) - end - - it 'refreshes the header for all accounts on specified domain' do - allow(account_example_com_a).to receive(:reset_header!) - allow(account_example_com_b).to receive(:reset_header!) - - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope) - expect(account_example_com_a).to have_received(:reset_header!).once - expect(account_example_com_b).to have_received(:reset_header!).once - end - - it 'does not refresh the header for accounts outside specified domain' do - allow(account_example_net).to receive(:reset_header!) - - cli.refresh - - expect(cli).to have_received(:parallelize_with_progress).with(scope).once - expect(account_example_net).to_not have_received(:reset_header!) + # One request from factory creation, none from task + expect(a_request(:get, net_avatar_url)) + .to have_been_made.once + expect(a_request(:get, net_header_url)) + .to have_been_made.once end end 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 { subject } + .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 +928,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 +964,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 +975,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 +1030,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 +1043,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 +1067,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 +1193,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 +1225,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 +1243,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 3ab42dc8c..b1515801e 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 eb57a3cd1..1745ea01b 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 ea58845c0..24f341c12 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 333ae3f2b..13deb05b6 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 9b5865372..d05e972e7 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 030f08721..199798052 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 030d9fcb1..82be10813 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 e3709afe3..081cd2dd4 100644 --- a/spec/lib/mastodon/cli/main_spec.rb +++ b/spec/lib/mastodon/cli/main_spec.rb @@ -4,17 +4,173 @@ 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 + 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 } + + it 'returns the Mastodon version' do + expect { subject } + .to output_results(Mastodon::Version.to_s) end end - 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 + describe '#self_destruct' do + let(:action) { :self_destruct } + + context 'with self destruct mode enabled' do + before do + allow(SelfDestructHelper).to receive(:self_destruct?).and_return(true) + end + + context 'with pending accounts' do + before { Fabricate(:account) } + + it 'reports about pending accounts' do + expect { subject } + .to output_results( + 'already enabled', + 'still pending deletion' + ) + .and raise_error(SystemExit) + end + end + + context 'with sidekiq notices being processed' do + before do + Account.delete_all + stats_double = instance_double(Sidekiq::Stats, enqueued: 5) + allow(Sidekiq::Stats).to receive(:new).and_return(stats_double) + end + + it 'reports about notices' do + expect { subject } + .to output_results( + 'already enabled', + 'notices are still being' + ) + .and raise_error(SystemExit) + end + end + + context 'with sidekiq failed deliveries' do + before do + Account.delete_all + stats_double = instance_double(Sidekiq::Stats, enqueued: 0, retry_size: 10) + allow(Sidekiq::Stats).to receive(:new).and_return(stats_double) + end + + it 'reports about notices' do + expect { subject } + .to output_results( + 'already enabled', + 'some have failed and are scheduled' + ) + .and raise_error(SystemExit) + end + end + + context 'with self descruct mode ready' do + before do + Account.delete_all + stats_double = instance_double(Sidekiq::Stats, enqueued: 0, retry_size: 0) + allow(Sidekiq::Stats).to receive(:new).and_return(stats_double) + end + + it 'reports about notices' do + expect { subject } + .to output_results( + 'already enabled', + 'can safely delete all data' + ) + .and raise_error(SystemExit) + end + end + end + + context 'with self destruct mode disabled' do + before do + allow(SelfDestructHelper).to receive(:self_destruct?).and_return(false) + end + + context 'with an incorrect response to hostname' do + before do + answer_hostname_incorrectly + end + + it 'exits silently' do + expect { subject } + .to raise_error(SystemExit) + end + end + + context 'with a correct response to hostname but no to proceed' do + before do + answer_hostname_correctly + decline_proceed + end + + it 'passes first step but stops before instructions' do + expect { subject } + .to output_results('operation WILL NOT') + .and raise_error(SystemExit) + end + end + + context 'with a correct response to hostname and yes to proceed' do + before do + answer_hostname_correctly + accept_proceed + end + + it 'instructs to set the appropriate environment variable' do + expect { subject } + .to output_results( + 'operation WILL NOT', + 'the following variable' + ) + end + end + + private + + def answer_hostname_incorrectly + allow(cli.shell) + .to receive(:ask) + .with('Type in the domain of the server to confirm:') + .and_return('wrong.host') + .once + end + + def answer_hostname_correctly + allow(cli.shell) + .to receive(:ask) + .with('Type in the domain of the server to confirm:') + .and_return(Rails.configuration.x.local_domain) + .once + end + + def decline_proceed + allow(cli.shell) + .to receive(:no?) + .with('Are you sure you want to proceed?') + .and_return(true) + .once + end + + def accept_proceed + allow(cli.shell) + .to receive(:no?) + .with('Are you sure you want to proceed?') + .and_return(false) + .once + end end end end diff --git a/spec/lib/mastodon/cli/maintenance_spec.rb b/spec/lib/mastodon/cli/maintenance_spec.rb index 12cd9ca8a..ca492bbf6 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 29f7d424a..10005107a 100644 --- a/spec/lib/mastodon/cli/media_spec.rb +++ b/spec/lib/mastodon/cli/media_spec.rb @@ -4,9 +4,253 @@ 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 + + context 'with --days option' do + before do + Fabricate(:media_attachment, remote_url: 'https://example.com/image.jpg', id: Mastodon::Snowflake.id_at(50.days.ago)) + Fabricate(:media_attachment, remote_url: 'https://example.com/image.jpg', id: Mastodon::Snowflake.id_at(5.days.ago)) + Fabricate(:media_attachment, remote_url: '', id: Mastodon::Snowflake.id_at(5.days.ago)) + end + + let(:options) { { days: 10 } } + + it 'redownloads the attachment file for the remote records more recent than the option' do + expect { subject } + .to output_results('Downloaded 1 media') + end + end + end + + describe '#remove_orphans' do + let(:action) { :remove_orphans } + + before do + FileUtils.mkdir_p Rails.public_path.join('system') + end + + context 'without any options' do + it 'runs without error' do + expect { subject } + .to output_results('Removed', 'orphans (approx') + end + end + + context 'when in azure mode' do + before do + allow(Paperclip::Attachment).to receive(:default_options).and_return(storage: :azure) + end + + it 'warns about usage and exits' do + expect { subject } + .to output_results('azure storage driver is not supported') + .and raise_error(SystemExit) + end + end + + context 'when in fog mode' do + before do + allow(Paperclip::Attachment).to receive(:default_options).and_return(storage: :fog) + end + + it 'warns about usage and exits' do + expect { subject } + .to output_results('fog storage driver is not supported') + .and raise_error(SystemExit) + end + end + + context 'when in filesystem mode' do + before do + allow(File).to receive(:delete).and_return(true) + media_attachment.delete + end + + let(:media_attachment) { Fabricate(:media_attachment) } + + it 'removes the unlinked files' do + expect { subject } + .to output_results('Removed', 'orphans (approx') + expect(File).to have_received(:delete).with(media_attachment.file.path) + 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 b4b018b3b..951ae3758 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 d5cae5bf4..cb0c80c11 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 ae58e74e5..e1b353eb9 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 2430a8841..63d494bbb 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 9e0ab9d06..6861e0488 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_pool_spec.rb b/spec/lib/request_pool_spec.rb index f179e6ca9..a31d07832 100644 --- a/spec/lib/request_pool_spec.rb +++ b/spec/lib/request_pool_spec.rb @@ -33,12 +33,12 @@ describe RequestPool do subject - threads = Array.new(20) do |_i| + threads = Array.new(5) do Thread.new do - 20.times do - subject.with('http://example.com') do |http_client| - http_client.get('/').flush - end + subject.with('http://example.com') do |http_client| + http_client.get('/').flush + # Nudge scheduler to yield and exercise the full pool + sleep(0.01) end end end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index f0861376b..c7620cf9b 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 000000000..66b0e8f9e --- /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 109533469..5817e3d1d 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 98e2ef913..cf6f3c795 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 61483f4bf..53f5039af 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 5a1d0f094..4797a3dc6 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 000000000..df5c86060 --- /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 1e0abc5e0..c712b7b9b 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 c1b7f7e9c..9dbdf0bc2 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 2cad04fcc..0e2bdcb71 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 8e2eec40f..88ad7aa02 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 bf364b625..eab196166 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 9572768cd..942d40d56 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 bc41662a1..a63c20c27 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/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 098c9cd90..2722538e1 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -93,4 +93,9 @@ class UserMailerPreview < ActionMailer::Preview def suspicious_sign_in UserMailer.suspicious_sign_in(User.first, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc) end + + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/failed_2fa + def failed_2fa + UserMailer.failed_2fa(User.first, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc) + end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 3c42a2bb7..404b83470 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,145 @@ 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 '#failed_2fa' do + let(:ip) { '192.168.0.1' } + let(:agent) { 'NCSA_Mosaic/2.0 (Windows 3.1)' } + let(:timestamp) { Time.now.utc } + let(:mail) { described_class.failed_2fa(receiver, ip, agent, timestamp) } + + it 'renders failed 2FA notification' do + receiver.update!(locale: nil) + + expect(mail) + .to be_present + .and(have_body_text(I18n.t('user_mailer.failed_2fa.explanation'))) + end + + include_examples 'localized subject', + 'user_mailer.failed_2fa.subject' + end + + 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 f0cd274ea..000000000 --- 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 8bbfb695d..000000000 --- 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 f4544740b..1f32c6082 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 9d683ca52..000000000 --- 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 7cd914774..7ef5ca94c 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -9,18 +9,16 @@ RSpec.describe Account do let(:bob) { Fabricate(:account, username: 'bob') } describe '#suspend!' do - it 'marks the account as suspended' do - subject.suspend! - expect(subject.suspended?).to be true - end - - it 'creates a deletion request' do - subject.suspend! - expect(AccountDeletionRequest.where(account: subject).exists?).to be true + it 'marks the account as suspended and creates a deletion request' do + expect { subject.suspend! } + .to change(subject, :suspended?).from(false).to(true) + .and(change { AccountDeletionRequest.exists?(account: subject) }.from(false).to(true)) 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 +205,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 +219,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 @@ -329,9 +335,11 @@ RSpec.describe Account do results = account.excluded_from_timeline_account_ids expect(results.size).to eq 3 - expect(results).to include(block.target_account.id) - expect(results).to include(mute.target_account.id) - expect(results).to include(block_by.account.id) + expect(results).to include( + block.target_account.id, + mute.target_account.id, + block_by.account.id + ) end end @@ -354,7 +362,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 +448,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 +490,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 +502,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 +542,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 +593,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 +708,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 +727,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 +751,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 @@ -825,6 +835,50 @@ RSpec.describe Account do end describe 'scopes' do + describe 'matches_uri_prefix' do + let!(:alice) { Fabricate :account, domain: 'host.example', uri: 'https://host.example/user/a' } + let!(:bob) { Fabricate :account, domain: 'top-level.example', uri: 'https://top-level.example' } + + it 'returns accounts which start with the value' do + results = described_class.matches_uri_prefix('https://host.example') + + expect(results.size) + .to eq(1) + expect(results) + .to include(alice) + .and not_include(bob) + end + + it 'returns accounts which equal the value' do + results = described_class.matches_uri_prefix('https://top-level.example') + + expect(results.size) + .to eq(1) + expect(results) + .to include(bob) + .and not_include(alice) + end + end + + describe 'auditable' do + let!(:alice) { Fabricate :account } + let!(:bob) { Fabricate :account } + + before do + 2.times { Fabricate :action_log, account: alice } + end + + it 'returns distinct accounts with action log records' do + results = described_class.auditable + + expect(results.size) + .to eq(1) + expect(results) + .to include(alice) + .and not_include(bob) + end + end + describe 'alphabetic' do it 'sorts by alphabetic order of domain and username' do matches = [ @@ -875,17 +929,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 +963,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 +996,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 +1016,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 7405bdfa2..a08fd723a 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 @@ -292,16 +296,11 @@ RSpec.describe AccountStatusesCleanupPolicy do let!(:old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) } - it 'returns statuses including max_id' do - expect(subject).to include(old_status.id) - end - - it 'returns statuses including older than max_id' do - expect(subject).to include(very_old_status.id) - end - - it 'does not return statuses newer than max_id' do - expect(subject).to_not include(slightly_less_old_status.id) + it 'returns statuses included the max_id and older than the max_id but not newer than max_id' do + expect(subject) + .to include(old_status.id) + .and include(very_old_status.id) + .and not_include(slightly_less_old_status.id) end end @@ -311,16 +310,11 @@ RSpec.describe AccountStatusesCleanupPolicy do let!(:old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) } let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) } - it 'returns statuses including min_id' do - expect(subject).to include(old_status.id) - end - - it 'returns statuses including newer than max_id' do - expect(subject).to include(slightly_less_old_status.id) - end - - it 'does not return statuses older than min_id' do - expect(subject).to_not include(very_old_status.id) + it 'returns statuses including min_id and newer than min_id, but not older than min_id' do + expect(subject) + .to include(old_status.id) + .and include(slightly_less_old_status.id) + .and not_include(very_old_status.id) end end @@ -335,12 +329,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.min_status_age = 2.years.seconds end - it 'does not return unrelated old status' do - expect(subject.pluck(:id)).to_not include(unrelated_status.id) - end - - it 'returns only oldest status for deletion' do - expect(subject.pluck(:id)).to eq [very_old_status.id] + it 'does not return unrelated old status and does return oldest status' do + expect(subject.pluck(:id)) + .to not_include(unrelated_status.id) + .and eq [very_old_status.id] end end @@ -354,12 +346,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = false end - it 'does not return the old direct message for deletion' do - expect(subject.pluck(:id)).to_not include(direct_message.id) - end - - it 'returns every other old status for deletion' do - expect(subject.pluck(:id)).to include(very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns every old status except does not return the old direct message for deletion' do + expect(subject.pluck(:id)) + .to not_include(direct_message.id) + .and include(very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -373,12 +363,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = true end - it 'does not return the old self-bookmarked message for deletion' do - expect(subject.pluck(:id)).to_not include(self_bookmarked.id) - end - - it 'returns every other old status for deletion' do - expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns every old status but does not return the old self-bookmarked message for deletion' do + expect(subject.pluck(:id)) + .to not_include(self_bookmarked.id) + .and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -392,12 +380,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = false end - it 'does not return the old self-bookmarked message for deletion' do - expect(subject.pluck(:id)).to_not include(self_faved.id) - end - - it 'returns every other old status for deletion' do - expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns every old status but does not return the old self-faved message for deletion' do + expect(subject.pluck(:id)) + .to not_include(self_faved.id) + .and include(direct_message.id, very_old_status.id, pinned_status.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -411,12 +397,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = false end - it 'does not return the old message with media for deletion' do - expect(subject.pluck(:id)).to_not include(status_with_media.id) - end - - it 'returns every other old status for deletion' do - expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns every old status but does not return the old message with media for deletion' do + expect(subject.pluck(:id)) + .to not_include(status_with_media.id) + .and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -430,12 +414,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = false end - it 'does not return the old poll message for deletion' do - expect(subject.pluck(:id)).to_not include(status_with_poll.id) - end - - it 'returns every other old status for deletion' do - expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns every old status but does not return the old poll message for deletion' do + expect(subject.pluck(:id)) + .to not_include(status_with_poll.id) + .and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -449,12 +431,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = false end - it 'does not return the old pinned message for deletion' do - expect(subject.pluck(:id)).to_not include(pinned_status.id) - end - - it 'returns every other old status for deletion' do - expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns every old status but does not return the old pinned message for deletion' do + expect(subject.pluck(:id)) + .to not_include(pinned_status.id) + .and include(direct_message.id, very_old_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -468,16 +448,11 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = false end - it 'does not return the recent toot' do - expect(subject.pluck(:id)).to_not include(recent_status.id) - end - - it 'does not return the unrelated toot' do - expect(subject.pluck(:id)).to_not include(unrelated_status.id) - end - - it 'returns every other old status for deletion' do - expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns every old status but does not return the recent or unrelated statuses' do + expect(subject.pluck(:id)) + .to not_include(recent_status.id) + .and not_include(unrelated_status.id) + .and include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -491,12 +466,10 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.keep_self_bookmark = true end - it 'does not return unrelated old status' do - expect(subject.pluck(:id)).to_not include(unrelated_status.id) - end - - it 'returns only normal statuses for deletion' do - expect(subject.pluck(:id)).to contain_exactly(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns normal statuses and does not return unrelated old status' do + expect(subject.pluck(:id)) + .to not_include(unrelated_status.id) + .and contain_exactly(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id, reblogged_secondary.id) end end @@ -505,20 +478,12 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.min_reblogs = 5 end - it 'does not return the recent toot' do - expect(subject.pluck(:id)).to_not include(recent_status.id) - end - - it 'does not return the toot reblogged 5 times' do - expect(subject.pluck(:id)).to_not include(reblogged_secondary.id) - end - - it 'does not return the unrelated toot' do - expect(subject.pluck(:id)).to_not include(unrelated_status.id) - end - - it 'returns old statuses not reblogged as much' do - expect(subject.pluck(:id)).to include(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id) + it 'returns old not-reblogged statuses but does not return the recent, 5-times reblogged, or unrelated statuses' do + expect(subject.pluck(:id)) + .to not_include(recent_status.id) + .and not_include(reblogged_secondary.id) + .and not_include(unrelated_status.id) + .and include(very_old_status.id, faved_primary.id, faved_secondary.id, reblogged_primary.id) end end @@ -527,20 +492,12 @@ RSpec.describe AccountStatusesCleanupPolicy do account_statuses_cleanup_policy.min_favs = 5 end - it 'does not return the recent toot' do - expect(subject.pluck(:id)).to_not include(recent_status.id) - end - - it 'does not return the toot faved 5 times' do - expect(subject.pluck(:id)).to_not include(faved_secondary.id) - end - - it 'does not return the unrelated toot' do - expect(subject.pluck(:id)).to_not include(unrelated_status.id) - end - - it 'returns old statuses not faved as much' do - expect(subject.pluck(:id)).to include(very_old_status.id, faved_primary.id, reblogged_primary.id, reblogged_secondary.id) + it 'returns old not-faved statuses but does not return the recent, 5-times faved, or unrelated statuses' do + expect(subject.pluck(:id)) + .to not_include(recent_status.id) + .and not_include(faved_secondary.id) + .and not_include(unrelated_status.id) + .and include(very_old_status.id, faved_primary.id, reblogged_primary.id, reblogged_secondary.id) end end end diff --git a/spec/models/account_statuses_filter_spec.rb b/spec/models/account_statuses_filter_spec.rb deleted file mode 100644 index fa7664d92..000000000 --- a/spec/models/account_statuses_filter_spec.rb +++ /dev/null @@ -1,229 +0,0 @@ -# frozen_string_literal: true - -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) { {} } - - def status!(visibility) - Fabricate(:status, account: account, visibility: visibility) - end - - def status_with_tag!(visibility, tag) - Fabricate(:status, account: account, visibility: visibility, tags: [tag]) - end - - def status_with_parent!(visibility) - Fabricate(:status, account: account, visibility: visibility, thread: Fabricate(:status)) - end - - def status_with_reblog!(visibility) - Fabricate(:status, account: account, visibility: visibility, reblog: Fabricate(:status)) - end - - def status_with_mention!(visibility, mentioned_account = nil) - Fabricate(:status, account: account, visibility: visibility).tap do |status| - Fabricate(:mention, status: status, account: mentioned_account || Fabricate(:account)) - end - end - - def status_with_media_attachment!(visibility) - Fabricate(:status, account: account, visibility: visibility).tap do |status| - Fabricate(:media_attachment, account: account, status: status) - end - end - - describe '#results' do - let(:tag) { Fabricate(:tag) } - - before do - status!(:public) - status!(:unlisted) - status!(:private) - status_with_parent!(:public) - status_with_reblog!(:public) - status_with_tag!(:public, tag) - status_with_mention!(:direct) - status_with_media_attachment!(:public) - end - - shared_examples 'filter params' do - context 'with only_media param' do - let(:params) { { only_media: true } } - - it 'returns only statuses with media' do - expect(subject.results.all?(&:with_media?)).to be true - end - end - - context 'with tagged param' 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 - end - end - - context 'with exclude_replies param' do - let(:params) { { exclude_replies: true } } - - it 'returns only statuses that are not replies' do - expect(subject.results.none?(&:reply?)).to be true - end - end - - context 'with exclude_reblogs param' do - let(:params) { { exclude_reblogs: true } } - - it 'returns only statuses that are not reblogs' do - expect(subject.results.none?(&:reblog?)).to be true - end - end - end - - context 'when accessed anonymously' 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 public replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end - - it 'returns public reblogs' do - expect(subject.results.pluck(:reblog_of_id)).to_not be_empty - end - - it_behaves_like 'filter params' - end - - context 'when accessed with a blocked account' do - let(:current_account) { Fabricate(:account) } - - before do - account.block!(current_account) - end - - it 'returns nothing' do - expect(subject.results.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 replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end - - it 'returns reblogs' do - expect(subject.results.pluck(:reblog_of_id)).to_not be_empty - end - - it_behaves_like 'filter params' - end - - context 'when accessed by a follower' do - let(:current_account) { Fabricate(:account) } - - before 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 replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end - - it 'returns reblogs' do - expect(subject.results.pluck(:reblog_of_id)).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) - end - end - - it_behaves_like 'filter params' - end - - 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 public replies' do - expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty - end - - it 'returns public reblogs' do - expect(subject.results.pluck(:reblog_of_id)).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) - end - end - - context 'when blocking a reblogged account' do - let(:reblog) { status_with_reblog!('public') } - - before do - current_account.block!(reblog.reblog.account) - end - - it 'does not return reblog of blocked account' do - expect(subject.results.pluck(:id)).to_not include(reblog.id) - end - end - - context 'when muting a reblogged account' do - let(:reblog) { status_with_reblog!('public') } - - before do - current_account.mute!(reblog.reblog.account) - end - - it 'does not return reblog of muted account' do - expect(subject.results.pluck(:id)).to_not include(reblog.id) - end - end - - context 'when blocked by a reblogged account' do - let(:reblog) { status_with_reblog!('public') } - - before do - reblog.reblog.account.block!(current_account) - end - - it 'does not return reblog of blocked-by account' do - expect(subject.results.pluck(:id)).to_not include(reblog.id) - end - end - - it_behaves_like 'filter params' - end - end -end diff --git a/spec/models/account_suggestions/source_spec.rb b/spec/models/account_suggestions/source_spec.rb new file mode 100644 index 000000000..d8227e01b --- /dev/null +++ b/spec/models/account_suggestions/source_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AccountSuggestions::Source do + describe '#base_account_scope' do + subject { FakeSource.new } + + before do + stub_const 'FakeSource', fake_source_class + end + + context 'with follows and follow requests' do + let!(:account_domain_blocked_account) { Fabricate(:account, domain: 'blocked.host') } + let!(:account) { Fabricate(:account) } + let!(:blocked_account) { Fabricate(:account) } + let!(:eligible_account) { Fabricate(:account) } + let!(:follow_recommendation_muted_account) { Fabricate(:account) } + let!(:follow_requested_account) { Fabricate(:account) } + let!(:following_account) { Fabricate(:account) } + let!(:moved_account) { Fabricate(:account, moved_to_account: Fabricate(:account)) } + + before do + Fabricate :account_domain_block, account: account, domain: account_domain_blocked_account.domain + Fabricate :block, account: account, target_account: blocked_account + Fabricate :follow_recommendation_mute, account: account, target_account: follow_recommendation_muted_account + Fabricate :follow_request, account: account, target_account: follow_requested_account + Fabricate :follow, account: account, target_account: following_account + end + + it 'returns eligible accounts' do + results = subject.get(account) + + expect(results) + .to include(eligible_account) + .and not_include(account_domain_blocked_account) + .and not_include(account) + .and not_include(blocked_account) + .and not_include(follow_recommendation_muted_account) + .and not_include(follow_requested_account) + .and not_include(following_account) + .and not_include(moved_account) + end + end + end + + private + + def fake_source_class + Class.new described_class do + def get(account, limit: 10) + base_account_scope(account) + .limit(limit) + end + end + end +end diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb index b47561dd4..9bc9f8061 100644 --- a/spec/models/admin/account_action_spec.rb +++ b/spec/models/admin/account_action_spec.rb @@ -47,11 +47,9 @@ RSpec.describe Admin::AccountAction do 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 + 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 1937da3aa..000000000 --- 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 43cc0e148..000000000 --- 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 32d398213..1e7283ca7 100644 --- a/spec/models/announcement_spec.rb +++ b/spec/models/announcement_spec.rb @@ -2,5 +2,176 @@ 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 + + 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 '#reactions' do + context 'with announcement_reactions present' do + let(:account_reaction_emoji) { Fabricate :custom_emoji } + let(:other_reaction_emoji) { Fabricate :custom_emoji } + let!(:account) { Fabricate(:account) } + let!(:announcement) { Fabricate(:announcement) } + + before do + Fabricate(:announcement_reaction, announcement: announcement, created_at: 10.days.ago, name: other_reaction_emoji.shortcode) + Fabricate(:announcement_reaction, announcement: announcement, created_at: 5.days.ago, account: account, name: account_reaction_emoji.shortcode) + Fabricate(:announcement_reaction) # For some other announcement + end + + it 'returns the announcement reactions for the announcement' do + results = announcement.reactions + + expect(results).to have_attributes( + size: eq(2), + first: have_attributes(name: other_reaction_emoji.shortcode, me: false), + last: have_attributes(name: account_reaction_emoji.shortcode, me: false) + ) + end + + it 'returns the announcement reactions for the announcement with `me` set correctly' do + results = announcement.reactions(account) + + expect(results).to have_attributes( + size: eq(2), + first: have_attributes(name: other_reaction_emoji.shortcode, me: false), + last: have_attributes(name: account_reaction_emoji.shortcode, me: true) + ) + 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 130311752..000000000 --- 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 0acff8237..c63483f96 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 new file mode 100644 index 000000000..2e1cd700b --- /dev/null +++ b/spec/models/concerns/account/counters_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Account::Counters do + let!(:account) { Fabricate(:account) } + + describe '#increment_count!' do + it 'increments the count' do + expect(account.followers_count).to eq 0 + account.increment_count!(:followers_count) + expect(account.followers_count).to eq 1 + end + + it 'increments the count in multi-threaded an environment' do + increment_by = 15 + wait_for_start = true + + threads = Array.new(increment_by) do + Thread.new do + true while wait_for_start + account.increment_count!(:statuses_count) + end + end + + wait_for_start = false + threads.each(&:join) + + expect(account.statuses_count).to eq increment_by + end + end + + describe '#decrement_count!' do + it 'decrements the count' do + account.followers_count = 15 + account.save! + expect(account.followers_count).to eq 15 + account.decrement_count!(:followers_count) + expect(account.followers_count).to eq 14 + end + + it 'decrements the count in multi-threaded an environment' do + decrement_by = 10 + wait_for_start = true + + account.statuses_count = 15 + account.save! + + threads = Array.new(decrement_by) do + Thread.new do + true while wait_for_start + account.decrement_count!(:statuses_count) + end + end + + wait_for_start = false + threads.each(&:join) + + expect(account.statuses_count).to eq 5 + end + end +end diff --git a/spec/models/concerns/account/finder_concern_spec.rb b/spec/models/concerns/account/finder_concern_spec.rb new file mode 100644 index 000000000..ab5149e98 --- /dev/null +++ b/spec/models/concerns/account/finder_concern_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Account::FinderConcern do + describe 'local finders' do + let!(:account) { Fabricate(:account, username: 'Alice') } + + describe '.find_local' do + it 'returns case-insensitive result' do + expect(Account.find_local('alice')).to eq(account) + end + + it 'returns correctly cased result' do + expect(Account.find_local('Alice')).to eq(account) + end + + it 'returns nil without a match' do + expect(Account.find_local('a_ice')).to be_nil + end + + it 'returns nil for regex style username value' do + expect(Account.find_local('al%')).to be_nil + end + + it 'returns nil for nil username value' do + expect(Account.find_local(nil)).to be_nil + end + + it 'returns nil for blank username value' do + expect(Account.find_local('')).to be_nil + end + end + + describe '.find_local!' do + it 'returns matching result' do + expect(Account.find_local!('alice')).to eq(account) + end + + it 'raises on non-matching result' do + expect { Account.find_local!('missing') }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises with blank username' do + expect { Account.find_local!('') }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises with nil username' do + expect { Account.find_local!(nil) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe 'remote finders' do + 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) + end + + it 'returns case-insensitive result' do + expect(Account.find_remote('ALICE', 'MASTODON.SOCIAL')).to eq(account) + end + + it 'returns nil when username does not match' do + expect(Account.find_remote('a_ice', 'mastodon.social')).to be_nil + end + + it 'returns nil when domain does not match' do + expect(Account.find_remote('alice', 'm_stodon.social')).to be_nil + end + + it 'returns nil for regex style domain value' do + expect(Account.find_remote('alice', 'm%')).to be_nil + end + + it 'returns nil for nil username value' do + expect(Account.find_remote(nil, 'domain')).to be_nil + end + + it 'returns nil for blank username value' do + expect(Account.find_remote('', 'domain')).to be_nil + end + end + + describe '.find_remote!' do + it 'returns matching result' do + expect(Account.find_remote!('alice', 'mastodon.social')).to eq(account) + end + + it 'raises on non-matching result' do + expect { Account.find_remote!('missing', 'mastodon.host') }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises with blank username' do + expect { Account.find_remote!('', '') }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises with nil username' do + expect { Account.find_remote!(nil, nil) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/models/concerns/account/interactions_spec.rb b/spec/models/concerns/account/interactions_spec.rb new file mode 100644 index 000000000..8e8142140 --- /dev/null +++ b/spec/models/concerns/account/interactions_spec.rb @@ -0,0 +1,719 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Account::Interactions do + let(:account) { Fabricate(:account, username: 'account') } + let(:account_id) { account.id } + let(:account_ids) { [account_id] } + let(:target_account) { Fabricate(:account, username: 'target') } + let(:target_account_id) { target_account.id } + let(:target_account_ids) { [target_account_id] } + + describe '.following_map' do + subject { Account.following_map(target_account_ids, account_id) } + + context 'when Account with Follow' do + it 'returns { target_account_id => true }' do + Fabricate(:follow, account: account, target_account: target_account) + expect(subject).to eq(target_account_id => { reblogs: true, notify: false, languages: nil }) + end + end + + context 'when Account with Follow but with reblogs disabled' do + it 'returns { target_account_id => { reblogs: false } }' do + Fabricate(:follow, account: account, target_account: target_account, show_reblogs: false) + expect(subject).to eq(target_account_id => { reblogs: false, notify: false, languages: nil }) + end + end + + context 'when Account without Follow' do + it 'returns {}' do + expect(subject).to eq({}) + end + end + end + + describe '.followed_by_map' do + subject { Account.followed_by_map(target_account_ids, account_id) } + + context 'when Account with Follow' do + it 'returns { target_account_id => true }' do + Fabricate(:follow, account: target_account, target_account: account) + expect(subject).to eq(target_account_id => true) + end + end + + context 'when Account without Follow' do + it 'returns {}' do + expect(subject).to eq({}) + end + end + end + + describe '.blocking_map' do + subject { Account.blocking_map(target_account_ids, account_id) } + + context 'when Account with Block' do + it 'returns { target_account_id => true }' do + Fabricate(:block, account: account, target_account: target_account) + expect(subject).to eq(target_account_id => true) + end + end + + context 'when Account without Block' do + it 'returns {}' do + expect(subject).to eq({}) + end + end + end + + describe '.muting_map' do + subject { Account.muting_map(target_account_ids, account_id) } + + context 'when Account with Mute' do + before do + Fabricate(:mute, target_account: target_account, account: account, hide_notifications: hide) + end + + context 'when Mute#hide_notifications?' do + let(:hide) { true } + + it 'returns { target_account_id => { notifications: true } }' do + expect(subject).to eq(target_account_id => { notifications: true }) + end + end + + context 'when not Mute#hide_notifications?' do + let(:hide) { false } + + it 'returns { target_account_id => { notifications: false } }' do + expect(subject).to eq(target_account_id => { notifications: false }) + end + end + end + + context 'when Account without Mute' do + it 'returns {}' do + expect(subject).to eq({}) + end + end + end + + describe '#follow!' do + it 'creates and returns Follow' do + expect do + expect(account.follow!(target_account)).to be_a Follow + end.to change { account.following.count }.by 1 + end + end + + describe '#block' do + it 'creates and returns Block' do + expect do + expect(account.block!(target_account)).to be_a Block + end.to change { account.block_relationships.count }.by 1 + end + end + + describe '#mute!' do + subject { account.mute!(target_account, notifications: arg_notifications) } + + context 'when Mute does not exist yet' do + context 'when arg :notifications is nil' do + let(:arg_notifications) { nil } + + it 'creates Mute, and returns Mute' do + expect do + expect(subject).to be_a Mute + end.to change { account.mute_relationships.count }.by 1 + end + end + + context 'when arg :notifications is false' do + let(:arg_notifications) { false } + + it 'creates Mute, and returns Mute' do + expect do + expect(subject).to be_a Mute + end.to change { account.mute_relationships.count }.by 1 + end + end + + context 'when arg :notifications is true' do + let(:arg_notifications) { true } + + it 'creates Mute, and returns Mute' do + expect do + expect(subject).to be_a Mute + end.to change { account.mute_relationships.count }.by 1 + end + end + end + + context 'when Mute already exists' do + before do + account.mute_relationships << mute + end + + let(:mute) do + Fabricate(:mute, + account: account, + target_account: target_account, + hide_notifications: hide_notifications) + end + + context 'when mute.hide_notifications is true' do + let(:hide_notifications) { true } + + context 'when arg :notifications is nil' do + let(:arg_notifications) { nil } + + it 'returns Mute without updating mute.hide_notifications' do + expect do + expect(subject).to be_a Mute + end.to_not change { mute.reload.hide_notifications? }.from(true) + end + end + + context 'when arg :notifications is false' do + let(:arg_notifications) { false } + + it 'returns Mute, and updates mute.hide_notifications false' do + expect do + expect(subject).to be_a Mute + end.to change { mute.reload.hide_notifications? }.from(true).to(false) + end + end + + context 'when arg :notifications is true' do + let(:arg_notifications) { true } + + it 'returns Mute without updating mute.hide_notifications' do + expect do + expect(subject).to be_a Mute + end.to_not change { mute.reload.hide_notifications? }.from(true) + end + end + end + + context 'when mute.hide_notifications is false' do + let(:hide_notifications) { false } + + context 'when arg :notifications is nil' do + let(:arg_notifications) { nil } + + it 'returns Mute, and updates mute.hide_notifications true' do + expect do + expect(subject).to be_a Mute + end.to change { mute.reload.hide_notifications? }.from(false).to(true) + end + end + + context 'when arg :notifications is false' do + let(:arg_notifications) { false } + + it 'returns Mute without updating mute.hide_notifications' do + expect do + expect(subject).to be_a Mute + end.to_not change { mute.reload.hide_notifications? }.from(false) + end + end + + context 'when arg :notifications is true' do + let(:arg_notifications) { true } + + it 'returns Mute, and updates mute.hide_notifications true' do + expect do + expect(subject).to be_a Mute + end.to change { mute.reload.hide_notifications? }.from(false).to(true) + end + end + end + end + end + + describe '#mute_conversation!' do + subject { account.mute_conversation!(conversation) } + + let(:conversation) { Fabricate(:conversation) } + + it 'creates and returns ConversationMute' do + expect do + expect(subject).to be_a ConversationMute + end.to change { account.conversation_mutes.count }.by 1 + end + end + + describe '#block_domain!' do + subject { account.block_domain!(domain) } + + let(:domain) { 'example.com' } + + it 'creates and returns AccountDomainBlock' do + expect do + expect(subject).to be_a AccountDomainBlock + end.to change { account.domain_blocks.count }.by 1 + end + end + + describe '#unfollow!' do + subject { account.unfollow!(target_account) } + + context 'when following target_account' do + it 'returns destroyed Follow' do + account.active_relationships.create(target_account: target_account) + expect(subject).to be_a Follow + expect(subject).to be_destroyed + end + end + + context 'when not following target_account' do + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#unblock!' do + subject { account.unblock!(target_account) } + + context 'when blocking target_account' do + it 'returns destroyed Block' do + account.block_relationships.create(target_account: target_account) + expect(subject).to be_a Block + expect(subject).to be_destroyed + end + end + + context 'when not blocking target_account' do + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#unmute!' do + subject { account.unmute!(target_account) } + + context 'when muting target_account' do + it 'returns destroyed Mute' do + account.mute_relationships.create(target_account: target_account) + expect(subject).to be_a Mute + expect(subject).to be_destroyed + end + end + + context 'when not muting target_account' do + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#unmute_conversation!' do + subject { account.unmute_conversation!(conversation) } + + let(:conversation) { Fabricate(:conversation) } + + context 'when muting the conversation' do + it 'returns destroyed ConversationMute' do + account.conversation_mutes.create(conversation: conversation) + expect(subject).to be_a ConversationMute + expect(subject).to be_destroyed + end + end + + context 'when not muting the conversation' do + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#unblock_domain!' do + subject { account.unblock_domain!(domain) } + + let(:domain) { 'example.com' } + + context 'when blocking the domain' do + it 'returns destroyed AccountDomainBlock' do + account_domain_block = Fabricate(:account_domain_block, domain: domain) + account.domain_blocks << account_domain_block + expect(subject).to be_a AccountDomainBlock + expect(subject).to be_destroyed + end + end + + context 'when unblocking the domain' do + it 'returns nil' do + expect(subject).to be_nil + end + end + end + + describe '#following?' do + subject { account.following?(target_account) } + + context 'when following target_account' do + it 'returns true' do + account.active_relationships.create(target_account: target_account) + expect(subject).to be true + end + end + + context 'when not following target_account' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#followed_by?' do + subject { account.followed_by?(target_account) } + + context 'when followed by target_account' do + it 'returns true' do + account.passive_relationships.create(account: target_account) + expect(subject).to be true + end + end + + context 'when not followed by target_account' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#blocking?' do + subject { account.blocking?(target_account) } + + context 'when blocking target_account' do + it 'returns true' do + account.block_relationships.create(target_account: target_account) + expect(subject).to be true + end + end + + context 'when not blocking target_account' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#domain_blocking?' do + subject { account.domain_blocking?(domain) } + + let(:domain) { 'example.com' } + + context 'when blocking the domain' do + it 'returns true' do + account_domain_block = Fabricate(:account_domain_block, domain: domain) + account.domain_blocks << account_domain_block + expect(subject).to be true + end + end + + context 'when not blocking the domain' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#muting?' do + subject { account.muting?(target_account) } + + context 'when muting target_account' do + it 'returns true' do + mute = Fabricate(:mute, account: account, target_account: target_account) + account.mute_relationships << mute + expect(subject).to be true + end + end + + context 'when not muting target_account' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#muting_conversation?' do + subject { account.muting_conversation?(conversation) } + + let(:conversation) { Fabricate(:conversation) } + + context 'when muting the conversation' do + it 'returns true' do + account.conversation_mutes.create(conversation: conversation) + expect(subject).to be true + end + end + + context 'when not muting the conversation' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#muting_notifications?' do + subject { account.muting_notifications?(target_account) } + + before do + mute = Fabricate(:mute, target_account: target_account, account: account, hide_notifications: hide) + account.mute_relationships << mute + end + + context 'when muting notifications of target_account' do + let(:hide) { true } + + it 'returns true' do + expect(subject).to be true + end + end + + context 'when not muting notifications of target_account' do + let(:hide) { false } + + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#requested?' do + subject { account.requested?(target_account) } + + context 'with requested by target_account' do + it 'returns true' do + Fabricate(:follow_request, account: account, target_account: target_account) + expect(subject).to be true + end + end + + context 'when not requested by target_account' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#favourited?' do + subject { account.favourited?(status) } + + let(:status) { Fabricate(:status, account: account, favourites: favourites) } + + context 'when favorited' do + let(:favourites) { [Fabricate(:favourite, account: account)] } + + it 'returns true' do + expect(subject).to be true + end + end + + context 'when not favorited' do + let(:favourites) { [] } + + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#reblogged?' do + subject { account.reblogged?(status) } + + let(:status) { Fabricate(:status, account: account, reblogs: reblogs) } + + context 'with reblogged' do + let(:reblogs) { [Fabricate(:status, account: account)] } + + it 'returns true' do + expect(subject).to be true + end + end + + context 'when not reblogged' do + let(:reblogs) { [] } + + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#pinned?' do + subject { account.pinned?(status) } + + let(:status) { Fabricate(:status, account: account) } + + context 'when pinned' do + it 'returns true' do + Fabricate(:status_pin, account: account, status: status) + expect(subject).to be true + end + end + + context 'when not pinned' do + it 'returns false' do + expect(subject).to be false + end + end + end + + describe '#remote_followers_hash' do + let(:me) { Fabricate(:account, username: 'Me') } + let(:remote_alice) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') } + let(:remote_bob) { Fabricate(:account, username: 'bob', domain: 'example.org', uri: 'https://example.org/users/bob') } + let(:remote_instance_actor) { Fabricate(:account, username: 'instance-actor', domain: 'example.org', uri: 'https://example.org') } + let(:remote_eve) { Fabricate(:account, username: 'eve', domain: 'foo.org', uri: 'https://foo.org/users/eve') } + + before do + remote_alice.follow!(me) + remote_bob.follow!(me) + remote_instance_actor.follow!(me) + remote_eve.follow!(me) + me.follow!(remote_alice) + end + + it 'returns correct hash for remote domains' do + expect(me.remote_followers_hash('https://example.org/')).to eq '20aecbe774b3d61c25094370baf370012b9271c5b172ecedb05caff8d79ef0c7' + expect(me.remote_followers_hash('https://foo.org/')).to eq 'ccb9c18a67134cfff9d62c7f7e7eb88e6b803446c244b84265565f4eba29df0e' + expect(me.remote_followers_hash('https://foo.org.evil.com/')).to eq '0000000000000000000000000000000000000000000000000000000000000000' + expect(me.remote_followers_hash('https://foo')).to eq '0000000000000000000000000000000000000000000000000000000000000000' + end + + it 'invalidates cache as needed when removing or adding followers' do + expect(me.remote_followers_hash('https://example.org/')).to eq '20aecbe774b3d61c25094370baf370012b9271c5b172ecedb05caff8d79ef0c7' + remote_instance_actor.unfollow!(me) + expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec' + remote_alice.unfollow!(me) + expect(me.remote_followers_hash('https://example.org/')).to eq '241b00794ce9b46aa864f3220afadef128318da2659782985bac5ed5bd436bff' + remote_alice.follow!(me) + expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec' + end + end + + describe '#local_followers_hash' do + let(:me) { Fabricate(:account, username: 'Me') } + let(:remote_alice) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') } + + before do + me.follow!(remote_alice) + end + + it 'returns correct hash for local users' do + expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) + end + + it 'invalidates cache as needed when removing or adding followers' do + expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) + me.unfollow!(remote_alice) + expect(remote_alice.local_followers_hash).to eq '0000000000000000000000000000000000000000000000000000000000000000' + me.follow!(remote_alice) + expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) + end + end + + describe 'muting an account' do + let(:me) { Fabricate(:account, username: 'Me') } + let(:you) { Fabricate(:account, username: 'You') } + + context 'with the notifications option unspecified' do + before do + me.mute!(you) + end + + it 'defaults to muting notifications' do + expect(me.muting_notifications?(you)).to be true + end + end + + context 'with the notifications option set to false' do + before do + me.mute!(you, notifications: false) + end + + it 'does not mute notifications' do + expect(me.muting_notifications?(you)).to be false + end + end + + context 'with the notifications option set to true' do + before do + me.mute!(you, notifications: true) + end + + it 'does mute notifications' do + expect(me.muting_notifications?(you)).to be true + end + end + end + + describe 'ignoring reblogs from an account' do + let!(:me) { Fabricate(:account, username: 'Me') } + let!(:you) { Fabricate(:account, username: 'You') } + + context 'with the reblogs option unspecified' do + before do + me.follow!(you) + end + + it 'defaults to showing reblogs' do + 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) + end + + it 'does mute reblogs' do + 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) + end + + it 'does not mute reblogs' do + expect(me.muting_reblogs?(you)).to be(false) + end + end + end + + describe '#lists_for_local_distribution' do + let(:account) { Fabricate(:user, current_sign_in_at: Time.now.utc).account } + let!(:inactive_follower_user) { Fabricate(:user, current_sign_in_at: 5.years.ago) } + let!(:follower_user) { Fabricate(:user, current_sign_in_at: Time.now.utc) } + let!(:follow_request_user) { Fabricate(:user, current_sign_in_at: Time.now.utc) } + + let!(:inactive_follower_list) { Fabricate(:list, account: inactive_follower_user.account) } + let!(:follower_list) { Fabricate(:list, account: follower_user.account) } + let!(:follow_request_list) { Fabricate(:list, account: follow_request_user.account) } + + let!(:self_list) { Fabricate(:list, account: account) } + + before do + inactive_follower_user.account.follow!(account) + follower_user.account.follow!(account) + follow_request_user.account.follow_requests.create!(target_account: account) + + inactive_follower_list.accounts << account + follower_list.accounts << account + follow_request_list.accounts << account + self_list.accounts << account + end + + it 'includes only the list from the active follower and from oneself' do + expect(account.lists_for_local_distribution.to_a).to contain_exactly(follower_list, self_list) + 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 000000000..ab249d62d --- /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/account_counters_spec.rb b/spec/models/concerns/account_counters_spec.rb deleted file mode 100644 index fb02d79f1..000000000 --- a/spec/models/concerns/account_counters_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe AccountCounters do - let!(:account) { Fabricate(:account) } - - describe '#increment_count!' do - it 'increments the count' do - expect(account.followers_count).to eq 0 - account.increment_count!(:followers_count) - expect(account.followers_count).to eq 1 - end - - it 'increments the count in multi-threaded an environment' do - increment_by = 15 - wait_for_start = true - - threads = Array.new(increment_by) do - Thread.new do - true while wait_for_start - account.increment_count!(:statuses_count) - end - end - - wait_for_start = false - threads.each(&:join) - - expect(account.statuses_count).to eq increment_by - end - end - - describe '#decrement_count!' do - it 'decrements the count' do - account.followers_count = 15 - account.save! - expect(account.followers_count).to eq 15 - account.decrement_count!(:followers_count) - expect(account.followers_count).to eq 14 - end - - it 'decrements the count in multi-threaded an environment' do - decrement_by = 10 - wait_for_start = true - - account.statuses_count = 15 - account.save! - - threads = Array.new(decrement_by) do - Thread.new do - true while wait_for_start - account.decrement_count!(:statuses_count) - end - end - - wait_for_start = false - threads.each(&:join) - - expect(account.statuses_count).to eq 5 - end - end -end diff --git a/spec/models/concerns/account_finder_concern_spec.rb b/spec/models/concerns/account_finder_concern_spec.rb deleted file mode 100644 index 25f4fdec4..000000000 --- a/spec/models/concerns/account_finder_concern_spec.rb +++ /dev/null @@ -1,109 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe AccountFinderConcern do - describe 'local finders' do - before do - @account = Fabricate(:account, username: 'Alice') - end - - describe '.find_local' do - it 'returns case-insensitive result' do - expect(Account.find_local('alice')).to eq(@account) - end - - it 'returns correctly cased result' do - expect(Account.find_local('Alice')).to eq(@account) - end - - it 'returns nil without a match' do - expect(Account.find_local('a_ice')).to be_nil - end - - it 'returns nil for regex style username value' do - expect(Account.find_local('al%')).to be_nil - end - - it 'returns nil for nil username value' do - expect(Account.find_local(nil)).to be_nil - end - - it 'returns nil for blank username value' do - expect(Account.find_local('')).to be_nil - end - end - - describe '.find_local!' do - it 'returns matching result' do - expect(Account.find_local!('alice')).to eq(@account) - end - - it 'raises on non-matching result' do - expect { Account.find_local!('missing') }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'raises with blank username' do - expect { Account.find_local!('') }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'raises with nil username' do - expect { Account.find_local!(nil) }.to raise_error(ActiveRecord::RecordNotFound) - end - end - end - - describe 'remote finders' do - before do - @account = Fabricate(:account, username: 'Alice', domain: 'mastodon.social') - end - - describe '.find_remote' do - it 'returns exact match result' do - 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) - end - - it 'returns nil when username does not match' do - expect(Account.find_remote('a_ice', 'mastodon.social')).to be_nil - end - - it 'returns nil when domain does not match' do - expect(Account.find_remote('alice', 'm_stodon.social')).to be_nil - end - - it 'returns nil for regex style domain value' do - expect(Account.find_remote('alice', 'm%')).to be_nil - end - - it 'returns nil for nil username value' do - expect(Account.find_remote(nil, 'domain')).to be_nil - end - - it 'returns nil for blank username value' do - expect(Account.find_remote('', 'domain')).to be_nil - end - end - - describe '.find_remote!' do - it 'returns matching result' do - expect(Account.find_remote!('alice', 'mastodon.social')).to eq(@account) - end - - it 'raises on non-matching result' do - expect { Account.find_remote!('missing', 'mastodon.host') }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'raises with blank username' do - expect { Account.find_remote!('', '') }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'raises with nil username' do - expect { Account.find_remote!(nil, nil) }.to raise_error(ActiveRecord::RecordNotFound) - end - end - end -end diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb deleted file mode 100644 index 41aedd18b..000000000 --- a/spec/models/concerns/account_interactions_spec.rb +++ /dev/null @@ -1,721 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe AccountInteractions do - let(:account) { Fabricate(:account, username: 'account') } - let(:account_id) { account.id } - let(:account_ids) { [account_id] } - let(:target_account) { Fabricate(:account, username: 'target') } - let(:target_account_id) { target_account.id } - let(:target_account_ids) { [target_account_id] } - - describe '.following_map' do - subject { Account.following_map(target_account_ids, account_id) } - - context 'when Account with Follow' do - it 'returns { target_account_id => true }' do - Fabricate(:follow, account: account, target_account: target_account) - expect(subject).to eq(target_account_id => { reblogs: true, notify: false, languages: nil }) - end - end - - context 'when Account with Follow but with reblogs disabled' do - it 'returns { target_account_id => { reblogs: false } }' do - Fabricate(:follow, account: account, target_account: target_account, show_reblogs: false) - expect(subject).to eq(target_account_id => { reblogs: false, notify: false, languages: nil }) - end - end - - context 'when Account without Follow' do - it 'returns {}' do - expect(subject).to eq({}) - end - end - end - - describe '.followed_by_map' do - subject { Account.followed_by_map(target_account_ids, account_id) } - - context 'when Account with Follow' do - it 'returns { target_account_id => true }' do - Fabricate(:follow, account: target_account, target_account: account) - expect(subject).to eq(target_account_id => true) - end - end - - context 'when Account without Follow' do - it 'returns {}' do - expect(subject).to eq({}) - end - end - end - - describe '.blocking_map' do - subject { Account.blocking_map(target_account_ids, account_id) } - - context 'when Account with Block' do - it 'returns { target_account_id => true }' do - Fabricate(:block, account: account, target_account: target_account) - expect(subject).to eq(target_account_id => true) - end - end - - context 'when Account without Block' do - it 'returns {}' do - expect(subject).to eq({}) - end - end - end - - describe '.muting_map' do - subject { Account.muting_map(target_account_ids, account_id) } - - context 'when Account with Mute' do - before do - Fabricate(:mute, target_account: target_account, account: account, hide_notifications: hide) - end - - context 'when Mute#hide_notifications?' do - let(:hide) { true } - - it 'returns { target_account_id => { notifications: true } }' do - expect(subject).to eq(target_account_id => { notifications: true }) - end - end - - context 'when not Mute#hide_notifications?' do - let(:hide) { false } - - it 'returns { target_account_id => { notifications: false } }' do - expect(subject).to eq(target_account_id => { notifications: false }) - end - end - end - - context 'when Account without Mute' do - it 'returns {}' do - expect(subject).to eq({}) - end - end - end - - describe '#follow!' do - it 'creates and returns Follow' do - expect do - expect(account.follow!(target_account)).to be_a Follow - end.to change { account.following.count }.by 1 - end - end - - describe '#block' do - it 'creates and returns Block' do - expect do - expect(account.block!(target_account)).to be_a Block - end.to change { account.block_relationships.count }.by 1 - end - end - - describe '#mute!' do - subject { account.mute!(target_account, notifications: arg_notifications) } - - context 'when Mute does not exist yet' do - context 'when arg :notifications is nil' do - let(:arg_notifications) { nil } - - it 'creates Mute, and returns Mute' do - expect do - expect(subject).to be_a Mute - end.to change { account.mute_relationships.count }.by 1 - end - end - - context 'when arg :notifications is false' do - let(:arg_notifications) { false } - - it 'creates Mute, and returns Mute' do - expect do - expect(subject).to be_a Mute - end.to change { account.mute_relationships.count }.by 1 - end - end - - context 'when arg :notifications is true' do - let(:arg_notifications) { true } - - it 'creates Mute, and returns Mute' do - expect do - expect(subject).to be_a Mute - end.to change { account.mute_relationships.count }.by 1 - end - end - end - - context 'when Mute already exists' do - before do - account.mute_relationships << mute - end - - let(:mute) do - Fabricate(:mute, - account: account, - target_account: target_account, - hide_notifications: hide_notifications) - end - - context 'when mute.hide_notifications is true' do - let(:hide_notifications) { true } - - context 'when arg :notifications is nil' do - let(:arg_notifications) { nil } - - it 'returns Mute without updating mute.hide_notifications' do - expect do - expect(subject).to be_a Mute - end.to_not change { mute.reload.hide_notifications? }.from(true) - end - end - - context 'when arg :notifications is false' do - let(:arg_notifications) { false } - - it 'returns Mute, and updates mute.hide_notifications false' do - expect do - expect(subject).to be_a Mute - end.to change { mute.reload.hide_notifications? }.from(true).to(false) - end - end - - context 'when arg :notifications is true' do - let(:arg_notifications) { true } - - it 'returns Mute without updating mute.hide_notifications' do - expect do - expect(subject).to be_a Mute - end.to_not change { mute.reload.hide_notifications? }.from(true) - end - end - end - - context 'when mute.hide_notifications is false' do - let(:hide_notifications) { false } - - context 'when arg :notifications is nil' do - let(:arg_notifications) { nil } - - it 'returns Mute, and updates mute.hide_notifications true' do - expect do - expect(subject).to be_a Mute - end.to change { mute.reload.hide_notifications? }.from(false).to(true) - end - end - - context 'when arg :notifications is false' do - let(:arg_notifications) { false } - - it 'returns Mute without updating mute.hide_notifications' do - expect do - expect(subject).to be_a Mute - end.to_not change { mute.reload.hide_notifications? }.from(false) - end - end - - context 'when arg :notifications is true' do - let(:arg_notifications) { true } - - it 'returns Mute, and updates mute.hide_notifications true' do - expect do - expect(subject).to be_a Mute - end.to change { mute.reload.hide_notifications? }.from(false).to(true) - end - end - end - end - end - - describe '#mute_conversation!' do - subject { account.mute_conversation!(conversation) } - - let(:conversation) { Fabricate(:conversation) } - - it 'creates and returns ConversationMute' do - expect do - expect(subject).to be_a ConversationMute - end.to change { account.conversation_mutes.count }.by 1 - end - end - - describe '#block_domain!' do - subject { account.block_domain!(domain) } - - let(:domain) { 'example.com' } - - it 'creates and returns AccountDomainBlock' do - expect do - expect(subject).to be_a AccountDomainBlock - end.to change { account.domain_blocks.count }.by 1 - end - end - - describe '#unfollow!' do - subject { account.unfollow!(target_account) } - - context 'when following target_account' do - it 'returns destroyed Follow' do - account.active_relationships.create(target_account: target_account) - expect(subject).to be_a Follow - expect(subject).to be_destroyed - end - end - - context 'when not following target_account' do - it 'returns nil' do - expect(subject).to be_nil - end - end - end - - describe '#unblock!' do - subject { account.unblock!(target_account) } - - context 'when blocking target_account' do - it 'returns destroyed Block' do - account.block_relationships.create(target_account: target_account) - expect(subject).to be_a Block - expect(subject).to be_destroyed - end - end - - context 'when not blocking target_account' do - it 'returns nil' do - expect(subject).to be_nil - end - end - end - - describe '#unmute!' do - subject { account.unmute!(target_account) } - - context 'when muting target_account' do - it 'returns destroyed Mute' do - account.mute_relationships.create(target_account: target_account) - expect(subject).to be_a Mute - expect(subject).to be_destroyed - end - end - - context 'when not muting target_account' do - it 'returns nil' do - expect(subject).to be_nil - end - end - end - - describe '#unmute_conversation!' do - subject { account.unmute_conversation!(conversation) } - - let(:conversation) { Fabricate(:conversation) } - - context 'when muting the conversation' do - it 'returns destroyed ConversationMute' do - account.conversation_mutes.create(conversation: conversation) - expect(subject).to be_a ConversationMute - expect(subject).to be_destroyed - end - end - - context 'when not muting the conversation' do - it 'returns nil' do - expect(subject).to be_nil - end - end - end - - describe '#unblock_domain!' do - subject { account.unblock_domain!(domain) } - - let(:domain) { 'example.com' } - - context 'when blocking the domain' do - it 'returns destroyed AccountDomainBlock' do - account_domain_block = Fabricate(:account_domain_block, domain: domain) - account.domain_blocks << account_domain_block - expect(subject).to be_a AccountDomainBlock - expect(subject).to be_destroyed - end - end - - context 'when unblocking the domain' do - it 'returns nil' do - expect(subject).to be_nil - end - end - end - - describe '#following?' do - subject { account.following?(target_account) } - - context 'when following target_account' do - it 'returns true' do - account.active_relationships.create(target_account: target_account) - expect(subject).to be true - end - end - - context 'when not following target_account' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#followed_by?' do - subject { account.followed_by?(target_account) } - - context 'when followed by target_account' do - it 'returns true' do - account.passive_relationships.create(account: target_account) - expect(subject).to be true - end - end - - context 'when not followed by target_account' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#blocking?' do - subject { account.blocking?(target_account) } - - context 'when blocking target_account' do - it 'returns true' do - account.block_relationships.create(target_account: target_account) - expect(subject).to be true - end - end - - context 'when not blocking target_account' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#domain_blocking?' do - subject { account.domain_blocking?(domain) } - - let(:domain) { 'example.com' } - - context 'when blocking the domain' do - it 'returns true' do - account_domain_block = Fabricate(:account_domain_block, domain: domain) - account.domain_blocks << account_domain_block - expect(subject).to be true - end - end - - context 'when not blocking the domain' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#muting?' do - subject { account.muting?(target_account) } - - context 'when muting target_account' do - it 'returns true' do - mute = Fabricate(:mute, account: account, target_account: target_account) - account.mute_relationships << mute - expect(subject).to be true - end - end - - context 'when not muting target_account' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#muting_conversation?' do - subject { account.muting_conversation?(conversation) } - - let(:conversation) { Fabricate(:conversation) } - - context 'when muting the conversation' do - it 'returns true' do - account.conversation_mutes.create(conversation: conversation) - expect(subject).to be true - end - end - - context 'when not muting the conversation' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#muting_notifications?' do - subject { account.muting_notifications?(target_account) } - - before do - mute = Fabricate(:mute, target_account: target_account, account: account, hide_notifications: hide) - account.mute_relationships << mute - end - - context 'when muting notifications of target_account' do - let(:hide) { true } - - it 'returns true' do - expect(subject).to be true - end - end - - context 'when not muting notifications of target_account' do - let(:hide) { false } - - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#requested?' do - subject { account.requested?(target_account) } - - context 'with requested by target_account' do - it 'returns true' do - Fabricate(:follow_request, account: account, target_account: target_account) - expect(subject).to be true - end - end - - context 'when not requested by target_account' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#favourited?' do - subject { account.favourited?(status) } - - let(:status) { Fabricate(:status, account: account, favourites: favourites) } - - context 'when favorited' do - let(:favourites) { [Fabricate(:favourite, account: account)] } - - it 'returns true' do - expect(subject).to be true - end - end - - context 'when not favorited' do - let(:favourites) { [] } - - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#reblogged?' do - subject { account.reblogged?(status) } - - let(:status) { Fabricate(:status, account: account, reblogs: reblogs) } - - context 'with reblogged' do - let(:reblogs) { [Fabricate(:status, account: account)] } - - it 'returns true' do - expect(subject).to be true - end - end - - context 'when not reblogged' do - let(:reblogs) { [] } - - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#pinned?' do - subject { account.pinned?(status) } - - let(:status) { Fabricate(:status, account: account) } - - context 'when pinned' do - it 'returns true' do - Fabricate(:status_pin, account: account, status: status) - expect(subject).to be true - end - end - - context 'when not pinned' do - it 'returns false' do - expect(subject).to be false - end - end - end - - describe '#remote_followers_hash' do - let(:me) { Fabricate(:account, username: 'Me') } - let(:remote_alice) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') } - let(:remote_bob) { Fabricate(:account, username: 'bob', domain: 'example.org', uri: 'https://example.org/users/bob') } - let(:remote_instance_actor) { Fabricate(:account, username: 'instance-actor', domain: 'example.org', uri: 'https://example.org') } - let(:remote_eve) { Fabricate(:account, username: 'eve', domain: 'foo.org', uri: 'https://foo.org/users/eve') } - - before do - remote_alice.follow!(me) - remote_bob.follow!(me) - remote_instance_actor.follow!(me) - remote_eve.follow!(me) - me.follow!(remote_alice) - end - - it 'returns correct hash for remote domains' do - expect(me.remote_followers_hash('https://example.org/')).to eq '20aecbe774b3d61c25094370baf370012b9271c5b172ecedb05caff8d79ef0c7' - expect(me.remote_followers_hash('https://foo.org/')).to eq 'ccb9c18a67134cfff9d62c7f7e7eb88e6b803446c244b84265565f4eba29df0e' - expect(me.remote_followers_hash('https://foo.org.evil.com/')).to eq '0000000000000000000000000000000000000000000000000000000000000000' - expect(me.remote_followers_hash('https://foo')).to eq '0000000000000000000000000000000000000000000000000000000000000000' - end - - it 'invalidates cache as needed when removing or adding followers' do - expect(me.remote_followers_hash('https://example.org/')).to eq '20aecbe774b3d61c25094370baf370012b9271c5b172ecedb05caff8d79ef0c7' - remote_instance_actor.unfollow!(me) - expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec' - remote_alice.unfollow!(me) - expect(me.remote_followers_hash('https://example.org/')).to eq '241b00794ce9b46aa864f3220afadef128318da2659782985bac5ed5bd436bff' - remote_alice.follow!(me) - expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec' - end - end - - describe '#local_followers_hash' do - let(:me) { Fabricate(:account, username: 'Me') } - let(:remote_alice) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') } - - before do - me.follow!(remote_alice) - end - - it 'returns correct hash for local users' do - expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) - end - - it 'invalidates cache as needed when removing or adding followers' do - expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) - me.unfollow!(remote_alice) - expect(remote_alice.local_followers_hash).to eq '0000000000000000000000000000000000000000000000000000000000000000' - me.follow!(remote_alice) - expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me)) - end - end - - describe 'muting an account' do - let(:me) { Fabricate(:account, username: 'Me') } - let(:you) { Fabricate(:account, username: 'You') } - - context 'with the notifications option unspecified' do - before do - me.mute!(you) - end - - it 'defaults to muting notifications' do - expect(me.muting_notifications?(you)).to be true - end - end - - context 'with the notifications option set to false' do - before do - me.mute!(you, notifications: false) - end - - it 'does not mute notifications' do - expect(me.muting_notifications?(you)).to be false - end - end - - context 'with the notifications option set to true' do - before do - me.mute!(you, notifications: true) - end - - it 'does mute notifications' do - expect(me.muting_notifications?(you)).to be true - end - end - end - - describe 'ignoring reblogs from an account' do - before do - @me = Fabricate(:account, username: 'Me') - @you = Fabricate(:account, username: 'You') - end - - context 'with the reblogs option unspecified' do - before do - @me.follow!(@you) - end - - it 'defaults to showing reblogs' do - 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) - end - - it 'does mute reblogs' do - 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) - end - - it 'does not mute reblogs' do - expect(@me.muting_reblogs?(@you)).to be(false) - end - end - end - - describe '#lists_for_local_distribution' do - let(:account) { Fabricate(:user, current_sign_in_at: Time.now.utc).account } - let!(:inactive_follower_user) { Fabricate(:user, current_sign_in_at: 5.years.ago) } - let!(:follower_user) { Fabricate(:user, current_sign_in_at: Time.now.utc) } - let!(:follow_request_user) { Fabricate(:user, current_sign_in_at: Time.now.utc) } - - let!(:inactive_follower_list) { Fabricate(:list, account: inactive_follower_user.account) } - let!(:follower_list) { Fabricate(:list, account: follower_user.account) } - let!(:follow_request_list) { Fabricate(:list, account: follow_request_user.account) } - - let!(:self_list) { Fabricate(:list, account: account) } - - before do - inactive_follower_user.account.follow!(account) - follower_user.account.follow!(account) - follow_request_user.account.follow_requests.create!(target_account: account) - - inactive_follower_list.accounts << account - follower_list.accounts << account - follow_request_list.accounts << account - self_list.accounts << account - end - - it 'includes only the list from the active follower and from oneself' do - expect(account.lists_for_local_distribution.to_a).to contain_exactly(follower_list, self_list) - end - end -end diff --git a/spec/models/concerns/remotable_spec.rb b/spec/models/concerns/remotable_spec.rb index b2aa56a70..097e6bf00 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 new file mode 100644 index 000000000..09fb21856 --- /dev/null +++ b/spec/models/concerns/status/threading_concern_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Status::ThreadingConcern do + describe '#ancestors' do + let!(:alice) { Fabricate(:account, username: 'alice') } + let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } + let!(:jeff) { Fabricate(:account, username: 'jeff') } + let!(:status) { Fabricate(:status, account: alice) } + let!(:reply_to_status) { Fabricate(:status, thread: status, account: jeff) } + let!(:reply_to_first_reply) { Fabricate(:status, thread: reply_to_status, account: bob) } + let!(:reply_to_second_reply) { Fabricate(:status, thread: reply_to_first_reply, account: alice) } + let!(:viewer) { Fabricate(:account, username: 'viewer') } + + it 'returns conversation history' do + expect(reply_to_second_reply.ancestors(4)).to include(status, reply_to_status, reply_to_first_reply) + end + + it 'does not return conversation history user is not allowed to see' do + reply_to_status.update(visibility: :private) + status.update(visibility: :direct) + + expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status, status) + end + + it 'does not return conversation history from blocked users' do + viewer.block!(jeff) + expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status) + end + + it 'does not return conversation history from muted users' do + viewer.mute!(jeff) + expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status) + end + + it 'does not return conversation history from silenced and not followed users' do + jeff.silence! + expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status) + end + + it 'does not return conversation history from blocked domains' do + viewer.block_domain!('example.com') + expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_first_reply) + end + + it 'ignores deleted records' do + first_status = Fabricate(:status, account: bob) + second_status = Fabricate(:status, thread: first_status, account: alice) + + # Create cache and delete cached record + second_status.ancestors(4) + first_status.destroy + + expect(second_status.ancestors(4)).to eq([]) + end + + it 'can return more records than previously requested' do + first_status = Fabricate(:status, account: bob) + second_status = Fabricate(:status, thread: first_status, account: alice) + third_status = Fabricate(:status, thread: second_status, account: alice) + + # Create cache + second_status.ancestors(1) + + expect(third_status.ancestors(2)).to eq([first_status, second_status]) + end + + it 'can return fewer records than previously requested' do + first_status = Fabricate(:status, account: bob) + second_status = Fabricate(:status, thread: first_status, account: alice) + third_status = Fabricate(:status, thread: second_status, account: alice) + + # Create cache + second_status.ancestors(2) + + expect(third_status.ancestors(1)).to eq([second_status]) + end + end + + describe '#descendants' do + let!(:alice) { Fabricate(:account, username: 'alice') } + let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } + let!(:jeff) { Fabricate(:account, username: 'jeff') } + let!(:status) { Fabricate(:status, account: alice) } + let!(:reply_to_status_from_alice) { Fabricate(:status, thread: status, account: alice) } + let!(:reply_to_status_from_bob) { Fabricate(:status, thread: status, account: bob) } + let!(:reply_to_alice_reply_from_jeff) { Fabricate(:status, thread: reply_to_status_from_alice, account: jeff) } + let!(:viewer) { Fabricate(:account, username: 'viewer') } + + it 'returns replies' do + expect(status.descendants(4)).to include(reply_to_status_from_alice, reply_to_status_from_bob, reply_to_alice_reply_from_jeff) + end + + it 'does not return replies user is not allowed to see' do + reply_to_status_from_alice.update(visibility: :private) + reply_to_alice_reply_from_jeff.update(visibility: :direct) + + expect(status.descendants(4, viewer)).to_not include(reply_to_status_from_alice, reply_to_alice_reply_from_jeff) + end + + it 'does not return replies from blocked users' do + viewer.block!(jeff) + expect(status.descendants(4, viewer)).to_not include(reply_to_alice_reply_from_jeff) + end + + it 'does not return replies from muted users' do + viewer.mute!(jeff) + expect(status.descendants(4, viewer)).to_not include(reply_to_alice_reply_from_jeff) + end + + it 'does not return replies from silenced and not followed users' do + jeff.silence! + expect(status.descendants(4, viewer)).to_not include(reply_to_alice_reply_from_jeff) + end + + it 'does not return replies from blocked domains' do + viewer.block_domain!('example.com') + expect(status.descendants(4, viewer)).to_not include(reply_to_status_from_bob) + end + + it 'promotes self-replies to the top while leaving the rest in order' do + a = Fabricate(:status, account: alice) + d = Fabricate(:status, account: jeff, thread: a) + e = Fabricate(:status, account: bob, thread: d) + c = Fabricate(:status, account: alice, thread: a) + f = Fabricate(:status, account: bob, thread: c) + + expect(a.descendants(20)).to eq [c, d, e, f] + end + end +end diff --git a/spec/models/concerns/status_threading_concern_spec.rb b/spec/models/concerns/status_threading_concern_spec.rb deleted file mode 100644 index 2eac1ca6e..000000000 --- a/spec/models/concerns/status_threading_concern_spec.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe StatusThreadingConcern do - describe '#ancestors' do - let!(:alice) { Fabricate(:account, username: 'alice') } - let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } - let!(:jeff) { Fabricate(:account, username: 'jeff') } - let!(:status) { Fabricate(:status, account: alice) } - let!(:reply_to_status) { Fabricate(:status, thread: status, account: jeff) } - let!(:reply_to_first_reply) { Fabricate(:status, thread: reply_to_status, account: bob) } - let!(:reply_to_second_reply) { Fabricate(:status, thread: reply_to_first_reply, account: alice) } - let!(:viewer) { Fabricate(:account, username: 'viewer') } - - it 'returns conversation history' do - expect(reply_to_second_reply.ancestors(4)).to include(status, reply_to_status, reply_to_first_reply) - end - - it 'does not return conversation history user is not allowed to see' do - reply_to_status.update(visibility: :private) - status.update(visibility: :direct) - - expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status, status) - end - - it 'does not return conversation history from blocked users' do - viewer.block!(jeff) - expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status) - end - - it 'does not return conversation history from muted users' do - viewer.mute!(jeff) - expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status) - end - - it 'does not return conversation history from silenced and not followed users' do - jeff.silence! - expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_status) - end - - it 'does not return conversation history from blocked domains' do - viewer.block_domain!('example.com') - expect(reply_to_second_reply.ancestors(4, viewer)).to_not include(reply_to_first_reply) - end - - it 'ignores deleted records' do - first_status = Fabricate(:status, account: bob) - second_status = Fabricate(:status, thread: first_status, account: alice) - - # Create cache and delete cached record - second_status.ancestors(4) - first_status.destroy - - expect(second_status.ancestors(4)).to eq([]) - end - - it 'can return more records than previously requested' do - first_status = Fabricate(:status, account: bob) - second_status = Fabricate(:status, thread: first_status, account: alice) - third_status = Fabricate(:status, thread: second_status, account: alice) - - # Create cache - second_status.ancestors(1) - - expect(third_status.ancestors(2)).to eq([first_status, second_status]) - end - - it 'can return fewer records than previously requested' do - first_status = Fabricate(:status, account: bob) - second_status = Fabricate(:status, thread: first_status, account: alice) - third_status = Fabricate(:status, thread: second_status, account: alice) - - # Create cache - second_status.ancestors(2) - - expect(third_status.ancestors(1)).to eq([second_status]) - end - end - - describe '#descendants' do - let!(:alice) { Fabricate(:account, username: 'alice') } - let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com') } - let!(:jeff) { Fabricate(:account, username: 'jeff') } - let!(:status) { Fabricate(:status, account: alice) } - let!(:reply_to_status_from_alice) { Fabricate(:status, thread: status, account: alice) } - let!(:reply_to_status_from_bob) { Fabricate(:status, thread: status, account: bob) } - let!(:reply_to_alice_reply_from_jeff) { Fabricate(:status, thread: reply_to_status_from_alice, account: jeff) } - let!(:viewer) { Fabricate(:account, username: 'viewer') } - - it 'returns replies' do - expect(status.descendants(4)).to include(reply_to_status_from_alice, reply_to_status_from_bob, reply_to_alice_reply_from_jeff) - end - - it 'does not return replies user is not allowed to see' do - reply_to_status_from_alice.update(visibility: :private) - reply_to_alice_reply_from_jeff.update(visibility: :direct) - - expect(status.descendants(4, viewer)).to_not include(reply_to_status_from_alice, reply_to_alice_reply_from_jeff) - end - - it 'does not return replies from blocked users' do - viewer.block!(jeff) - expect(status.descendants(4, viewer)).to_not include(reply_to_alice_reply_from_jeff) - end - - it 'does not return replies from muted users' do - viewer.mute!(jeff) - expect(status.descendants(4, viewer)).to_not include(reply_to_alice_reply_from_jeff) - end - - it 'does not return replies from silenced and not followed users' do - jeff.silence! - expect(status.descendants(4, viewer)).to_not include(reply_to_alice_reply_from_jeff) - end - - it 'does not return replies from blocked domains' do - viewer.block_domain!('example.com') - expect(status.descendants(4, viewer)).to_not include(reply_to_status_from_bob) - end - - it 'promotes self-replies to the top while leaving the rest in order' do - a = Fabricate(:status, account: alice) - d = Fabricate(:status, account: jeff, thread: a) - e = Fabricate(:status, account: bob, thread: d) - c = Fabricate(:status, account: alice, thread: a) - f = Fabricate(:status, account: bob, thread: c) - - expect(a.descendants(20)).to eq [c, d, e, f] - end - end -end diff --git a/spec/models/conversation_mute_spec.rb b/spec/models/conversation_mute_spec.rb deleted file mode 100644 index 3d5504a65..000000000 --- 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 8a6487c32..a0903e597 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:' } @@ -78,12 +78,23 @@ RSpec.describe CustomEmoji do end end - describe 'pre_validation' do - let(:custom_emoji) { Fabricate(:custom_emoji, domain: 'wWw.MaStOdOn.CoM') } + describe 'Normalizations' do + describe 'downcase domain value' do + context 'with a mixed case domain value' do + it 'normalizes the value to downcased' do + custom_emoji = Fabricate.build(:custom_emoji, domain: 'wWw.MaStOdOn.CoM') - it 'downcases' do - custom_emoji.valid? - expect(custom_emoji.domain).to eq('www.mastodon.com') + expect(custom_emoji.domain).to eq('www.mastodon.com') + end + end + + context 'with a nil domain value' do + it 'leaves the value as nil' do + custom_emoji = Fabricate.build(:custom_emoji, domain: nil) + + expect(custom_emoji.domain).to be_nil + end + end end end end diff --git a/spec/models/custom_filter_keyword_spec.rb b/spec/models/custom_filter_keyword_spec.rb index 09a36fa3e..4e3ab060a 100644 --- a/spec/models/custom_filter_keyword_spec.rb +++ b/spec/models/custom_filter_keyword_spec.rb @@ -3,4 +3,33 @@ require 'rails_helper' RSpec.describe CustomFilterKeyword do + describe '#to_regex' do + context 'when whole_word is true' do + it 'builds a regex with boundaries and the keyword' do + keyword = described_class.new(whole_word: true, keyword: 'test') + + expect(keyword.to_regex).to eq(/(?mix:\b#{Regexp.escape(keyword.keyword)}\b)/) + end + + it 'builds a regex with starting boundary and the keyword when end with non-word' do + keyword = described_class.new(whole_word: true, keyword: 'test#') + + expect(keyword.to_regex).to eq(/(?mix:\btest\#)/) + end + + it 'builds a regex with end boundary and the keyword when start with non-word' do + keyword = described_class.new(whole_word: true, keyword: '#test') + + expect(keyword.to_regex).to eq(/(?mix:\#test\b)/) + end + end + + context 'when whole_word is false' do + it 'builds a regex with the keyword' do + keyword = described_class.new(whole_word: false, keyword: 'test') + + expect(keyword.to_regex).to eq(/test/i) + end + end + end end diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb index f5e9b7ea9..940493633 100644 --- a/spec/models/custom_filter_spec.rb +++ b/spec/models/custom_filter_spec.rb @@ -3,4 +3,33 @@ require 'rails_helper' RSpec.describe CustomFilter do + describe 'Validations' do + it 'requires presence of title' do + record = described_class.new(title: '') + record.valid? + + expect(record).to model_have_error_on_field(:title) + end + + it 'requires presence of context' do + record = described_class.new(context: nil) + record.valid? + + expect(record).to model_have_error_on_field(:context) + end + + it 'requires non-empty of context' do + record = described_class.new(context: []) + record.valid? + + expect(record).to model_have_error_on_field(:context) + end + + it 'requires valid context value' do + record = described_class.new(context: ['invalid']) + record.valid? + + expect(record).to model_have_error_on_field(:context) + end + end end diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb deleted file mode 100644 index 4e7b8f2e2..000000000 --- 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_allow_spec.rb b/spec/models/domain_allow_spec.rb index 49e16376e..12504211a 100644 --- a/spec/models/domain_allow_spec.rb +++ b/spec/models/domain_allow_spec.rb @@ -3,16 +3,18 @@ require 'rails_helper' describe DomainAllow do - describe 'scopes' do - describe 'matches_domain' do - let(:domain) { Fabricate(:domain_allow, domain: 'example.com') } - let(:other_domain) { Fabricate(:domain_allow, domain: 'example.biz') } + describe 'Validations' do + it 'is invalid without a domain' do + domain_allow = Fabricate.build(:domain_allow, domain: nil) + domain_allow.valid? + expect(domain_allow).to model_have_error_on_field(:domain) + end - it 'returns the correct records' do - results = described_class.matches_domain('example.com') - - expect(results).to eq([domain]) - end + it 'is invalid if the same normalized domain already exists' do + _domain_allow = Fabricate(:domain_allow, domain: 'にゃん') + domain_allow_with_normalized_value = Fabricate.build(:domain_allow, domain: 'xn--r9j5b5b') + domain_allow_with_normalized_value.valid? + expect(domain_allow_with_normalized_value).to model_have_error_on_field(:domain) end end end diff --git a/spec/models/domain_block_spec.rb b/spec/models/domain_block_spec.rb index 67f53fa78..d595441fd 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 c38142be1..000000000 --- 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 58865f94a..000000000 --- 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 d437d170d..000000000 --- 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 01faec0e7..f30e27e70 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 fd8e90901..26fb1b953 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 000000000..abeada5d5 --- /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 2b70e396b..872697485 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 2fca1e1c1..702245444 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/instance_spec.rb b/spec/models/instance_spec.rb new file mode 100644 index 000000000..3e811d332 --- /dev/null +++ b/spec/models/instance_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Instance do + describe 'Scopes' do + before { described_class.refresh } + + describe '#searchable' do + let(:expected_domain) { 'host.example' } + let(:blocked_domain) { 'other.example' } + + before do + Fabricate :account, domain: expected_domain + Fabricate :account, domain: blocked_domain + Fabricate :domain_block, domain: blocked_domain + end + + it 'returns records not domain blocked' do + results = described_class.searchable.pluck(:domain) + + expect(results) + .to include(expected_domain) + .and not_include(blocked_domain) + end + end + + describe '#matches_domain' do + let(:host_domain) { 'host.example.com' } + let(:host_under_domain) { 'host_under.example.com' } + let(:other_domain) { 'other.example' } + + before do + Fabricate :account, domain: host_domain + Fabricate :account, domain: host_under_domain + Fabricate :account, domain: other_domain + end + + it 'returns matching records' do + expect(described_class.matches_domain('host.exa').pluck(:domain)) + .to include(host_domain) + .and not_include(other_domain) + + expect(described_class.matches_domain('ple.com').pluck(:domain)) + .to include(host_domain) + .and not_include(other_domain) + + expect(described_class.matches_domain('example').pluck(:domain)) + .to include(host_domain) + .and include(other_domain) + + expect(described_class.matches_domain('host_').pluck(:domain)) # Preserve SQL wildcards + .to include(host_domain) + .and include(host_under_domain) + .and not_include(other_domain) + end + end + + describe '#by_domain_and_subdomains' do + let(:exact_match_domain) { 'example.com' } + let(:subdomain_domain) { 'foo.example.com' } + let(:partial_domain) { 'grexample.com' } + + before do + Fabricate(:account, domain: exact_match_domain) + Fabricate(:account, domain: subdomain_domain) + Fabricate(:account, domain: partial_domain) + end + + it 'returns matching instances' do + results = described_class.by_domain_and_subdomains('example.com').pluck(:domain) + + expect(results) + .to include(exact_match_domain) + .and include(subdomain_domain) + .and not_include(partial_domain) + end + end + + describe '#with_domain_follows' do + let(:example_domain) { 'example.host' } + let(:other_domain) { 'other.host' } + let(:none_domain) { 'none.host' } + + before do + example_account = Fabricate(:account, domain: example_domain) + other_account = Fabricate(:account, domain: other_domain) + Fabricate(:account, domain: none_domain) + + Fabricate :follow, account: example_account + Fabricate :follow, target_account: other_account + end + + it 'returns instances with domain accounts that have follows' do + results = described_class.with_domain_follows(['example.host', 'other.host', 'none.host']).pluck(:domain) + + expect(results) + .to include(example_domain) + .and include(other_domain) + .and not_include(none_domain) + end + end + end +end diff --git a/spec/models/list_account_spec.rb b/spec/models/list_account_spec.rb deleted file mode 100644 index c9853d4a0..000000000 --- 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 621ad3968..000000000 --- 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 2214d3ef3..000000000 --- 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 90e4f2f47..89916f9f5 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 050083d0f..000000000 --- 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/notification_spec.rb b/spec/models/notification_spec.rb index d6e228202..172d1c65b 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -58,6 +58,88 @@ RSpec.describe Notification do end end + describe 'Setting account from activity_type' do + context 'when activity_type is a Status' do + it 'sets the notification from_account correctly' do + status = Fabricate(:status) + + notification = Fabricate.build(:notification, activity_type: 'Status', activity: status) + + expect(notification.from_account).to eq(status.account) + end + end + + context 'when activity_type is a Follow' do + it 'sets the notification from_account correctly' do + follow = Fabricate(:follow) + + notification = Fabricate.build(:notification, activity_type: 'Follow', activity: follow) + + expect(notification.from_account).to eq(follow.account) + end + end + + context 'when activity_type is a Favourite' do + it 'sets the notification from_account correctly' do + favourite = Fabricate(:favourite) + + notification = Fabricate.build(:notification, activity_type: 'Favourite', activity: favourite) + + expect(notification.from_account).to eq(favourite.account) + end + end + + context 'when activity_type is a FollowRequest' do + it 'sets the notification from_account correctly' do + follow_request = Fabricate(:follow_request) + + notification = Fabricate.build(:notification, activity_type: 'FollowRequest', activity: follow_request) + + expect(notification.from_account).to eq(follow_request.account) + end + end + + context 'when activity_type is a Poll' do + it 'sets the notification from_account correctly' do + poll = Fabricate(:poll) + + notification = Fabricate.build(:notification, activity_type: 'Poll', activity: poll) + + expect(notification.from_account).to eq(poll.account) + end + end + + context 'when activity_type is a Report' do + it 'sets the notification from_account correctly' do + report = Fabricate(:report) + + notification = Fabricate.build(:notification, activity_type: 'Report', activity: report) + + expect(notification.from_account).to eq(report.account) + end + end + + context 'when activity_type is a Mention' do + it 'sets the notification from_account correctly' do + mention = Fabricate(:mention) + + notification = Fabricate.build(:notification, activity_type: 'Mention', activity: mention) + + expect(notification.from_account).to eq(mention.status.account) + end + end + + context 'when activity_type is an Account' do + it 'sets the notification from_account correctly' do + account = Fabricate(:account) + + notification = Fabricate.build(:notification, activity_type: 'Account', account: account) + + expect(notification.account).to eq(account) + end + end + end + describe '.preload_cache_collection_target_statuses' do subject do described_class.preload_cache_collection_target_statuses(notifications) do |target_statuses| diff --git a/spec/models/poll_spec.rb b/spec/models/poll_spec.rb index 8ae04ca41..5aa5548cc 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 7d687d16f..a17c7532e 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 a31bf71cc..000000000 --- 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 fbbdf6258..8a1a01e89 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 b3e855c12..fccd42aaa 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 7ed49e733..000000000 --- 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 4b0852f08..6baf0ea42 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 0093dcd8d..c514c63b3 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 286c17e69..000000000 --- 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 75842e25b..bed411c36 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 5ed5c5d76..a1e24e835 100644 --- a/spec/models/setting_spec.rb +++ b/spec/models/setting_spec.rb @@ -13,175 +13,65 @@ RSpec.describe Setting do end describe '.[]' do + let(:key) { 'key' } + let(:cache_key) { 'cache-key' } + let(:cache_value) { 'cache-value' } + before do - allow(described_class).to receive(:rails_initialized?).and_return(rails_initialized) + allow(described_class).to receive(:cache_key).with(key).and_return(cache_key) end - let(:key) { 'key' } - - context 'when rails_initialized? is falsey' do - let(:rails_initialized) { false } - - it 'calls RailsSettings::Base#[]' do - expect(RailsSettings::Base).to receive(:[]).with(key) - described_class[key] - end - end - - context 'when rails_initialized? is truthy' do + context 'when Rails.cache does not exists' do before do - allow(RailsSettings::Base).to receive(:cache_key).with(key, nil).and_return(cache_key) + allow(described_class).to receive(:default_settings).and_return(default_settings) + + Fabricate(:setting, var: key, value: 42) if save_setting + + Rails.cache.delete(cache_key) end - let(:rails_initialized) { true } - let(:cache_key) { 'cache-key' } - let(:cache_value) { 'cache-value' } + let(:default_value) { 'default_value' } + let(:default_settings) { { key => default_value } } + let(:save_setting) { true } - it 'calls not RailsSettings::Base#[]' do - expect(RailsSettings::Base).to_not receive(:[]).with(key) - described_class[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) - Rails.cache.delete(cache_key) - end - - let(:object) { nil } - let(:default_value) { 'default_value' } - let(:default_settings) { { key => default_value } } - let(:records) { [Fabricate(:setting, var: key, value: nil)] } - - it 'calls RailsSettings::Settings.object' do - expect(RailsSettings::Settings).to receive(:object).with(key) - described_class[key] - end - - context 'when RailsSettings::Settings.object returns truthy' do - let(:object) { db_val } - let(:db_val) { instance_double(described_class, value: 'db_val') } - - context 'when default_value is a Hash' 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) - - described_class[key] - end - end - - context 'when default_value is not a Hash' do - let(:default_value) { 'default_value' } - - it 'returns db_val.value' do - expect(described_class[key]).to be db_val.value - end - end - end - - context 'when RailsSettings::Settings.object returns falsey' do - let(:object) { nil } - - it 'returns default_settings[key]' do - expect(described_class[key]).to be default_settings[key] - end - end - end - - context 'when Rails.cache exists' do - before do - Rails.cache.write(cache_key, cache_value) - end - - it 'does not query the database' do + context 'when the setting has been saved to database' do + it 'returns the value from database' do callback = double allow(callback).to receive(:call) + ActiveSupport::Notifications.subscribed callback, 'sql.active_record' do - described_class[key] + expect(described_class[key]).to eq 42 end - expect(callback).to_not have_received(:call) - end - it 'returns the cached value' do - expect(described_class[key]).to eq cache_value - end - end - end - end - - describe '.all_as_records' do - before do - allow_any_instance_of(Settings::ScopedSettings).to receive(:thing_scoped).and_return(records) - allow(described_class).to receive(:default_settings).and_return(default_settings) - end - - let(:key) { 'key' } - let(:default_value) { 'default_value' } - let(:default_settings) { { key => default_value } } - let(:original_setting) { Fabricate(:setting, var: key, value: nil) } - let(:records) { [original_setting] } - - it 'returns a Hash' do - expect(described_class.all_as_records).to be_a Hash - end - - context 'when records includes Setting with var as the key' do - let(:records) { [original_setting] } - - it 'includes the original Setting' do - setting = described_class.all_as_records[key] - expect(setting).to eq original_setting - end - end - - context 'when records includes nothing' do - let(:records) { [] } - - context 'when default_value is not a Hash' do - it 'includes Setting with value of default_value' do - setting = described_class.all_as_records[key] - - expect(setting).to be_a described_class - expect(setting).to have_attributes(var: key) - expect(setting).to have_attributes(value: 'default_value') + expect(callback).to have_received(:call) end end - context 'when default_value is a Hash' do - let(:default_value) { { 'foo' => 'fuga' } } + context 'when the setting has not been saved to database' do + let(:save_setting) { false } - it 'returns {}' do - expect(described_class.all_as_records).to eq({}) + it 'returns default_settings[key]' do + expect(described_class[key]).to be default_settings[key] end end end - end - describe '.default_settings' do - subject { described_class.default_settings } - - before do - allow(RailsSettings::Default).to receive(:enabled?).and_return(enabled) - end - - context 'when RailsSettings::Default.enabled? is false' do - let(:enabled) { false } - - it 'returns {}' do - expect(subject).to eq({}) + context 'when Rails.cache exists' do + before do + Rails.cache.write(cache_key, cache_value) end - end - context 'when RailsSettings::Settings.enabled? is true' do - let(:enabled) { true } + it 'does not query the database' do + callback = double + allow(callback).to receive(:call) + ActiveSupport::Notifications.subscribed callback, 'sql.active_record' do + described_class[key] + end + expect(callback).to_not have_received(:call) + end - it 'returns instance of RailsSettings::Default' do - expect(subject).to be_a RailsSettings::Default + it 'returns the cached value' do + expect(described_class[key]).to eq cache_value end end end diff --git a/spec/models/software_update_spec.rb b/spec/models/software_update_spec.rb new file mode 100644 index 000000000..0a494b0c4 --- /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 660b2e92a..da375009a 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 f38e7c237..4c6d886a5 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 9679c836a..000000000 --- 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 dbb3d4bb3..000000000 --- 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 5bd630aaa..000000000 --- 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 240147ecc..000000000 --- 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 4d6e5c380..69aaeed0a 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 @@ -92,6 +100,38 @@ RSpec.describe Tag do end end + describe '.recently_used' do + let(:account) { Fabricate(:account) } + let(:other_person_status) { Fabricate(:status) } + let(:out_of_range) { Fabricate(:status, account: account) } + let(:older_in_range) { Fabricate(:status, account: account) } + let(:newer_in_range) { Fabricate(:status, account: account) } + let(:unused_tag) { Fabricate(:tag) } + let(:used_tag_one) { Fabricate(:tag) } + let(:used_tag_two) { Fabricate(:tag) } + let(:used_tag_on_out_of_range) { Fabricate(:tag) } + + before do + stub_const 'Tag::RECENT_STATUS_LIMIT', 2 + + other_person_status.tags << used_tag_one + + out_of_range.tags << used_tag_on_out_of_range + + older_in_range.tags << used_tag_one + older_in_range.tags << used_tag_two + + newer_in_range.tags << used_tag_one + end + + it 'returns tags used by account within last X statuses ordered most used first' do + results = described_class.recently_used(account) + + expect(results) + .to eq([used_tag_one, used_tag_two]) + end + end + describe '.find_normalized' do it 'returns tag for a multibyte case-insensitive name' do upcase_string = 'abcABCabcABCやゆよ' diff --git a/spec/models/unavailable_domain_spec.rb b/spec/models/unavailable_domain_spec.rb deleted file mode 100644 index b868779f2..000000000 --- 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 ee0efbb43..000000000 --- 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 27b967414..d5234ebe8 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 bb61c02a6..213022e83 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? @@ -44,28 +38,66 @@ RSpec.describe User do user.save(validate: false) expect(user.valid?).to be true end + end - it 'cleans out empty string from languages' do - user = Fabricate.build(:user, chosen_languages: ['']) - user.valid? - expect(user.chosen_languages).to be_nil + describe 'Normalizations' do + describe 'locale' do + it 'preserves valid locale' do + user = Fabricate.build(:user, locale: 'en') + + expect(user.locale).to eq('en') + end + + it 'cleans out invalid locale' do + user = Fabricate.build(:user, locale: 'toto') + + expect(user.locale).to be_nil + end + end + + describe 'time_zone' do + it 'preserves valid timezone' do + user = Fabricate.build(:user, time_zone: 'UTC') + + expect(user.time_zone).to eq('UTC') + end + + it 'cleans out invalid timezone' do + user = Fabricate.build(:user, time_zone: 'toto') + + expect(user.time_zone).to be_nil + end + end + + describe 'languages' do + it 'preserves valid options for languages' do + user = Fabricate.build(:user, chosen_languages: ['en', 'fr', '']) + + expect(user.chosen_languages).to eq(['en', 'fr']) + end + + it 'cleans out empty string from languages' do + user = Fabricate.build(:user, chosen_languages: ['']) + + expect(user.chosen_languages).to be_nil + end end end - describe 'scopes' do + describe 'scopes', :sidekiq_inline 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 +134,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 +201,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 +217,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 +232,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 +251,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 +344,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 +448,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,15 +457,16 @@ 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 - describe '#confirm!' do + describe '#mark_email_as_confirmed!' do subject(:user) { Fabricate(:user, confirmed_at: confirmed_at) } before do ActionMailer::Base.deliveries.clear - user.confirm! + user.mark_email_as_confirmed! end after { ActionMailer::Base.deliveries.clear } @@ -470,7 +478,7 @@ RSpec.describe User do expect(user.confirmed_at).to be_present end - it 'delivers mails' do + it 'delivers mails', :sidekiq_inline do expect(ActionMailer::Base.deliveries.count).to eq 2 end end diff --git a/spec/models/web/setting_spec.rb b/spec/models/web/setting_spec.rb deleted file mode 100644 index 3182c6721..000000000 --- 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 4579ebb82..9631245e1 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 715dd7574..effaf92e9 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 90abdfea7..8c37acc39 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 9f4e94a6c..d7a21d8e3 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 9e81a4f5f..af9f7716b 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 488d48f52..28cb65d78 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 cf7e7d924..cb869c7d9 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 e254e2cf4..4c89f3f37 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 6e57b1372..7ecff4be4 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 3e047bbe9..a0d9a008b 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 50a312f44..cbe3735d8 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 0d479e0ca..29ba02c26 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 a657fce4b..b40a87888 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 8f2533fa6..4fc417807 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 576bfa4ab..4a9931490 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 000000000..e19ba6161 --- /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 7791cde15..35da3cc62 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 384119f25..7854547d2 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) } @@ -63,7 +64,7 @@ RSpec.describe UserPolicy do context 'when record.confirmed?' do it 'denies' do - john.user.confirm! + john.user.mark_email_as_confirmed! expect(subject).to_not permit(admin, john.user) end end diff --git a/spec/presenters/account_relationships_presenter_spec.rb b/spec/presenters/account_relationships_presenter_spec.rb index 5c2ba54e0..282cae4f0 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 c21ffd36e..853babb84 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 f20dce593..3e8a2c9f7 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 7746c8cd7..af6a93b82 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 2645f74e4..aaf587f49 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,17 @@ 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]) -Sidekiq::Testing.inline! +WebMock.disable_net_connect!(allow: Chewy.settings[:host], allow_localhost: RUN_SYSTEM_SPECS) Sidekiq.logger = nil +# System tests config +DatabaseCleaner.strategy = [:deletion] + Devise::Test::ControllerHelpers.module_eval do alias_method :original_sign_in, :sign_in @@ -35,37 +47,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 +87,48 @@ 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_inline) do |example| + Sidekiq::Testing.inline!(&example) + 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.before do |example| + allow(Resolv::DNS).to receive(:open).and_raise('Real DNS queries are disabled, stub Resolv::DNS as needed') unless example.metadata[:type] == :system 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 +136,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 +146,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 000000000..bf067cdc3 --- /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 000000000..b13e79b12 --- /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 000000000..fdc0a3a93 --- /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 000000000..bae7d448b --- /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/follower_accounts_spec.rb b/spec/requests/api/v1/accounts/follower_accounts_spec.rb new file mode 100644 index 000000000..7ff92d6a4 --- /dev/null +++ b/spec/requests/api/v1/accounts/follower_accounts_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'API V1 Accounts FollowerAccounts' 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) } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + + before do + alice.follow!(account) + bob.follow!(account) + end + + describe 'GET /api/v1/accounts/:acount_id/followers' do + it 'returns accounts following the given account', :aggregate_failures do + get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq 2 + expect([body_as_json[0][:id], body_as_json[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) + end + + it 'does not return blocked users', :aggregate_failures do + user.account.block!(bob) + get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq 1 + expect(body_as_json[0][:id]).to eq alice.id.to_s + end + + context 'when requesting user is blocked' do + before do + account.block!(user.account) + end + + it 'hides results' do + get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers + expect(body_as_json.size).to eq 0 + end + end + + context 'when requesting user is the account owner' do + let(:user) { account.user } + + it 'returns all accounts, including muted accounts' do + account.mute!(bob) + get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers + + expect(body_as_json.size).to eq 2 + expect([body_as_json[0][:id], body_as_json[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) + end + end + end +end diff --git a/spec/requests/api/v1/accounts/following_accounts_spec.rb b/spec/requests/api/v1/accounts/following_accounts_spec.rb new file mode 100644 index 000000000..b343a4865 --- /dev/null +++ b/spec/requests/api/v1/accounts/following_accounts_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'API V1 Accounts FollowingAccounts' 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) } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + + before do + account.follow!(alice) + account.follow!(bob) + end + + describe 'GET /api/v1/accounts/:account_id/following' do + it 'returns accounts followed by the given account', :aggregate_failures do + get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq 2 + expect([body_as_json[0][:id], body_as_json[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) + end + + it 'does not return blocked users', :aggregate_failures do + user.account.block!(bob) + get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq 1 + expect(body_as_json[0][:id]).to eq alice.id.to_s + end + + context 'when requesting user is blocked' do + before do + account.block!(user.account) + end + + it 'hides results' do + get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers + expect(body_as_json.size).to eq 0 + end + end + + context 'when requesting user is the account owner' do + let(:user) { account.user } + + it 'returns all accounts, including muted accounts' do + account.mute!(bob) + get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers + + expect(body_as_json.size).to eq 2 + expect([body_as_json[0][:id], body_as_json[1][:id]]).to contain_exactly(alice.id.to_s, bob.id.to_s) + 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 000000000..3727af7e8 --- /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 000000000..48c0337e5 --- /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 000000000..4c022c7c1 --- /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/requests/api/v1/accounts/notes_spec.rb b/spec/requests/api/v1/accounts/notes_spec.rb new file mode 100644 index 000000000..4f3ac68c7 --- /dev/null +++ b/spec/requests/api/v1/accounts/notes_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +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' } + + describe 'POST /api/v1/accounts/:account_id/note' do + subject do + post "/api/v1/accounts/#{account.id}/note", params: { comment: comment }, headers: headers + end + + context 'when account note has reasonable length', :aggregate_failures do + let(:comment) { 'foo' } + + 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', :aggregate_failures do + let(:comment) { 'a' * 2_001 } + + 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 + 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 000000000..c293715f7 --- /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 000000000..b06ce0509 --- /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 000000000..76b32e7b2 --- /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 ee6e925aa..000000000 --- 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/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb new file mode 100644 index 000000000..e543c4136 --- /dev/null +++ b/spec/requests/api/v1/accounts_spec.rb @@ -0,0 +1,354 @@ +# frozen_string_literal: true + +require 'rails_helper' + +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}" } } + + describe 'GET /api/v1/accounts/:id' do + context 'when logged out' do + let(:account) { Fabricate(:account) } + + 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 /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 'creates a user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json[:access_token]).to_not be_blank + + user = User.find_by(email: 'hello@world.tld') + expect(user).to_not be_nil + 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 /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 + subject do + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers + end + + context 'with unlocked account' do + let(:locked) { false } + + it 'creates a following relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + + json = body_as_json + + expect(json[:following]).to be true + expect(json[:requested]).to be false + + expect(user.account.following?(other_account)).to be true + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + context 'with locked account' do + let(:locked) { true } + + it 'creates a follow request relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + + json = body_as_json + + expect(json[:following]).to be false + expect(json[:requested]).to be true + + expect(user.account.requested?(other_account)).to be true + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + end + + context 'when modifying follow options' do + let(:locked) { false } + + before do + user.account.follow!(other_account, reblogs: false, notify: false) + end + + it 'changes reblogs option' do + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers, params: { reblogs: true } + + expect(body_as_json).to include({ + following: true, + showing_reblogs: true, + notifying: false, + }) + end + + it 'changes notify option' do + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers, params: { notify: true } + + expect(body_as_json).to include({ + following: true, + showing_reblogs: false, + notifying: true, + }) + end + + it 'changes languages option' do + post "/api/v1/accounts/#{other_account.id}/follow", headers: headers, params: { languages: %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 /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) + end + + it 'removes the following relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.following?(other_account)).to be false + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + 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) + end + + it 'removes the followed relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.followed_by?(other_account)).to be false + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + 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) + end + + it 'creates a blocking relation', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.following?(other_account)).to be false + expect(user.account.blocking?(other_account)).to be true + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + 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) + end + + it 'removes the blocking relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.blocking?(other_account)).to be false + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + 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) + end + + it 'mutes notifications', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.following?(other_account)).to be true + expect(user.account.muting?(other_account)).to be true + expect(user.account.muting_notifications?(other_account)).to be true + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + 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) + end + + it 'does not mute notifications', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.following?(other_account)).to be true + expect(user.account.muting?(other_account)).to be true + expect(user.account.muting_notifications?(other_account)).to be false + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + 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) + end + + it 'mutes notifications', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.following?(other_account)).to be true + expect(user.account.muting?(other_account)).to be true + expect(user.account.muting_notifications?(other_account)).to be true + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end + + 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) + end + + it 'removes the muting relation between user and target user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(user.account.muting?(other_account)).to be false + end + + it_behaves_like 'forbidden for wrong scope', 'read:accounts' + end +end diff --git a/spec/requests/api/v1/admin/account_actions_spec.rb b/spec/requests/api/v1/admin/account_actions_spec.rb index 9295d262d..4167911a1 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 000000000..1615581f0 --- /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 4382cb84e..3f33b50f3 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 000000000..87534a74b --- /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 96000e3ef..662a8f9a8 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 7a5ac28c5..1fb6fc822 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 d512def86..16656e020 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 d03886c51..98b954dd4 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,12 @@ 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 - subject + it 'returns the correct ip block', :aggregate_failures do + expect { subject } + .to change_severity_level + .and change_comment_value 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', @@ -202,12 +189,12 @@ RSpec.describe 'IP Blocks' do })) end - it 'updates the severity correctly' do - expect { subject }.to change { ip_block.reload.severity }.from('no_access').to('sign_up_requires_approval') + def change_severity_level + change { ip_block.reload.severity }.from('no_access').to('sign_up_requires_approval') end - it 'updates the comment correctly' do - expect { subject }.to change { ip_block.reload.comment }.from('Spam').to('Decreasing severity') + def change_comment_value + change { ip_block.reload.comment }.from('Spam').to('Decreasing severity') end context 'when ip block does not exist' do @@ -226,21 +213,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 000000000..15f2df84c --- /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 91c3c11f5..5403457db 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 000000000..9178335ba --- /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 000000000..031be17f5 --- /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 000000000..48842828b --- /dev/null +++ b/spec/requests/api/v1/admin/trends/links/links_spec.rb @@ -0,0 +1,130 @@ +# 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 + expect { subject } + .to change_link_trendable_to_true + + expect(response).to have_http_status(200) + expects_correct_link_data + end + + def change_link_trendable_to_true + change { preview_card.reload.trendable }.from(false).to(true) + end + + def expects_correct_link_data + 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 + expect { subject } + .to_not change_link_trendable + + expect(response).to have_http_status(200) + end + + def change_link_trendable + 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 dafe168c5..e1455fe79 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 88f9eee36..acabbc93f 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 000000000..62543157c --- /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 000000000..18f4fddc2 --- /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 000000000..2db52ac72 --- /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 000000000..2f0dc7294 --- /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/requests/api/v1/directories_spec.rb b/spec/requests/api/v1/directories_spec.rb new file mode 100644 index 000000000..0a1864d13 --- /dev/null +++ b/spec/requests/api/v1/directories_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Directories API' do + let(:user) { Fabricate(:user, confirmed_at: nil) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:follows' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/directories' do + context 'with no params' do + before do + 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, + domain: nil, + 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( + :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, + domain: 'host.example', + 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( + :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 + + 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 + + let(:eligible_remote_account) do + Fabricate( + :account, + domain: 'host.example', + discoverable: true, + username: 'eligible_remote' + ) + end + + 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.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 + 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') } + + 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) + expect(body_as_json.first[:id]).to include(local_account.id.to_s) + expect(response.body).to_not include(remote_account.id.to_s) + end + end + + context 'when ordered by active' do + it 'returns accounts in order of most recent status activity' do + old_stat = Fabricate(:account_stat, last_status_at: 1.day.ago) + new_stat = Fabricate(:account_stat, last_status_at: 1.minute.ago) + + 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(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_stat).account + travel_to 10.seconds.from_now + account_new = Fabricate(:account_stat).account + + get '/api/v1/directory', headers: headers, params: { order: 'new' } + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(2) + expect(body_as_json.first[:id]).to include(account_new.id.to_s) + expect(body_as_json.second[:id]).to include(account_old.id.to_s) + end + end + end +end diff --git a/spec/requests/api/v1/domain_blocks_spec.rb b/spec/requests/api/v1/domain_blocks_spec.rb index 0f4fd4e90..954497ebe 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/requests/api/v1/emails/confirmations_spec.rb b/spec/requests/api/v1/emails/confirmations_spec.rb new file mode 100644 index 000000000..8f5171ee7 --- /dev/null +++ b/spec/requests/api/v1/emails/confirmations_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Confirmations' do + let(:confirmed_at) { nil } + let(:user) { Fabricate(:user, confirmed_at: confirmed_at) } + 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' + + context 'with an oauth token' do + context 'when user was created by a different application' do + let(:user) { Fabricate(:user, confirmed_at: confirmed_at, created_by_application: Fabricate(:application)) } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + end + + context 'when user was created by the same application' do + before do + user.update(created_by_application: token.application) + end + + context 'when the account is already confirmed' do + let(:confirmed_at) { Time.now.utc } + + it 'returns http forbidden' do + subject + + expect(response).to have_http_status(403) + end + + 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 + subject + + expect(response).to have_http_status(200) + end + end + end + + context 'when the account is unconfirmed' do + it 'returns http success' do + 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 + subject + + expect(response).to have_http_status(401) + end + end + end + + 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 + context 'when the account is not confirmed' do + it 'returns the confirmation status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to be false + end + end + + context 'when the account is confirmed' do + let(:confirmed_at) { Time.now.utc } + + it 'returns the confirmation status successfully', :aggregate_failures do + subject + + 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 the confirmation status successfully', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to be false + end + end + + context 'when the account is confirmed' do + let(:confirmed_at) { Time.now.utc } + + it 'returns the confirmation status successfully', :aggregate_failures do + subject + + 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 + subject + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v1/endorsements_spec.rb b/spec/requests/api/v1/endorsements_spec.rb new file mode 100644 index 000000000..e267f2abd --- /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 000000000..2d8a42e71 --- /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 000000000..f7b453b74 --- /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 6c171f6e4..f499dd1d0 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 @@ -147,7 +147,7 @@ RSpec.describe 'FeaturedTags' do expect(body).to be_empty end - it 'deletes the featured tag' do + it 'deletes the featured tag', :sidekiq_inline do delete "/api/v1/featured_tags/#{id}", headers: headers featured_tag = FeaturedTag.find_by(id: id) diff --git a/spec/requests/api/v1/follow_requests_spec.rb b/spec/requests/api/v1/follow_requests_spec.rb index 9d4ef8cd5..a8898ccb3 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 000000000..52ed1ba4b --- /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 000000000..600584ecc --- /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 000000000..4f2bc91ad --- /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 000000000..397ecff08 --- /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 000000000..64982de68 --- /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 000000000..8ab8bf99c --- /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 000000000..1a7975f8b --- /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 000000000..24de98d88 --- /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 000000000..65b8d78c7 --- /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 000000000..0b7dd8314 --- /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 000000000..de4998235 --- /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 383e09d0c..4635e936f 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/markers_spec.rb b/spec/requests/api/v1/markers_spec.rb new file mode 100644 index 000000000..a1ca4ba75 --- /dev/null +++ b/spec/requests/api/v1/markers_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API Markers' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:statuses write:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v1/markers' do + before do + Fabricate(:marker, timeline: 'home', last_read_id: 123, user: user) + Fabricate(:marker, timeline: 'notifications', last_read_id: 456, user: user) + + get '/api/v1/markers', headers: headers, params: { timeline: %w(home notifications) } + end + + it 'returns markers', :aggregate_failures do + json = body_as_json + + expect(response).to have_http_status(200) + expect(json.key?(:home)).to be true + expect(json[:home][:last_read_id]).to eq '123' + expect(json.key?(:notifications)).to be true + expect(json[:notifications][:last_read_id]).to eq '456' + end + end + + describe 'POST /api/v1/markers' do + context 'when no marker exists' do + before do + post '/api/v1/markers', headers: headers, params: { home: { last_read_id: '69420' } } + end + + it 'creates a marker', :aggregate_failures do + expect(response).to have_http_status(200) + expect(user.markers.first.timeline).to eq 'home' + expect(user.markers.first.last_read_id).to eq 69_420 + end + end + + context 'when a marker exists' do + before do + post '/api/v1/markers', headers: headers, params: { home: { last_read_id: '69420' } } + post '/api/v1/markers', headers: headers, params: { home: { last_read_id: '70120' } } + end + + it 'updates a marker', :aggregate_failures do + expect(response).to have_http_status(200) + expect(user.markers.first.timeline).to eq 'home' + expect(user.markers.first.last_read_id).to eq 70_120 + end + end + end +end diff --git a/spec/requests/api/v1/media_spec.rb b/spec/requests/api/v1/media_spec.rb new file mode 100644 index 000000000..2c2932808 --- /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 000000000..b2782a0c2 --- /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 000000000..222ff67fc --- /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', :sidekiq_inline 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/peers/search_spec.rb b/spec/requests/api/v1/peers/search_spec.rb new file mode 100644 index 000000000..dcdea387a --- /dev/null +++ b/spec/requests/api/v1/peers/search_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'API Peers Search' do + describe 'GET /api/v1/peers/search' do + context 'when peers api is disabled' do + before do + Setting.peers_api_enabled = false + end + + it 'returns http not found response' do + get '/api/v1/peers/search' + + expect(response) + .to have_http_status(404) + end + end + + context 'with no search param' do + it 'returns http success and empty response' do + get '/api/v1/peers/search' + + expect(response) + .to have_http_status(200) + expect(body_as_json) + .to be_blank + end + end + + context 'with invalid search param' do + it 'returns http success and empty response' do + get '/api/v1/peers/search', params: { q: 'ftp://Invalid-Host!!.valüe' } + + expect(response) + .to have_http_status(200) + expect(body_as_json) + .to be_blank + end + end + + context 'with search param' do + let!(:account) { Fabricate(:account, domain: 'host.example') } + + before { Instance.refresh } + + it 'returns http success and json with known domains' do + get '/api/v1/peers/search', params: { q: 'host.example' } + + expect(response) + .to have_http_status(200) + expect(body_as_json.size) + .to eq(1) + expect(body_as_json.first) + .to eq(account.domain) + end + 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 000000000..1c8a818d5 --- /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 000000000..6f4188c35 --- /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 000000000..26a9b848e --- /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 000000000..ba3d2b306 --- /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 000000000..49ccde275 --- /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 000000000..d3007740a --- /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/favourited_by_accounts_spec.rb b/spec/requests/api/v1/statuses/favourited_by_accounts_spec.rb new file mode 100644 index 000000000..44296f4c3 --- /dev/null +++ b/spec/requests/api/v1/statuses/favourited_by_accounts_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API V1 Statuses Favourited by Accounts' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:accounts' } + # let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + + context 'with an oauth token' do + subject do + get "/api/v1/statuses/#{status.id}/favourited_by", headers: headers, params: { limit: 2 } + end + + describe 'GET /api/v1/statuses/:status_id/favourited_by' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Favourite.create!(account: alice, status: status) + Favourite.create!(account: bob, status: status) + end + + it 'returns http success and accounts who favourited the status' do + subject + + expect(response) + .to have_http_status(200) + expect(response.headers['Link'].links.size) + .to eq(2) + + expect(body_as_json.size) + .to eq(2) + expect(body_as_json) + .to contain_exactly( + include(id: alice.id.to_s), + include(id: bob.id.to_s) + ) + end + + it 'does not return blocked users' do + user.account.block!(bob) + + subject + + expect(body_as_json.size) + .to eq 1 + expect(body_as_json.first[:id]).to eq(alice.id.to_s) + end + end + end + + context 'without an oauth token' do + subject do + get "/api/v1/statuses/#{status.id}/favourited_by", params: { limit: 2 } + end + + context 'with a private status' do + let(:status) { Fabricate(:status, account: user.account, visibility: :private) } + + describe 'GET #index' do + before do + Fabricate(:favourite, status: status) + end + + it 'returns http unauthorized' do + subject + + 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) } + + describe 'GET #index' do + before do + Fabricate(:favourite, status: status) + end + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + 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 000000000..033aed7e2 --- /dev/null +++ b/spec/requests/api/v1/statuses/favourites_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Favourites', :sidekiq_inline 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' 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 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 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 000000000..db07fa424 --- /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/reblogged_by_accounts_spec.rb b/spec/requests/api/v1/statuses/reblogged_by_accounts_spec.rb new file mode 100644 index 000000000..6f99ce946 --- /dev/null +++ b/spec/requests/api/v1/statuses/reblogged_by_accounts_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API V1 Statuses Reblogged by Accounts' do + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:accounts' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + + context 'with an oauth token' do + subject do + get "/api/v1/statuses/#{status.id}/reblogged_by", headers: headers, params: { limit: 2 } + end + + describe 'GET /api/v1/statuses/:status_id/reblogged_by' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Fabricate(:status, account: alice, reblog_of_id: status.id) + Fabricate(:status, account: bob, reblog_of_id: status.id) + end + + it 'returns accounts who reblogged the status', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + expect(response.headers['Link'].links.size) + .to eq(2) + + expect(body_as_json.size) + .to eq(2) + expect(body_as_json) + .to contain_exactly( + include(id: alice.id.to_s), + include(id: bob.id.to_s) + ) + end + + it 'does not return blocked users' do + user.account.block!(bob) + + subject + + expect(body_as_json.size) + .to eq 1 + expect(body_as_json.first[:id]).to eq(alice.id.to_s) + end + end + end + + context 'without an oauth token' do + subject do + get "/api/v1/statuses/#{status.id}/reblogged_by", params: { limit: 2 } + end + + context 'with a private status' do + let(:status) { Fabricate(:status, account: user.account, visibility: :private) } + + describe 'GET #index' do + before do + Fabricate(:status, reblog_of_id: status.id) + end + + it 'returns http unauthorized' do + subject + + 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) } + + describe 'GET #index' do + before do + Fabricate(:status, reblog_of_id: status.id) + end + + it 'returns http success' do + subject + + expect(response).to have_http_status(200) + end + 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 000000000..c79ec8964 --- /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/requests/api/v1/statuses_spec.rb b/spec/requests/api/v1/statuses_spec.rb new file mode 100644 index 000000000..201674fcc --- /dev/null +++ b/spec/requests/api/v1/statuses_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe '/api/v1/statuses' do + context 'with an oauth token' do + 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 + + 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 + subject + + expect(response).to have_http_status(200) + end + + context 'when post includes filtered terms' do + let(:status) { Fabricate(:status, text: 'this toot is about that banned word') } + + before do + user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }]) + end + + it 'returns filter information', :aggregate_failures do + subject + + 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', + filter_action: 'hide', + }), + keyword_matches: ['banned'], + }) + end + end + + context 'when post is explicitly filtered' do + let(:status) { Fabricate(:status, text: 'hello world') } + + before do + filter = user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide) + filter.statuses.create!(status_id: status.id) + end + + it 'returns filter information', :aggregate_failures do + subject + + 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', + filter_action: 'hide', + }), + status_matches: [status.id.to_s], + }) + end + end + + context 'when reblog includes filtered terms' do + let(:status) { Fabricate(:status, reblog: Fabricate(:status, text: 'this toot is about that banned word')) } + + before do + user.account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }]) + end + + it 'returns filter information', :aggregate_failures do + subject + + 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', + filter_action: 'hide', + }), + keyword_matches: ['banned'], + }) + end + end + end + + describe 'GET /api/v1/statuses/:id/context' do + let(:scopes) { 'read:statuses' } + let(:status) { Fabricate(:status, account: user.account) } + + before do + Fabricate(:status, account: user.account, thread: status) + end + + it 'returns http success' do + get "/api/v1/statuses/#{status.id}/context", headers: headers + + expect(response).to have_http_status(200) + end + end + + 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 + it 'returns rate limit headers', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + 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 + end + + context 'with a safeguard' do + let!(:alice) { Fabricate(:account, username: 'alice') } + let!(:bob) { Fabricate(:account, username: 'bob') } + + 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 + + expect(response).to have_http_status(422) + 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 + let(:params) { {} } + + it 'returns rate limit headers', :aggregate_failures do + subject + + expect(response).to have_http_status(422) + expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s + end + end + + context 'when exceeding rate limit' do + before do + rate_limiter = RateLimiter.new(user.account, family: :statuses) + RateLimiter::FAMILIES[:statuses][:limit].times { rate_limiter.record! } + end + + it 'returns rate limit headers', :aggregate_failures do + subject + + expect(response).to have_http_status(429) + 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 /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) } + + it_behaves_like 'forbidden for wrong scope', 'read read:statuses' + + it 'removes the status', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(Status.find_by(id: status.id)).to be_nil + end + end + + 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) } + + it_behaves_like 'forbidden for wrong scope', 'read read:statuses' + + it 'updates the status', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(status.reload.text).to eq 'I am updated' + end + end + end + + context 'without an oauth token' do + context 'with a private status' do + let(:status) { Fabricate(:status, visibility: :private) } + + describe 'GET /api/v1/statuses/:id' do + it 'returns http unauthorized' do + get "/api/v1/statuses/#{status.id}" + + expect(response).to have_http_status(404) + end + end + + describe 'GET /api/v1/statuses/:id/context' do + before do + Fabricate(:status, thread: status) + end + + it 'returns http unauthorized' do + 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, visibility: :public) } + + describe 'GET /api/v1/statuses/:id' do + it 'returns http success' do + get "/api/v1/statuses/#{status.id}" + + expect(response).to have_http_status(200) + end + end + + describe 'GET /api/v1/statuses/:id/context' do + before do + Fabricate(:status, thread: status) + end + + it 'returns http success' do + get "/api/v1/statuses/#{status.id}/context" + + expect(response).to have_http_status(200) + end + end + end + end +end diff --git a/spec/requests/api/v1/suggestions_spec.rb b/spec/requests/api/v1/suggestions_spec.rb index 42b7f8662..dc89613fc 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 300ddf805..db74a6f03 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 000000000..e57e9643b --- /dev/null +++ b/spec/requests/api/v1/timelines/home_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Home', :sidekiq_inline 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 000000000..4afa40e58 --- /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 000000000..a8f20213e --- /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 000000000..fd0483abb --- /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 000000000..bbe8299ac --- /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 new file mode 100644 index 000000000..990fa5d0b --- /dev/null +++ b/spec/requests/api/v2/media_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +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 + 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 000000000..13bcf1798 --- /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 000000000..5f1c97b8a --- /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 000000000..6314f43aa --- /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 902f21db4..dbba22874 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 000000000..d4cc40bce --- /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://cb6e6126.ngrok.io:4000", + "script-src 'self' https://cb6e6126.ngrok.io 'wasm-unsafe-eval'" + ) + end +end diff --git a/spec/requests/custom_css_spec.rb b/spec/requests/custom_css_spec.rb new file mode 100644 index 000000000..5271ed4a5 --- /dev/null +++ b/spec/requests/custom_css_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Custom CSS' do + include RoutingHelper + + describe 'GET /custom.css' do + context 'without any CSS or User Roles' do + it 'returns empty stylesheet' do + get '/custom.css' + + expect(response.content_type).to include('text/css') + expect(response.body.presence).to be_nil + end + end + + context 'with CSS settings' do + before do + Setting.custom_css = expected_css + end + + it 'returns stylesheet from settings' do + get '/custom.css' + + expect(response.content_type).to include('text/css') + expect(response.body.strip).to eq(expected_css) + end + + def expected_css + <<~CSS.strip + body { background-color: red; } + CSS + end + end + + context 'with highlighted colored UserRole records' do + before do + _highlighted_colored = Fabricate :user_role, highlighted: true, color: '#336699', id: '123_123_123' + _highlighted_no_color = Fabricate :user_role, highlighted: true, color: '' + _no_highlight_with_color = Fabricate :user_role, highlighted: false, color: '' + end + + it 'returns stylesheet from settings' do + get '/custom.css' + + expect(response.content_type).to include('text/css') + expect(response.body.strip).to eq(expected_css) + end + + def expected_css + <<~CSS.strip + .user-role-123123123 { + --user-role-accent: #336699; + } + CSS + end + end + 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 ec26ecba7..000000000 --- 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 000000000..c44ef2419 --- /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 000000000..cc6557cab --- /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 000000000..0d37c4114 --- /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 000000000..e6364fe8c --- /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 000000000..401828c4a --- /dev/null +++ b/spec/requests/signature_verification_spec.rb @@ -0,0 +1,398 @@ +# 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 valid signature on a GET request that has a query string' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # 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?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success?foo=42', 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 'when the query string is missing from the signature verification (compatibility quirk)' 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?foo=42', 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 mismatching query string' do + let(:signature_header) do + 'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # 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?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' }) + + get '/activitypub/success?foo=43', headers: { + 'Host' => 'www.example.com', + 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', + 'Signature' => signature_header, + } + + expect(body_as_json).to match( + signed_request: true, + signature_actor_id: nil, + error: anything + ) + 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 68a1478be..000000000 --- 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 000000000..04134b71f --- /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 000000000..ca10a51a0 --- /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 000000000..0934b0fde --- /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 000000000..779f1bba5 --- /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 000000000..d8d7f355d --- /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 000000000..915bc094c --- /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, :sidekiq_inline 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 2a3be8212..23f0b24c4 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 4b2b8ec87..338d66b30 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 6fe1f0618..89efe95c8 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 43cf7192e..3d61e8675 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 14c24c70c..8ff4fd270 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 c329542d7..b7c0b8928 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 e399e88f3..15939e484 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 e0e70a3b8..01db1149a 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 15a5de18d..d8f2536d2 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 cf9416f03..7f7fb850c 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 c15e197cb..28177a3db 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 983780ae9..ef67d7067 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 b3c086208..60420d802 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 1cd036f48..4f89cd220 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 f7a88a917..0ac113f10 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 5975c81a1..b9e95b825 100644 --- a/spec/services/activitypub/fetch_featured_collection_service_spec.rb +++ b/spec/services/activitypub/fetch_featured_collection_service_spec.rb @@ -31,7 +31,7 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do } end - let(:status_json_pinned_unknown_unreachable) do + let(:status_json_pinned_unknown_reachable) do { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Note', @@ -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 @@ -65,7 +75,8 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do stub_request(:get, 'https://example.com/account/pinned/known').to_return(status: 200, body: Oj.dump(status_json_pinned_known)) 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/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable)) + 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 @@ -103,6 +115,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do end it_behaves_like 'sets pinned posts' + + context 'when there is a single item, with the array compacted away' do + let(:items) { 'https://example.com/account/pinned/unknown-reachable' } + + before do + stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable)) + subject.call(actor, note: true, hashtag: false) + end + + it 'sets expected posts as pinned posts' do + expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly( + 'https://example.com/account/pinned/unknown-reachable' + ) + end + end end context 'when the endpoint is a paginated Collection' do @@ -124,6 +151,21 @@ RSpec.describe ActivityPub::FetchFeaturedCollectionService, type: :service do end it_behaves_like 'sets pinned posts' + + context 'when there is a single item, with the array compacted away' do + let(:items) { 'https://example.com/account/pinned/unknown-reachable' } + + before do + stub_request(:get, 'https://example.com/account/pinned/unknown-reachable').to_return(status: 200, body: Oj.dump(status_json_pinned_unknown_reachable)) + subject.call(actor, note: true, hashtag: false) + end + + it 'sets expected posts as pinned posts' do + expect(actor.pinned_statuses.pluck(:uri)).to contain_exactly( + 'https://example.com/account/pinned/unknown-reachable' + ) + end + end end end end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb index ac7484d96..f33a928da 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService, type: :service do end describe '#call' do - let(:account) { subject.call('https://example.com/alice', id: true) } + let(:account) { subject.call('https://example.com/alice') } shared_examples 'sets profile data' do it 'returns an account' do diff --git a/spec/services/activitypub/fetch_remote_actor_service_spec.rb b/spec/services/activitypub/fetch_remote_actor_service_spec.rb index 93d31b69d..944a2f8b1 100644 --- a/spec/services/activitypub/fetch_remote_actor_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_actor_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService, type: :service do end describe '#call' do - let(:account) { subject.call('https://example.com/alice', id: true) } + let(:account) { subject.call('https://example.com/alice') } shared_examples 'sets profile data' do it 'returns an account' do diff --git a/spec/services/activitypub/fetch_remote_key_service_spec.rb b/spec/services/activitypub/fetch_remote_key_service_spec.rb index cd8f29ddd..0b14da4f4 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' } @@ -45,7 +55,7 @@ RSpec.describe ActivityPub::FetchRemoteKeyService, type: :service do end describe '#call' do - let(:account) { subject.call(public_key_id, id: false) } + let(:account) { subject.call(public_key_id) } context 'when the key is a sub-object from the actor' do before do diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb index 826b67d88..0fb32d20b 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 } @@ -226,7 +225,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do end end - context 'with statuses referencing other statuses' do + context 'with statuses referencing other statuses', :sidekiq_inline do before do stub_const 'ActivityPub::FetchRemoteStatusService::DISCOVERIES_PER_REQUEST', 5 end diff --git a/spec/services/activitypub/fetch_replies_service_spec.rb b/spec/services/activitypub/fetch_replies_service_spec.rb index bf8e29676..a76b996c2 100644 --- a/spec/services/activitypub/fetch_replies_service_spec.rb +++ b/spec/services/activitypub/fetch_replies_service_spec.rb @@ -34,10 +34,25 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do describe '#call' do context 'when the payload is a Collection with inlined replies' do + context 'when there is a single reply, with the array compacted away' do + let(:items) { 'http://example.com/self-reply-1' } + + it 'queues the expected worker' do + allow(FetchReplyWorker).to receive(:push_bulk) + + subject.call(status, payload) + + expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1']) + end + end + 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 +62,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 +83,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 +97,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 +122,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 +136,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 db454d7ad..9abe03181 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', :sidekiq_inline 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 1433d0c50..63502c546 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,9 +265,7 @@ 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 + expect(Status.exists?(uri: 'https://example.com/users/bob/fake-status')).to be false end 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 9d90e5eb8..53cbaf4cc 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 9bfaa3580..05af12599 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 253230496..b37b6da1f 100644 --- a/spec/services/app_sign_up_service_spec.rb +++ b/spec/services/app_sign_up_service_spec.rb @@ -10,46 +10,121 @@ 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 the email address requires approval' do + before do + Setting.registrations_mode = 'open' + Fabricate(:email_domain_block, allow_with_approval: true, domain: 'email.com') + end + + it 'creates an unapproved user', :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.approved?).to be false + + expect(user.account).to_not be_nil + expect(user.invite_request).to be_nil + end + end + + context 'when the email address requires approval through MX records' do + before do + Setting.registrations_mode = 'open' + Fabricate(:email_domain_block, allow_with_approval: true, domain: 'smtp.email.com') + allow(User).to receive(:skip_mx_check?).and_return(false) + + resolver = instance_double(Resolv::DNS, :timeouts= => nil) + + allow(resolver).to receive(:getresources) + .with('email.com', Resolv::DNS::Resource::IN::MX) + .and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'smtp.email.com')]) + allow(resolver).to receive(:getresources).with('email.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('email.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) + allow(resolver).to receive(:getresources).with('smtp.email.com', Resolv::DNS::Resource::IN::A).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: '2.3.4.5')]) + allow(resolver).to receive(:getresources).with('smtp.email.com', Resolv::DNS::Resource::IN::AAAA).and_return([instance_double(Resolv::DNS::Resource::IN::AAAA, address: 'fd00::2')]) + allow(Resolv::DNS).to receive(:open).and_yield(resolver) + end + + it 'creates an unapproved user', :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.approved?).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/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb index d07645ab6..602250ee9 100644 --- a/spec/services/authorize_follow_service_spec.rb +++ b/spec/services/authorize_follow_service_spec.rb @@ -41,7 +41,7 @@ RSpec.describe AuthorizeFollowService, type: :service do expect(bob.following?(sender)).to be true end - it 'sends an accept activity' do + it 'sends an accept activity', :sidekiq_inline do expect(a_request(:post, bob.inbox_url)).to have_been_made.once end end diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 1363c81d0..1c59d5ed0 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe BatchedRemoveStatusService, type: :service do +RSpec.describe BatchedRemoveStatusService, :sidekiq_inline, type: :service do subject { described_class.new } let!(:alice) { Fabricate(:account) } @@ -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 36dce9d19..7ad00fff6 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,22 +53,22 @@ 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 + it 'leaves the domains status and attachments, but clears media', :sidekiq_inline do expect { bad_status_plain.reload }.to_not raise_error expect { bad_status_with_attachment.reload }.to_not raise_error expect { bad_attachment.reload }.to_not raise_error diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb index 5f7c2e8da..9e4ff8e59 100644 --- a/spec/services/block_service_spec.rb +++ b/spec/services/block_service_spec.rb @@ -31,7 +31,7 @@ RSpec.describe BlockService, type: :service do expect(sender.blocking?(bob)).to be true end - it 'sends a block activity' do + it 'sends a block activity', :sidekiq_inline do expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once end end diff --git a/spec/services/bulk_import_row_service_spec.rb b/spec/services/bulk_import_row_service_spec.rb index 5e09845b5..a77acc073 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 281b642ea..c3a716058 100644 --- a/spec/services/bulk_import_service_spec.rb +++ b/spec/services/bulk_import_service_spec.rb @@ -13,13 +13,6 @@ RSpec.describe BulkImportService do end describe '#call' do - around do |example| - Sidekiq::Testing.fake! do - example.run - Sidekiq::Worker.clear_all - end - end - 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 61e5c3c9b..1965b7daa 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 + describe '#call on local account', :sidekiq_inline 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 + describe '#call on remote account', :sidekiq_inline 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 3b554f9ea..77237dffb 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) @@ -19,6 +20,8 @@ RSpec.describe FanOutOnWriteService, type: :service do ProcessMentionsService.new.call(status) ProcessHashtagsService.new.call(status) + Fabricate(:media_attachment, status: status, account: alice) + allow(redis).to receive(:publish) subject.call(status) @@ -35,7 +38,7 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(home_feed_of(alice)).to include status.id end - it 'is added to the home feed of a follower' do + it 'is added to the home feed of a follower', :sidekiq_inline do expect(home_feed_of(bob)).to include status.id expect(home_feed_of(tom)).to include status.id end @@ -48,6 +51,7 @@ RSpec.describe FanOutOnWriteService, type: :service do it 'is broadcast to the public stream' do expect(redis).to have_received(:publish).with('timeline:public', anything) expect(redis).to have_received(:publish).with('timeline:public:local', anything) + expect(redis).to have_received(:publish).with('timeline:public:media', anything) end end @@ -58,7 +62,7 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(home_feed_of(alice)).to include status.id end - it 'is added to the home feed of the mentioned follower' do + it 'is added to the home feed of the mentioned follower', :sidekiq_inline do expect(home_feed_of(bob)).to include status.id end @@ -79,7 +83,7 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(home_feed_of(alice)).to include status.id end - it 'is added to the home feed of a follower' do + it 'is added to the home feed of a follower', :sidekiq_inline do expect(home_feed_of(bob)).to include status.id expect(home_feed_of(tom)).to include status.id end @@ -97,7 +101,7 @@ RSpec.describe FanOutOnWriteService, type: :service do expect(home_feed_of(alice)).to include status.id end - it 'is added to the home feed of the mentioned follower' do + it 'is added to the home feed of the mentioned follower', :sidekiq_inline do expect(home_feed_of(bob)).to include status.id end @@ -109,5 +113,22 @@ 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' 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') + 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/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb index 782c235c4..3143e7b66 100644 --- a/spec/services/favourite_service_spec.rb +++ b/spec/services/favourite_service_spec.rb @@ -33,7 +33,7 @@ RSpec.describe FavouriteService, type: :service do expect(status.favourites.first).to_not be_nil end - it 'sends a like activity' do + it 'sends a like activity', :sidekiq_inline do expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once end end diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index 133d66441..d8ca310b2 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/fetch_resource_service_spec.rb b/spec/services/fetch_resource_service_spec.rb index 0f1068471..78037a06c 100644 --- a/spec/services/fetch_resource_service_spec.rb +++ b/spec/services/fetch_resource_service_spec.rb @@ -57,7 +57,7 @@ RSpec.describe FetchResourceService, type: :service do let(:json) do { - id: 1, + id: 'http://example.com/foo', '@context': ActivityPub::TagManager::CONTEXT, type: 'Note', }.to_json @@ -83,27 +83,27 @@ RSpec.describe FetchResourceService, type: :service do let(:content_type) { 'application/activity+json; charset=utf-8' } let(:body) { json } - it { is_expected.to eq [1, { prefetched_body: body, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] } end context 'when content type is ld+json with profile' do let(:content_type) { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' } let(:body) { json } - it { is_expected.to eq [1, { prefetched_body: body, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: body }] } end context 'when link header is present' do let(:headers) { { 'Link' => '; rel="alternate"; type="application/activity+json"' } } - it { is_expected.to eq [1, { prefetched_body: json, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] } end context 'when content type is text/html' do let(:content_type) { 'text/html' } let(:body) { '' } - it { is_expected.to eq [1, { prefetched_body: json, id: true }] } + it { is_expected.to eq ['http://example.com/foo', { prefetched_body: json }] } end end end diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index c2ad0d717..cf4de34c8 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -150,7 +150,7 @@ RSpec.describe FollowService, type: :service do expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil end - it 'sends a follow activity to the inbox' do + it 'sends a follow activity to the inbox', :sidekiq_inline do expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once end end diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb index 1904ac8dc..7d005c8a1 100644 --- a/spec/services/import_service_spec.rb +++ b/spec/services/import_service_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe ImportService, type: :service do +RSpec.describe ImportService, :sidekiq_inline, type: :service do include RoutingHelper let!(:account) { Fabricate(:account, locked: false) } @@ -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/mute_service_spec.rb b/spec/services/mute_service_spec.rb index 50f74ff27..a2ca2ffe1 100644 --- a/spec/services/mute_service_spec.rb +++ b/spec/services/mute_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe MuteService, type: :service do redis.del(home_timeline_key) end - it "clears account's statuses" do + it "clears account's statuses", :sidekiq_inline do FeedManager.instance.push_to_home(account, status) FeedManager.instance.push_to_home(account, other_account_status) diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 8fcb58658..38a33c522 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 @@ -163,7 +166,7 @@ RSpec.describe NotifyService, type: :service do context 'when email notification is enabled' do let(:enabled) { true } - it 'sends email' do + it 'sends email', :sidekiq_inline do expect { subject }.to change(ActionMailer::Base.deliveries, :count).by(1) end end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index d201292e1..1e5c420a6 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 54e0d94ee..663babae8 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/purge_domain_service_spec.rb b/spec/services/purge_domain_service_spec.rb index e96618310..6d8af14de 100644 --- a/spec/services/purge_domain_service_spec.rb +++ b/spec/services/purge_domain_service_spec.rb @@ -5,25 +5,25 @@ require 'rails_helper' RSpec.describe PurgeDomainService, type: :service do subject { described_class.new } - let!(:old_account) { Fabricate(:account, domain: 'obsolete.org') } - let!(:old_status_plain) { Fabricate(:status, account: old_account) } - let!(:old_status_with_attachment) { Fabricate(:status, account: old_account) } - let!(:old_attachment) { Fabricate(:media_attachment, account: old_account, status: old_status_with_attachment, file: attachment_fixture('attachment.jpg')) } + let(:domain) { 'obsolete.org' } + let!(:account) { Fabricate(:account, domain: domain) } + let!(:status_plain) { Fabricate(:status, account: account) } + let!(:status_with_attachment) { Fabricate(:status, account: account) } + let!(:attachment) { Fabricate(:media_attachment, account: account, status: status_with_attachment, file: attachment_fixture('attachment.jpg')) } describe 'for a suspension' do - before do - subject.call('obsolete.org') + it 'refreshes instance view and removes associated records' do + expect { subject.call(domain) } + .to change { domain_instance_exists }.from(true).to(false) + + expect { account.reload }.to raise_exception ActiveRecord::RecordNotFound + expect { status_plain.reload }.to raise_exception ActiveRecord::RecordNotFound + expect { status_with_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound + expect { attachment.reload }.to raise_exception ActiveRecord::RecordNotFound end - it 'removes the remote accounts\'s statuses and media attachments' do - expect { old_account.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { old_status_plain.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { old_status_with_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { old_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound - end - - it 'refreshes instances view' do - expect(Instance.where(domain: 'obsolete.org').exists?).to be false + def domain_instance_exists + Instance.exists?(domain: domain) end end end diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb index 7b85e37ed..e5d0a2d6c 100644 --- a/spec/services/reblog_service_spec.rb +++ b/spec/services/reblog_service_spec.rb @@ -46,7 +46,7 @@ RSpec.describe ReblogService, type: :service do Status .where(id: reblog_of_id) .where(text: 'discard-status-text') - .update_all(deleted_at: Time.now.utc) # rubocop:disable Rails/SkipsModelValidations + .update_all(deleted_at: Time.now.utc) end end end @@ -86,9 +86,5 @@ RSpec.describe ReblogService, type: :service do it 'distributes to followers' do expect(ActivityPub::DistributionWorker).to have_received(:perform_async) end - - it 'sends an announce activity to the author' do - expect(a_request(:post, bob.inbox_url)).to have_been_made.once - end end end diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb index d28104b2c..48316a6c4 100644 --- a/spec/services/reject_follow_service_spec.rb +++ b/spec/services/reject_follow_service_spec.rb @@ -41,7 +41,7 @@ RSpec.describe RejectFollowService, type: :service do expect(bob.following?(sender)).to be false end - it 'sends a reject activity' do + it 'sends a reject activity', :sidekiq_inline do expect(a_request(:post, bob.inbox_url)).to have_been_made.once end end diff --git a/spec/services/remove_from_followers_service_spec.rb b/spec/services/remove_from_followers_service_spec.rb index 1b29cdcbe..21da38a97 100644 --- a/spec/services/remove_from_followers_service_spec.rb +++ b/spec/services/remove_from_followers_service_spec.rb @@ -33,7 +33,7 @@ RSpec.describe RemoveFromFollowersService, type: :service do expect(bob.followed_by?(sender)).to be false end - it 'sends a reject activity' do + it 'sends a reject activity', :sidekiq_inline do expect(a_request(:post, sender.inbox_url)).to have_been_made.once end end diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index 77b01d307..109acfb09 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe RemoveStatusService, type: :service do +RSpec.describe RemoveStatusService, :sidekiq_inline, type: :service do subject { described_class.new } let!(:alice) { Fabricate(:account) } @@ -12,79 +12,87 @@ 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!(:media_attachment) { Fabricate(:media_attachment, account: alice) } + let!(:status) { PostStatusService.new.call(alice, text: "Hello @#{bob.pretty_acct} ThisIsASecret", media_ids: [media_attachment.id]) } + 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 'publishes to public media timeline' do + allow(redis).to receive(:publish).with(any_args) + + subject.call(status) + + expect(redis).to have_received(:publish).with('timeline:public:media', Oj.dump(event: :delete, payload: status.id.to_s)) 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 +100,35 @@ 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 + end + end + + context 'when removed status is a reblog of a non-follower' do + let!(:original_status) { Fabricate(:status, account: bill, 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, bill.inbox_url).with( + body: hash_including({ + 'type' => 'Undo', + 'object' => hash_including({ + 'type' => 'Announce', + '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 b8ceedb85..39e14a6a1 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', :sidekiq_inline 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 ed22a8147..1c4c3b401 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') @@ -182,7 +195,7 @@ RSpec.describe ResolveAccountService, type: :service do expect(account.uri).to eq 'https://ap.example.com/users/foo' end - it 'merges accounts' do + it 'merges accounts', :sidekiq_inline do account = subject.call('foo@ap.example.com') expect(status.reload.account_id).to eq account.id @@ -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 99761b6c7..5270cc10d 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 @@ -139,6 +139,7 @@ describe ResolveURLService, type: :service do stub_request(:get, url).to_return(status: 302, headers: { 'Location' => status_url }) body = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter).to_json stub_request(:get, status_url).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' }) + stub_request(:get, uri).to_return(body: body, headers: { 'Content-Type' => 'application/activity+json' }) end it 'returns status by url' do diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 497ec7447..39adf4387 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 000000000..c8821348a --- /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 f9206b7ea..4a5f8a0b6 100644 --- a/spec/services/suspend_account_service_spec.rb +++ b/spec/services/suspend_account_service_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe SuspendAccountService, type: :service do +RSpec.describe SuspendAccountService, :sidekiq_inline, type: :service do shared_examples 'common behavior' do subject { described_class.new.call(account) } @@ -10,23 +10,30 @@ 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 account.suspend! + + Fabricate(:media_attachment, file: attachment_fixture('boop.ogg'), account: account) end - it "unmerges from local followers' feeds" do - subject + it 'unmerges from feeds of local followers and changes file mode and preserves suspended flag' do + expect { subject } + .to change_file_mode + .and 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_file_mode + change { File.stat(account.media_attachments.first.file.path).mode } + end + + def not_change_suspended_flag + not_change(account, :suspended?) end end @@ -44,11 +51,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 515dd1a99..5f6418f5d 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 e583a22de..383977d35 100644 --- a/spec/services/unallow_domain_service_spec.rb +++ b/spec/services/unallow_domain_service_spec.rb @@ -5,62 +5,56 @@ require 'rails_helper' RSpec.describe UnallowDomainService, type: :service do subject { described_class.new } - let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: 'evil.org') } + let(:bad_domain) { 'evil.org' } + let!(:bad_account) { Fabricate(:account, username: 'badguy666', domain: bad_domain) } let!(:bad_status_harassment) { Fabricate(:status, account: bad_account, text: 'You suck') } let!(:bad_status_mean) { Fabricate(:status, account: bad_account, text: 'Hahaha') } let!(:bad_attachment) { Fabricate(:media_attachment, account: bad_account, status: bad_status_mean, file: attachment_fixture('attachment.jpg')) } - let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: 'evil.org', suspended: true, silenced: true) } - let!(:domain_allow) { Fabricate(:domain_allow, domain: 'evil.org') } + let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: bad_domain, suspended: true, silenced: true) } + let!(:domain_allow) { Fabricate(:domain_allow, domain: bad_domain) } - context 'with limited federation mode' do + context 'with limited federation mode', :sidekiq_inline 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 - before do - subject.call(domain_allow) - end + it 'makes the domain not allowed and removes accounts from that domain' do + expect { subject.call(domain_allow) } + .to change { bad_domain_allowed }.from(true).to(false) + .and change { bad_domain_account_exists }.from(true).to(false) - it 'removes the allowed domain' do - expect(DomainAllow.allowed?('evil.org')).to be false - end - - it 'removes remote accounts from that domain' do - expect(Account.where(domain: 'evil.org').exists?).to be false - end - - it 'removes the remote accounts\'s statuses and media attachments' do - expect { bad_status_harassment.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { bad_status_mean.reload }.to raise_exception ActiveRecord::RecordNotFound - expect { bad_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound + expect { already_banned_account.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { bad_status_harassment.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { bad_status_mean.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { bad_attachment.reload }.to raise_error(ActiveRecord::RecordNotFound) end end end 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 - before do - subject.call(domain_allow) - end + it 'makes the domain not allowed but preserves accounts from the domain' do + expect { subject.call(domain_allow) } + .to change { bad_domain_allowed }.from(true).to(false) + .and not_change { bad_domain_account_exists }.from(true) - it 'removes the allowed domain' do - expect(DomainAllow.allowed?('evil.org')).to be false - end - - it 'does not remove accounts from that domain' do - expect(Account.where(domain: 'evil.org').exists?).to be true - end - - it 'removes the remote accounts\'s statuses and media attachments' do expect { bad_status_harassment.reload }.to_not raise_error expect { bad_status_mean.reload }.to_not raise_error expect { bad_attachment.reload }.to_not raise_error end end end + + def bad_domain_allowed + DomainAllow.allowed?(bad_domain) + end + + def bad_domain_account_exists + Account.exists?(domain: bad_domain) + end end diff --git a/spec/services/unblock_domain_service_spec.rb b/spec/services/unblock_domain_service_spec.rb index 27dbc92ad..3d6d82ff6 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/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb index 86632c393..9813c5e7f 100644 --- a/spec/services/unblock_service_spec.rb +++ b/spec/services/unblock_service_spec.rb @@ -33,7 +33,7 @@ RSpec.describe UnblockService, type: :service do expect(sender.blocking?(bob)).to be false end - it 'sends an unblock activity' do + it 'sends an unblock activity', :sidekiq_inline do expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once end end diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 3e65e610b..18a25a676 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -20,7 +20,7 @@ RSpec.describe UnfollowService, type: :service do end end - describe 'remote ActivityPub' do + describe 'remote ActivityPub', :sidekiq_inline do let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do @@ -38,7 +38,7 @@ RSpec.describe UnfollowService, type: :service do end end - describe 'remote ActivityPub (reverse)' do + describe 'remote ActivityPub (reverse)', :sidekiq_inline do let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do diff --git a/spec/services/unmute_service_spec.rb b/spec/services/unmute_service_spec.rb deleted file mode 100644 index 236f837e2..000000000 --- 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 7ef2630ae..c848767cd 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', :sidekiq_inline 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_account_service_spec.rb b/spec/services/update_account_service_spec.rb index 6318cc95f..9f4e36862 100644 --- a/spec/services/update_account_service_spec.rb +++ b/spec/services/update_account_service_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe UpdateAccountService, type: :service do subject { described_class.new } - describe 'switching form locked to unlocked accounts' do + describe 'switching form locked to unlocked accounts', :sidekiq_inline do let(:account) { Fabricate(:account, locked: true) } let(:alice) { Fabricate(:account) } let(:bob) { Fabricate(:account) } diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 9c53ebb2f..55651c305 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 @@ -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 ed228abeb..dc60976d0 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/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 000000000..4aba65b40 --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +Capybara.server_host = 'localhost' +Capybara.server_port = 3000 +Capybara.app_host = "http://#{Capybara.server_host}:#{Capybara.server_port}" + +require 'selenium/webdriver' + +Capybara.register_driver :chrome do |app| + Capybara::Selenium::Driver.new(app, browser: :chrome) +end + +Capybara.register_driver :headless_chrome do |app| + options = Selenium::WebDriver::Chrome::Options.new + options.add_argument '--headless=new' + options.add_argument '--window-size=1680,1050' + options.browser_version = '120' + + Capybara::Selenium::Driver.new( + app, + browser: :chrome, + options: options + ) +end + +Capybara.javascript_driver = :headless_chrome + +RSpec.configure do |config| + config.before(:each, type: :system) do + driven_by Capybara.javascript_driver + end +end diff --git a/spec/support/command_line_helpers.rb b/spec/support/command_line_helpers.rb new file mode 100644 index 000000000..6f9d63d93 --- /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 000000000..43cfbade8 --- /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 000000000..091c842bd --- /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 000000000..213e873b4 --- /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 16ebda564..2c9b5514a 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 d65f54f00..ce09eb006 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/javascript_errors.rb b/spec/support/javascript_errors.rb new file mode 100644 index 000000000..a36bf6017 --- /dev/null +++ b/spec/support/javascript_errors.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.after(:each, type: :system) do + errors = page.driver.browser.logs.get(:browser) + if errors.present? + aggregate_failures 'javascript errrors' do + errors.each do |error| + expect(error.level).to_not eq('SEVERE'), error.message + next unless error.level == 'WARNING' + + warn 'WARN: javascript warning' + warn error.message + end + end + end + end +end diff --git a/spec/support/omniauth_mocks.rb b/spec/support/omniauth_mocks.rb new file mode 100644 index 000000000..9883adec7 --- /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 000000000..3c7140b48 --- /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 000000000..eba4095e4 --- /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 de7ae17e6..2b345ddef 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 @@ -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 000000000..39657586f --- /dev/null +++ b/spec/support/streaming_server_manager.rb @@ -0,0 +1,120 @@ +# 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 + + config.around :each, type: :system do |example| + # Streaming server needs DB access but `use_transactional_tests` rolls back + # every transaction. Disable this feature for streaming 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 + + 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 000000000..a3b816b4d --- /dev/null +++ b/spec/system/new_statuses_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'NewStatuses', :sidekiq_inline 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_on '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_on 'Publish!' + end + + expect(subject).to have_css('.status__content__text', text: status_text) + end +end diff --git a/spec/system/share_entrypoint_spec.rb b/spec/system/share_entrypoint_spec.rb new file mode 100644 index 000000000..fd02d1120 --- /dev/null +++ b/spec/system/share_entrypoint_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'ShareEntrypoint' 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 share_path + end + + it 'can be used to post a new status' do + expect(subject).to have_css('div#mastodon-compose') + expect(subject).to have_css('.compose-form__publish-button-wrapper > button') + + status_text = 'This is a new status!' + + within('.compose-form') do + fill_in "What's on your mind?", with: status_text + click_on 'Publish!' + end + + expect(subject).to have_css('.notification-bar-message', text: 'Post published.') + end +end diff --git a/spec/system/unlogged_spec.rb b/spec/system/unlogged_spec.rb new file mode 100644 index 000000000..c3ebf51d7 --- /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 3d3d50f65..6292f0737 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 7144d2891..570ddb31c 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/email_mx_validator_spec.rb b/spec/validators/email_mx_validator_spec.rb index 876d73c18..21b1ad0a1 100644 --- a/spec/validators/email_mx_validator_spec.rb +++ b/spec/validators/email_mx_validator_spec.rb @@ -111,7 +111,7 @@ describe EmailMxValidator do allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: '2.3.4.5')]) - allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: 'fd00::2')]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([instance_double(Resolv::DNS::Resource::IN::AAAA, address: 'fd00::2')]) allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) diff --git a/spec/validators/existing_username_validator_spec.rb b/spec/validators/existing_username_validator_spec.rb new file mode 100644 index 000000000..4f1dd55a1 --- /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 86b6511d6..51b0683d2 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 000000000..cb693dcd8 --- /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 95feb043d..f2a253489 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 8535ddd75..614f5d390 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 e8f8a4543..d5109f990 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 6f353eeaf..0eb5f8368 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 f2220e32b..4f32b7b39 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 000000000..82a1dee6d --- /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=